├── src ├── helpers.php ├── Http │ ├── Middleware │ │ ├── RegisterScript.php │ │ └── DumpHandler.php │ └── Controllers │ │ └── DatastarController.php ├── Helpers │ ├── Request.php │ ├── Action.php │ └── Datastar.php ├── Validation │ └── SignalValidator.php ├── Models │ └── Config.php ├── DatastarServiceProvider.php └── Services │ └── Sse.php ├── examples └── hello-world │ └── resources │ └── views │ ├── datastar │ └── hello-world.blade.php │ └── index.blade.php ├── LICENSE.md ├── phpunit.xml ├── config └── datastar.php ├── composer.json ├── CHANGELOG.md ├── README.md └── public └── datastar └── 1.0.0-RC.6 ├── datastar.js └── datastar-aliased.js /src/helpers.php: -------------------------------------------------------------------------------- 1 | 10 | {{ substr($message, 0, $i + 1) }} 11 | 12 | @endpatchelements 13 | @php 14 | // Sleep for the provided delay in milliseconds. 15 | usleep($delay * 1000); 16 | @endphp 17 | @endfor 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © PutYourLightsOn 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/Http/Middleware/RegisterScript.php: -------------------------------------------------------------------------------- 1 | isSuccessful() 21 | || !str_contains($response->headers->get('content-type'), 'text/html') 22 | ) { 23 | return $response; 24 | } 25 | 26 | $content = $response->getContent(); 27 | $path = asset('vendor/datastar/' . static::VERSION . '/datastar.js'); 28 | $asset = ''; 29 | $content = str_replace('', $asset . '', $content); 30 | $response->setContent($content); 31 | 32 | return $response; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | 14 | app 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Helpers/Request.php: -------------------------------------------------------------------------------- 1 | true, 11 | 12 | /** 13 | * The name of the signals variable that will be injected into Datastar templates. 14 | */ 15 | 'signalsVariableName' => 'signals', 16 | 17 | /** 18 | * The event options to override the Datastar defaults. Null values will be ignored. 19 | */ 20 | 'defaultEventOptions' => [ 21 | 'retryDuration' => 1000, 22 | ], 23 | 24 | /** 25 | * The element options to override the Datastar defaults. Null values will be ignored. 26 | */ 27 | 'defaultElementOptions' => [ 28 | 'settleDuration' => null, 29 | 'useViewTransition' => null, 30 | ], 31 | 32 | /** 33 | * The signal options to override the Datastar defaults. Null values will be ignored. 34 | */ 35 | 'defaultSignalOptions' => [ 36 | 'onlyIfMissing' => null, 37 | ], 38 | 39 | /** 40 | * The execute script options to override the Datastar defaults. Null values will be ignored. 41 | */ 42 | 'defaultExecuteScriptOptions' => [ 43 | 'autoRemove' => null, 44 | 'attributes' => null, 45 | ], 46 | ]; 47 | -------------------------------------------------------------------------------- /src/Http/Middleware/DumpHandler.php: -------------------------------------------------------------------------------- 1 | headers->get('Datastar-Request'))) { 23 | return $next($request); 24 | } 25 | 26 | VarDumper::setHandler(function($var) { 27 | $cloner = new VarCloner(); 28 | $dumper = new HtmlDumper(base_path(), config('view.compiled')); 29 | 30 | $stream = fopen('php://memory', 'r+'); 31 | $dumper->setOutput($stream); 32 | $data = $cloner->cloneVar($var); 33 | $dumper->dumpWithSource($data); 34 | 35 | rewind($stream); 36 | $output = stream_get_contents($stream); 37 | fclose($stream); 38 | 39 | if ($output !== false) { 40 | sse()->dump($output); 41 | } 42 | }); 43 | 44 | return $next($request); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "putyourlightson/laravel-datastar", 3 | "description": "A reactive hypermedia framework for Laravel.", 4 | "version": "1.0.0-RC.8", 5 | "type": "library", 6 | "license": "mit", 7 | "require": { 8 | "php": "^8.2", 9 | "laravel/framework": ">=11.0", 10 | "starfederation/datastar-php": "^1.0.0-RC.3" 11 | }, 12 | "require-dev": { 13 | "craftcms/ecs": "dev-main", 14 | "craftcms/phpstan": "dev-main", 15 | "pestphp/pest": "^3.0" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Putyourlightson\\Datastar\\": "src/" 20 | }, 21 | "files": [ 22 | "src/helpers.php" 23 | ] 24 | }, 25 | "extra": { 26 | "laravel": { 27 | "providers": [ 28 | "Putyourlightson\\Datastar\\DatastarServiceProvider" 29 | ] 30 | } 31 | }, 32 | "scripts": { 33 | "check-cs": "ecs check --ansi", 34 | "fix-cs": "ecs check --ansi --fix", 35 | "phpstan": "phpstan --memory-limit=1G", 36 | "test": "vendor/bin/pest" 37 | }, 38 | "config": { 39 | "allow-plugins": { 40 | "pestphp/pest-plugin": true 41 | }, 42 | "optimize-autoloader": true, 43 | "sort-packages": true 44 | }, 45 | "support": { 46 | "docs": "https://github.com/putyourlightson/laravel-datastar", 47 | "source": "https://github.com/putyourlightson/laravel-datastar", 48 | "issues": "https://github.com/putyourlightson/laravel-datastar/issues" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/hello-world/resources/views/index.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Datastar SDK Demo 5 | 6 | 7 | 8 |
9 |
10 |

11 | Datastar SDK Demo 12 |

13 | Datastar Logo 14 |
15 |

16 | SSE events will be streamed from the backend to the frontend. 17 |

