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 | []((https://packagist.org/packages/putyourlightson/laravel-datastar))
2 | [](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 |
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 `