├── 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 |
16 | @csrf 17 | 18 |
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 | 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 |

Logo Turbo Laravel

2 | 3 |

4 | Latest Workflow Run 5 | Latest Stable Version 6 | License 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 |
67 | I'm a link 68 | 69 | 70 | ... 71 | 72 |
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 | 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 `