18 |
19 | 22 | 23 |
24 | 27 |
28 |
29 |
Hello, world!
30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Helpers/Action.php: -------------------------------------------------------------------------------- 1 | [$csrfHeader => $token]]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Validation/SignalValidator.php: -------------------------------------------------------------------------------- 1 | resetErrors(); 25 | } catch (ValidationException $exception) { 26 | $this->sendErrorResponse($exception->errors()); 27 | } 28 | 29 | return $validated; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function validateWithBag(string $errorBag): array 36 | { 37 | $validated = []; 38 | 39 | try { 40 | $validated = parent::validateWithBag($errorBag); 41 | $this->resetErrors($errorBag); 42 | } catch (ValidationException $exception) { 43 | $this->sendErrorResponse($exception->errors(), $exception->errorBag); 44 | } 45 | 46 | return $validated; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function validated(): array 53 | { 54 | $validated = []; 55 | 56 | try { 57 | $validated = parent::validated(); 58 | $this->resetErrors(); 59 | } catch (ValidationException $exception) { 60 | $this->sendErrorResponse($exception->errors()); 61 | } 62 | 63 | return $validated; 64 | } 65 | 66 | /** 67 | * Sends an error response with the validation errors. 68 | */ 69 | private function sendErrorResponse(array $errors, string $errorBag = null): void 70 | { 71 | $errorKey = $errorBag ?? static::ERROR_KEY; 72 | sse()->patchSignals([$errorKey => $errors]) 73 | ->getEventStream() 74 | ->send(); 75 | 76 | // Exit to prevent further processing of the request. 77 | exit; 78 | } 79 | 80 | /** 81 | * Resets the errors in the specified error bag. 82 | */ 83 | private function resetErrors(string $errorBag = null): void 84 | { 85 | $errorKey = $errorBag ?? static::ERROR_KEY; 86 | $signals = array_fill_keys(array_keys($this->rules), null); 87 | sse()->patchSignals([$errorKey => $signals]); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Models/Config.php: -------------------------------------------------------------------------------- 1 | getKey(); 44 | 45 | return hash_hmac(self::HASH_ALGORITHM, $value, $key); 46 | } 47 | 48 | public function __construct(array $attributes = []) 49 | { 50 | foreach ($attributes as $key => $value) { 51 | $this->$key = $value; 52 | } 53 | } 54 | 55 | /** 56 | * Returns a hashed, JSON-encoded array of attributes. 57 | */ 58 | public function getHashed(): string 59 | { 60 | $attributes = array_filter([ 61 | 'route' => $this->route, 62 | 'params' => $this->params, 63 | ]); 64 | $encoded = json_encode($attributes); 65 | 66 | $checksum = self::hash($encoded); 67 | 68 | return $checksum . $encoded; 69 | } 70 | 71 | /** 72 | * Validates the model. 73 | */ 74 | public function validate(): void 75 | { 76 | $validator = Validator::make([ 77 | 'route' => $this->route, 78 | 'params' => $this->params, 79 | ], [ 80 | 'route' => 'required', 81 | 'params' => function(string $attribute, mixed $params, Closure $fail) { 82 | $this->validateParams($attribute, $params, $fail); 83 | }, 84 | ]); 85 | 86 | if ($validator->fails()) { 87 | throw new ValidationException($validator); 88 | } 89 | } 90 | 91 | /** 92 | * Validates that none of the params are objects, recursively. 93 | */ 94 | private function validateParams(string $attribute, mixed $params, Closure $fail): void 95 | { 96 | $signalsVariableName = config('datastar.signalsVariableName'); 97 | 98 | foreach ($params as $key => $value) { 99 | if ($key === $signalsVariableName) { 100 | $fail('Param `' . $signalsVariableName . '` is reserved. Use a different name or modify the name of the signals variable using the `signalsVariableName` config setting.'); 101 | return; 102 | } 103 | 104 | if (is_object($value)) { 105 | $fail('Param `' . $key . '` is an object, which is a forbidden param type in the context of a Datastar request.'); 106 | return; 107 | } 108 | 109 | if (is_array($value)) { 110 | $this->validateParams($attribute, $value, $fail); 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Helpers/Datastar.php: -------------------------------------------------------------------------------- 1 | getConfig($view, $variables); 22 | $uri = action([DatastarController::class, 'view'], ['config' => $config->getHashed()]); 23 | 24 | return $this->get($uri, $options); 25 | } 26 | 27 | /** 28 | * Returns a Datastar `@post` action to a controller action. 29 | */ 30 | public function action(string|array $route, array $params = [], array|string $options = []): string 31 | { 32 | $config = $this->getConfig($route, $params); 33 | $uri = action([DatastarController::class, 'action'], ['config' => $config->getHashed()]); 34 | 35 | return $this->post($uri, $options); 36 | } 37 | 38 | /** 39 | * Returns a Datastar `@get` action to the given URI. 40 | */ 41 | public function get(string $uri, array|string $options = []): string 42 | { 43 | return Action::getAction('get', $uri, $options); 44 | } 45 | 46 | /** 47 | * Returns a Datastar `@post` action to the given URI. 48 | */ 49 | public function post(string $uri, array|string $options = []): string 50 | { 51 | return Action::getAction('post', $uri, $options); 52 | } 53 | 54 | /** 55 | * Returns a Datastar `@put` action to the given URI. 56 | */ 57 | public function put(string $uri, array|string $options = []): string 58 | { 59 | return Action::getAction('put', $uri, $options); 60 | } 61 | 62 | /** 63 | * Returns a Datastar `@patch` action to the given URI. 64 | */ 65 | public function patch(string $uri, array|string $options = []): string 66 | { 67 | return Action::getAction('patch', $uri, $options); 68 | } 69 | 70 | /** 71 | * Returns a Datastar `@delete` action to the given URI. 72 | */ 73 | public function delete(string $uri, array|string $options = []): string 74 | { 75 | return Action::getAction('delete', $uri, $options); 76 | } 77 | 78 | /** 79 | * Reads and returns the signals passed into the request. 80 | */ 81 | public function readSignals(): array 82 | { 83 | return Request::readSignals(); 84 | } 85 | 86 | /** 87 | * Sets server sent event options. 88 | */ 89 | public function setSseEventOptions(array $options = []): void 90 | { 91 | app(Sse::class)->setSseEventOptions($options); 92 | } 93 | 94 | 95 | /** 96 | * Returns a Datastar config for the given route and parameters. 97 | */ 98 | private function getConfig(string|array $route, array $params = []): Config 99 | { 100 | $config = new Config([ 101 | 'route' => $route, 102 | 'params' => $params, 103 | ]); 104 | 105 | try { 106 | $config->validate(); 107 | } catch (ValidationException $exception) { 108 | throw new BadRequestHttpException($exception->getMessage()); 109 | } 110 | 111 | return $config; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/DatastarServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/../config/datastar.php', 'datastar'); 21 | 22 | $this->app->singleton(Sse::class, function() { 23 | return new Sse(); 24 | }); 25 | } 26 | 27 | public function boot(): void 28 | { 29 | $this->publishes([ 30 | __DIR__ . '/../config/datastar.php' => config_path('datastar.php'), 31 | ], 'datastar-config'); 32 | 33 | $this->publishes([ 34 | __DIR__ . '/../public' => public_path('vendor'), 35 | ], 'public'); 36 | 37 | $this->registerRoutes(); 38 | $this->registerDumpHandler(); 39 | $this->registerScript(); 40 | $this->registerDirectives(); 41 | } 42 | 43 | private function registerRoutes(): void 44 | { 45 | Route::middleware(['web'])->group(function() { 46 | Route::get('/datastar-controller/view', [DatastarController::class, 'view']); 47 | Route::post('/datastar-controller/action', [DatastarController::class, 'action']); 48 | }); 49 | } 50 | 51 | private function registerDumpHandler(): void 52 | { 53 | $this->app['router']->pushMiddlewareToGroup('web', DumpHandler::class); 54 | } 55 | 56 | private function registerScript(): void 57 | { 58 | if (config('datastar.registerScript', true) === false) { 59 | return; 60 | } 61 | 62 | $this->app['router']->pushMiddlewareToGroup('web', RegisterScript::class); 63 | } 64 | 65 | /** 66 | * @uses Sse::patchElements() 67 | * @uses Sse::removeElements() 68 | * @uses Sse::patchSignals() 69 | * @uses Sse::executeScript() 70 | * @uses Sse::location() 71 | * @uses Sse::setSseInProcess 72 | */ 73 | private function registerDirectives(): void 74 | { 75 | Blade::directive('patchelements', function(string $expression) { 76 | return $this->getDirective("setSseInProcess('patchElements', $expression); ob_start()"); 77 | }); 78 | 79 | Blade::directive('endpatchelements', function() { 80 | return $this->getDirective("patchElements(ob_get_clean())"); 81 | }); 82 | 83 | Blade::directive('removeelements', function(string $expression) { 84 | return $this->getDirective("removeElements($expression)"); 85 | }); 86 | 87 | Blade::directive('patchsignals', function(string $expression) { 88 | return $this->getDirective("patchSignals($expression)"); 89 | }); 90 | 91 | Blade::directive('executescript', function(string $expression) { 92 | return $this->getDirective("setSseInProcess('executeScript', $expression); ob_start()"); 93 | }); 94 | 95 | Blade::directive('endexecutescript', function() { 96 | return $this->getDirective("executeScript(ob_get_clean())"); 97 | }); 98 | 99 | Blade::directive('location', function(string $expression) { 100 | return $this->getDirective("location($expression)"); 101 | }); 102 | } 103 | 104 | private function getDirective(string $expression): string 105 | { 106 | return "$expression ?>"; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Notes for Datastar 2 | 3 | ## 1.0.0-RC.8 - 2025-10-26 4 | 5 | > [!NOTE] 6 | > The Datastar syntax has changed in this release. Please see the [release notes](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.6) and modify your code accordingly. 7 | 8 | - The package now includes Datastar [1.0.0-RC.6](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.6). 9 | 10 | ## 1.0.0-RC.7 - 2025-10-05 11 | 12 | - Added a check before clearing the output buffer to ensure it exists ([#7](https://github.com/putyourlightson/laravel-datastar/issues/7)). 13 | - Fixed source map filenames. 14 | 15 | ## 1.0.0-RC.6 - 2025-08-23 16 | 17 | - Improved error handling. 18 | - Improved handling of calls to `dump()` and `dd()`. 19 | 20 | ## 1.0.0-RC.5 - 2025-08-17 21 | 22 | - The package now includes Datastar [1.0.0-RC.5](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.5). 23 | - Fixed a bug in which options were being double JSON encoded. 24 | 25 | ## 1.0.0-RC.4 - 2025-08-12 26 | 27 | - Made it possible to pass options in as string, for cases when JSON encoding is not desirable. 28 | - Improved error handling. 29 | 30 | ## 1.0.0-RC.3 - 2025-08-09 31 | 32 | - The package now includes Datastar [1.0.0-RC.4](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.4). 33 | - The `datastar()->get()` and equivalent Blade directives now only support passing a route URI. 34 | - Added the ability to render views in backend requests by passing a view path `path.to.view` to `datastar()->view()`. 35 | - Added the ability to pass controller actions to backend requests by passing an array `['MyController', 'myAction']` to `datastar()->action()`. 36 | - Added the `getValidator()` method to the `sse()` helper. 37 | - Added the `validate()` and `validateWithBag()` methods to the `sse()` helper. 38 | - Added the `shouldCloseSession()` method to the `sse()` helper that determines whether the session should be closed when the event stream begins. 39 | - The session is now closed by default when the `getEventStream()` method is called, to prevent session locking. 40 | - Replaced the `DatastarEventStream` trait with the `sse()` helper. 41 | - Renamed the `getStreamedResponse()` method to `getEventStream()`. 42 | - Renamed the `renderDatastarView()` method to `renderView()`. 43 | 44 | ## 1.0.0-RC.2 - 2025-07-17 45 | 46 | - The package now includes Datastar [1.0.0-RC.2](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.2). 47 | 48 | ## 1.0.0-RC.1 - 2025-07-15 49 | 50 | - The package now requires Datastar [1.0.0-RC.1](https://github.com/starfederation/datastar/releases/tag/v1.0.0-RC.1). 51 | - Renamed the `fragments` Blade directive to `patchelements`. 52 | - Renamed the `removefragments` Blade directive to `removeelements`. 53 | - Renamed the `mergesignals` Blade directive to `patchsignals`. 54 | - Renamed the `defaultFragmentOptions` config setting to `defaultElementOptions`. 55 | - Removed the `removesignals` Blade directive. 56 | - Removed the `datastar()->getFragments()` helper method. 57 | - Removed the `SignalsModel` class. The `signals` variable passed into Datastar templates is now a regular array. Use the `patchsignals` Twig tag to update and remove signals. 58 | 59 | ## 1.0.0-beta.8 - 2025-04-15 60 | 61 | ### Added 62 | 63 | - Added a `datastar()->getFragments()` helper method that fetches and merge fragments into the DOM. 64 | 65 | ## 1.0.0-beta.7 - 2025-04-09 66 | 67 | ### Changed 68 | 69 | - Update the Datastar library to version 1.0.0-beta.11. 70 | 71 | ## 1.0.0-beta.6 - 2025-03-01 72 | 73 | ### Changed 74 | 75 | - Update the Datastar library to version 1.0.0-beta.9. 76 | 77 | ## 1.0.0-beta.5 - 2025-02-25 78 | 79 | ### Changed 80 | 81 | - Update the Datastar library to version 1.0.0-beta.8. 82 | 83 | ## 1.0.0-beta.4 - 2025-02-14 84 | 85 | ### Changed 86 | 87 | - Update the Datastar library to version 1.0.0-beta.7. 88 | 89 | ## 1.0.0-beta.3 - 2025-02-12 90 | 91 | ### Changed 92 | 93 | - Extract methods into the SSE service class. 94 | 95 | ## 1.0.0-beta.2 - 2025-02-02 96 | 97 | ### Added 98 | 99 | - Added the `location` Blade directive. 100 | 101 | ## 1.0.0-beta.1 - 2025-01-13 102 | 103 | - Initial beta release. 104 | -------------------------------------------------------------------------------- /src/Http/Controllers/DatastarController.php: -------------------------------------------------------------------------------- 1 | getConfig(); 29 | $view = $config->route; 30 | $variables = $config->params; 31 | 32 | return sse()->getEventStream(function() use ($view, $variables) { 33 | sse()->renderView($view, $variables); 34 | }); 35 | } 36 | 37 | /** 38 | * Runs a controller action. 39 | */ 40 | public function action(): ?Response 41 | { 42 | $config = $this->getConfig(); 43 | 44 | $route = $config->route; 45 | $params = $config->params; 46 | $method = '__invoke'; 47 | 48 | if (is_array($route)) { 49 | $route = $config->route[0] ?? null; 50 | if (empty($route)) { 51 | throw new BadRequestHttpException('A controller must be specified in the route.'); 52 | } 53 | 54 | $method = $config->route[1] ?? null; 55 | if (empty($method)) { 56 | throw new BadRequestHttpException('A controller and method must be specified in the route.'); 57 | } 58 | } 59 | 60 | if (!str_contains($route, '\\')) { 61 | $route = 'App\\Http\\Controllers\\' . $route; 62 | } 63 | 64 | if (!class_exists($route)) { 65 | throw new BadRequestHttpException("Controller `$route` does not exist. Make sure you’re using a valid namespace and that the class is autoloaded."); 66 | } 67 | 68 | $controller = app($route); 69 | 70 | if (!method_exists($controller, $method)) { 71 | throw new BadRequestHttpException("Method `$method` does not exist on controller `$route`."); 72 | } 73 | 74 | $params = $this->resolveRouteBindings($controller, $method, $params); 75 | 76 | $middlewareStack = $this->buildMiddlewareStack($controller, $method); 77 | 78 | return app(Pipeline::class) 79 | ->send(request()) 80 | ->through($middlewareStack) 81 | ->then(function() use ($controller, $method, $params) { 82 | return app()->call([$controller, $method], $params); 83 | }); 84 | } 85 | 86 | /** 87 | * Returns the validated configuration from the request. 88 | * 89 | * @throws BadRequestHttpException if the configuration is invalid or tampered with. 90 | */ 91 | private function getConfig(): Config 92 | { 93 | $hashedConfig = request()->input('config'); 94 | $config = Config::fromHashed($hashedConfig); 95 | 96 | if ($config === null) { 97 | throw new BadRequestHttpException('Submitted data was tampered.'); 98 | } 99 | 100 | return $config; 101 | } 102 | 103 | /** 104 | * Resolves route bindings for the given controller and method. 105 | */ 106 | private function resolveRouteBindings($controller, string $method, array $rawParams): array 107 | { 108 | $reflection = new ReflectionMethod($controller, $method); 109 | $resolved = []; 110 | 111 | foreach ($reflection->getParameters() as $param) { 112 | $name = $param->getName(); 113 | $type = $param->getType(); 114 | 115 | if (!($type instanceof ReflectionNamedType) || $type->isBuiltin()) { 116 | $resolved[$name] = $rawParams[$name] ?? null; 117 | continue; 118 | } 119 | 120 | $className = $type->getName(); 121 | 122 | if (is_subclass_of($className, Model::class) && isset($rawParams[$name])) { 123 | /** @var UrlRoutable $instance */ 124 | $instance = App::make($className); 125 | $resolved[$name] = $instance->resolveRouteBinding($rawParams[$name]); 126 | } else { 127 | $resolved[$name] = $rawParams[$name] ?? App::make($className); 128 | } 129 | } 130 | 131 | return $resolved; 132 | } 133 | 134 | /** 135 | * Builds the middleware stack for the given controller and method. 136 | */ 137 | private function buildMiddlewareStack(object $controller, string $method): array 138 | { 139 | if (!($controller instanceof HasMiddleware)) { 140 | return []; 141 | } 142 | 143 | $middlewareStack = []; 144 | $aliases = app('router')->getMiddleware(); 145 | 146 | foreach ($controller::middleware() as $middleware) { 147 | if ($middleware instanceof Middleware) { 148 | if ($this->middlewareShouldApply($middleware, $method)) { 149 | if (is_callable($middleware->middleware)) { 150 | $resolved = $middleware->middleware; 151 | } elseif (is_string($middleware->middleware)) { 152 | $resolved = $aliases[$middleware->middleware] ?? $middleware->middleware; 153 | } else { 154 | throw new BadRequestHttpException('Invalid middleware type.'); 155 | } 156 | $middlewareStack[] = $resolved; 157 | } 158 | } else { 159 | $resolved = $aliases[$middleware] ?? $middleware; 160 | $middlewareStack[] = $resolved; 161 | } 162 | } 163 | 164 | return $middlewareStack; 165 | } 166 | 167 | /** 168 | * Returns whether middleware should apply to the given method. 169 | */ 170 | private function middlewareShouldApply(Middleware $middleware, string $method): bool 171 | { 172 | if (!empty($middleware->only) && !in_array($method, $middleware->only)) { 173 | return false; 174 | } 175 | 176 | if (!empty($middleware->except) && in_array($method, $middleware->except)) { 177 | return false; 178 | } 179 | 180 | return true; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Services/Sse.php: -------------------------------------------------------------------------------- 1 | isStreamedResponse = true; 67 | 68 | $eventStream = function() use ($callable) { 69 | if ($this->shouldCloseSession && session_status() === PHP_SESSION_ACTIVE) { 70 | session_write_close(); 71 | } 72 | 73 | echo $this->getEventOutput(); 74 | if (ob_get_contents()) { 75 | ob_flush(); 76 | } 77 | flush(); 78 | 79 | if (is_callable($callable)) { 80 | try { 81 | $callable(); 82 | } catch (Throwable $exception) { 83 | $this->throwException($exception); 84 | } 85 | } 86 | }; 87 | 88 | return new StreamedResponse($eventStream, 200, ServerSentEventGenerator::headers()); 89 | } 90 | 91 | /** 92 | * Returns the output of all events as a string. 93 | */ 94 | public function getEventOutput(bool $reset = true): string 95 | { 96 | $data = ''; 97 | foreach ($this->sseEvents as $event) { 98 | $data .= $event->getOutput(); 99 | } 100 | 101 | if ($reset) { 102 | $this->resetEvents(); 103 | } 104 | 105 | return $data; 106 | } 107 | 108 | /** 109 | * Returns a validator for the signals passed into the request. 110 | */ 111 | public function getValidator(array $rules, array $messages = [], array $attributes = []): SignalValidator 112 | { 113 | return Request::getValidator($rules, $messages, $attributes); 114 | } 115 | 116 | /** 117 | * Validates the signals passed into the request. 118 | */ 119 | public function validate(array $rules, array $messages = [], array $attributes = []): array 120 | { 121 | return Request::getValidator($rules, $messages, $attributes) 122 | ->validate(); 123 | } 124 | 125 | /** 126 | * Validates the signals passed into the request using a provided error bag. 127 | */ 128 | public function validateWithBag(string $errorBag, array $rules, array $messages = [], array $attributes = []): array 129 | { 130 | return Request::getValidator($rules, $messages, $attributes) 131 | ->validateWithBag($errorBag); 132 | } 133 | 134 | /** 135 | * Reads and returns the signals passed into the request. 136 | */ 137 | public function readSignals(): array 138 | { 139 | return Request::readSignals(); 140 | } 141 | 142 | /** 143 | * Patches elements into the DOM. 144 | */ 145 | public function patchElements(string $data, array $options = []): static 146 | { 147 | $options = $this->patchEventOptions( 148 | config('datastar.defaultElementOptions', []), 149 | $this->sseEventOptions, 150 | $options, 151 | ); 152 | $event = new PatchElements($data, $options); 153 | 154 | $this->processEvent($event); 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Removes elements from the DOM. 161 | */ 162 | public function removeElements(string $selector, array $options = []): static 163 | { 164 | $options = $this->patchEventOptions( 165 | config('datastar.defaultElementOptions', []), 166 | $this->sseEventOptions, 167 | $options, 168 | ); 169 | $event = new RemoveElements($selector, $options); 170 | 171 | $this->processEvent($event); 172 | 173 | return $this; 174 | } 175 | 176 | /** 177 | * Patches signals. 178 | */ 179 | public function patchSignals(array $signals, array $options = []): static 180 | { 181 | $options = $this->patchEventOptions( 182 | config('datastar.defaultSignalOptions', []), 183 | $this->sseEventOptions, 184 | $options, 185 | ); 186 | $event = new PatchSignals($signals, $options); 187 | 188 | $this->processEvent($event); 189 | 190 | return $this; 191 | } 192 | 193 | /** 194 | * Executes JavaScript in the browser. 195 | */ 196 | public function executeScript(string $script, array $options = []): static 197 | { 198 | $options = $this->patchEventOptions( 199 | config('datastar.defaultExecuteScriptOptions', []), 200 | $this->sseEventOptions, 201 | $options, 202 | ); 203 | $event = new ExecuteScript($script, $options); 204 | 205 | $this->processEvent($event); 206 | 207 | return $this; 208 | } 209 | 210 | /** 211 | * Redirects the browser by setting the location to the provided URI. 212 | */ 213 | public function location(string $uri, array $options = []): static 214 | { 215 | $options = $this->patchEventOptions( 216 | config('datastar.defaultExecuteScriptOptions', []), 217 | $this->sseEventOptions, 218 | $options, 219 | ); 220 | $event = new Location($uri, $options); 221 | 222 | $this->processEvent($event); 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * Renders a view. 229 | */ 230 | public function renderView(string $view, array $variables = []): static 231 | { 232 | $signals = $this->readSignals(); 233 | $variables = array_merge( 234 | [config('datastar.signalsVariableName', 'signals') => $signals], 235 | $variables, 236 | ); 237 | 238 | if (strtolower(request()->header('Content-Type')) === 'application/json') { 239 | // Clear out params to prevent them from being processed by controller actions. 240 | request()->query->replace(); 241 | request()->request->replace(); 242 | } 243 | 244 | try { 245 | $output = view($view, $variables)->render(); 246 | if (!empty(trim($output))) { 247 | $this->patchElements($output); 248 | } 249 | } catch (Throwable $exception) { 250 | $this->throwException($exception); 251 | } 252 | 253 | return $this; 254 | } 255 | 256 | /** 257 | * Resets the current events. 258 | */ 259 | public function resetEvents(): static 260 | { 261 | $this->sseEvents = []; 262 | 263 | return $this; 264 | } 265 | 266 | /** 267 | * Sets server sent event options for the current request. 268 | */ 269 | public function setSseEventOptions(array $options): static 270 | { 271 | $this->sseEventOptions = $options; 272 | 273 | return $this; 274 | } 275 | 276 | /** 277 | * Sets the server sent event method and options currently in process. 278 | */ 279 | public function setSseInProcess(?string $method, array $options = []): static 280 | { 281 | $this->sseMethodInProcess = $method; 282 | $this->sseOptionsInProcess = $options; 283 | 284 | return $this; 285 | } 286 | 287 | /** 288 | * Determines whether the session should be closed when the event stream begins. 289 | */ 290 | public function shouldCloseSession(bool $value): static 291 | { 292 | $this->shouldCloseSession = $value; 293 | 294 | return $this; 295 | } 296 | 297 | /** 298 | * Patches an exception response or logs a console error. 299 | * 300 | * @phpstan-return never 301 | */ 302 | public function throwException(Throwable $exception): void 303 | { 304 | $this->getEventStream(function() use ($exception) { 305 | $exceptionHandler = app(ExceptionHandler::class); 306 | if ($exceptionHandler->shouldReport($exception)) { 307 | $exceptionHandler->report($exception); 308 | } 309 | 310 | if (config('app.debug')) { 311 | $response = $exceptionHandler->render(app('request'), $exception); 312 | $event = new PatchElements($response->getContent()); 313 | } else { 314 | $message = 'A server error occurred.'; 315 | $event = new ExecuteScript('console.error(' . json_encode($message) . ');'); 316 | } 317 | 318 | echo $event->getOutput(); 319 | })->send(); 320 | 321 | exit(1); 322 | } 323 | 324 | /** 325 | * Prepends dumped content to the `` tag. 326 | */ 327 | public function dump(string $output): void 328 | { 329 | $this->patchElements($output, [ 330 | 'selector' => 'body', 331 | 'mode' => ElementPatchMode::Prepend, 332 | ]); 333 | 334 | if (!$this->isStreamedResponse) { 335 | $this->getEventStream()->send(); 336 | } 337 | } 338 | 339 | /** 340 | * Returns patch event options with null values removed. 341 | */ 342 | private function patchEventOptions(array ...$optionSets): array 343 | { 344 | $options = array_merge( 345 | config('datastar.defaultEventOptions', []), 346 | $this->sseOptionsInProcess, 347 | ); 348 | 349 | $this->sseOptionsInProcess = []; 350 | 351 | foreach ($optionSets as $optionSet) { 352 | $options = array_merge($options, $optionSet); 353 | } 354 | 355 | return array_filter($options, fn($value) => $value !== null); 356 | } 357 | 358 | /** 359 | * Processes an event. 360 | */ 361 | private function processEvent(EventInterface $event): void 362 | { 363 | $this->verifySseMethodInProcess($event); 364 | 365 | $this->sseEvents[] = $event; 366 | 367 | if ($this->isStreamedResponse) { 368 | // Clean and end all existing output buffers. 369 | while (ob_get_level() > 0) { 370 | ob_end_clean(); 371 | } 372 | 373 | echo $event->getOutput(); 374 | 375 | if (ob_get_contents()) { 376 | ob_end_flush(); 377 | } 378 | flush(); 379 | 380 | // Start a new output buffer to capture any subsequent inline content. 381 | ob_start(); 382 | } 383 | 384 | $this->setSseInProcess(null); 385 | } 386 | 387 | /** 388 | * Verifies that another SSE method is not already in process. 389 | */ 390 | private function verifySseMethodInProcess(EventInterface $event): void 391 | { 392 | if ($this->sseMethodInProcess === null) { 393 | return; 394 | } 395 | 396 | $sseMethods = [ 397 | PatchElements::class => 'patchElements', 398 | RemoveElements::class => 'removeElements', 399 | PatchSignals::class => 'patchSignals', 400 | ExecuteScript::class => 'executeScript', 401 | ]; 402 | 403 | $method = $sseMethods[$event::class] ?? null; 404 | if ($method === null) { 405 | return; 406 | } 407 | 408 | if ($method !== $this->sseMethodInProcess) { 409 | $message = 'The SSE method `' . $method . '` cannot be called when `' . $this->sseMethodInProcess . '` is already in process.'; 410 | if ($method === 'patchElements') { 411 | $message .= ' Ensure that you are not setting or removing signals inside `@patchelements` or `@executescript` directives.'; 412 | } 413 | $this->throwException(new Exception($message)); 414 | } 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stable Version](https://img.shields.io/packagist/v/putyourlightson/laravel-datastar?label=stable)]((https://packagist.org/packages/putyourlightson/laravel-datastar)) 2 | [![Total Downloads](https://img.shields.io/packagist/dt/putyourlightson/laravel-datastar)](https://packagist.org/packages/putyourlightson/laravel-datastar) 3 | 4 |

5 | 6 | # Datastar Package for Laravel 7 | 8 | ### A reactive hypermedia framework for Laravel. 9 | 10 | > [!WARNING] 11 | > Updating from a previous version? View the [release notes](https://github.com/putyourlightson/laravel-datastar/blob/develop/CHANGELOG.md). 12 | 13 | This package integrates the [Datastar hypermedia framework](https://data-star.dev/) with [Laravel](https://laravel.com/), allowing you to create reactive frontends driven by Blade views _or_ controllers. It aims to replace the need for front-end frameworks such as React, Vue.js and Alpine.js + htmx, and instead lets you manage state and use logic from your Laravel backend. 14 | 15 | Use-cases: 16 | 17 | - Live search and filtering 18 | - Loading more elements / Infinite scroll 19 | - Paginating, ordering and filtering lists 20 | - Submitting forms and running actions 21 | - Pretty much anything to do with reactive front-ends 22 | 23 | ## License 24 | 25 | This package is licensed for free under the MIT License. 26 | 27 | ## Requirements 28 | 29 | This package requires [Laravel](https://laravel.com/) 11.0.0 or later. 30 | 31 | ## Installation 32 | 33 | Install manually using composer, then run the `artisan vendor:publish --tag=public` command to publish the public assets. 34 | 35 | ```shell 36 | composer require putyourlightson/laravel-datastar:^1.0.0-RC.3 37 | 38 | php artisan vendor:publish --tag=public 39 | ``` 40 | 41 | ## Overview 42 | 43 | The Datastar package for Laravel allows you to handle backend requests by sending SSE events using [Blade directives](#blade-directives) in views _or_ [using controllers](#using-controllers). The former requires less setup and is more straightforward, while the latter provides more flexibility. 44 | 45 | Here’s a trivial example that toggles some backend state using the Blade view `datastar/toggle.blade.php` to handle the request. 46 | 47 | ```blade 48 | {{-- main.blade.php --}} 49 | 50 |
51 |
52 | 55 |
56 | ``` 57 | 58 | ```blade 59 | {{-- datastar/toggle.blade.php --}} 60 | 61 | @php 62 | $enabled = $signals['enabled'] ?? false; 63 | @endphp 64 | 65 | @patchsignals(['enabled' => !$enabled]) 66 | 67 | @patchelements 68 | 69 | {{ $enabled ? 'Enable' : 'Disable' }} 70 | 71 | @endpatchelements 72 | ``` 73 | 74 | ## Usage 75 | 76 | Start by reading the [Getting Started](https://data-star.dev/guide/getting_started) guide to learn how to use Datastar on the frontend. The Datastar package for Laravel only handles backend requests. 77 | 78 | > [!TIP] 79 | > The Datastar [VSCode extension](https://marketplace.visualstudio.com/items?itemName=starfederation.datastar-vscode) and [IntelliJ plugin](https://plugins.jetbrains.com/plugin/26072-datastar-support) have autocomplete for all `data-*` attributes. 80 | 81 | When working with signals, note that you can convert a PHP array into a JSON object using the `json_encode` function. 82 | 83 | ```blade 84 | {{-- main.blade.php --}} 85 | 86 | @php 87 | $signals = ['foo' => 1, 'bar' => 2]; 88 | @endphp 89 | 90 |
91 | ``` 92 | 93 | ### Datastar Helper 94 | 95 | The `datastar()` helper function is available in Blade views and returns a `Datastar` helper that can be used to generate action requests to the Datastar controller. The Datastar controller can either render a view, run a controller action, or call a route, each of which respond by sending an event stream containing zero or more SSE events. 96 | 97 | [Signals](#signals) are also sent as part of the request, and are made available in Datastar views using the `$signals` variable. 98 | 99 | #### `datastar()->view()` 100 | 101 | Returns a `@get()` action request to render a view. The value should be a dot-separated path to a Blade view. 102 | 103 | ```blade 104 | // Sends a `GET` request that renders a Blade view 105 | {{ datastar()->view('path.to.view') }} 106 | ``` 107 | 108 | Variables can be passed in as a second argument, that will be available in the rendered view. 109 | 110 | > [!WARNING] 111 | > Variables are tamper-proof yet visible in the source code in plain text, so you should avoid passing in any sensitive data. 112 | > Only primitive data types can be used as variables: **strings**, **numbers**, **booleans** and **arrays**. Objects and models _cannot_ be used. 113 | 114 | ```blade 115 | // Sends a `GET` request that renders a Blade view 116 | {{ datastar()->view('path.to.view', ['foo' => 'bar']) }} 117 | ``` 118 | 119 | Options can be passed into the `@get()` action using a third argument. 120 | 121 | ```blade 122 | // Sends a `GET` request that renders a Blade view 123 | {{ datastar()->view('path.to.view', ['foo' => 'bar'], ['contentType' => 'form']) }} 124 | ``` 125 | 126 | #### `datastar()->action()` 127 | 128 | Returns a `@post()` action request to run a controller action. The value should be an array with a controller class name as the first value and an action name as the second. A CSRF token is automatically generated and sent along with the request. 129 | 130 | ```blade 131 | // Sends a `POST` request that runs a controller action 132 | {{ datastar()->action(['MyController', 'update']) }} 133 | ``` 134 | 135 | Params can be passed in as a second argument, that will be available as arguments to the controller action 136 | 137 | > [!WARNING] 138 | > Params are tamper-proof yet visible in the source code in plain text, so you should avoid passing in any sensitive data. 139 | > Only primitive data types can be used as params: **strings**, **numbers**, **booleans** and **arrays**. Objects and models _cannot_ be used. Route-model binding works with controller actions. 140 | 141 | ```blade 142 | // Sends a `POST` request that runs a controller action 143 | {{ datastar()->action(['MyController', 'update'], ['foo' => 'bar']) }} 144 | ``` 145 | 146 | Options can be passed into the `@get()` action using a third argument. 147 | 148 | ```blade 149 | // Sends a `POST` request that runs a controller action 150 | {{ datastar()->action(['MyController', 'update'], ['foo' => 'bar'], ['contentType' => 'form']) }} 151 | ``` 152 | 153 | #### `datastar()->get()` 154 | 155 | Returns a `@get()` action request that calls a route. The value must be a defined route. 156 | 157 | ```blade 158 | // Sends a `GET` request to a route 159 | {{ datastar()->get('/uri') }} 160 | ``` 161 | 162 | Options can be passed into the `@get()` action using a second argument. 163 | 164 | ```blade 165 | // Sends a `GET` request to a route 166 | {{ datastar()->get('/uri', ['contentType' => 'form']) }} 167 | ``` 168 | 169 | #### `datastar()->post()` 170 | 171 | Works the same as [`datastar()->get()`](#datastar-get) but returns a `@post()` action request that calls a route. A CSRF token is automatically generated and sent along with the request. 172 | 173 | ```blade 174 | // Sends a `POST` request to a route 175 | {{ datastar()->post('/uri') }} 176 | ``` 177 | 178 | #### `datastar()->put()` 179 | 180 | Works the same as [`datastar()->post()`](#datastar-post) but returns a `@put()` action request that calls a route. 181 | 182 | ```blade 183 | // Sends a `PUT` request to a route 184 | {{ datastar()->put('/uri') }} 185 | ``` 186 | 187 | #### `datastar()->patch()` 188 | 189 | Works the same as [`datastar()->post()`](#datastar-post) but returns a `@patch()` action request that calls a route. 190 | 191 | ```blade 192 | // Sends a `PATCH` request to a route 193 | {{ datastar()->patch('/uri') }} 194 | ``` 195 | 196 | #### `datastar()->delete()` 197 | 198 | Works the same as [`datastar()->post()`](#datastar-post) but returns a `@delete()` action request that calls a route. 199 | 200 | ```blade 201 | // Sends a `DELETE` request to a route 202 | {{ datastar()->delete('/uri') }} 203 | ``` 204 | 205 | ### Blade Directives 206 | 207 | Datastar Blade directives can patch and remove elements, patch signals, execute JavaScript and redirect the browser. 208 | 209 | #### Patch Elements 210 | 211 | The `@patchelements` directive allows you to [patch elements](https://data-star.dev/guide/getting_started#patching-elements) into the DOM. 212 | 213 | ```blade 214 | {{-- main.blade.php --}} 215 | 216 |
217 | 218 | 223 | ``` 224 | 225 | ```blade 226 | {{-- datastar/search.blade.php --}} 227 | 228 | @patchelements 229 |
230 | ... 231 |
232 | @endpatchelements 233 | 234 | @patchelements 235 | 238 | @endpatchelements 239 | ``` 240 | 241 | This will swap the elements with the IDs `results` and `search` into the DOM. Note that elements with those IDs **must** already exist in the DOM, unless a mode is specified (see below). 242 | 243 | ##### Element Patch Options 244 | 245 | Elements are patched into the DOM based on element IDs, by default. It’s possible to pass other modes and other [element patch options](https://data-star.dev/reference/sse_events#datastar-patch-elements) in as an argument. 246 | 247 | ```blade 248 | @patchelements(['selector' => '#list', 'mode' => 'append']) 249 |
  • A new list item
  • 250 | @endpatchelements 251 | ``` 252 | 253 | ##### Automatic Element Patching 254 | 255 | Any elements output in a Datastar template (outside any `@patchelements` tags) will be automatically wrapped in a `@patchelements` directive. This makes it possible to write your views in a way that makes them more reusable. 256 | 257 | ```blade 258 | {{-- datastar/search.blade.php --}} 259 | 260 |
    261 | ``` 262 | 263 | The view above is the equivalent of writing: 264 | 265 | ```blade 266 | {{-- datastar/search.blade.php --}} 267 | 268 | @patchelements 269 |
    270 | @endpatchelements 271 | ``` 272 | 273 | While automatic element patching is convenient, it is less explicit and more restrictive (since [element patch options](#element-patch-options) cannot be used), so should only be used when appropriate. 274 | 275 | #### Remove Elements 276 | 277 | Elements can be removed from the DOM using the `@removeelements` directive, which accepts a CSS selector. 278 | 279 | ```blade 280 | @removeelements('#list') 281 | ``` 282 | 283 | #### Patch Signals 284 | 285 | The `@patchsignals` directive allows you to [patch signals](https://data-star.dev/guide/reactive_signals#patching-signals) into the frontend signals. 286 | 287 | > [!NOTE] 288 | > Signals patches **cannot** be wrapped in `@patchelements` directives, since each patch creates a server-sent event which will conflict with the element’s contents. 289 | 290 | ```blade 291 | {{- Sets the value of the `username` signal. -}} 292 | @patchsignals(['username' => 'johnny']) 293 | 294 | {{- Sets multiple signal values using an array of key-value pairs. -}} 295 | @patchsignals(['username' => 'bobby', 'success' => true]) 296 | 297 | {{- Removes the `username` signal by setting it to `null`. -}} 298 | @patchsignals(['username' => null]) 299 | ``` 300 | 301 | #### Signal Patch Options 302 | 303 | It’s possible to pass [signal patch options](https://data-star.dev/reference/sse_events#datastar-patch-signals) in as a second argument. 304 | 305 | ```blade 306 | @patchsignals(['username' => 'johnny'], ['onlyIfMissing' => true]) 307 | ``` 308 | 309 | ### Executing JavaScript 310 | 311 | The `@executescript` directive allows you to send JavaScript to the browser to be executed on the front-end. 312 | 313 | ```blade 314 | @executescript 315 | alert('Username is valid'); 316 | @endexecutescript 317 | ``` 318 | 319 | #### Execute Script Options 320 | 321 | It’s possible to pass execute script options in as an argument. They are applied to the `