├── resources
├── views
│ ├── components
│ │ ├── page-requires-reload.blade.php
│ │ ├── exempts-page-from-cache.blade.php
│ │ ├── page-view-transition.blade.php
│ │ ├── exempts-page-from-preview.blade.php
│ │ ├── refreshes-with.blade.php
│ │ ├── refresh-scroll.blade.php
│ │ ├── refresh-method.blade.php
│ │ ├── stream-from.blade.php
│ │ ├── frame.blade.php
│ │ └── stream.blade.php
│ └── turbo-stream.blade.php
└── boost
│ └── guidelines
│ └── core.blade.php
├── stubs
└── resources
│ └── js
│ ├── libs
│ └── turbo.js
│ └── elements
│ └── turbo-echo-stream-tag.js
├── CONTRIBUTORS.md
├── src
├── Http
│ ├── TurboNativeRedirectResponse.php
│ ├── TurboResponseFactory.php
│ ├── TurboStreamResponseFailedException.php
│ ├── Controllers
│ │ ├── HotwireNativeNavigationController.php
│ │ └── Concerns
│ │ │ ├── InteractsWithTurboNativeNavigation.php
│ │ │ └── InteractsWithHotwireNativeNavigation.php
│ ├── MultiplePendingTurboStreamResponse.php
│ ├── HotwireNativeRedirectResponse.php
│ ├── Middleware
│ │ └── TurboMiddleware.php
│ └── PendingTurboStreamResponse.php
├── Views
│ ├── UnidentifiableRecordException.php
│ ├── RecordIdentifier.php
│ └── Components
│ │ └── RefreshesWith.php
├── Exceptions
│ ├── TurboStreamTargetException.php
│ └── PageRefreshStrategyException.php
├── Facades
│ ├── Limiter.php
│ ├── Turbo.php
│ └── TurboStream.php
├── Features.php
├── Broadcasting
│ ├── Limiter.php
│ ├── Rendering.php
│ ├── Factory.php
│ └── PendingBroadcast.php
├── Commands
│ ├── Tasks
│ │ └── EnsureCsrfTokenMetaTagExists.php
│ └── TurboInstallCommand.php
├── Testing
│ ├── ConvertTestResponseToTurboStreamCollection.php
│ ├── InteractsWithTurbo.php
│ ├── AssertableTurboStream.php
│ └── TurboStreamMatcher.php
├── Broadcasters
│ ├── Broadcaster.php
│ └── LaravelBroadcaster.php
├── NamesResolver.php
├── Jobs
│ └── BroadcastAction.php
├── Events
│ └── TurboStreamBroadcast.php
├── Models
│ ├── ModelObserver.php
│ ├── Naming
│ │ └── Name.php
│ └── Broadcasts.php
├── globals.php
├── helpers.php
├── Turbo.php
└── TurboServiceProvider.php
├── CHANGELOG.md
├── rector.php
├── routes
└── turbo.php
├── docs
├── known-issues.md
├── csrf.md
├── upgrade.md
├── installation.md
├── turbo-frames.md
├── validation-response-redirects.md
├── conventions.md
├── hotwire-native.md
├── overview.md
├── helpers.md
├── testing.md
└── turbo-streams.md
├── LICENSE.md
├── .php-cs-fixer.dist.php
├── README.md
├── composer.json
└── config
└── turbo-laravel.php
/resources/views/components/page-requires-reload.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/stubs/resources/js/libs/turbo.js:
--------------------------------------------------------------------------------
1 | import * as Turbo from '@hotwired/turbo';
2 |
3 | export default Turbo;
4 |
--------------------------------------------------------------------------------
/resources/views/components/exempts-page-from-cache.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/resources/views/components/page-view-transition.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/resources/views/components/exempts-page-from-preview.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/CONTRIBUTORS.md:
--------------------------------------------------------------------------------
1 | # Contributors
2 |
3 | Sent a PR? Add yourself to the list!
4 |
5 | * [Tony Messias](https://github.com/tonysm)
6 |
--------------------------------------------------------------------------------
/resources/views/components/refreshes-with.blade.php:
--------------------------------------------------------------------------------
1 | @props(['method' => 'replace', 'scroll' => 'reset'])
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/Http/TurboNativeRedirectResponse.php:
--------------------------------------------------------------------------------
1 |
2 | @if ($partial ?? false)
3 | @include($partial, $partialData)
4 | @elseif ($content ?? false)
5 | {{ $content }}
6 | @endif
7 |
8 |
--------------------------------------------------------------------------------
/resources/views/components/refresh-scroll.blade.php:
--------------------------------------------------------------------------------
1 | @props(['scroll' => 'reset'])
2 |
3 | @php
4 | throw_unless(in_array($scroll, ['reset', 'preserve']), HotwiredLaravel\TurboLaravel\Exceptions\PageRefreshStrategyException::invalidRefreshScroll($scroll));
5 | @endphp
6 |
7 |
8 |
--------------------------------------------------------------------------------
/resources/views/components/refresh-method.blade.php:
--------------------------------------------------------------------------------
1 | @props(['method' => 'replace'])
2 |
3 | @php
4 | throw_unless(in_array($method, ['replace', 'morph']), HotwiredLaravel\TurboLaravel\Exceptions\PageRefreshStrategyException::invalidRefreshMethod($method));
5 | @endphp
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/Http/TurboResponseFactory.php:
--------------------------------------------------------------------------------
1 | Turbo::TURBO_STREAM_FORMAT]);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/resources/views/components/stream-from.blade.php:
--------------------------------------------------------------------------------
1 | @props(['source', 'type' => 'private'])
2 |
3 | @php
4 | $channel = $source instanceof Illuminate\Contracts\Broadcasting\HasBroadcastChannel
5 | ? $source->broadcastChannel()
6 | : $source;
7 | @endphp
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/Views/UnidentifiableRecordException.php:
--------------------------------------------------------------------------------
1 | withPaths([
10 | __DIR__.'/src',
11 | __DIR__.'/tests',
12 | ])
13 | ->withPreparedSets(
14 | deadCode: true,
15 | codeQuality: true,
16 | typeDeclarations: true,
17 | privatization: true,
18 | earlyReturn: true,
19 | )
20 | ->withAttributesSets()
21 | ->withPhpSets()
22 | ->withPhpVersion(PhpVersion::PHP_82);
23 |
--------------------------------------------------------------------------------
/src/Http/Controllers/HotwireNativeNavigationController.php:
--------------------------------------------------------------------------------
1 | name('turbo_recede_historical_location');
7 | Route::get('resume_historical_location', [HotwireNativeNavigationController::class, 'resume'])->name('turbo_resume_historical_location');
8 | Route::get('refresh_historical_location', [HotwireNativeNavigationController::class, 'refresh'])->name('turbo_refresh_historical_location');
9 |
--------------------------------------------------------------------------------
/src/Facades/Limiter.php:
--------------------------------------------------------------------------------
1 | keys = [];
12 | }
13 |
14 | public function shouldLimit(string $key): bool
15 | {
16 | if (! isset($this->keys[$key]) || $this->keys[$key]->isPast()) {
17 | $this->keys[$key] = now()->addMilliseconds($this->delay);
18 |
19 | return false;
20 | }
21 |
22 | return true;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/resources/views/components/frame.blade.php:
--------------------------------------------------------------------------------
1 | @props(['id', 'loading' => null, 'src' => null, 'target' => null])
2 |
3 | @php
4 | $domId = (function ($id) {
5 | if (is_string($id)) {
6 | return $id;
7 | }
8 |
9 | if ($id instanceof Illuminate\Database\Eloquent\Model) {
10 | return dom_id($id);
11 | }
12 |
13 | return dom_id(...$id);
14 | })($id);
15 | @endphp
16 |
17 | {{ $slot }}
24 |
--------------------------------------------------------------------------------
/src/Commands/Tasks/EnsureCsrfTokenMetaTagExists.php:
--------------------------------------------------------------------------------
1 | )/',
15 | "\\1 \n\\1\\2",
16 | $contents,
17 | ));
18 | }
19 |
20 | return $next($file);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Testing/ConvertTestResponseToTurboStreamCollection.php:
--------------------------------------------------------------------------------
1 | loadHTML($response->content());
15 | $elements = $document->getElementsByTagName('turbo-stream');
16 |
17 | $streams = collect();
18 |
19 | /** @var \DOMElement $element */
20 | foreach ($elements as $element) {
21 | $streams->push($element);
22 | }
23 |
24 | return $streams;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Exceptions/PageRefreshStrategyException.php:
--------------------------------------------------------------------------------
1 | redirectToHotwireNativeAction($action, $fallbackUrl, $redirectType, $options);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/docs/known-issues.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Known Issues
4 | description: Known Issues
5 | order: 13
6 | ---
7 |
8 | # Known Issues
9 |
10 | If you ever encounter an issue with the package, look here first for documented solutions.
11 |
12 | ## Fixing Laravel's Previous URL Issue
13 |
14 | Visits from Turbo Frames will hit your application and Laravel by default keeps track of previously visited URLs to be used with helpers like `url()->previous()`, for instance. This might be confusing because chances are that you wouldn't want to redirect users to the URL of the most recent Turbo Frame that hit your app. So, to avoid storing Turbo Frames visits as Laravel's previous URL, head to the [issue](https://github.com/hotwired-laravel/turbo-laravel/issues/60#issuecomment-1123142591) where a solution was discussed.
15 |
--------------------------------------------------------------------------------
/src/Broadcasters/Broadcaster.php:
--------------------------------------------------------------------------------
1 | singular);
22 | }
23 |
24 | public static function partialNameFor(Model $model): string
25 | {
26 | $name = Name::forModel($model);
27 |
28 | $replacements = [
29 | '{plural}' => $name->plural,
30 | '{singular}' => $name->element,
31 | ];
32 |
33 | $pattern = value(static::$partialsPathResolver, $model);
34 |
35 | return str_replace(array_keys($replacements), array_values($replacements), value($pattern, $model));
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Tony Messias
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Views/RecordIdentifier.php:
--------------------------------------------------------------------------------
1 | record = $record;
25 | }
26 |
27 | public function domId(?string $prefix = null): string
28 | {
29 | if ($recordId = $this->record->getKey()) {
30 | return sprintf('%s%s%s', $this->domClass($prefix), self::DELIMITER, $recordId);
31 | }
32 |
33 | return $this->domClass($prefix ?: static::NEW_PREFIX);
34 | }
35 |
36 | public function domClass(?string $prefix = null): string
37 | {
38 | $singular = Name::forModel($this->record)->singular;
39 | $delimiter = static::DELIMITER;
40 |
41 | return trim("{$prefix}{$delimiter}{$singular}", $delimiter);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Testing/InteractsWithTurbo.php:
--------------------------------------------------------------------------------
1 | withHeader('Accept', Turbo::TURBO_STREAM_FORMAT);
15 | }
16 |
17 | /**
18 | * @deprecated use hotwireNative (but the User-Agent will change when yo do that!)
19 | */
20 | public function turboNative(): self
21 | {
22 | return $this->withHeader('User-Agent', 'Turbo Native Android; Mozilla: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.3 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/43.4');
23 | }
24 |
25 | public function hotwireNative(): self
26 | {
27 | return $this->withHeader('User-Agent', 'Hotwire Native Android; Mozilla: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.3 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/43.4');
28 | }
29 |
30 | public function fromTurboFrame(string $frame): self
31 | {
32 | return $this->withHeader('Turbo-Frame', $frame);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/docs/csrf.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: CSRF Protection
4 | description: CSRF Protection
5 | order: 10
6 | ---
7 |
8 | # CSRF Protection
9 |
10 | Laravel has built-in CSRF protection in place. It prevents our app from processing any non-GET requests that doesn't include a valid CSRF Token that was generated in our backend.
11 |
12 | So, to allow a POST form to be processed, we usually need to add a `@csrf` Blade directive to our forms:
13 |
14 | ```blade
15 |
19 | ```
20 |
21 | Since Turbo.js intercepts form submissions and converts those to fetch requests (AJAX), we don't actually _need_ the `@csrf` token applied to each form. Turbo is smart enough to read our page's meta tags, look for one named `csrf-token` and use its contents to add the token to all form submissions it intercepts. Jetstream and Breeze both ship with such element in the layout files, but in case you're missing it in your views, it should look like this:
22 |
23 | ```blade
24 |
25 | ```
26 |
27 | With that being said, you may still want to use the `@csrf` Blade directive if you want to support users with JavaScript disabled, since the forms will still work if they contain the CSRF token.
28 |
--------------------------------------------------------------------------------
/src/Broadcasting/Rendering.php:
--------------------------------------------------------------------------------
1 | name(), data: $content->getData());
18 | }
19 |
20 | if ($content instanceof HtmlString) {
21 | return new static(inlineContent: $content->toHtml(), escapeInlineContent: false);
22 | }
23 |
24 | return new static(inlineContent: $content, escapeInlineContent: true);
25 | }
26 |
27 | public static function empty(): self
28 | {
29 | return new self;
30 | }
31 |
32 | public static function forModel(Model $model): self
33 | {
34 | return new self(
35 | NamesResolver::partialNameFor($model),
36 | [
37 | NamesResolver::resourceVariableName($model) => $model,
38 | ],
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/upgrade.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Upgrade Guide
4 | description: Upgrade from 1.x to 2.x
5 | order: 1
6 | ---
7 |
8 | # Upgrade Guide
9 |
10 | ## Upgrading from 1.x to 2.x
11 |
12 | For version 2.x, we're migrating from `hotwired/turbo-laravel` to `hotwired-laravel/turbo-laravel`. That's just so folks don't get confused thinking this is an official Hotwired project, which it's not. Even if you're on `1.x`, it's recommended to migrate to `hotwired-laravel/turbo-laravel`.
13 |
14 | First, update the namespaces from the previous package. You can either do it from your IDE by searching for `Tonysm\TurboLaravel` and replacing it with `HotwiredLaravel\TurboLaravel` on your application (make sure you include all folders), or you can run the following command if you're on a macOS or Linux machine:
15 |
16 | ```bash
17 | find app config resources tests -type f -exec sed -i 's/Tonysm\\TurboLaravel/HotwiredLaravel\\TurboLaravel/g' {} +
18 | ```
19 |
20 | Next, update your views referencing the old components as `asEvent());
20 | }
21 |
22 | public function asEvent(): \HotwiredLaravel\TurboLaravel\Events\TurboStreamBroadcast
23 | {
24 | $event = new TurboStreamBroadcast(
25 | $this->channels,
26 | $this->action,
27 | $this->target,
28 | $this->targets,
29 | $this->partial,
30 | $this->partialData,
31 | $this->inlineContent,
32 | $this->escapeInlineContent,
33 | $this->attributes,
34 | );
35 |
36 | $event->socket = $this->socket;
37 |
38 | return $event;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Views/Components/RefreshesWith.php:
--------------------------------------------------------------------------------
1 | method = $method;
35 | $this->scroll = $scroll;
36 | }
37 |
38 | /**
39 | * Get the view / contents that represent the component.
40 | *
41 | * @return \Illuminate\Contracts\View\View|\Closure|string
42 | */
43 | public function render()
44 | {
45 | return view('turbo-laravel::components.refreshes-with');
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | notPath('bootstrap/*')
5 | ->notPath('storage/*')
6 | ->notPath('resources/view/mail/*')
7 | ->in([
8 | __DIR__ . '/src',
9 | __DIR__ . '/tests',
10 | ])
11 | ->name('*.php')
12 | ->notName('*.blade.php')
13 | ->ignoreDotFiles(true)
14 | ->ignoreVCS(true);
15 |
16 | $config = new PhpCsFixer\Config();
17 |
18 | $config->setRules([
19 | '@PSR2' => true,
20 | 'array_syntax' => ['syntax' => 'short'],
21 | 'ordered_imports' => ['sort_algorithm' => 'alpha'],
22 | 'no_unused_imports' => true,
23 | 'not_operator_with_successor_space' => true,
24 | 'trailing_comma_in_multiline' => true,
25 | 'phpdoc_scalar' => true,
26 | 'unary_operator_spaces' => true,
27 | 'binary_operator_spaces' => true,
28 | 'blank_line_before_statement' => [
29 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
30 | ],
31 | 'phpdoc_single_line_var_spacing' => true,
32 | 'phpdoc_var_without_name' => true,
33 | 'class_attributes_separation' => [
34 | 'elements' => [
35 | 'method' => 'one',
36 | ],
37 | ],
38 | 'method_argument_space' => [
39 | 'on_multiline' => 'ensure_fully_multiline',
40 | 'keep_multiple_spaces_after_comma' => true,
41 | ],
42 | 'single_trait_insert_per_statement' => true,
43 | ])->setFinder($finder);
44 |
45 | return $config;
46 |
--------------------------------------------------------------------------------
/src/Facades/Turbo.php:
--------------------------------------------------------------------------------
1 | {
4 | if (type === 'presence') {
5 | return window.Echo.join(channel)
6 | }
7 |
8 | if (type === 'private') {
9 | return window.Echo.private(channel)
10 | }
11 |
12 | return window.Echo.channel(channel)
13 | }
14 |
15 | class TurboEchoStreamSourceElement extends HTMLElement {
16 | async connectedCallback() {
17 | connectStreamSource(this)
18 | this.subscription = subscribeTo(this.type, this.channel)
19 | .listen('.HotwiredLaravel\\TurboLaravel\\Events\\TurboStreamBroadcast', (e) => {
20 | this.dispatchMessageEvent(e.message)
21 | })
22 | }
23 |
24 | disconnectedCallback() {
25 | disconnectStreamSource(this)
26 | if (this.subscription) {
27 | window.Echo.leave(this.channel)
28 | this.subscription = null
29 | }
30 | }
31 |
32 | dispatchMessageEvent(data) {
33 | const event = new MessageEvent('message', { data })
34 | return this.dispatchEvent(event)
35 | }
36 |
37 | get channel() {
38 | return this.getAttribute('channel')
39 | }
40 |
41 | get type() {
42 | return this.getAttribute('type') || 'private'
43 | }
44 | }
45 |
46 | if (customElements.get('turbo-echo-stream-source') === undefined) {
47 | customElements.define('turbo-echo-stream-source', TurboEchoStreamSourceElement)
48 | }
49 |
--------------------------------------------------------------------------------
/src/Broadcasters/LaravelBroadcaster.php:
--------------------------------------------------------------------------------
1 | null, 'targets' => null, 'mergeAttrs' => []])
2 |
3 | @php
4 | $defaultActions = [
5 | 'append', 'prepend',
6 | 'update', 'replace',
7 | 'before', 'after',
8 | 'remove',
9 | ];
10 |
11 | if (! $target && ! $targets && in_array($action, $defaultActions)) {
12 | throw HotwiredLaravel\TurboLaravel\Exceptions\TurboStreamTargetException::targetMissing();
13 | }
14 |
15 | if ($target && $targets) {
16 | throw HotwiredLaravel\TurboLaravel\Exceptions\TurboStreamTargetException::multipleTargets();
17 | }
18 |
19 | $targetTag = (function ($target, $targets) {
20 | if (! $target && ! $targets) {
21 | return null;
22 | }
23 |
24 | return $target ? 'target' : 'targets';
25 | })($target, $targets);
26 |
27 | $targetValue = (function ($target, $targets) {
28 | if (! $target && ! $targets) {
29 | return null;
30 | }
31 |
32 | if ($targets) {
33 | return $targets;
34 | }
35 |
36 | if (is_string($target)) {
37 | return $target;
38 | }
39 |
40 | if ($target instanceof Illuminate\Database\Eloquent\Model) {
41 | return dom_id($target);
42 | }
43 |
44 | return dom_id(...$target);
45 | })($target, $targets);
46 | @endphp
47 |
48 | merge(array_merge($targetTag ?? false ? [$targetTag => $targetValue] : [], ["action" => $action], $mergeAttrs)) }}>
49 | @if (($slot?->isNotEmpty() ?? false) && $action !== "remove")
50 | {{ $slot }}
51 | @endif
52 |
53 |
--------------------------------------------------------------------------------
/src/Testing/AssertableTurboStream.php:
--------------------------------------------------------------------------------
1 | turboStreams = $turboStreams;
17 | }
18 |
19 | public function has(int $expectedTurboStreamsCount): self
20 | {
21 | Assert::assertCount($expectedTurboStreamsCount, $this->turboStreams);
22 |
23 | return $this;
24 | }
25 |
26 | public function hasTurboStream(?Closure $callback = null): self
27 | {
28 | $callback ??= fn ($matcher) => $matcher;
29 | $attrs = collect();
30 |
31 | $matches = $this->turboStreams
32 | ->mapInto(TurboStreamMatcher::class)
33 | ->filter(function (TurboStreamMatcher $matcher) use ($callback, $attrs): bool {
34 | $matcher = $callback($matcher);
35 |
36 | if (! $matcher->matches()) {
37 | $attrs->add($matcher->attrs());
38 |
39 | return false;
40 | }
41 |
42 | return true;
43 | });
44 |
45 | Assert::assertTrue(
46 | $matches->count() === 1,
47 | sprintf(
48 | 'Expected to find a matching Turbo Stream for `%s`, but %s',
49 | $attrs->unique()->join(' '),
50 | trans_choice('{0} none was found.|[2,*] :count were found.', $matches->count()),
51 | )
52 | );
53 |
54 | return $this;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Events/TurboStreamBroadcast.php:
--------------------------------------------------------------------------------
1 | channels;
31 | }
32 |
33 | public function broadcastWith(): array
34 | {
35 | return [
36 | 'message' => $this->render(),
37 | ];
38 | }
39 |
40 | public function render(): string
41 | {
42 | return View::make('turbo-laravel::turbo-stream', [
43 | 'action' => $this->action,
44 | 'target' => $this->target,
45 | 'targets' => $this->targets,
46 | 'partial' => $this->partial ?: null,
47 | 'partialData' => $this->partialData ?: [],
48 | 'content' => $this->escapeInlineContent ? $this->inlineContent : new HtmlString($this->inlineContent),
49 | 'attrs' => $this->attrs ?: [],
50 | ])->render();
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Http/MultiplePendingTurboStreamResponse.php:
--------------------------------------------------------------------------------
1 | pendingStreams = collect($pendingStreams);
22 | }
23 |
24 | /**
25 | * @param array|Collection $pendingStreams
26 | */
27 | public static function forStreams($pendingStreams): self
28 | {
29 | return new self($pendingStreams);
30 | }
31 |
32 | /**
33 | * Create an HTTP response that represents the object.
34 | *
35 | * @param \Illuminate\Http\Request $request
36 | * @return \Symfony\Component\HttpFoundation\Response
37 | */
38 | public function toResponse($request)
39 | {
40 | return TurboResponseFactory::makeStream($this->render());
41 | }
42 |
43 | public function render(): string
44 | {
45 | return $this->pendingStreams
46 | ->map(fn (PendingTurboStreamResponse $pendingStream): string => $pendingStream->render())
47 | ->implode(PHP_EOL);
48 | }
49 |
50 | public function toHtml()
51 | {
52 | return new HtmlString($this->render());
53 | }
54 |
55 | public function __toString(): string
56 | {
57 | return $this->render();
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | ## Introduction
10 |
11 | This package gives you a set of conventions to make the most out of [Hotwire](https://hotwired.dev/) in Laravel.
12 |
13 | #### Inspiration
14 |
15 | This package was inspired by the [Turbo Rails gem](https://github.com/hotwired/turbo-rails).
16 |
17 | #### Bootcamp
18 |
19 | If you want a more hands-on introduction, head out to [Bootcamp](https://turbo-laravel.com/guides). It covers building a multi-platform app in Turbo.
20 |
21 | ## Official Documentation
22 |
23 | Documentation for Turbo Laravel can be found on the [Turbo Laravel website](https://turbo-laravel.com).
24 |
25 | ### Changelog
26 |
27 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
28 |
29 | ### Contributing
30 |
31 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details.
32 |
33 | ### Security Vulnerabilities
34 |
35 | Drop me an email at [tonysm@hey.com](mailto:tonysm@hey.com?subject=Security%20Vulnerability) if you want to report
36 | security vulnerabilities.
37 |
38 | ### License
39 |
40 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
41 |
42 | ### Credits
43 |
44 | - [Tony Messias](https://github.com/tonysm)
45 | - [All Contributors](./CONTRIBUTORS.md)
46 |
--------------------------------------------------------------------------------
/src/Models/ModelObserver.php:
--------------------------------------------------------------------------------
1 | afterCommit = config('turbo-laravel.queue');
19 | }
20 |
21 | /**
22 | * @param Model|Broadcasts $model
23 | */
24 | public function saved(Model $model): void
25 | {
26 | if ($this->shouldBroadcastRefresh($model)) {
27 | $model->broadcastRefresh()->later();
28 | }
29 |
30 | if ($this->shouldBroadcast($model)) {
31 | if ($model->wasRecentlyCreated) {
32 | $model->broadcastInsert()->later();
33 | } else {
34 | $model->broadcastReplace()->later();
35 | }
36 | }
37 | }
38 |
39 | /**
40 | * @param Model|Broadcasts $model
41 | */
42 | public function deleted(Model $model): void
43 | {
44 | if ($this->shouldBroadcastRefresh($model)) {
45 | $model->broadcastRefresh()->later();
46 | }
47 |
48 | if ($this->shouldBroadcast($model)) {
49 | $model->broadcastRemove()->later();
50 | }
51 | }
52 |
53 | private function shouldBroadcastRefresh(Model $model): bool
54 | {
55 | if (property_exists($model, 'broadcastsRefreshes')) {
56 | return true;
57 | }
58 |
59 | return property_exists($model, 'broadcastsRefreshesTo');
60 | }
61 |
62 | private function shouldBroadcast(Model $model): bool
63 | {
64 | if (property_exists($model, 'broadcasts')) {
65 | return true;
66 | }
67 |
68 | if (property_exists($model, 'broadcastsTo')) {
69 | return true;
70 | }
71 |
72 | return method_exists($model, 'broadcastsTo');
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/globals.php:
--------------------------------------------------------------------------------
1 |
14 |
Loading...
15 |
16 | ```
17 |
18 | In this case, the `@domid()` directive is being used to create a DOM ID that looks like this `create_comment_post_123`. There's also a Blade Component that ships with Turbo Laravel and can be used like this:
19 |
20 | ```blade
21 |
22 |
Loading...
23 |
24 | ```
25 |
26 | When using the Blade Component, you don't have to worry about using the `@domid()` directive or the `dom_id()` function, as this gets handled automatically by the package. You may also pass a string if you want to enforce your own DOM ID.
27 |
28 | Any other attribute passed to the Blade Component will get forwarded to the underlying `` element, so if you want to turn a Turbo Frame into a lazy-loading Turbo Frame using the Blade Component, you can do it like so:
29 |
30 | ```blade
31 |
36 |
Loading...
37 |
38 | ```
39 |
40 | This will work for any other attribute you want to forward to the underlying component.
41 |
42 | ## Detecting Turbo Frames Requests
43 |
44 | You may want to detect if a request came from a Turbo Frame in the backend. You may use the `wasFromTurboFrame()` method for that:
45 |
46 | ```php
47 | if ($request->wasFromTurboFrame()) {
48 | // ...
49 | }
50 | ```
51 |
52 | When used like this, the macro will return `true` if the `X-Turbo-Frame` custom HTTP header is present in the request (which Turbo adds automatically), or `false` otherwise.
53 |
54 | You may also check if the request came from a specific Turbo Frame:
55 |
56 | ```php
57 | if ($request->wasFromTurboFrame(dom_id($post, 'create_comment'))) {
58 | // ...
59 | }
60 | ```
61 |
--------------------------------------------------------------------------------
/src/Http/HotwireNativeRedirectResponse.php:
--------------------------------------------------------------------------------
1 | withQueryString((new static($fallbackUrl))->getQueryString());
19 | }
20 |
21 | /**
22 | * Sets the flashed data via query strings when redirecting to Hotwire Native routes.
23 | *
24 | * @param string $key
25 | * @param mixed $value
26 | * @return static
27 | */
28 | public function with($key, $value = null)
29 | {
30 | $params = $this->getQueryString();
31 |
32 | return $this->withoutQueryStrings()
33 | ->setTargetUrl($this->getTargetUrl().'?'.http_build_query($params + [$key => urlencode((string) $value)]));
34 | }
35 |
36 | /**
37 | * Sets multiple query strings at the same time.
38 | */
39 | protected function withQueryString(array $params): static
40 | {
41 | foreach ($params as $key => $val) {
42 | $this->with($key, $val);
43 | }
44 |
45 | return $this;
46 | }
47 |
48 | /**
49 | * Returns the query string as an array.
50 | */
51 | protected function getQueryString(): array
52 | {
53 | parse_str(str_contains($this->getTargetUrl(), '?') ? Str::after($this->getTargetUrl(), '?') : '', $query);
54 |
55 | return $query;
56 | }
57 |
58 | /**
59 | * Returns the target URL without the query strings.
60 | */
61 | protected function withoutQueryStrings(): self
62 | {
63 | $fragment = str_contains($this->getTargetUrl(), '#') ? Str::after($this->getTargetUrl(), '#') : '';
64 |
65 | return $this->withoutFragment()
66 | ->setTargetUrl(Str::before($this->getTargetUrl(), '?').($fragment ? "#{$fragment}" : ''));
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/Http/Controllers/Concerns/InteractsWithHotwireNativeNavigation.php:
--------------------------------------------------------------------------------
1 | redirectToTurboNativeAction('recede', $url);
12 | }
13 |
14 | protected function resumeOrRedirectTo(string $url)
15 | {
16 | return $this->redirectToTurboNativeAction('resume', $url);
17 | }
18 |
19 | protected function refreshOrRedirectTo(string $url)
20 | {
21 | return $this->redirectToTurboNativeAction('refresh', $url);
22 | }
23 |
24 | protected function recedeOrRedirectBack(?string $fallbackUrl, array $options = [])
25 | {
26 | return $this->redirectToTurboNativeAction('recede', $fallbackUrl, 'back', $options);
27 | }
28 |
29 | protected function resumeOrRedirectBack(?string $fallbackUrl, array $options = [])
30 | {
31 | return $this->redirectToTurboNativeAction('resume', $fallbackUrl, 'back', $options);
32 | }
33 |
34 | protected function refreshOrRedirectBack(?string $fallbackUrl, array $options = [])
35 | {
36 | return $this->redirectToTurboNativeAction('refresh', $fallbackUrl, 'back', $options);
37 | }
38 |
39 | protected function redirectToTurboNativeAction(string $action, string $fallbackUrl, string $redirectType = 'to', array $options = [])
40 | {
41 | if (request()->wasFromTurboNative()) {
42 | return TurboNativeRedirectResponse::createFromFallbackUrl($action, $fallbackUrl);
43 | }
44 |
45 | if ($redirectType === 'back') {
46 | return redirect()->back($options['status'] ?? 302, $options['headers'] ?? [], $fallbackUrl);
47 | }
48 |
49 | return redirect($fallbackUrl);
50 | }
51 |
52 | protected function redirectToHotwireNativeAction(string $action, string $fallbackUrl, string $redirectType = 'to', array $options = [])
53 | {
54 | if (request()->wasFromTurboNative()) {
55 | return TurboNativeRedirectResponse::createFromFallbackUrl($action, $fallbackUrl);
56 | }
57 |
58 | if ($redirectType === 'back') {
59 | return redirect()->back($options['status'] ?? 302, $options['headers'] ?? [], $fallbackUrl);
60 | }
61 |
62 | return redirect($fallbackUrl);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/helpers.php:
--------------------------------------------------------------------------------
1 | domId($prefix);
22 | }
23 | }
24 |
25 | if (! function_exists('dom_class')) {
26 | /**
27 | * Generates the DOM CSS Class for a specific model.
28 | */
29 | function dom_class(object $model, string $prefix = ''): string
30 | {
31 | return (new RecordIdentifier($model))->domClass($prefix);
32 | }
33 | }
34 |
35 | if (! function_exists('turbo_stream')) {
36 | /**
37 | * Builds the Turbo Streams.
38 | *
39 | * @param Model|Collection|array|string|null $model = null
40 | * @param string|null $action = null
41 | */
42 | function turbo_stream($model = null, ?string $action = null): MultiplePendingTurboStreamResponse|PendingTurboStreamResponse
43 | {
44 | if (is_array($model) || $model instanceof Collection) {
45 | return MultiplePendingTurboStreamResponse::forStreams($model);
46 | }
47 |
48 | if ($model === null) {
49 | return new PendingTurboStreamResponse;
50 | }
51 |
52 | return PendingTurboStreamResponse::forModel($model, $action);
53 | }
54 | }
55 |
56 | if (! function_exists('turbo_stream_view')) {
57 | /**
58 | * Renders a Turbo Stream view wrapped with the correct Content-Types in the response.
59 | *
60 | * @param string|\Illuminate\View\View $view
61 | * @param array $data = [] the binding params to be passed to the view.
62 | */
63 | function turbo_stream_view($view, array $data = []): Response|ResponseFactory
64 | {
65 | if (! $view instanceof View) {
66 | $view = view($view, $data);
67 | }
68 |
69 | return TurboResponseFactory::makeStream($view->render());
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Turbo.php:
--------------------------------------------------------------------------------
1 | isHotwireNativeVisit();
34 | }
35 |
36 | /**
37 | * @deprecated use setVisitingFromHotwireNative
38 | */
39 | public function setVisitingFromTurboNative(): self
40 | {
41 | return $this->setVisitingFromHotwireNative();
42 | }
43 |
44 | public function isHotwireNativeVisit(): bool
45 | {
46 | return $this->visitFromHotwireNative;
47 | }
48 |
49 | public function setVisitingFromHotwireNative(): self
50 | {
51 | $this->visitFromHotwireNative = true;
52 |
53 | return $this;
54 | }
55 |
56 | public function setTurboTrackingRequestId(string $requestId): self
57 | {
58 | $this->turboRequestId = $requestId;
59 |
60 | return $this;
61 | }
62 |
63 | public function currentRequestId(): ?string
64 | {
65 | return $this->turboRequestId;
66 | }
67 |
68 | /**
69 | * @param bool|Closure $toOthers
70 | * @return \Illuminate\Support\HigherOrderTapProxy|mixed
71 | */
72 | public function broadcastToOthers($toOthers = true)
73 | {
74 | if (is_bool($toOthers)) {
75 | $this->broadcastToOthersOnly = $toOthers;
76 |
77 | return null;
78 | }
79 |
80 | $this->broadcastToOthersOnly = true;
81 |
82 | if ($toOthers instanceof Closure) {
83 | return tap($toOthers(), function (): void {
84 | $this->broadcastToOthersOnly = false;
85 | });
86 | }
87 |
88 | return null;
89 | }
90 |
91 | public function shouldBroadcastToOthers(): bool
92 | {
93 | return $this->broadcastToOthersOnly;
94 | }
95 |
96 | public function broadcaster(): Broadcaster
97 | {
98 | return resolve(Broadcaster::class);
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hotwired-laravel/turbo-laravel",
3 | "description": "Turbo Laravel gives you a set of conventions to make the most out of the Hotwire stack (inspired by turbo-rails gem).",
4 | "keywords": [
5 | "hotwired",
6 | "hotwire",
7 | "turbo",
8 | "turbo-laravel"
9 | ],
10 | "homepage": "https://github.com/hotwired-laravel/turbo-laravel",
11 | "license": "MIT",
12 | "authors": [
13 | {
14 | "name": "Tony Messias",
15 | "email": "tonysm@hey.com",
16 | "homepage": "https://tonysm.com",
17 | "role": "Developer"
18 | }
19 | ],
20 | "require": {
21 | "php": "^8.2",
22 | "illuminate/support": "^11.0|^12.0"
23 | },
24 | "require-dev": {
25 | "laravel/pint": "^1.10",
26 | "orchestra/testbench": "^9.0|^10.0",
27 | "orchestra/workbench": "^9.0|^10.0",
28 | "phpunit/phpunit": "^10.5|^11.5"
29 | },
30 | "autoload": {
31 | "psr-4": {
32 | "HotwiredLaravel\\TurboLaravel\\": "src"
33 | },
34 | "files": [
35 | "src/helpers.php",
36 | "src/globals.php"
37 | ]
38 | },
39 | "autoload-dev": {
40 | "psr-4": {
41 | "HotwiredLaravel\\TurboLaravel\\Tests\\": "tests",
42 | "Workbench\\App\\": "workbench/app/",
43 | "Workbench\\Database\\Factories\\": "workbench/database/factories/",
44 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
45 | }
46 | },
47 | "scripts": {
48 | "psalm": "vendor/bin/psalm",
49 | "test": "vendor/bin/phpunit --colors=always",
50 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage",
51 | "post-autoload-dump": [
52 | "@clear",
53 | "@prepare"
54 | ],
55 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
56 | "prepare": "@php vendor/bin/testbench package:discover --ansi",
57 | "build": "@php vendor/bin/testbench workbench:build --ansi",
58 | "serve": [
59 | "@build",
60 | "Composer\\Config::disableProcessTimeout",
61 | "@php vendor/bin/testbench serve"
62 | ],
63 | "lint": [
64 | "@php vendor/bin/pint"
65 | ]
66 | },
67 | "config": {
68 | "sort-packages": true
69 | },
70 | "extra": {
71 | "laravel": {
72 | "providers": [
73 | "\\HotwiredLaravel\\TurboLaravel\\TurboServiceProvider"
74 | ],
75 | "aliases": {
76 | "Turbo": "\\HotwiredLaravel\\TurboLaravel\\Facades\\Turbo",
77 | "TurboStream": "\\HotwiredLaravel\\TurboLaravel\\Facades\\TurboStream"
78 | }
79 | }
80 | },
81 | "minimum-stability": "dev",
82 | "prefer-stable": true
83 | }
84 |
--------------------------------------------------------------------------------
/src/Models/Naming/Name.php:
--------------------------------------------------------------------------------
1 | className = $className;
57 | $name->classNameWithoutRootNamespace = static::removeRootNamespaces($className);
58 | $name->singular = (string) Str::of($name->classNameWithoutRootNamespace)->replace('\\', '')->snake();
59 | $name->plural = Str::plural($name->singular);
60 | $name->element = (string) Str::of(class_basename($className))->snake();
61 |
62 | return $name;
63 | }
64 |
65 | private static function removeRootNamespaces(string $className): string
66 | {
67 | // We will attempt to strip out only the root namespace from the model's FQCN. For that, we will use
68 | // the configured namespaces, stripping out the first one that matches on a Str::startsWith check.
69 | // Namespaces are configurable. We'll default back to class_basename when no namespace matches.
70 |
71 | foreach (config('turbo-laravel.models_namespace') as $rootNs) {
72 | if (Str::startsWith($className, $rootNs)) {
73 | return Str::replaceFirst($rootNs, '', $className);
74 | }
75 | }
76 |
77 | return class_basename($className);
78 | }
79 |
80 | private function __construct()
81 | {
82 | // This is only instantiated using the build factory method.
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/config/turbo-laravel.php:
--------------------------------------------------------------------------------
1 | env('APP_ENV', 'production') !== 'testing',
22 |
23 | /*
24 | |--------------------------------------------------------------------------
25 | | Root Model Namespaces
26 | |--------------------------------------------------------------------------
27 | |
28 | | When generating DOM IDs for models, we need to strip out the root namespaces from the model's FQCN. Please,
29 | | if you use non-conventional folder structures, make sure you add your custom namespaces to this list. The
30 | | first one that matches a "starts with" check will be used and removed from the model's FQCN for DOM IDs.
31 | |
32 | */
33 |
34 | 'models_namespace' => [
35 | 'App\\Models\\',
36 | 'App\\',
37 | ],
38 |
39 | /*
40 | |--------------------------------------------------------------------------
41 | | Automatically Register Turbo Middleware
42 | |--------------------------------------------------------------------------
43 | |
44 | | When set to `true` the TurboMiddleware will be automatically
45 | | *prepended* to the web routes middleware stack. If you want
46 | | to disable this behavior, set this to false.
47 | |
48 | */
49 |
50 | 'automatically_register_middleware' => true,
51 |
52 | /*
53 | |--------------------------------------------------------------------------
54 | | Turbo Laravel Features
55 | |--------------------------------------------------------------------------
56 | |
57 | | Bellow you can enable/disable some of the features provided by the package.
58 | |
59 | */
60 | 'features' => [
61 | Features::hotwireNativeRoutes(),
62 | ],
63 |
64 | /*
65 | |--------------------------------------------------------------------------
66 | | Guessed Route Exceptions
67 | |--------------------------------------------------------------------------
68 | |
69 | | The URIs that should be excluded from the guessing redirect route behavior.
70 | |
71 | */
72 | 'redirect_guessing_exceptions' => [
73 | // '/some-page'
74 | ],
75 | ];
76 |
--------------------------------------------------------------------------------
/src/Testing/TurboStreamMatcher.php:
--------------------------------------------------------------------------------
1 | matches()` call to check if this
23 | // Turbo Stream has all the attributes at once.
24 |
25 | $matcher->wheres[$prop] = $value;
26 |
27 | return $matcher;
28 | }
29 |
30 | public function see(string $content): self
31 | {
32 | $matcher = clone $this;
33 |
34 | // Similarly to how we do with the attributes, the contents
35 | // of the Turbo Stream tag the user wants to assert will
36 | // be store for latter, after the `->matches()` call.
37 |
38 | $matcher->contents[] = $content;
39 |
40 | return $matcher;
41 | }
42 |
43 | public function matches(?Closure $callback = null): bool
44 | {
45 | // We first pass the current instance to the callback given in the
46 | // `->assertTurboStream(fn)` call. This is where the `->where()`
47 | // and `->see()` methods will be called by the developers.
48 |
49 | if ($callback instanceof \Closure) {
50 | return $callback($this)->matches();
51 | }
52 |
53 | // After registering the desired attributes and contents the developers want
54 | // to assert against the Turbo Stream, we can check if this Turbo Stream
55 | // has the props first and then if the Turbo Stream's contents match.
56 |
57 | if (! $this->matchesProps()) {
58 | return false;
59 | }
60 |
61 | return $this->matchesContents();
62 | }
63 |
64 | public function attrs(): string
65 | {
66 | return $this->makeAttributes($this->wheres);
67 | }
68 |
69 | private function matchesProps(): bool
70 | {
71 | foreach ($this->wheres as $prop => $value) {
72 | $propValue = $this->turboStream->getAttribute($prop);
73 |
74 | if (! $propValue || $propValue !== $value) {
75 | return false;
76 | }
77 | }
78 |
79 | return true;
80 | }
81 |
82 | private function matchesContents(): bool
83 | {
84 | if ($this->contents === []) {
85 | return true;
86 | }
87 |
88 | foreach ($this->contents as $content) {
89 | Assert::assertStringContainsString($content, $this->renderElement());
90 | }
91 |
92 | return true;
93 | }
94 |
95 | private function renderElement(): string
96 | {
97 | $html = '';
98 | $children = $this->turboStream->childNodes;
99 |
100 | foreach ($children as $child) {
101 | $html .= $child->ownerDocument->saveXML($child);
102 | }
103 |
104 | return $html;
105 | }
106 |
107 | private function makeAttributes(array $attributes): string
108 | {
109 | return (new ComponentAttributeBag($attributes))->toHtml();
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/Facades/TurboStream.php:
--------------------------------------------------------------------------------
1 | fake($callback));
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/docs/validation-response-redirects.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Validation Response
4 | description: Validation Responses in Laravel and Hotwire
5 | order: 9
6 | ---
7 |
8 | # Validation Response
9 |
10 | By default, Laravel redirects failed validation exceptions "back" to the page where the request came from. This isn't usually a problem, in fact it's the expected behavior, since that page usually is the one where the form which triggered the request renders.
11 |
12 | However, this is a bit of a problem when it comes to Turbo Frames, since a form might get injected into a page that doesn't initially render it. The problem is that after a failed validation exception from that form, Laravel would redirect it "back" to the page where the form got injected and since the form is not rendered there initially, the user would see the form disappear.
13 |
14 | In other words, we can't redirect "back" to display the form again with the error messages, because the form might not be re-rendered there originally. Instead, Turbo expects that we return a non-200 HTTP status code with the form and validation messages right way after a failed validation exception is thrown.
15 |
16 | Turbo Laravel automatically prepends a `TurboMiddleware` on the web route group. The middleware will intercept the response when it detects that Laravel is responding after a `ValidationException`. Instead of letting it send the "redirect back" response, it will try to guess where the form for that request usually renders and send an internal request back to the app to render the form, then update the status code so it renders as a 422 instead of 200.
17 |
18 | To guess where the form is located at we rely on the route resource naming convention. For any route name ending in `.store`, it will guess that the form can be located in a similar route ending with `.create` for the same resource. Similarly, for any route ending with `.update`, it will guess the form is located at a route ending with `.edit`. Additionally, for any route ending with `.destroy`, it will guess the form is located at a route ending with `.delete` (this is the only convention that is not there by default in Laravel's conventions.)
19 |
20 | For this internal request, the middleware will pass along any resource the current route has as well as any query string that was passed.
21 |
22 | Here are some examples:
23 |
24 | - `posts.comments.store` will guess the form is at the `posts.comments.create` route with the `{post}` route param.
25 | - `comments.store` will guess the form is at the `comments.create` route with no route params.
26 | - `comments.update` will guess the form is at the `comments.edit` with the `{comment}` param.
27 |
28 | If a guessed route name doesn't exist (which will always happen if you don't use the route resource convention), the middleware will not change the default handling of validation errors, so the regular "redirect back" behavior will act.
29 |
30 | When you're not using the [resource route naming convention](/docs/conventions), you may override redirect behavior by catching the `ValidationException` and re-throwing it setting the correct location where the form renders using the `redirectTo` method. If the exception has that, the middleware will respect it and make a GET request to that location instead of trying to guess it:
31 |
32 | ```php
33 | public function store()
34 | {
35 | try {
36 | request()->validate(['name' => 'required']);
37 | } catch (\Illuminate\Validation\ValidationException $exception) {
38 | throw $exception->redirectTo(url('/somewhere'));
39 | }
40 | }
41 | ```
42 |
43 | If you want to register exceptions to this route guessing behavior, add the URIs to the `redirect_guessing_exceptions` key in the `config/turbo-laravel.php` config file:
44 |
45 | ```php
46 | return [
47 | // ...
48 | 'redirect_guessing_exceptions' => [
49 | '/some-page',
50 | ],
51 | ];
52 | ```
53 |
54 | ## The Turbo HTTP Middleware
55 |
56 | Turbo Laravel ships with a middleware which applies some conventions on your redirects, like the one for how failed validations are handled automatically by Laravel as described before. Read more about this in the [Conventions](#conventions) section of the documentation.
57 |
58 | **The middleware is automatically prepended to your web route group middleware stack**. You may want to add the middleware to other groups. When doing so, make sure it's at the top of the middleware stack:
59 |
60 | ```php
61 | \HotwiredLaravel\TurboLaravel\Http\Middleware\TurboMiddleware::class,
62 | ```
63 |
64 | Like so:
65 |
66 | ```php
67 |
68 | namespace App\Http;
69 |
70 | use Illuminate\Foundation\Http\Kernel as HttpKernel;
71 |
72 | class Kernel extends HttpKernel
73 | {
74 | protected $middlewareGroups = [
75 | 'web' => [
76 | \HotwiredLaravel\TurboLaravel\Http\Middleware\TurboMiddleware::class,
77 | // other middlewares...
78 | ],
79 | ];
80 | }
81 | ```
82 |
--------------------------------------------------------------------------------
/docs/conventions.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Conventions
4 | description: All the (optional) conventions and recommendations
5 | order: 4
6 | ---
7 |
8 | # Conventions
9 |
10 | The conventions described below are **NOT mandatory**. Feel free to pick what you like and also come up with your own conventions. With that out of the way, here's a list of conventions you may find helpful.
11 |
12 | ## Resource Routes
13 |
14 | Laravel supports [resource routes](https://laravel.com/docs/controllers#resource-controllers) and that plays really well with Hotwire for most things. This creates route names such as `posts.index`, `posts.store`, etc.
15 |
16 | If you don't want to use resource routes, at least consider using the naming convention: render forms in route names ending in `.create`, `.edit`, or `.delete`, and name their handler routes ending with `.store`, `.update`, or `.destroy`, accordingly.
17 |
18 | Turbo Laravel uses this naming convention so it doesn't redirect after failed validations and, instead, triggers another internal request to the application as well so it can re-render the form returning a 422 response with. The form should re-render with the `old()` input values and any validation messages as well.
19 |
20 | You may want to define exceptions to the route guessing behavior. In that's the case, set them in the `redirect_guessing_exceptions` in the `config/turbo-laravel.php` config file:
21 |
22 | ```php
23 | return [
24 | // ...
25 | 'redirect_guessing_exceptions' => [
26 | '/some-page',
27 | ],
28 | ];
29 | ```
30 |
31 | When using this config, the redirection behavior will still happen, but the package will not attempt to guess the routes that render the forms on those routes. See the [Validation Response Redirects](/docs/validation-response-redirects) page to know more about why this happens.
32 |
33 | ## Partials
34 |
35 | You may want to split up your views in smaller chunks (aka. "partials"), such as a `comments/_comment.blade.php` to display a comment resource, or `comments/_form.blade.php` to display the form for both creating and updating comments. This allows you to reuse these _partials_ in [Turbo Streams](/docs/turbo-streams).
36 |
37 | Alternatively, you may override the pattern to a `{plural}.partials.{singular}` convention for your partials location by calling the `Turbo::usePartialsSubfolderPattern()` method of the Turbo Facade from your `AppServiceProvider::boot()` method:
38 |
39 | ```php
40 | 'partials.{singular}');
75 | }
76 | }
77 | ```
78 |
79 | You may also want to define your own pattern, which you can do by either specifying a string where you have the `{plural}` and `{singular}` placeholders available, but you can also specify a Closure, which will receive the model instance. On that Closure, you must return a string with the view location using the dot convention of Laravel. For instance, the subfolder pattern sets the config value to `{plural}.partials.{singular}` instead of the default, which is `{plural}._{singular}`. These will resolve to `comments.partials.comment` and `comments._comment` views, respectively.
80 |
81 | The models' partials (such as a `comments/_comment.blade.php` for a `Comment` model) may only rely on having a single `$comment` variable passed to them. That's because Turbo Stream Model Broadcasts - which is an _optional_ feature, by the way - relies on these conventions to figure out the partial for a given model when broadcasting and will also pass the model to such partial, using the class basename as the variable instance in _camelCase_. Again, this is optional, you can customize most of these things or create your own model broadcasting convention. Read the [Broadcasting](/docs/broadcasting) section to know more.
82 |
83 | ## Turbo Stream Channel Names
84 |
85 | _Note: Turbo Stream Broadcasts are optional._
86 |
87 | You may use the model's Fully Qualified Class Name (aka. FQCN) as your Broadcasting Channel authorization routes with a wildcard, such as `App.Models.Comment.{comment}` for a `Comment` model living in `App\\Models\\` - the wildcard's name doesn't matter, as long as there is one. This is the default [broadcasting channel naming convention](https://laravel.com/docs/8.x/broadcasting#model-broadcasting-conventions) in Laravel.
88 |
--------------------------------------------------------------------------------
/docs/hotwire-native.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Hotwire Native
4 | description: Hotwire Native Helpers
5 | order: 11
6 | ---
7 |
8 | # Hotwire Native
9 |
10 | Hotwire also has a [mobile side](https://native.hotwired.dev/) and Turbo Laravel provides some helpers to help integrating with that.
11 |
12 | Turbo visits made by a Hotwire Native client should send a custom `User-Agent` header. Using that header, we can detect in the backend that a request is coming from a Hotwire Native client instead of a regular web browser.
13 |
14 | This is useful if you want to customize the behavior a little bit different based on that information. For instance, you may want to include some elements for mobile users, like a mobile-only CSS stylesheet, for instance. To do so, you may use the `@hotwirenative` Blade directive in your Blade views:
15 |
16 | ```blade
17 | @hotwirenative
18 |
19 | @endhotwirenative
20 | ```
21 |
22 | Alternatively, you may want to include some elements only if the client requesting it is _NOT_ a Hotwire Native client using the `@unlesshotwirenative` Blade helpers:
23 |
24 | ```blade
25 | @unlesshotwirenative
26 |
Hello, Non-Hotwire Native Users!
27 | @endunlesshotwirenative
28 | ```
29 |
30 | You may also check if the request was made from a Hotwire Native visit using the request macro:
31 |
32 | ```php
33 | if (request()->wasFromHotwireNative()) {
34 | // ...
35 | }
36 | ```
37 |
38 | Or the Turbo Facade directly, like so:
39 |
40 | ```php
41 | use HotwiredLaravel\TurboLaravel\Facades\Turbo;
42 |
43 | if (Turbo::isHotwireNativeVisit()) {
44 | // ...
45 | }
46 | ```
47 |
48 | ## Interacting With Hotwire Native Navigation
49 |
50 | Hotwire Native will hook into Turbo's visits so it displays them on mobile mimicking the mobile way of stacking screens instead of just replace elements on the same screen. This helps the native feel of our hybrid app.
51 |
52 | However, sometimes we may need to customize the behavior of form request handler to avoid a weird screen jumping effect happening on the mobile client. Instead of regular redirects, we can send some signals by redirecting to specific routes that are detected by the Hotwire Native client.
53 |
54 | For instance, if a form submission request came from a Hotwire Native client, the form was probably rendered on a native modal, which is not part of the screen stack, so we can just tell Turbo to `refresh` the current screen it has on stack instead. There are 3 signals we can send to the Hotwire Native client:
55 |
56 | | Signal | Route| Description|
57 | |---|---|---|
58 | | `recede` | `/recede_historical_location` | Go back to previous screen |
59 | | `resume` | `/resume_historical_location` | Stay on the current screen as is |
60 | | `refresh`| `/refresh_historical_location` | Stay on the current screen but refresh |
61 |
62 | Sending these signals is a matter of detecting if the request came from a Hotwire Native client and, if so, redirect the user to these signal URLs instead. The Hotwire Native client should detect the redirect was from one of these special routes and trigger the desired behavior.
63 |
64 | You may use the `InteractsWithHotwireNativeNavigation` trait on your controllers to achieve this behavior and fallback to a regular redirect if the request wasn't from a Hotwire Native client:
65 |
66 | ```php
67 | use HotwiredLaravel\TurboLaravel\Http\Controllers\Concerns\InteractsWithHotwireNativeNavigation;
68 |
69 | class TraysController extends Controller
70 | {
71 | use InteractsWithHotwireNativeNavigation;
72 |
73 | public function store()
74 | {
75 | // Tray creation...
76 |
77 | return $this->recedeOrRedirectTo(route('trays.show', $tray));
78 | }
79 | }
80 | ```
81 |
82 | In this example, when the request to create trays comes from a Hotwire Native client, we're going to redirect to the `/recede_historical_location` URL instead of the `trays.show` route. However, if the request was made from your web app, we're going to redirect the client to the `trays.show` route.
83 |
84 | There are a couple of redirect helpers available:
85 |
86 | ```php
87 | $this->recedeOrRedirectTo(string $url);
88 | $this->resumeOrRedirectTo(string $url);
89 | $this->refreshOrRedirectTo(string $url);
90 | $this->recedeOrRedirectBack(string $fallbackUrl, array $options = []);
91 | $this->resumeOrRedirectBack(string $fallbackUrl, array $options = []);
92 | $this->refreshOrRedirectBack(string $fallbackUrl, array $options = []);
93 | ```
94 |
95 | It's common to flash messages using the `->with()` method of the Redirect response in Laravel. However, since a Hotwire Native request will never actually redirect somewhere where the flash message will be rendered, the behavior of the `->with()` method was slightly modified too.
96 |
97 | If you're setting flash messages like this after a form submission:
98 |
99 | ```php
100 | use HotwiredLaravel\TurboLaravel\Http\Controllers\Concerns\InteractsWithHotwireNativeNavigation;
101 |
102 | class TraysController extends Controller
103 | {
104 | use InteractsWithHotwireNativeNavigation;
105 |
106 | public function store()
107 | {
108 | // Tray creation...
109 |
110 | return $this->recedeOrRedirectTo(route('trays.show', $tray))
111 | ->with('status', __('Tray created.'));
112 | }
113 | }
114 | ```
115 |
116 | If a request was sent from a Hotwire Native client, the flashed messages will be added to the query string instead of flashed into the session like they'd normally be. In this example, it would redirect like this:
117 |
118 | ```
119 | /recede_historical_location?status=Tray%20created.
120 | ```
121 |
122 | In the Hotwire Native client, you should be able to intercept these redirects, retrieve the flash messages from the query string and create native toasts, if you'd like to.
123 |
124 | If the request wasn't from a Hotwire Native client, the message would be flashed into the session as normal, and the client would receive a redirect to the `trays.show` route in this case.
125 |
126 | If you don't want these routes enabled, feel free to disable them by commenting out the feature on your `config/turbo-laravel.php` file (make sure the Turbo Laravel configs are published):
127 |
128 | ```php
129 | return [
130 | 'features' => [
131 | // Features::hotwireNativeRoutes(),
132 | ],
133 | ];
134 | ```
135 |
--------------------------------------------------------------------------------
/src/Commands/TurboInstallCommand.php:
--------------------------------------------------------------------------------
1 | updateLayouts();
22 | $this->publishJsFiles();
23 | $this->installJsDependencies();
24 |
25 | $this->newLine();
26 | $this->components->info('Turbo Laravel was installed successfully.');
27 | }
28 |
29 | private function publishJsFiles(): void
30 | {
31 | File::ensureDirectoryExists(resource_path('js/elements'));
32 | File::ensureDirectoryExists(resource_path('js/libs'));
33 |
34 | File::copy(__DIR__.'/../../stubs/resources/js/libs/turbo.js', resource_path('js/libs/turbo.js'));
35 | File::copy(__DIR__.'/../../stubs/resources/js/elements/turbo-echo-stream-tag.js', resource_path('js/elements/turbo-echo-stream-tag.js'));
36 |
37 | File::put(resource_path('js/app.js'), $this->appJsImportLines());
38 | File::put(resource_path('js/libs/index.js'), $this->libsIndexJsImportLines());
39 | }
40 |
41 | private function appJsImportLines(): string
42 | {
43 | $prefix = $this->usingImportmaps() ? '' : './';
44 |
45 | $imports = [
46 | "import '{$prefix}bootstrap';",
47 | "import '{$prefix}elements/turbo-echo-stream-tag';",
48 | "import '{$prefix}libs';",
49 | ];
50 |
51 | return implode("\n", $imports);
52 | }
53 |
54 | private function libsIndexJsImportLines(): string
55 | {
56 | $imports = [];
57 |
58 | $imports[] = $this->usingImportmaps()
59 | ? "import 'libs/turbo';"
60 | : "import './turbo';";
61 |
62 | return implode("\n", $imports);
63 | }
64 |
65 | private function installJsDependencies(): void
66 | {
67 | if ($this->usingImportmaps()) {
68 | $this->updateImportmapsDependencies();
69 | } else {
70 | $this->updateNpmDependencies();
71 | $this->runInstallAndBuildCommand();
72 | }
73 | }
74 |
75 | private function updateNpmDependencies(): void
76 | {
77 | static::updateNodePackages(fn ($packages): array => $this->jsDependencies() + $packages);
78 | }
79 |
80 | private function runInstallAndBuildCommand(): void
81 | {
82 | if (file_exists(base_path('pnpm-lock.yaml'))) {
83 | $this->runCommands(['pnpm install', 'pnpm run build']);
84 | } elseif (file_exists(base_path('yarn.lock'))) {
85 | $this->runCommands(['yarn install', 'yarn run build']);
86 | } elseif (file_exists(base_path('bun.lockb'))) {
87 | $this->runCommands(['bun install', 'bun run build']);
88 | } else {
89 | $this->runCommands(['npm install', 'npm run build']);
90 | }
91 | }
92 |
93 | private function runCommands(array $commands): void
94 | {
95 | $process = Process::fromShellCommandline(implode(' && ', $commands), null, null, null, null);
96 |
97 | if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) {
98 | try {
99 | $process->setTty(true);
100 | } catch (RuntimeException $e) {
101 | $this->output->writeln(' WARN > '.$e->getMessage().PHP_EOL);
102 | }
103 | }
104 |
105 | $process->run(function ($type, string $line): void {
106 | $this->output->write(' '.$line);
107 | });
108 | }
109 |
110 | private function updateImportmapsDependencies(): void
111 | {
112 | $dependencies = array_keys($this->jsDependencies());
113 |
114 | ProcessFacade::forever()->run(array_merge([
115 | $this->phpBinary(),
116 | 'artisan',
117 | 'importmap:pin',
118 | ], $dependencies), fn ($_type, $output) => $this->output->write($output));
119 | }
120 |
121 | private function jsDependencies(): array
122 | {
123 | return [
124 | '@hotwired/turbo' => '^8.0.4',
125 | 'laravel-echo' => '^1.15.0',
126 | 'pusher-js' => '^8.0.1',
127 | ];
128 | }
129 |
130 | private function usingImportmaps(): bool
131 | {
132 | return File::exists(base_path('routes/importmap.php'));
133 | }
134 |
135 | protected static function updateNodePackages(callable $callback, $dev = true)
136 | {
137 | if (! File::exists(base_path('package.json'))) {
138 | return;
139 | }
140 |
141 | $configurationKey = $dev ? 'devDependencies' : 'dependencies';
142 |
143 | $packages = json_decode(File::get(base_path('package.json')), true);
144 |
145 | $packages[$configurationKey] = $callback(
146 | array_key_exists($configurationKey, $packages) ? $packages[$configurationKey] : [],
147 | $configurationKey
148 | );
149 |
150 | ksort($packages[$configurationKey]);
151 |
152 | File::put(
153 | base_path('package.json'),
154 | json_encode($packages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL
155 | );
156 | }
157 |
158 | private function updateLayouts(): void
159 | {
160 | $this->existingLayoutFiles()->each(fn ($file) => (new Pipeline(app()))
161 | ->send($file)
162 | ->through(array_filter([
163 | Tasks\EnsureCsrfTokenMetaTagExists::class,
164 | ]))
165 | ->thenReturn());
166 | }
167 |
168 | private function existingLayoutFiles()
169 | {
170 | return collect(['app', 'guest'])
171 | ->map(fn ($file) => resource_path("views/layouts/{$file}.blade.php"))
172 | ->filter(fn ($file) => File::exists($file));
173 | }
174 |
175 | private function phpBinary(): string
176 | {
177 | return (new PhpExecutableFinder)->find(false) ?: 'php';
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/Http/Middleware/TurboMiddleware.php:
--------------------------------------------------------------------------------
1 |
30 | */
31 | private array $except = [];
32 |
33 | public function __construct()
34 | {
35 | $this->except = config('turbo-laravel.redirect_guessing_exceptions', []);
36 | }
37 |
38 | /**
39 | * @param \Illuminate\Http\Request $request
40 | * @return RedirectResponse|mixed
41 | */
42 | public function handle($request, Closure $next)
43 | {
44 | $this->encryptedCookies = $request->cookies->all();
45 |
46 | if ($this->hotwireNativeVisit($request)) {
47 | TurboFacade::setVisitingFromHotwireNative();
48 | }
49 |
50 | if ($requestId = $request->header('X-Turbo-Request-Id', null)) {
51 | TurboFacade::setTurboTrackingRequestId($requestId);
52 | }
53 |
54 | return $this->turboResponse($next($request), $request);
55 | }
56 |
57 | /**
58 | * @param \Illuminate\Http\Request $request
59 | */
60 | private function hotwireNativeVisit($request): bool
61 | {
62 | return Str::contains($request->userAgent(), ['Hotwire Native', 'Turbo Native']);
63 | }
64 |
65 | /**
66 | * @param mixed $next
67 | * @return RedirectResponse|mixed
68 | */
69 | private function turboResponse($response, Request $request)
70 | {
71 | if (! $this->turboVisit($request) && ! $this->hotwireNativeVisit($request)) {
72 | return $response;
73 | }
74 |
75 | if (! $response instanceof RedirectResponse) {
76 | return $response;
77 | }
78 |
79 | // We get the response's encrypted cookies and merge them with the
80 | // encrypted cookies of the first request to make sure that are
81 | // sub-sequent request will use the most up-to-date values.
82 |
83 | $responseCookies = collect($response->headers->getCookies())
84 | ->mapWithKeys(fn (Cookie $cookie) => [$cookie->getName() => $cookie->getValue()])
85 | ->all();
86 |
87 | $this->encryptedCookies = array_replace_recursive($this->encryptedCookies, $responseCookies);
88 |
89 | // When throwing a ValidationException and the app uses named routes convention, we can guess
90 | // the form route for the current endpoint, make an internal request there, and return the
91 | // response body with the form over a 422 status code (works better for Hotwire Native).
92 |
93 | if ($response->exception instanceof ValidationException && ($formRedirectUrl = $this->guessFormRedirectUrl($request, $response->exception->redirectTo))) {
94 | $response->setTargetUrl($formRedirectUrl);
95 |
96 | return tap($this->handleRedirectInternally($request, $response), function () use ($request): void {
97 | App::instance('request', $request);
98 | Facade::clearResolvedInstance('request');
99 | });
100 | }
101 |
102 | return $response->setStatusCode(303);
103 | }
104 |
105 | private function kernel(): Kernel
106 | {
107 | return App::make(Kernel::class);
108 | }
109 |
110 | /**
111 | * @param Response $response
112 | * @return Response
113 | */
114 | private function handleRedirectInternally(\Illuminate\Http\Request $request, \Illuminate\Http\RedirectResponse $response)
115 | {
116 | $kernel = $this->kernel();
117 |
118 | do {
119 | $response = $kernel->handle(
120 | $request = $this->createRequestFrom($response->headers->get('Location'), $request)
121 | );
122 | } while ($response->isRedirect());
123 |
124 | if ($response->isOk()) {
125 | $response->setStatusCode(422);
126 | }
127 |
128 | return $response;
129 | }
130 |
131 | private function createRequestFrom(string $url, Request $baseRequest)
132 | {
133 | $request = Request::create($url, 'GET');
134 |
135 | $request->headers->replace($baseRequest->headers->all());
136 | $request->cookies->replace($this->encryptedCookies);
137 |
138 | return $request;
139 | }
140 |
141 | /**
142 | * @return bool
143 | */
144 | private function turboVisit(\Illuminate\Http\Request $request)
145 | {
146 | return Str::contains($request->header('Accept', ''), Turbo::TURBO_STREAM_FORMAT);
147 | }
148 |
149 | private function guessFormRedirectUrl(\Illuminate\Http\Request $request, ?string $defaultRedirectUrl = null)
150 | {
151 | if ($this->inExceptArray($request)) {
152 | return $defaultRedirectUrl;
153 | }
154 |
155 | $route = $request->route();
156 | $name = optional($route)->getName();
157 |
158 | if (! $route || ! $name) {
159 | return $defaultRedirectUrl;
160 | }
161 |
162 | $formRouteName = $this->guessRouteName($name);
163 |
164 | // If the guessed route doesn't exist, send it back to wherever Laravel defaults to.
165 |
166 | if (! $formRouteName || ! Route::has($formRouteName)) {
167 | return $defaultRedirectUrl;
168 | }
169 |
170 | return route($formRouteName, $route->parameters() + request()->query());
171 | }
172 |
173 | protected function guessRouteName(string $routeName): ?string
174 | {
175 | if (! Str::endsWith($routeName, ['.store', '.update', '.destroy'])) {
176 | return null;
177 | }
178 |
179 | return str_replace(['.store', '.update', '.destroy'], ['.create', '.edit', '.delete'], $routeName);
180 | }
181 |
182 | protected function inExceptArray(Request $request): bool
183 | {
184 | foreach ($this->except as $except) {
185 | if ($except !== '/') {
186 | $except = trim($except, '/');
187 | }
188 |
189 | if ($request->fullUrlIs($except) || $request->is($except)) {
190 | return true;
191 | }
192 | }
193 |
194 | return false;
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/TurboServiceProvider.php:
--------------------------------------------------------------------------------
1 | configurePublications();
32 | $this->configureRoutes();
33 |
34 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'turbo-laravel');
35 |
36 | $this->configureComponents();
37 | $this->configureMacros();
38 | $this->configureRequestAndResponseMacros();
39 | $this->configureTestResponseMacros();
40 | $this->configureMiddleware();
41 | }
42 |
43 | public function register(): void
44 | {
45 | $this->mergeConfigFrom(__DIR__.'/../config/turbo-laravel.php', 'turbo-laravel');
46 |
47 | $this->app->scoped(Turbo::class);
48 | $this->app->bind(Broadcaster::class, LaravelBroadcaster::class);
49 | $this->app->scoped(Limiter::class);
50 | }
51 |
52 | private function configureComponents(): void
53 | {
54 | $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade): void {
55 | $blade->anonymousComponentPath(__DIR__.'/../resources/views/components', 'turbo');
56 | });
57 | }
58 |
59 | private function configurePublications(): void
60 | {
61 | if (! $this->app->runningInConsole()) {
62 | return;
63 | }
64 |
65 | $this->publishes([
66 | __DIR__.'/../config/turbo-laravel.php' => config_path('turbo-laravel.php'),
67 | ], 'turbo-config');
68 |
69 | $this->publishes([
70 | __DIR__.'/../resources/views' => base_path('resources/views/vendor/turbo-laravel'),
71 | ], 'turbo-views');
72 |
73 | $this->publishes([
74 | __DIR__.'/../routes/turbo.php' => base_path('routes/turbo.php'),
75 | ], 'turbo-routes');
76 |
77 | $this->commands([
78 | TurboInstallCommand::class,
79 | ]);
80 | }
81 |
82 | private function configureRoutes(): void
83 | {
84 | if (Features::enabled('turbo_routes')) {
85 | $this->loadRoutesFrom(__DIR__.'/../routes/turbo.php');
86 | }
87 | }
88 |
89 | private function configureMacros(): void
90 | {
91 | Blade::if('turbonative', fn () => TurboFacade::isHotwireNativeVisit());
92 |
93 | Blade::if('unlessturbonative', fn (): bool => ! TurboFacade::isHotwireNativeVisit());
94 |
95 | Blade::if('hotwirenative', fn () => TurboFacade::isHotwireNativeVisit());
96 |
97 | Blade::if('unlesshotwirenative', fn (): bool => ! TurboFacade::isHotwireNativeVisit());
98 |
99 | Blade::directive('domid', fn ($expression): string => "");
100 |
101 | Blade::directive('domclass', fn ($expression): string => "");
102 |
103 | Blade::directive('channel', fn ($expression): string => "broadcastChannel(); ?>");
104 | }
105 |
106 | private function configureRequestAndResponseMacros(): void
107 | {
108 | ResponseFacade::macro('turboStream', fn ($model = null, ?string $action = null): MultiplePendingTurboStreamResponse|PendingTurboStreamResponse => turbo_stream($model, $action));
109 |
110 | ResponseFacade::macro('turboStreamView', fn ($view, array $data = []): Response|ResponseFactory => turbo_stream_view($view, $data));
111 |
112 | Request::macro('wantsTurboStream', fn (): bool => Str::contains($this->header('Accept'), Turbo::TURBO_STREAM_FORMAT));
113 |
114 | Request::macro('wantsTurboStreams', fn (): bool => $this->wantsTurboStream());
115 |
116 | Request::macro('wasFromTurboNative', fn (): bool => TurboFacade::isHotwireNativeVisit());
117 |
118 | Request::macro('wasFromHotwireNative', fn (): bool => TurboFacade::isHotwireNativeVisit());
119 |
120 | Request::macro('wasFromTurboFrame', function (?string $frame = null): bool {
121 | if (! $frame) {
122 | return $this->hasHeader('Turbo-Frame');
123 | }
124 |
125 | return $this->header('Turbo-Frame', null) === $frame;
126 | });
127 | }
128 |
129 | private function configureTestResponseMacros(): void
130 | {
131 | if (! app()->environment('testing')) {
132 | return;
133 | }
134 |
135 | TestResponse::macro('assertTurboStream', function (?callable $callback = null): void {
136 | Assert::assertStringContainsString(
137 | Turbo::TURBO_STREAM_FORMAT,
138 | $this->headers->get('Content-Type'),
139 | );
140 |
141 | if ($callback === null) {
142 | return;
143 | }
144 |
145 | $turboStreams = (new ConvertTestResponseToTurboStreamCollection)($this);
146 | $callback(new AssertableTurboStream($turboStreams));
147 | });
148 |
149 | TestResponse::macro('assertNotTurboStream', function (): void {
150 | Assert::assertStringNotContainsString(
151 | Turbo::TURBO_STREAM_FORMAT,
152 | $this->headers->get('Content-Type'),
153 | );
154 | });
155 |
156 | TestResponse::macro('assertRedirectRecede', function (array $with = []): void {
157 | $this->assertRedirectToRoute('turbo_recede_historical_location', $with);
158 | });
159 |
160 | TestResponse::macro('assertRedirectResume', function (array $with = []): void {
161 | $this->assertRedirectToRoute('turbo_resume_historical_location', $with);
162 | });
163 |
164 | TestResponse::macro('assertRedirectRefresh', function (array $with = []): void {
165 | $this->assertRedirectToRoute('turbo_refresh_historical_location', $with);
166 | });
167 | }
168 |
169 | protected function configureMiddleware(): void
170 | {
171 | if (! config('turbo-laravel.automatically_register_middleware', true)) {
172 | return;
173 | }
174 |
175 | /** @var Kernel $kernel */
176 | $kernel = resolve(Kernel::class);
177 | $kernel->prependMiddlewareToGroup('web', TurboMiddleware::class);
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/docs/overview.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Overview
4 | description: A quick overview of Hotwire
5 | order: 3
6 | ---
7 |
8 | # Overview
9 |
10 | It's highly recommended that you read the [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction) first before continuing here. However, a quick intro will be provided here and we'll link to the Turbo documentations when relevant.
11 |
12 | Turbo is the heart of Hotwire. In essence, it's a JavaScript library that turns regular web applications (aka. multi-page web applications) into something that _feels_ like a single-page application (SPA).
13 |
14 | It provides a bunch of components that allows us to build modern web applications with minimal JavaScript. It relies on sending **H**TML **O**ver **T**he **Wire** (hence the name), instead of JSON, which is how JavaScript-heavy web applications are built, typically consuming some sort of JSON API.
15 |
16 | When Turbo.js is started in the browser, it intercepts link clicks and form submissions to convert those into fetch requests (aka. AJAX) instead of letting the browser do a full page refresh. The component in Turbo that handles this behavior is called [Turbo Drive](https://turbo.hotwired.dev/handbook/drive).
17 |
18 | Turbo Drive will do the heavy-lifting of the _SPA feel_ in our application. Just by turning it on, the [perceived performance](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/Perceived_performance) should be noticeable. The default behavior of Turbo will be to _replace_ the contents of the `` tag in our page with the one from the response it gets from the link or form submission.
19 |
20 | Additionally, since Turbo 8, we can also instruct Turbo to [_morph_ the page](https://turbo.hotwired.dev/handbook/page_refreshes) instead of just replacing its contents by adding a meta tag on the pages we can it enabled:
21 |
22 | ```html
23 |
24 |
25 | ```
26 |
27 | Alternatively, Turbo Laravel provides some Blade components to make it easier (and autocomplete friendlier) to interact with these Turbo page configurations:
28 |
29 | ```blade
30 |
31 | ```
32 |
33 | Turbo Drive does a lot for us, and with _morphing_ it gets even more powerful, but sometimes you can want to [decompose a page into independent sections](https://turbo.hotwired.dev/handbook/frames) (for different reasons, such as having more control over HTTP caching for these sections). For these use cases, Turbo offers _Turbo Frames_.
34 |
35 | Turbo Frames are custom HTML tags that Turbo provides. You can think of those as "modern iframes", if you will. When link clicks or form submissions happen inside of a Turbo Frame, instead of replacing or morphing the entire page, Turbo will only affect that specific Turbo Frame's content. It will do so by extracting a _matching Turbo Frame_ (one that has the same DOM ID) on the response.
36 |
37 | Here's how you can use Turbo Frames:
38 |
39 | ```html
40 |
41 |
Hello, World!
42 |
43 | Click me
44 |
45 | ```
46 |
47 | Alternatively, you may want a Turbo Frame to immediately fetch its contents instead of waiting for a user interaction. For that, you may add a `[src]` attribute to the Turbo Frame tag with the URL of where Turbo should fetch that content from. This technique is called [Lazy-loading Turbo Frames](https://turbo.hotwired.dev/handbook/frames#lazy-loading-frames):
48 |
49 | ```blade
50 |
51 |
Loading...
52 |
53 | ```
54 |
55 | A lazy-loaded Turbo Frame will dispatch a fetch request (aka. AJAX) as soon as it enters the DOM, replacing its contents with the contents of a matching Turbo Frame in the response HTML. Optionally, you may add a `[loading=lazy]` attribute to the lazy-loaded Turbo Frame so Turbo will only fetch its content when the Turbo Frame is visible (within the viewport):
56 |
57 | ```blade
58 |
59 |
Loading...
60 |
61 | ```
62 |
63 | You may also trigger a Turbo Frame with forms and links that are _outside_ of the frame tag by adding a `[data-turbo-frame]` attribute in the link, form, or submit buttons, passing the ID of the Turbo Frame:
64 |
65 | ```blade
66 |
73 | ```
74 |
75 | Turbo Drive and Turbo Frames allows us to build A LOT of different sorts of interactions. However, sometimes you may want to update multiple sections of a page after a form submission, for instance. For those use cases, Turbo provides another custom HTML tag called [Turbo Streams](https://turbo.hotwired.dev/handbook/streams).
76 |
77 | All link clicks and form submissions that Turbo intercepts are annotated by Turbo, which tells our back-end application that Turbo is _on_, so we can return a special type of response that only contains Turbo Streams. Turbo.js will do so by adding a custom [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types) of `text/vnd.turbo-stream.html` to the [`Accept` HTTP Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept).
78 |
79 | Turbo Streams allows for a more fine-grained control over the page updates. For instance, here's an example of a Turbro Stream that appends a new comment to a comments section:
80 |
81 | ```html
82 |
83 |
84 | ...
85 |
86 |
87 | ```
88 |
89 | The `[action=append]` will add the contents of what's inside the `` tag into the element that has a DOM ID matching the `[target=comments]` attribute, so `#comments` in this case.
90 |
91 | There are 8 _default_ Turbo Stream actions in Turbo:
92 |
93 | | Action | Description |
94 | |---|---|
95 | | `append` | Appends the contents of the `` tag into the target or targets |
96 | | `prepend` | Prepends the contents of the `` tag to the target or targets |
97 | | `update` | Updates the target or targets with the contents of the `` tag (keeps the targeted elements around) |
98 | | `replace` | Replaces the target or targets with the contents of the `` tag (actually removes the targets) |
99 | | `before` | Inserts the contents of the `` tag _before_ the targeted elements |
100 | | `after` | Inserts the contents of the `` tag _after_ the targeted elements |
101 | | `remove` | Removes the targeted elements (doesn't require a `` tag) |
102 | | `refresh` | Signals to Turbo Drive to do a page refresh (doesn't require a `` tag, nor "target") |
103 |
104 | All the default Turbo Stream actions, except the `refresh` one, require a `target` or a `targets` attribute. The difference here is that if you use the `target` attribute, it expects a DOM ID of the target element, and if you use the `targets` attribute, it expects a CSS selector of the target(s) element(s).
105 |
106 | All of the default actions require the contents of the new or updated element to be wrapped inside a `` tag, except for the `remove` and `refresh` actions. That's because Turbo Stream tags can be activated by simply adding them to the document. They'll get activate based on the action and then get removed from the DOM. Having the `` ensure the content is not visible in the browser as it gets activated.
107 |
108 | I keep saying "default action", well, that's because Turbo allows us to create our own [custom actions](https://turbo.hotwired.dev/handbook/streams#custom-actions):
109 |
110 | ```js
111 | import { StreamActions } from "@hotwired/turbo"
112 |
113 | StreamActions.log = function () {
114 | console.log(this.getAttribute("message"))
115 | }
116 | ```
117 |
118 | In this case, we can use this action like so:
119 |
120 | ```html
121 |
122 | ```
123 |
124 | This will get "Hello World" printed on the DevTools Console. With custom actions, you can do pretty much anything on the document.
125 |
126 | So far, all vanilla Hotwire and Turbo.
127 |
--------------------------------------------------------------------------------
/src/Broadcasting/Factory.php:
--------------------------------------------------------------------------------
1 | isBroadcasting;
38 |
39 | $this->isBroadcasting = false;
40 |
41 | try {
42 | return $callback();
43 | } finally {
44 | $this->isBroadcasting = $original;
45 | }
46 | }
47 |
48 | public function fake(): static
49 | {
50 | $this->recording = true;
51 |
52 | return $this;
53 | }
54 |
55 | public function broadcastAppend($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
56 | {
57 | return $this->broadcastAction('append', $content, $target, $targets, $channel, $attributes);
58 | }
59 |
60 | public function broadcastPrepend($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
61 | {
62 | return $this->broadcastAction('prepend', $content, $target, $targets, $channel, $attributes);
63 | }
64 |
65 | public function broadcastBefore($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
66 | {
67 | return $this->broadcastAction('before', $content, $target, $targets, $channel, $attributes);
68 | }
69 |
70 | public function broadcastAfter($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
71 | {
72 | return $this->broadcastAction('after', $content, $target, $targets, $channel, $attributes);
73 | }
74 |
75 | public function broadcastUpdate($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
76 | {
77 | return $this->broadcastAction('update', $content, $target, $targets, $channel, $attributes);
78 | }
79 |
80 | public function broadcastReplace($content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
81 | {
82 | return $this->broadcastAction('replace', $content, $target, $targets, $channel, $attributes);
83 | }
84 |
85 | public function broadcastRemove(Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
86 | {
87 | return $this->broadcastAction('remove', null, $target, $targets, $channel, $attributes);
88 | }
89 |
90 | public function broadcastRefresh(Channel|Model|Collection|array|string|null $channel = null)
91 | {
92 | return $this->broadcastAction(
93 | action: 'refresh',
94 | channel: $channel,
95 | attributes: array_filter(['request-id' => $requestId = Turbo::currentRequestId()]),
96 | )->lazyCancelIf(fn (PendingBroadcast $broadcast): bool => (
97 | $this->shouldLimitPageRefreshesOn($broadcast->channels, $requestId)
98 | ));
99 | }
100 |
101 | public function broadcastAction(string $action, $content = null, Model|string|null $target = null, ?string $targets = null, Channel|Model|Collection|array|string|null $channel = null, array $attributes = [])
102 | {
103 | $broadcast = new PendingBroadcast(
104 | channels: $channel ? $this->resolveChannels($channel) : [],
105 | action: $action,
106 | target: $target instanceof Model ? $this->resolveTargetFor($target, resource: $target->wasRecentlyCreated) : $target,
107 | targets: $targets,
108 | rendering: $this->resolveRendering($content),
109 | attributes: $attributes,
110 | );
111 |
112 | if ($this->recording) {
113 | $broadcast->fake($this);
114 | }
115 |
116 | return $broadcast->cancelIf(! $this->isBroadcasting);
117 | }
118 |
119 | public function record(PendingBroadcast $broadcast): static
120 | {
121 | $this->recordedStreams[] = $broadcast;
122 |
123 | return $this;
124 | }
125 |
126 | protected function shouldLimitPageRefreshesOn(array $channels, ?string $requestId): bool
127 | {
128 | return Limiter::shouldLimit($this->pageRefreshLimiterKeyFor($channels, $requestId));
129 | }
130 |
131 | protected function pageRefreshLimiterKeyFor(array $channels, ?string $requestId): string
132 | {
133 | $keys = array_map(fn (Channel $channel) => $channel->name, $channels);
134 |
135 | sort($keys);
136 |
137 | $key = sha1(implode('/', array_values($keys) + array_filter([$requestId])));
138 |
139 | return 'turbo-refreshes-limiter-'.$key;
140 | }
141 |
142 | protected function resolveRendering($content)
143 | {
144 | if ($content instanceof Rendering) {
145 | return $content;
146 | }
147 |
148 | return $content ? Rendering::forContent($content) : Rendering::empty();
149 | }
150 |
151 | protected function resolveChannels(Channel|Model|Collection|array|string $channel)
152 | {
153 | if (is_array($channel) || $channel instanceof Collection) {
154 | return collect($channel)->flatMap(fn ($channel) => $this->resolveChannels($channel))->values()->filter()->all();
155 | }
156 |
157 | if (is_string($channel)) {
158 | return [new Channel($channel)];
159 | }
160 |
161 | return [$channel];
162 | }
163 |
164 | protected function resolveTargetFor(Model $target, bool $resource = false): string
165 | {
166 | if ($resource) {
167 | return $this->getResourceNameFor($target);
168 | }
169 |
170 | return dom_id($target);
171 | }
172 |
173 | protected function getResourceNameFor(Model $model): string
174 | {
175 | return Name::forModel($model)->plural;
176 | }
177 |
178 | public function clearRecordedBroadcasts(): self
179 | {
180 | $this->recordedStreams = [];
181 |
182 | return $this;
183 | }
184 |
185 | public function assertBroadcasted(?callable $callback): static
186 | {
187 | $result = collect($this->recordedStreams)->filter($callback);
188 |
189 | Assert::assertGreaterThanOrEqual(1, $result->count(), 'Expected to have broadcasted Turbo Streams, but it did not.');
190 |
191 | return $this;
192 | }
193 |
194 | public function assertBroadcastedTimes(?callable $callback, $times = 1, $message = null): static
195 | {
196 | $result = collect($this->recordedStreams)->filter($callback);
197 |
198 | Assert::assertCount($times, $result, $message ?: sprintf(
199 | 'Expected to have broadcasted %d Turbo %s, but broadcasted %d Turbo %s instead.',
200 | $times,
201 | (string) str('Stream')->plural($times),
202 | $result->count(),
203 | (string) str('Stream')->plural($result->count()),
204 | ));
205 |
206 | return $this;
207 | }
208 |
209 | public function assertNothingWasBroadcasted()
210 | {
211 | return $this->assertBroadcastedTimes(fn (): true => true, 0, sprintf('Expected to not have broadcasted any Turbo Stream, but broadcasted %d instead.', count($this->recordedStreams)));
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/src/Broadcasting/PendingBroadcast.php:
--------------------------------------------------------------------------------
1 |
62 | */
63 | protected array $deferredCancelCallbacks = [];
64 |
65 | public function __construct(array $channels, public string $action, Rendering $rendering, public ?string $target = null, public ?string $targets = null, public array $attributes = [])
66 | {
67 | $this->to($channels);
68 | $this->rendering($rendering);
69 | }
70 |
71 | public function to($channel): self
72 | {
73 | $this->channels = $this->normalizeChannels($channel, Channel::class);
74 |
75 | return $this;
76 | }
77 |
78 | public function toPrivateChannel($channel): self
79 | {
80 | $this->channels = $this->normalizeChannels($channel, PrivateChannel::class);
81 |
82 | return $this;
83 | }
84 |
85 | public function toPresenceChannel($channel): self
86 | {
87 | $this->channels = $this->normalizeChannels($channel, PresenceChannel::class);
88 |
89 | return $this;
90 | }
91 |
92 | public function action(string $action): self
93 | {
94 | $this->action = $action;
95 |
96 | return $this;
97 | }
98 |
99 | public function target(string $target): self
100 | {
101 | $this->target = $target;
102 | $this->targets = null;
103 |
104 | return $this;
105 | }
106 |
107 | public function targets(string $targets): self
108 | {
109 | $this->targets = $targets;
110 | $this->target = null;
111 |
112 | return $this;
113 | }
114 |
115 | public function toOthers(bool $toOthers = true): self
116 | {
117 | $this->sendToOthers = $toOthers;
118 |
119 | return $this;
120 | }
121 |
122 | public function partial(?string $partial, array $data = []): self
123 | {
124 | return $this->view($partial, $data);
125 | }
126 |
127 | public function view(?string $view, array $data = []): self
128 | {
129 | return $this->rendering(new Rendering($view, $data));
130 | }
131 |
132 | public function content(\Illuminate\Contracts\View\View|\Illuminate\Support\HtmlString|string $content)
133 | {
134 | return $this->rendering(Rendering::forContent($content));
135 | }
136 |
137 | public function attributes(array $attributes): static
138 | {
139 | $this->attributes = $attributes;
140 |
141 | return $this;
142 | }
143 |
144 | public function morph(): self
145 | {
146 | return $this->method('morph');
147 | }
148 |
149 | public function method(?string $method = null): self
150 | {
151 | if ($method) {
152 | return $this->attributes(array_merge($this->attributes, [
153 | 'method' => $method,
154 | ]));
155 | }
156 |
157 | return $this->attributes(Arr::except($this->attributes, 'method'));
158 | }
159 |
160 | public function rendering(Rendering $rendering): static
161 | {
162 | $this->partialView = $rendering->partial;
163 | $this->partialData = $rendering->data;
164 | $this->inlineContent = $rendering->inlineContent;
165 | $this->escapeInlineContent = $rendering->escapeInlineContent;
166 |
167 | return $this;
168 | }
169 |
170 | public function later(bool $later = true): self
171 | {
172 | $this->sendLater = $later;
173 |
174 | return $this;
175 | }
176 |
177 | public function cancel(): static
178 | {
179 | $this->wasCancelled = true;
180 |
181 | return $this;
182 | }
183 |
184 | public function cancelIf($condition): static
185 | {
186 | $this->wasCancelled = $this->wasCancelled || boolval(value($condition, $this));
187 |
188 | return $this;
189 | }
190 |
191 | public function lazyCancelIf(callable $condition): static
192 | {
193 | $this->deferredCancelCallbacks[] = $condition;
194 |
195 | return $this;
196 | }
197 |
198 | public function fake($recorder = null): static
199 | {
200 | $this->isRecording = true;
201 | $this->recorder = $recorder;
202 |
203 | return $this;
204 | }
205 |
206 | public function render(): HtmlString
207 | {
208 | $event = new TurboStreamBroadcast(
209 | $this->channels,
210 | $this->action,
211 | $this->target,
212 | $this->targets,
213 | $this->partialView,
214 | $this->partialData,
215 | $this->inlineContent,
216 | $this->escapeInlineContent,
217 | $this->attributes,
218 | );
219 |
220 | return new HtmlString($event->render());
221 | }
222 |
223 | public function __destruct()
224 | {
225 | if ($this->shouldBeCancelled()) {
226 | return;
227 | }
228 |
229 | if ($this->isRecording) {
230 | $this->recorder?->record($this);
231 |
232 | return;
233 | }
234 |
235 | $broadcaster = Turbo::broadcaster();
236 |
237 | $socket = $this->sendToOthers || Turbo::shouldBroadcastToOthers()
238 | ? Broadcast::socket()
239 | : null;
240 |
241 | $broadcaster->broadcast(
242 | $this->channels,
243 | $this->sendLater,
244 | $this->action,
245 | $this->target,
246 | $this->targets,
247 | $this->partialView,
248 | $this->partialData,
249 | $this->inlineContent,
250 | $this->escapeInlineContent,
251 | $this->attributes,
252 | $socket,
253 | );
254 | }
255 |
256 | protected function shouldBeCancelled(): bool
257 | {
258 | if ($this->wasCancelled) {
259 | return true;
260 | }
261 |
262 | foreach ($this->deferredCancelCallbacks as $condition) {
263 | if (value($condition, $this)) {
264 | return true;
265 | }
266 | }
267 |
268 | return false;
269 | }
270 |
271 | protected function normalizeChannels($channel, $channelClass)
272 | {
273 | if ($channel instanceof Channel) {
274 | return [$channel];
275 | }
276 |
277 | return collect(Arr::wrap($channel))
278 | ->flatMap(function ($channel) use ($channelClass) {
279 | if ($channel instanceof Model && method_exists($channel, 'asTurboStreamBroadcastingChannel')) {
280 | return $channel->asTurboStreamBroadcastingChannel();
281 | }
282 |
283 | if ($channel instanceof Channel) {
284 | return [$channel];
285 | }
286 |
287 | return [
288 | new $channelClass(
289 | $channel instanceof HasBroadcastChannel ? $channel->broadcastChannel() : $channel
290 | ),
291 | ];
292 | })
293 | ->values()
294 | ->filter()
295 | ->all();
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/resources/boost/guidelines/core.blade.php:
--------------------------------------------------------------------------------
1 | ## Hotwire/Turbo Core Principles
2 | - For standard application development, use Hotwire (Turbo + Stimulus)
3 | - For most interactions, use regular links and form submits (Turbo Drive will make them fast and dynamic)
4 | - Decompose pages with Turbo Frames for independent sections that update separately
5 | - Use Turbo Streams for real-time updates and dynamic content changes
6 | - Leverage Stimulus for progressive JavaScript enhancement when Turbo isn't sufficient (if Stimulus is available)
7 | - Prefer server-side template rendering and state management over client-side frameworks and state
8 | - Use data attributes for JavaScript hooks and CSS styling for as much as possible
9 |
10 | ## Base Helpers
11 | @verbatim
12 | - Turbo automatically handles page navigation, form submissions, and CSRF protection
13 | - You may configure morphing and scroll preservation for a page (or layout) with: ``
14 | - Generate unique DOM IDs from models: use the `dom_id($model, 'optional_prefix')` global function or Blade directive `@domid($model, 'optional_prefix')`
15 | - Generate CSS classes from models: use the `dom_class($model, 'optional_prefix')` global function or Blade directive `@domclass($model, 'optional_prefix')`
16 | @endverbatim
17 |
18 | ## Turbo Frames Best Practices
19 | - Use frames to decompose pages into independent sections that can update without full page reloads
20 | - Forms and links inside frames automatically target their containing frame (no configuration needed)
21 | - You may override the default frame target of a link or form with `[data-turbo-frame]` attribute:
22 | - Use a frame's DOM ID to target a specific frame
23 | - Use the value `_top` to break out of frames and navigate the full page
24 | - The `[:id]` prop accepts models and automatically generates DOM IDs for them
25 | - The `[:src]` prop accepts a URL to lazy-load from content. Optionally, you may pair it with a `[loading=lazy]` so it only loads when the element is visible in the viewport
26 |
27 | Example:
28 | @verbatim
29 | ```blade
30 |
31 |
{{ $post->title }}
32 |
{{ $post->content }}
33 | Edit
34 |
39 |
40 | ```
41 | @endverbatim
42 |
43 | ## Turbo Streams for Dynamic Updates
44 |
45 | - You may return Turbo Streams from controllers after form submissions to update specific page elements (always check if the request accepts Turbo Streams for resilience)
46 | @verbatim
47 |
48 | public function store(Request $request)
49 | {
50 | $post = Post::create($request->validated());
51 |
52 | if ($request->wantsTurboStream()) {
53 | return turbo_stream([
54 | turbo_stream()->append('posts', view('posts.partials.post', ['post' => $post])),
55 | turbo_stream()->update('create_post', view('posts.partials.form', ['post' => new Post()])),
56 | // turbo_stream()->prepend('some_dom_id', view('posts.partials.post', ['post' => $post])),
57 | // turbo_stream()->before('some_dom_id', view('...'))
58 | // turbo_stream()->after('some_dom_id', view('...'))
59 | // turbo_stream()->replace('some_dom_id', view('...'))
60 | // turbo_stream()->remove('some_dom_id')
61 | ]);
62 | }
63 |
64 | return back();
65 | }
66 |
67 | @endverbatim
68 | - Turbo Streams can also be broadcasted using Laravel Echo for real-time updates to all users connected to a channel:
69 | @verbatim
70 |
71 |
72 |
73 |
74 |
75 | // Ensure the channel is defined in `routes/channels.php`:
76 | Broadcast::channel(Post::class, function (User $user, Post $post) {
77 | return $user->belongsToProject($post->project);
78 | });
79 |
80 | // Add the trait to the model:
81 | use HotwiredLaravel\TurboLaravel\Models\Broadcasts;
82 |
83 | class Post extends Model
84 | {
85 | use Broadcasts;
86 | }
87 |
88 | // When you want to trigger the broadcasting from anywhere (including model events)...
89 | $post->broadcastUpdate();
90 | $post->broadcastRemove();
91 | $post->broadcastAppend()->to('posts');
92 |
93 | @endverbatim
94 |
95 | ## Form Handling & Validation
96 | - Use Laravel's resource route naming conventions for automatic form re-rendering, if the matching route exists:
97 | - `*.store` action redirects to `*.create` route (shows form again with validation errors)
98 | - `*.update` action redirects to `*.edit` route (shows form again with validation errors)
99 | - `*.destroy` action redirects to `*.delete` route
100 | - Validation errors are automatically displayed when using this convention with Turbo
101 |
102 | ## Performance & UX Enhancements
103 | - Use `data-turbo-permanent` to preserve specific elements during Turbo navigation (prevents re-rendering):
104 | @verbatim
105 | ```blade
106 |
107 |
108 |
109 | ```
110 | @endverbatim
111 | - Preloading is automatically enabled on all links. You may disable it for specific links with the `data-turbo-preload` attribute (if you need to):
112 | @verbatim
113 | ```blade
114 |
115 | {{ $post->title }}
116 |
117 | ```
118 | @endverbatim
119 |
120 | ## Testing Hotwire/Turbo
121 | @verbatim
122 |
123 | public function test_creating_post_returns_turbo_stream()
124 | {
125 | $this->turbo()
126 | ->post(route('posts.store'), ['title' => 'Test Post'])
127 | ->assertTurboStream(fn (AssertableTurboStream $turboStreams) => (
128 | $turboStreams->has(2)
129 | && $turboStreams->hasTurboStream(fn ($turboStream) => (
130 | $turboStream->where('target', 'flash_messages')
131 | ->where('action', 'prepend')
132 | ->see('Post was successfully created!')
133 | ))
134 | && $turboStreams->hasTurboStream(fn ($turboStream) => (
135 | $turboStream->where('target', 'posts')
136 | ->where('action', 'append')
137 | ->see('Test Post')
138 | ))
139 | ));
140 | }
141 |
142 | @endverbatim
143 | @verbatim
144 |
145 | public function test_frame_request_returns_partial_content()
146 | {
147 | $this->fromTurboFrame(dom_id($post))
148 | ->get(route('posts.update', $post))
149 | ->assertSee('', false)
150 | ->assertViewIs('posts.edit');
151 | }
152 |
153 | @endverbatim
154 | @verbatim
155 |
156 | use HotwiredLaravel\TurboLaravel\Facades\TurboStream;
157 | use HotwiredLaravel\TurboLaravel\Broadcasting\PendingBroadcast;
158 |
159 | public function test_post_creation_broadcasts_stream()
160 | {
161 | TurboStream::fake();
162 |
163 | $post = Post::create(['title' => 'Test Post']);
164 |
165 | TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($post) {
166 | return $broadcast->target === 'posts'
167 | && $broadcast->action === 'append'
168 | && $broadcast->partialView === 'posts.partials.post'
169 | && $broadcast->partialData['post']->is($post)
170 | && count($broadcast->channels) === 1
171 | && $broadcast->channels[0]->name === sprintf('private-%s', $post->broadcastChannel());
172 | });
173 | }
174 |
175 | @endverbatim
176 | @verbatim
177 |
178 | use HotwiredLaravel\TurboLaravel\Facades\TurboStream;
179 | use HotwiredLaravel\TurboLaravel\Broadcasting\PendingBroadcast;
180 |
181 | public function creating_comments_from_native_recedes()
182 | {
183 | $post = Post::factory()->create();
184 |
185 | $this->assertCount(0, $post->comments);
186 |
187 | $this->hotwireNative()->post(route('posts.comments.store', $post), [
188 | 'content' => 'Hello World',
189 | ])->assertRedirectRecede(['status' => __('Comment created.')]);
190 |
191 | $this->assertCount(1, $post->refresh()->comments);
192 | $this->assertEquals('Hello World', $post->comments->first()->content);
193 | }
194 |
195 | @endverbatim
196 |
--------------------------------------------------------------------------------
/src/Models/Broadcasts.php:
--------------------------------------------------------------------------------
1 | broadcastAppendTo(
59 | $this->broadcastDefaultStreamables(inserting: true)
60 | );
61 | }
62 |
63 | public function broadcastPrepend(): PendingBroadcast
64 | {
65 | return $this->broadcastPrependTo(
66 | $this->broadcastDefaultStreamables(inserting: true)
67 | );
68 | }
69 |
70 | public function broadcastBefore(string $target, bool $inserting = true): PendingBroadcast
71 | {
72 | return $this->broadcastBeforeTo(
73 | $this->broadcastDefaultStreamables($inserting),
74 | $target
75 | );
76 | }
77 |
78 | public function broadcastAfter(string $target, bool $inserting = true): PendingBroadcast
79 | {
80 | return $this->broadcastAfterTo(
81 | $this->broadcastDefaultStreamables($inserting),
82 | $target
83 | );
84 | }
85 |
86 | public function broadcastInsert(): PendingBroadcast
87 | {
88 | $action = is_array($this->broadcasts) && isset($this->broadcasts['insertsBy'])
89 | ? $this->broadcasts['insertsBy']
90 | : 'append';
91 |
92 | return $this->broadcastActionTo(
93 | $this->broadcastDefaultStreamables(inserting: true),
94 | $action,
95 | rendering: Rendering::forModel($this),
96 | );
97 | }
98 |
99 | public function broadcastReplace(): PendingBroadcast
100 | {
101 | return $this->broadcastReplaceTo(
102 | $this->broadcastDefaultStreamables()
103 | );
104 | }
105 |
106 | public function broadcastUpdate(): PendingBroadcast
107 | {
108 | return $this->broadcastUpdateTo(
109 | $this->broadcastDefaultStreamables()
110 | );
111 | }
112 |
113 | public function broadcastRemove(): PendingBroadcast
114 | {
115 | return $this->broadcastRemoveTo(
116 | $this->broadcastDefaultStreamables()
117 | );
118 | }
119 |
120 | public function broadcastRefresh(): PendingBroadcast
121 | {
122 | return $this->broadcastRefreshTo(
123 | $this->broadcastRefreshDefaultStreamables()
124 | );
125 | }
126 |
127 | public function broadcastAppendTo($streamable): PendingBroadcast
128 | {
129 | return $this->broadcastActionTo($streamable, 'append', rendering: Rendering::forModel($this));
130 | }
131 |
132 | public function broadcastPrependTo($streamable): PendingBroadcast
133 | {
134 | return $this->broadcastActionTo($streamable, 'prepend', rendering: Rendering::forModel($this));
135 | }
136 |
137 | public function broadcastBeforeTo($streamable, string $target): PendingBroadcast
138 | {
139 | return $this->broadcastActionTo($streamable, 'before', $target, rendering: Rendering::forModel($this));
140 | }
141 |
142 | public function broadcastAfterTo($streamable, string $target): PendingBroadcast
143 | {
144 | return $this->broadcastActionTo($streamable, 'after', $target, rendering: Rendering::forModel($this));
145 | }
146 |
147 | public function broadcastReplaceTo($streamable): PendingBroadcast
148 | {
149 | return $this->broadcastActionTo($streamable, 'replace', rendering: Rendering::forModel($this));
150 | }
151 |
152 | public function broadcastUpdateTo($streamable): PendingBroadcast
153 | {
154 | return $this->broadcastActionTo($streamable, 'update', rendering: Rendering::forModel($this));
155 | }
156 |
157 | public function broadcastRemoveTo($streamable): PendingBroadcast
158 | {
159 | return $this->broadcastActionTo($streamable, 'remove', rendering: Rendering::empty());
160 | }
161 |
162 | public function broadcastRefreshTo($streamable): PendingBroadcast
163 | {
164 | return TurboStream::broadcastRefresh(
165 | $this->toChannels(Collection::wrap($streamable))
166 | )->cancelIf(fn () => static::isIgnoringTurboStreamBroadcasts());
167 | }
168 |
169 | public function asTurboStreamBroadcastingChannel()
170 | {
171 | return $this->toChannels(Collection::wrap($this->broadcastDefaultStreamables($this->wasRecentlyCreated)));
172 | }
173 |
174 | public function broadcastActionTo($streamables, string $action, ?string $target = null, array $attributes = [], ?Rendering $rendering = null): PendingBroadcast
175 | {
176 | return TurboStream::broadcastAction(
177 | action: $action,
178 | target: $target ?: $this->broadcastDefaultTarget($action),
179 | targets: null,
180 | channel: $this->toChannels(Collection::wrap($streamables)),
181 | attributes: $attributes,
182 | content: $rendering ?? Rendering::empty(),
183 | )->cancelIf(static::isIgnoringTurboStreamBroadcasts());
184 | }
185 |
186 | protected function broadcastRefreshDefaultStreamables()
187 | {
188 | return $this->broadcastDefaultStreamables(inserting: $this->wasRecentlyCreated, broadcastToProperty: 'broadcastsRefreshesTo', broadcastsProperty: 'broadcastsRefreshes');
189 | }
190 |
191 | /**
192 | * @deprecated There was a typo here. Use `broadcastDefaultStreamables` instead.
193 | */
194 | protected function brodcastDefaultStreamables(bool $inserting = false, string $broadcastToProperty = 'broadcastsTo', string $broadcastsProperty = 'broadcasts')
195 | {
196 | return $this->broadcastDefaultStreamables($inserting, $broadcastToProperty, $broadcastsProperty);
197 | }
198 |
199 | protected function broadcastDefaultStreamables(bool $inserting = false, string $broadcastToProperty = 'broadcastsTo', string $broadcastsProperty = 'broadcasts')
200 | {
201 | if (property_exists($this, $broadcastToProperty)) {
202 | return Collection::wrap($this->{$broadcastToProperty})
203 | ->map(fn ($related) => $this->{$related})
204 | ->values()
205 | ->all();
206 | }
207 |
208 | if (method_exists($this, $broadcastToProperty)) {
209 | return $this->{$broadcastToProperty}();
210 | }
211 |
212 | if ($inserting && is_array($this->{$broadcastsProperty}) && isset($this->{$broadcastsProperty}['stream'])) {
213 | return $this->{$broadcastsProperty}['stream'];
214 | }
215 |
216 | return $this->broadcastDefaultStreamableForCurrentModel($inserting);
217 | }
218 |
219 | protected function broadcastDefaultRefreshStreamables()
220 | {
221 | if (property_exists($this, 'broadcastsRefreshesTo') && is_array($this->broadcastsRefreshesTo) && isset($this->broadcastsRefreshesTo['stream'])) {
222 | return $this->broadcastsRefreshesTo['stream'];
223 | }
224 |
225 | return $this->broadcastDefaultStreamableForCurrentModel(inserting: true);
226 | }
227 |
228 | protected function broadcastDefaultStreamableForCurrentModel(bool $inserting)
229 | {
230 | if ($inserting) {
231 | return Name::forModel($this)->plural;
232 | }
233 |
234 | return $this;
235 | }
236 |
237 | protected function toChannels(Collection $streamables): array
238 | {
239 | return $streamables->filter()->map(function ($streamable): \Illuminate\Broadcasting\Channel|\Illuminate\Broadcasting\PrivateChannel {
240 | if ($streamable instanceof Channel) {
241 | return $streamable;
242 | }
243 |
244 | return new PrivateChannel(
245 | is_string($streamable) ? $streamable : $streamable->broadcastChannel()
246 | );
247 | })->values()->all();
248 | }
249 |
250 | protected function broadcastDefaultTarget(string $action): string
251 | {
252 | // Inserting the new element in the DOM will affect
253 | // the parent container, while the other actions
254 | // will, by default, only affect the element.
255 |
256 | if (in_array($action, ['append', 'prepend'])) {
257 | return Name::forModel($this)->plural;
258 | }
259 |
260 | return dom_id($this);
261 | }
262 | }
263 |
--------------------------------------------------------------------------------
/docs/helpers.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Helpers
4 | description: All the helpers the package provides
5 | order: 5
6 | ---
7 |
8 | # Helpers
9 |
10 | Turbo Laravel has a set of Blade Directives, Components, helper functions, and request/response macros to help making the most out of Turbo in Laravel.
11 |
12 | ## Blade Directives
13 |
14 | ### The DOM ID Blade Directive
15 |
16 | Since Turbo relies a lot on DOM IDs, the package offers a helper to generate unique DOM IDs based on your models. You may use the `@domid` Blade Directive in your Blade views like so:
17 |
18 | ```blade
19 |
20 |
21 |
22 | ```
23 |
24 | This will generate a DOM ID string using your model's basename and its ID, such as `post_123`. You may also give it a prefix that will be added to the DOM ID, such as:
25 |
26 | ```blade
27 |
28 |
29 |
30 | ```
31 |
32 | Which will generate a `comments_post_123` DOM ID, assuming your Post model has an ID of `123`.
33 |
34 | ## Blade Components
35 |
36 | ### The Turbo Frame Blade Component
37 |
38 | You may also prefer using the `` Blade component that ships with the package. This way, you don't need to worry about using the `@domid()` helper for your Turbo Frame:
39 |
40 | ```blade
41 |
42 |
43 |
44 | ```
45 |
46 | To the `:id` prop, you may pass a string, which will be used as-is as the DOM ID, an Eloquent model instance, which will be passed to the `dom_id()` function that ships with the package (the same one as the `@domid()` Blade directive uses behind the scenes), or an array tuple where the first item is an instance of an Eloquent model and the second is the prefix of the DOM ID, something like this:
47 |
48 | ```blade
49 |
50 |
51 |
52 | ```
53 |
54 | Additionally, you may also pass along any prop that is supported by the Turbo Frame custom Element to the `` Blade component, like `target`, `src`, or `loading`. These are the listed attributes, but any other attribute will also be forwarded to the `` tag that will be rendered by the `` component. For a full list of what's possible to do with Turbo Frames, see the [documentation](https://turbo.hotwired.dev/handbook/frames).
55 |
56 | ### The Turbo Stream Blade Component
57 |
58 | If you're rendering a Turbo Stream inside a your Blade files, you may use the `` helper:
59 |
60 | ```blade
61 |
62 | @include('posts.partials.post', ['post' => $post])
63 |
64 | ```
65 |
66 | Just like in the Turbo Frames' `:id` prop, the `:target` prop of the Turbo Stream component accepts a string, a model instance, or an array to resolve the DOM ID using the `dom_id()` function.
67 |
68 | ### The Refresh Method Blade Component
69 |
70 | We can configure which update method Turbo should so to update the document:
71 |
72 | | Method | Description |
73 | |---|---|
74 | | `replace` | Updates the entire body of the document on Turbo Visits |
75 | | `morph` | Uses DOM morphing to update the document instead of replacing everything |
76 |
77 | Here's how you can use it:
78 |
79 | ```blade
80 |
81 | ```
82 |
83 | The output would be:
84 |
85 | ```blade
86 |
87 | ```
88 |
89 | ### The Refresh Scroll Behavior Blade Component
90 |
91 | You can also configure the scroll behavior on Turbo:
92 |
93 | | Behavior | Description |
94 | |---|---|
95 | | `reset` | Resets the scroll position to the top, mimicking for the browser handles new page visits |
96 | | `preserve` | Preserves the current scroll position (usually results in a better UX when used with the `morph` method) |
97 |
98 | Here's how you can use it:
99 |
100 | ```blade
101 |
102 | ```
103 |
104 | The output would be:
105 |
106 | ```blade
107 |
108 | ```
109 |
110 | ### The Refresh Behaviors Blade Component
111 |
112 | You may configure both the refresh method and scroll behavior using the `` component in your main layout's `` tag or on specific pages to configure how Turbo should update the page. Here's an example:
113 |
114 | ```blade
115 |
116 | ```
117 |
118 | This will render two HTML `` tags:
119 |
120 | ```html
121 |
122 |
123 | ```
124 |
125 | ### The Page Cache Exemption Blade Component
126 |
127 | This component may be added to any page you don't want Turbo to keep a cache in the page cache. Example:
128 |
129 | ```blade
130 |
131 | ```
132 |
133 | It will render the HTML `` tag:
134 |
135 | ```html
136 |
137 | ```
138 |
139 | ### The Page Preview Exemption Blade Component
140 |
141 | This component may be added to any page you don't want Turbo to show as a preview on regular navigation visits. No-preview pages will only be used in restoration visits (when you use the browser's back or forward buttons, or when when moving backward in the navigation stack). Example:
142 |
143 | ```blade
144 |
145 | ```
146 |
147 | It will render the HTML `` tag:
148 |
149 | ```html
150 |
151 | ```
152 |
153 | ### The Page Reload Blade Component
154 |
155 | This component may be added to any page you want Turbo to reload. This will break out of Turbo Frame navigations. May be used at a login screen, for instance. Example:
156 |
157 | ```blade
158 |
159 | ```
160 |
161 | It will render the HTML `` tag:
162 |
163 | ```html
164 |
165 | ```
166 |
167 | ## Helper Functions
168 |
169 | The package ships with a set of helper functions. These functions are all namespaced under `HotwiredLaravel\\TurboLaravel\\` but we also add them globally for convenience, so you may use them directly without the `use` statements (this is useful in contexts like Blade views, for instance).
170 |
171 | ### The DOM ID Helper Function
172 |
173 | The mentioned namespaced `dom_id()` helper function may also be used from anywhere in your application, like so:
174 |
175 | ```php
176 | use function HotwiredLaravel\TurboLaravel\dom_id;
177 |
178 | dom_id($comment);
179 | ```
180 |
181 | When a new instance of a model is passed to any of these DOM ID helpers, since it doesn't have an ID, it will prefix the resource name with a `create_` prefix. This way, new instances of an `App\\Models\\Comment` model will generate a `create_comment` DOM ID.
182 |
183 | These helpers strip out the model's FQCN (see [config/turbo-laravel.php](https://github.com/hotwired-laravel/turbo-laravel/blob/main/config/turbo-laravel.php) if you use an unconventional location for your models).
184 |
185 | ### The DOM CSS Class Helper Function
186 |
187 | The `dom_class()` helper function may be used from anywhere in your application, like so:
188 |
189 | ```php
190 | use function HotwiredLaravel\TurboLaravel\dom_class;
191 |
192 | dom_class($comment);
193 | ```
194 |
195 | This function will generate the DOM class named based on your model's class name. If you have an instance of a `App\Models\Comment` model, it will generate a `comment` DOM class.
196 |
197 | Similarly to the `dom_id()` function, you may also pass a context prefix as the second parameter:
198 |
199 | ```php
200 | dom_class($comment, 'reactions_list');
201 | ```
202 |
203 | This will generate a DOM class of `reactions_list_comment`.
204 |
205 | ### The Turbo Stream Helper Function
206 |
207 | You may generate Turbo Streams using the `Response::turboStream()` macro, but you may also do so using the `turbo_stream()` helper function:
208 |
209 | ```php
210 | use function HotwiredLaravel\TurboLaravel\turbo_stream;
211 |
212 | turbo_stream()->append($comment);
213 | ```
214 |
215 | Both the `Response::turboStream()` and the `turbo_stream()` function work the same way. The `turbo_stream()` function may be easier to use.
216 |
217 | ### The Turbo Stream View Helper Function
218 |
219 | You may combo Turbo Streams using the `turbo_stream([])` function passing an array, but you may prefer to create a separate Blade view with all the Turbo Streams, this way you may also use template extensions and everything else Blade offers:
220 |
221 | ```php
222 | use function HotwiredLaravel\TurboLaravel\turbo_stream_view;
223 |
224 | return turbo_stream_view('comments.turbo.created', [
225 | 'comment' => $comment,
226 | ]);
227 | ```
228 |
229 | ## Request & Response Macros
230 |
231 | ### Detect If Request Accepts Turbo Streams
232 |
233 | The `request()->wantsTurboStream()` macro added to the request class will check if the request accepts Turbo Stream and return `true` or `false` accordingly.
234 |
235 | Turbo will add a `Accept: text/vnd.turbo-stream.html, ...` header to the requests. That's how we can detect if the request came from a client using Turbo.
236 |
237 | ### Detect If Request Was Made From Turbo Frame
238 |
239 | The `request()->wasFromTurboFrame()` macro added to the request class will check if the request was made from a Turbo Frame. When used with no parameters, it returns `true` if the request has a `Turbo-Frame` header, no matter which specific Turbo Frame.
240 |
241 | Additionally, you may specific the optional `$frame` parameter. When that's passed, it returns `true` if it has a `Turbo-Frame` header where the value matches the specified `$frame`. Otherwise, it will return `false`:
242 |
243 | ```php
244 | if (request()->wasFromTurboFrame(dom_id($post, 'create_comment'))) {
245 | // ...
246 | }
247 | ```
248 |
249 | ### Detect If Request Was Made From Hotwire Native Client
250 |
251 | The `request()->wasFromHotwireNative()` macro added to the request class will check if the request came from a Hotwire Native client and returns `true` or `false` accordingly.
252 |
253 | Hotwire Native clients are encouraged to override the `User-Agent` header in the WebViews to mention the words `Hotwire Native` on them. This is what this macro uses to detect if it came from a Hotwire Native client.
254 |
255 | ### Turbo Stream Response Macro
256 |
257 | The `response()->turboStream()` macro works similarly to the `turbo_stream()` function above. It was only added to the response for convenience.
258 |
259 | ### The Turbo Stream View Response Macro
260 |
261 | The `response()->turboStreamView()` macro works similarly to the `turbo_stream_view()` function above. It was only added to the response for convenience.
262 |
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Testing
4 | description: Testing Helpers
5 | order: 12
6 | ---
7 |
8 | # Testing
9 |
10 | Testing a Hotwired app is like testing a regular Laravel app. However, Turbo Laravel comes with a set of helpers that may be used to ease testing some aspects that are specific to Turbo:
11 |
12 | 1. **Turbo HTTP Request Helpers**. When you may want to mimic a Turbo visit, or a Hotwire Native visit, or a request coming from a Turbo Frame.
13 | 1. **Turbo Streams on HTTP Responses.** When you may want to test the Turbo Streams returned from HTTP requests.
14 | 1. **Turbo Stream Broadcasts.** When you're either using the broadcast methods on your models using the `Broadcasts` trait, or when you're using [Handmade Turbo Stream Broadcasts](/docs/broadcasting#content-handmade-broadcasts).
15 |
16 | Let's dig into those aspects and how you may test them.
17 |
18 | ## Turbo HTTP Request Helpers
19 |
20 | To enhance your testing capabilities when using Turbo, Turbo Laravel adds a few macros to the `TestResponse` that Laravel uses under the hood. It also ships with a `InteractsWithTurbo` trait that adds Turbo-specific testing helper methods. The goal is to allow mimicking a request and inspecting the response in a very Laravel way.
21 |
22 | ### Acting as Turbo Visits
23 |
24 | Turbo visits are marked with a `Accept: text/vnd.turbo-stream.html, ...` header, which you may want to respond differently (maybe returning a Turbo Streams document instead of plain HTML). To be able to make request adding that header, you may add the `InteractsWithTurbo` trait to your current test class (or to the base `TestCase`). Then, you may use the `$this->turbo()` method before issuing a request:
25 |
26 | ```php
27 | use HotwiredLaravel\TurboLaravel\Testing\InteractsWithTurbo;
28 |
29 | class CreateCommentsTest extends TestCase
30 | {
31 | use InteractsWithTurbo;
32 |
33 | /** @test */
34 | public function creates_comments()
35 | {
36 | $post = Post::factory()->create();
37 |
38 | $this->assertCount(0, $post->comments);
39 |
40 | $this->turbo()->post(route('posts.comments.store', $post), [
41 | 'content' => 'Hello World',
42 | ])->assertOk();
43 |
44 | $this->assertCount(1, $post->refresh()->comments);
45 | $this->assertEquals('Hello World', $post->comments->first()->content);
46 | }
47 | }
48 | ```
49 |
50 | When using this method, calls to `request()->wantsTurboStream()` will return `true`.
51 |
52 | ## Acting as Turbo Frame Requests
53 |
54 | You may want to handle requests a bit differently based on whether they came from a request triggered inside a Turbo Frame or not. To mimic a request coming from a Turbo Frame, you may use the `fromTurboFrame()` helper from the `InteractsWithTurbo` trait:
55 |
56 | ```php
57 | use HotwiredLaravel\TurboLaravel\Testing\InteractsWithTurbo;
58 |
59 | class CreateCommentsTest extends TestCase
60 | {
61 | use InteractsWithTurbo;
62 |
63 | /** @test */
64 | public function create_comment()
65 | {
66 | $article = Article::factory()->create();
67 |
68 | $this->fromTurboFrame(dom_id($article, 'create_comment'))
69 | ->post(route('articles.comments.store', $article), [...])
70 | ->assertRedirect();
71 | }
72 | }
73 | ```
74 |
75 | ### Acting as Hotwire Native
76 |
77 | Additionally, when you're building a Hotwire Native mobile app, you may want to issue a request pretending to be sent from a Hotwire Native client. That's done by setting the `User-Agent` header to something that mentions the word `Hotwire Native`. The `InteractsWithTurbo` trait also has a `$this->hotwireNative()` method you may use that automatically sets the header correctly:
78 |
79 | ```php
80 | use HotwiredLaravel\TurboLaravel\Testing\InteractsWithTurbo;
81 |
82 | class CreateCommentsTest extends TestCase
83 | {
84 | use InteractsWithTurbo;
85 |
86 | /** @test */
87 | public function creating_comments_from_native_recedes()
88 | {
89 | $post = Post::factory()->create();
90 |
91 | $this->assertCount(0, $post->comments);
92 |
93 | $this->hotwireNative()->post(route('posts.comments.store', $post), [
94 | 'content' => 'Hello World',
95 | ])->assertOk();
96 |
97 | $this->assertCount(1, $post->refresh()->comments);
98 | $this->assertEquals('Hello World', $post->comments->first()->content);
99 | }
100 | }
101 | ```
102 |
103 | When using this method, calls to `request()->wasFromHotwireNative()` will return `true`. Additionally, the `@hotwirenative` and `@unlesshotwirenative` Blade directives will render as expected.
104 |
105 | A few other macros were added to the `TestResponse` class to make it easier to assert based on the `recede`, `resume`, and `refresh` redirects using the specific assert methods:
106 |
107 | | Method | Descrition |
108 | |---|---|
109 | | `assertRedirectRecede(array $with = [])` | Asserts that a redirect was returned to the `/recede_historical_location` route. |
110 | | `assertRedirectResume(array $with = [])` | Asserts that a redirect was returned to the `/resume_historical_location` route. |
111 | | `assertRedirectRefresh(array $with = [])` | Asserts that a redirect was returned to the `/refresh_historical_location` route. |
112 |
113 | The `$with` argument will ensure that not only the route is correct, but also any flashed message will be included in the query string:
114 |
115 | ```php
116 | use HotwiredLaravel\TurboLaravel\Testing\InteractsWithTurbo;
117 |
118 | class CreateCommentsTest extends TestCase
119 | {
120 | use InteractsWithTurbo;
121 |
122 | /** @test */
123 | public function creating_comments_from_native_recedes()
124 | {
125 | $post = Post::factory()->create();
126 |
127 | $this->assertCount(0, $post->comments);
128 |
129 | $this->hotwireNative()->post(route('posts.comments.store', $post), [
130 | 'content' => 'Hello World',
131 | ])->assertRedirectRecede(['status' => __('Comment created.')]);
132 |
133 | $this->assertCount(1, $post->refresh()->comments);
134 | $this->assertEquals('Hello World', $post->comments->first()->content);
135 | }
136 | }
137 | ```
138 |
139 | ## Asserting Turbo Stream HTTP Responses
140 |
141 | You may test if you got a Turbo Stream response by using the `assertTurboStream()` response helper macro. Similarly, you may assert that your response was _not_ a Turbo Stream response by using the `assertNotTurboStream()` response helper macro:
142 |
143 | ```php
144 | use HotwiredLaravel\TurboLaravel\Testing\InteractsWithTurbo;
145 |
146 | class CreateTodosTest extends TestCase
147 | {
148 | use InteractsWithTurbo;
149 |
150 | /** @test */
151 | public function creating_todo_from_turbo_request_returns_turbo_stream_response()
152 | {
153 | $this->turbo()->post(route('todos.store'), [
154 | 'content' => 'Test the app',
155 | ])->assertTurboStream();
156 | }
157 |
158 | /** @test */
159 | public function creating_todo_from_regular_request_does_not_return_turbo_stream_response()
160 | {
161 | // Notice we're not chaining the `$this->turbo()` method here.
162 | $this->post(route('todos.store'), [
163 | 'content' => 'Test the app',
164 | ])->assertNotTurboStream();
165 | }
166 | }
167 | ```
168 |
169 | The controller for such response would be something like this:
170 |
171 | ```php
172 | class TodosController
173 | {
174 | public function store()
175 | {
176 | $todo = auth()->user()->todos()->create(request()->validate([
177 | 'content' => ['required'],
178 | ]));
179 |
180 | if (request()->wantsTurboStream()) {
181 | return turbo_stream($todo);
182 | }
183 |
184 | return redirect()->route('todos.index');
185 | }
186 | }
187 | ```
188 |
189 | ## Fluent Turbo Stream Assertions
190 |
191 | The `assertTurboStream()` macro accepts a callback which allows you to assert specific details about your returned Turbo Streams. The callback takes an instance of the `AssertableTurboStream` class, which has some matching methods to help you building your specific assertion. In the following example, we're asserting that 2 Turbo Streams were returned, as well as their targets, actions, and even HTML content:
192 |
193 | ```php
194 | /** @test */
195 | public function create_todos()
196 | {
197 | $this->get(route('todos.store'))
198 | ->assertTurboStream(fn (AssertableTurboStream $turboStreams) => (
199 | $turboStreams->has(2)
200 | && $turboStreams->hasTurboStream(fn ($turboStream) => (
201 | $turboStream->where('target', 'flash_messages')
202 | ->where('action', 'prepend')
203 | ->see('Todo was successfully created!')
204 | ))
205 | && $turboStreams->hasTurboStream(fn ($turboStream) => (
206 | $turboStream->where('target', 'todos')
207 | ->where('action', 'append')
208 | ->see('Test the app')
209 | ))
210 | ));
211 | }
212 | ```
213 |
214 | ## Testing Turbo Stream Broadcasts
215 |
216 | You may assert that Turbo Stream broadcasts were sent from any mechanism provided by Turbo Laravel by using the `TurboStream::fake()` abstraction. This allows you to capture any kind of Turbo Stream broadcasting that happens inside your application and assert on them:
217 |
218 | ```php
219 | use App\Models\Todo;
220 | use HotwiredLaravel\TurboLaravel\Facades\TurboStream;
221 | use HotwiredLaravel\TurboLaravel\Broadcasting\PendingBroadcast;
222 |
223 | class CreatesCommentsTest extends TestCase
224 | {
225 | /** @test */
226 | public function content_is_required()
227 | {
228 | TurboStream::fake();
229 |
230 | $todo = Todo::factory()->create();
231 |
232 | $this->turbo()->post(route('todos.comments.store', $todo), [
233 | 'content' => null,
234 | ])->assertInvalid(['content']);
235 |
236 | TurboStream::assertNothingWasBroadcasted();
237 | }
238 |
239 | /** @test */
240 | public function creates_comments()
241 | {
242 | TurboStream::fake();
243 |
244 | $todo = Todo::factory()->create();
245 |
246 | $this->turbo()->post(route('todos.comments.store', $todo), [
247 | 'content' => 'Hey, this is really nice!',
248 | ])->assertTurboStream();
249 |
250 | TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($todo) {
251 | return $broadcast->target === 'comments'
252 | && $broadcast->action === 'append'
253 | && $broadcast->partialView === 'comments.partials.comment'
254 | && $broadcast->partialData['comment']->is($todo->comments->first())
255 | && count($broadcast->channels) === 1
256 | && $broadcast->channels[0]->name === sprintf('private-%s', $todo->broadcastChannel());
257 | });
258 | }
259 | }
260 | ```
261 |
262 | *Note: If you're using the automatic model changes broadcasting, make sure your `turbo-laravel.queue` config key is set to false, otherwise actions may not be dispatched during test because the model observer only fires them after the transaction is committed, which never happens in tests since they run inside a transaction.*
263 |
--------------------------------------------------------------------------------
/src/Http/PendingTurboStreamResponse.php:
--------------------------------------------------------------------------------
1 | exists || (method_exists($model, 'trashed') && $model->trashed())) {
50 | return $builder->buildAction(
51 | action: 'remove',
52 | target: $builder->resolveTargetFor($model),
53 | );
54 | }
55 |
56 | if ($model->wasRecentlyCreated) {
57 | return $builder->buildAction(
58 | action: $action ?: 'append',
59 | target: $builder->resolveTargetFor($model, resource: true),
60 | rendering: Rendering::forModel($model),
61 | );
62 | }
63 |
64 | return $builder->buildAction(
65 | action: $action ?: 'replace',
66 | target: $builder->resolveTargetFor($model),
67 | rendering: Rendering::forModel($model),
68 | );
69 | }
70 |
71 | public function target(Model|string $target, bool $resource = false): self
72 | {
73 | $this->useTarget = $target instanceof Model ? $this->resolveTargetFor($target, $resource) : $target;
74 | $this->useTargets = null;
75 |
76 | return $this;
77 | }
78 |
79 | public function targets(Model|string $targets): self
80 | {
81 | $this->useTarget = null;
82 | $this->useTargets = $targets instanceof Model ? $this->resolveTargetFor($targets, resource: true) : $targets;
83 |
84 | return $this;
85 | }
86 |
87 | public function action(string $action): self
88 | {
89 | $this->useAction = $action;
90 |
91 | return $this;
92 | }
93 |
94 | public function partial(string $view, array $data = []): self
95 | {
96 | return $this->view($view, $data);
97 | }
98 |
99 | public function view(string $view, array $data = []): self
100 | {
101 | $this->partialView = $view;
102 | $this->partialData = $data;
103 |
104 | return $this;
105 | }
106 |
107 | public function attributes(array $attributes): self
108 | {
109 | $this->useCustomAttributes = $attributes;
110 |
111 | return $this;
112 | }
113 |
114 | public function morph(): self
115 | {
116 | return $this->method('morph');
117 | }
118 |
119 | public function method(?string $method = null): self
120 | {
121 | if ($method) {
122 | return $this->attributes(array_merge($this->useCustomAttributes, [
123 | 'method' => $method,
124 | ]));
125 | }
126 |
127 | return $this->attributes(Arr::except($this->useCustomAttributes, 'method'));
128 | }
129 |
130 | public function append(Model|string $target, $content = null): self
131 | {
132 | return $this->buildAction(
133 | action: 'append',
134 | target: $target instanceof Model ? $this->resolveTargetFor($target, resource: true) : $target,
135 | content: $content,
136 | rendering: $target instanceof Model ? Rendering::forModel($target) : null,
137 | );
138 | }
139 |
140 | public function appendAll(Model|string $targets, $content = null): self
141 | {
142 | return $this->buildActionAll(
143 | action: 'append',
144 | targets: $targets,
145 | content: $content,
146 | );
147 | }
148 |
149 | public function prepend(Model|string $target, $content = null): self
150 | {
151 | return $this->buildAction(
152 | action: 'prepend',
153 | target: $target instanceof Model ? $this->resolveTargetFor($target, resource: true) : $target,
154 | content: $content,
155 | rendering: $target instanceof Model ? Rendering::forModel($target) : null,
156 | );
157 | }
158 |
159 | public function prependAll(Model|string $targets, $content = null): self
160 | {
161 | return $this->buildActionAll(
162 | action: 'prepend',
163 | targets: $targets,
164 | content: $content,
165 | );
166 | }
167 |
168 | public function before(Model|string $target, $content = null): self
169 | {
170 | return $this->buildAction(
171 | action: 'before',
172 | target: $target,
173 | content: $content,
174 | );
175 | }
176 |
177 | public function beforeAll(Model|string $targets, $content = null): self
178 | {
179 | return $this->buildActionAll(
180 | action: 'before',
181 | targets: $targets,
182 | content: $content,
183 | );
184 | }
185 |
186 | public function after(Model|string $target, $content = null): self
187 | {
188 | return $this->buildAction(
189 | action: 'after',
190 | target: $target,
191 | content: $content,
192 | );
193 | }
194 |
195 | public function afterAll(Model|string $targets, $content = null): self
196 | {
197 | return $this->buildActionAll(
198 | action: 'after',
199 | targets: $targets,
200 | content: $content,
201 | );
202 | }
203 |
204 | public function update(Model|string $target, $content = null): self
205 | {
206 | return $this->buildAction(
207 | action: 'update',
208 | target: $target,
209 | content: $content,
210 | rendering: $target instanceof Model ? Rendering::forModel($target) : null,
211 | );
212 | }
213 |
214 | public function updateAll(Model|string $targets, $content = null): self
215 | {
216 | return $this->buildActionAll(
217 | action: 'update',
218 | targets: $targets,
219 | content: $content,
220 | );
221 | }
222 |
223 | public function replace(Model|string $target, $content = null): self
224 | {
225 | return $this->buildAction(
226 | action: 'replace',
227 | target: $target,
228 | content: $content,
229 | rendering: $target instanceof Model ? Rendering::forModel($target) : null,
230 | );
231 | }
232 |
233 | public function replaceAll(Model|string $targets, $content = null): self
234 | {
235 | return $this->buildActionAll(
236 | action: 'replace',
237 | targets: $targets,
238 | content: $content,
239 | );
240 | }
241 |
242 | public function remove(Model|string $target): self
243 | {
244 | return $this->buildAction(
245 | action: 'remove',
246 | target: $target,
247 | );
248 | }
249 |
250 | public function removeAll(Model|string $targets): self
251 | {
252 | return $this->buildActionAll(
253 | action: 'remove',
254 | targets: $targets,
255 | );
256 | }
257 |
258 | public function refresh(): self
259 | {
260 | return $this->buildAction('refresh')
261 | ->attributes(array_filter(['request-id' => Turbo::currentRequestId()]));
262 | }
263 |
264 | private function buildAction(string $action, Model|string|null $target = null, $content = null, ?Rendering $rendering = null, array $attributes = []): static
265 | {
266 | $this->useAction = $action;
267 | $this->useTarget = $target instanceof Model ? $this->resolveTargetFor($target) : $target;
268 | $this->partialView = $rendering?->partial;
269 | $this->partialData = $rendering?->data ?? [];
270 | $this->useCustomAttributes = $attributes;
271 | $this->inlineContent = $content;
272 |
273 | return $this;
274 | }
275 |
276 | private function buildActionAll(string $action, Model|string $targets, $content = null, array $attributes = []): static
277 | {
278 | $this->useAction = $action;
279 | $this->useTarget = null;
280 | $this->useTargets = $targets instanceof Model ? $this->resolveTargetFor($targets, resource: true) : $targets;
281 | $this->useCustomAttributes = $attributes;
282 | $this->inlineContent = $content;
283 |
284 | return $this;
285 | }
286 |
287 | public function broadcastTo($channel, ?callable $callback = null)
288 | {
289 | $callback ??= function (): void {};
290 |
291 | return tap($this, function () use ($channel, $callback): void {
292 | $callback($this->asPendingBroadcast($channel));
293 | });
294 | }
295 |
296 | public function broadcastToPrivateChannel($channel, ?callable $callback = null)
297 | {
298 | $callback ??= function (): void {};
299 |
300 | return $this->broadcastTo(null, function (PendingBroadcast $broadcast) use ($channel, $callback): void {
301 | $broadcast->toPrivateChannel($channel);
302 | $callback($broadcast);
303 | });
304 | }
305 |
306 | public function broadcastToPresenceChannel($channel, ?callable $callback = null)
307 | {
308 | $callback ??= function (): void {};
309 |
310 | return $this->broadcastTo(null, function (PendingBroadcast $broadcast) use ($channel, $callback): void {
311 | $callback($broadcast->toPresenceChannel($channel));
312 | });
313 | }
314 |
315 | private function asPendingBroadcast($channel)
316 | {
317 | return TurboStream::broadcastAction(
318 | action: $this->useAction,
319 | target: $this->useTarget,
320 | targets: $this->useTargets,
321 | channel: $channel,
322 | attributes: $this->useCustomAttributes,
323 | )->rendering($this->contentAsRendering());
324 | }
325 |
326 | private function contentAsRendering()
327 | {
328 | if ($this->inlineContent) {
329 | return Rendering::forContent($this->inlineContent);
330 | }
331 |
332 | return new Rendering(
333 | $this->partialView,
334 | $this->partialData,
335 | );
336 | }
337 |
338 | /**
339 | * Create an HTTP response that represents the object.
340 | *
341 | * @param \Illuminate\Http\Request $request
342 | * @return \Symfony\Component\HttpFoundation\Response
343 | */
344 | public function toResponse($request)
345 | {
346 | if (! in_array($this->useAction, ['remove', 'refresh']) && in_array($this->useAction, $this->defaultActions) && ! $this->partialView && $this->inlineContent === null) {
347 | throw TurboStreamResponseFailedException::missingPartial();
348 | }
349 |
350 | return TurboResponseFactory::makeStream($this->render());
351 | }
352 |
353 | public function render(): string
354 | {
355 | return view('turbo-laravel::turbo-stream', [
356 | 'action' => $this->useAction,
357 | 'target' => $this->useTarget,
358 | 'targets' => $this->useTargets,
359 | 'partial' => $this->partialView,
360 | 'partialData' => $this->partialData,
361 | 'content' => $this->renderInlineContent(),
362 | 'attrs' => $this->useCustomAttributes,
363 | ])->render();
364 | }
365 |
366 | public function toHtml()
367 | {
368 | return new HtmlString($this->render());
369 | }
370 |
371 | public function __toString(): string
372 | {
373 | return $this->render();
374 | }
375 |
376 | /**
377 | * @return string|HtmlString|null
378 | */
379 | private function renderInlineContent()
380 | {
381 | if (! $this->inlineContent) {
382 | return null;
383 | }
384 |
385 | if ($this->inlineContent instanceof View) {
386 | return new HtmlString($this->inlineContent->render());
387 | }
388 |
389 | return $this->inlineContent;
390 | }
391 |
392 | private function resolveTargetFor(Model $target, bool $resource = false): string
393 | {
394 | if ($resource) {
395 | return $this->getResourceNameFor($target);
396 | }
397 |
398 | return dom_id($target);
399 | }
400 |
401 | private function getResourceNameFor(Model $model): string
402 | {
403 | return Name::forModel($model)->plural;
404 | }
405 | }
406 |
--------------------------------------------------------------------------------
/docs/turbo-streams.md:
--------------------------------------------------------------------------------
1 | ---
2 | extends: _layouts.docs
3 | title: Turbo Streams
4 | description: The Turbo Streams Components and Helpers
5 | order: 7
6 | ---
7 |
8 | # Turbo Streams
9 |
10 | Out of everything Turbo provides, it's Turbo Streams that benefits the most from a tight backend integration.
11 |
12 | Turbo Laravel offers helper functions, Blade Components, and [Model traits](/docs/broadcasting) to generate Turbo Streams. Turbo will add a new `Content-Type` to the HTTP Accept header (`Accept: text/vnd.turbo-stream.html, ...`) on Form submissions. This is a signal to the backend that we can return a Turbo Stream response for that form submission instead of an HTML document, if we want to.
13 |
14 | Here's an example of a route handler detecting and returning a Turbo Stream response to a form submission:
15 |
16 | ```php
17 | Route::post('posts/{post}/comments', function (Post $post) {
18 | $comment = $post->comments()->create(/** params */);
19 |
20 | if (request()->wantsTurboStream()) {
21 | return turbo_stream($comment);
22 | }
23 |
24 | return back();
25 | });
26 | ```
27 |
28 | The `request()->wantsTurboStream()` macro added to the request class will check if the request accepts Turbo Stream and return `true` or `false` accordingly.
29 |
30 | The `turbo_stream()` helper function may be used to generate streams, but you may also use the `response()->turboStream()` macro as well. In the docs, we'll only use the helper function, but you may use either one of those.
31 |
32 | Here's what the HTML response will look like:
33 |
34 | ```html
35 |
36 |
37 |
38 |
Hello, World
39 |
40 |
41 |
42 | ```
43 |
44 | Most of these things were "guessed" based on the [conventions](/docs/conventions) we talked about earlier. But you can override most things, like so:
45 |
46 | ```php
47 | turbo_stream($comment)->target('post_comments');
48 | ```
49 |
50 | This would render the following Turbo Stream:
51 |
52 | ```html
53 |
54 |
55 |
56 |
Hello, World
57 |
58 |
59 |
60 | ```
61 |
62 | Although it's handy to pass a model instance to the `turbo_stream()` function - which will be used to decide the default values of the Turbo Stream response based on the model's current state, sometimes you may want to build a Turbo Stream response manually:
63 |
64 | ```php
65 | turbo_stream()
66 | ->target('comments')
67 | ->action('append')
68 | ->view('comments.partials.comment', ['comment' => $comment]);
69 | ```
70 |
71 | There are also shorthand methods which may be used as well:
72 |
73 | ```php
74 | turbo_stream()->append($comment);
75 | turbo_stream()->prepend($comment);
76 | turbo_stream()->before($comment);
77 | turbo_stream()->after($comment);
78 | turbo_stream()->replace($comment);
79 | turbo_stream()->update($comment);
80 | turbo_stream()->remove($comment);
81 | turbo_stream()->refresh();
82 | ```
83 |
84 | You may pass an instance of an Eloquent model to all these shorthand methods, except the `refresh` one, which will be used to figure things out like `target`, the `view`, and will also pass that model instance to the view.
85 |
86 | For a model `App\Models\Comment`, the [convention](/docs/conventions) says that the view is located at `resources/views/comments/_comment.blade.php`. Based on the model's class basename, it will figure out the name of the variable that the view should depend on, which would be `$comment` in this case, so it would pass the model instance down to the view automatically. For that reason, when using the convention (which is optional), the model view must only depend on the model instance to be available (no globals or other locals with no defaults).
87 |
88 | Alternatively, you may also pass strings to the shorthand stream builders, which will be used as the target, and an optional content string, which will be rendered instead of a partial, for instance:
89 |
90 | ```php
91 | turbo_stream()->append('statuses', __('Comment created!'));
92 | ```
93 |
94 | The optional content parameter expects either a string, a view instance, or an instance of Laravel's `Illuminate\Support\HtmlString`, so you could do something like:
95 |
96 | ```php
97 | turbo_stream()->append('some_dom_id', view('greetings', [
98 | 'name' => 'Tester',
99 | ]));
100 | ```
101 |
102 | Or more explicitly by passing an instance of the `HtmlString` as content:
103 |
104 | ```php
105 | use Illuminate\Support\Facades\Blade;
106 | use Illuminate\Support\HtmlString;
107 |
108 | turbo_stream()->append('statuses', new HtmlString(
109 | Blade::render('
Hello, {{ $name }}
', ['name' => 'Tony'])
110 | ));
111 | ```
112 |
113 | Which will result in a Turbo Stream like this:
114 |
115 | ```html
116 |
117 |
118 |
Hello, Tony
119 |
120 |
121 | ```
122 |
123 | For both the `before` and `after` methods you need additional calls to specify the view template you want to insert, since the given model/string will only be used to specify the target, something like:
124 |
125 | ```php
126 | turbo_stream()
127 | ->before($comment)
128 | ->view('comments.partials.flash_message', [
129 | 'message' => __('Comment created!'),
130 | ]);
131 | ```
132 |
133 | Just like the other shorthand stream builders, you may also pass an option content string or `HtmlString` instance to the `before` and `after` shorthands. When doing that, you don't need to specify the view section.
134 |
135 | ```php
136 | turbo_stream()->before($comment, __('Oh, hey!'));
137 | ```
138 |
139 | You can read more about Turbo Streams in the [Turbo Handbook](https://turbo.hotwired.dev/handbook/streams).
140 |
141 | As mentioned earlier, passing a model to the `turbo_stream()` helper (or the shorthand Turbo Stream builders) will pre-fill the pending response object with some defaults based on the model's state.
142 |
143 | It will build a `remove` Turbo Stream if the model was just deleted (or if it was trashed - in case it's a Soft Deleted model), an `append` if the model was recently created (which you can override the action as the second parameter), a `replace` if the model was just updated (you can also change it to `update` using the second parameter.) Here's how overriding would look like:
144 |
145 | ```php
146 | return turbo_stream($comment, 'append');
147 | ```
148 |
149 | ## Turbo Streams & Morph
150 |
151 | Both the `update` and `replace` Turbo Stream actions can specify a `[method="morph"]` attribute, so the action will use DOM morphing instead of the default renderer.
152 |
153 | To generate a Turbo Stream with the `[method="morph"]` attribute, chain the `morph()` method:
154 |
155 | ```php
156 | turbo_stream()->replace(dom_id($post, 'comments'), view('comments.partials.comment', [
157 | 'comment' => $comment,
158 | ]))->morph();
159 | ```
160 |
161 | This would generate the following Turbo Stream HTML:
162 |
163 | ```html
164 |
165 | ...
166 |
167 | ```
168 |
169 | And here's the `update` action version:
170 |
171 | ```php
172 | turbo_stream()->update(dom_id($post, 'comments'), view('comments.partials.comment', [
173 | 'comment' => $comment,
174 | ]))->morph();
175 | ```
176 |
177 | This would generate the following Turbo Stream HTML:
178 |
179 | ```html
180 |
181 | ...
182 |
183 | ```
184 |
185 | ## Target Multiple Elements
186 |
187 | Turbo Stream elements can either have a `target` with a DOM ID or a `targets` attribute with a CSS selector to [match multiple elements](https://turbo.hotwired.dev/reference/streams#targeting-multiple-elements). You may use the `xAll` shorthand methods to set the `targets` attribute instead of `target`:
188 |
189 | ```php
190 | turbo_stream()->appendAll('.comment', 'Some content');
191 | turbo_stream()->prependAll('.comment', 'Some content');
192 | turbo_stream()->updateAll('.comment', 'Some content');
193 | turbo_stream()->replaceAll('.comment', 'Some content');
194 | turbo_stream()->beforeAll('.comment', 'Some content');
195 | turbo_stream()->afterAll('.comment', 'Some content');
196 | turbo_stream()->removeAll('.comment');
197 | ```
198 |
199 | With the exception of the `removeAll` method, the `xAll` methods accept a string of inline content, an instance of a View (which may be created using the `view()` function provided by Laravel), or an instance of the `HtmlSafe` class as the second parameter.
200 |
201 | When creating Turbo Streams using the builders, you may also specify the CSS class using the `targets()` (plural) method instead of the `target()` (singular) version:
202 |
203 | ```php
204 | turbo_stream()
205 | ->targets('.comment')
206 | ->action('append')
207 | ->view('comments.partials.comment', ['comment' => $comment]);
208 | ```
209 |
210 | ## Turbo Stream Macros
211 |
212 | The `turbo_stream()` function returns an instance of `PendingTurboStreamResponse`, which is _macroable_. This means you can create your own DSL for your custom Turbo Streams. Let's say you always return flash messages from your controllers like so:
213 |
214 | ```php
215 | class ChirpsController extends Controller
216 | {
217 | public function destroy(Request $request, Chirp $chirp)
218 | {
219 | $this->authorize('delete', $chirp);
220 |
221 | $chirp->delete();
222 |
223 | if ($request->wantsTurboStream()) {
224 | return turbo_stream([
225 | turbo_stream($chirp),
226 | turbo_stream()->append('notifications', view('layouts.notification', [
227 | 'message' => __('Chirp deleted.'),
228 | ])),
229 | ]);
230 | }
231 |
232 | // ...
233 | }
234 | }
235 | ```
236 |
237 | Chances are you're gonna return flash messages from all your controllers, so you could create a custom macro like so:
238 |
239 | ```php
240 | class AppServiceProvider extends ServiceProvider
241 | {
242 | public function boot()
243 | {
244 | PendingTurboStreamResponse::macro('flash', function (string $message) {
245 | return $this->append('notifications', view('layouts.notification', [
246 | 'message' => $message,
247 | ]));
248 | });
249 | }
250 | }
251 | ```
252 |
253 | You could then rewrite that controller like so:
254 |
255 | ```php
256 | class ChirpsController extends Controller
257 | {
258 | public function destroy(Request $request, Chirp $chirp)
259 | {
260 | $this->authorize('delete', $chirp);
261 |
262 | $chirp->delete();
263 |
264 | if ($request->wantsTurboStream()) {
265 | return turbo_stream([
266 | turbo_stream($chirp),
267 | turbo_stream()->append('notifications', view('layouts.notification', [
268 | 'message' => __('Chirp deleted.'),
269 | ])),
270 | turbo_stream()->flash(__('Chirp deleted.')),
271 | ]);
272 | }
273 |
274 | // ...
275 | }
276 | }
277 | ```
278 |
279 | ## Turbo Streams Combo
280 |
281 | You may combine multiple Turbo Streams in a single response like so:
282 |
283 | ```php
284 | return turbo_stream([
285 | turbo_stream()
286 | ->append($comment)
287 | ->target(dom_id($comment->post, 'comments')),
288 | turbo_stream()
289 | ->update(dom_id($comment->post, 'comments_count'), view('posts.partials.comments_count', [
290 | 'post' => $comment->post,
291 | ])),
292 | ]);
293 | ```
294 |
295 | Although this is a valid option, it might feel like too much work for a controller. If that's the case, you may use [Custom Turbo Stream Views](#custom-turbo-stream-views).
296 |
297 | ## Custom Turbo Stream Views
298 |
299 | Although combining Turbo Streams in a single response right there in the controller is a valid option, it may feel like too much work for a controller. If that's the case, you may want to extract the Turbo Streams to a Blade view and respond with that instead:
300 |
301 | ```php
302 | return turbo_stream_view('comments.turbo.created_stream', [
303 | 'comment' => $comment,
304 | ]);
305 | ```
306 |
307 | Similar to the `turbo_stream()` helper function and the `Response::turboStream()` macro, you may prefer using the `Response::turboStreamView()` macro. It works the same way.
308 |
309 | Here's an example of a more complex custom Turbo Stream view:
310 |
311 | ```blade
312 | @include('layouts.turbo.flash_stream')
313 |
314 |
315 |
316 | @include('comments.partials.comment', ['comment' => $comment])
317 |
318 |
319 | ```
320 |
321 | Remember, these are Blade views, so you have the full power of Blade at your hands. In this example, we're including a shared Turbo Stream partial which could append any flash messages we may have. That `layouts.turbo.flash_stream` could look like this:
322 |
323 | ```blade
324 | @if (session()->has('status'))
325 |
326 |
327 | @include('layouts.partials.flash')
328 |
329 |
330 | @endif
331 | ```
332 |
333 | Similar to the `` Blade component, there's also a `` Blade component that can simplify things a bit. It has the same convention of figuring out the DOM ID when you're passing a model instance or an array as `target` attribute of the `` component. When using the component version, there's no need to specify the template wrapper for the Turbo Stream tag, as that will be added by the component itself. So, the same example would look something like this:
334 |
335 | ```blade
336 | @include('layouts.turbo.flash_stream')
337 |
338 |
339 | @include('comments.partials.comment', ['comment' => $comment])
340 |
341 | ```
342 |
343 | I hope you can see how powerful this can be to reusing views.
344 |
345 | ## Custom Actions
346 |
347 | You may also use the `` Blade component for your custom actions as well:
348 |
349 | ```blade
350 |
351 | ```
352 |
353 | Custom actions are only supported from Blade views. You cannot return those from controllers using the Pending Streams Builder.
354 |
--------------------------------------------------------------------------------