├── stubs ├── resources │ └── js │ │ ├── libs │ │ ├── index-importmap.js │ │ ├── index-node.js │ │ └── stimulus.js │ │ └── controllers │ │ ├── index-node.js │ │ └── index-importmap.js ├── controller.stub └── bridge.stub ├── CHANGELOG.md ├── config └── stimulus-laravel.php ├── src ├── Commands │ ├── CoreMakeCommand.php │ ├── PublishCommand.php │ ├── PublishBoostCommand.php │ ├── ManifestCommand.php │ ├── MakeCommand.php │ ├── Concerns │ │ ├── InstallsForImportmap.php │ │ └── InstallsForNode.php │ └── InstallCommand.php ├── Features.php ├── Facades │ └── StimulusLaravel.php ├── Manifest.php ├── StimulusLaravelServiceProvider.php ├── StimulusGenerator.php └── StimulusLaravel.php ├── rector.php ├── LICENSE.md ├── composer.json ├── .ai ├── stimulus-bridge.blade.php └── stimulus.blade.php ├── resources └── dist │ └── stimulus-loading.js └── README.md /stubs/resources/js/libs/index-importmap.js: -------------------------------------------------------------------------------- 1 | import 'controllers' 2 | -------------------------------------------------------------------------------- /stubs/resources/js/libs/index-node.js: -------------------------------------------------------------------------------- 1 | import '../controllers' 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `stimulus-laravel` will be documented in this file. 4 | -------------------------------------------------------------------------------- /stubs/controller.stub: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | // Connects to data-controller="[attribute]" 4 | export default class extends Controller { 5 | connect() { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /stubs/bridge.stub: -------------------------------------------------------------------------------- 1 | import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge" 2 | 3 | // Connects to data-controller="[attribute]" 4 | export default class extends BridgeComponent { 5 | static component = "[component]" 6 | 7 | // 8 | } 9 | -------------------------------------------------------------------------------- /stubs/resources/js/libs/stimulus.js: -------------------------------------------------------------------------------- 1 | import { Application } from '@hotwired/stimulus' 2 | 3 | const Stimulus = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | Stimulus.debug = false 7 | 8 | window.Stimulus = Stimulus 9 | 10 | export { Stimulus } 11 | -------------------------------------------------------------------------------- /config/stimulus-laravel.php: -------------------------------------------------------------------------------- 1 | resource_path(implode(DIRECTORY_SEPARATOR, ['js', 'controllers'])), 7 | 'features' => [ 8 | Features::directives(), 9 | ], 10 | ]; 11 | -------------------------------------------------------------------------------- /stubs/resources/js/controllers/index-node.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by `php artisan stimulus:install` 2 | // Run that command whenever you add a new controller or create them with 3 | // `php artisan stimulus:make controllerName` 4 | 5 | import { Stimulus } from "../libs/stimulus"; 6 | -------------------------------------------------------------------------------- /src/Commands/CoreMakeCommand.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/Commands/PublishCommand.php: -------------------------------------------------------------------------------- 1 | usingImportmap()) { 17 | $this->components->warn('The assets are only meant for Importmaps Laravel.'); 18 | 19 | return self::FAILURE; 20 | } 21 | 22 | $this->callSilently('vendor:publish', [ 23 | '--tag' => 'stimulus-laravel-assets', 24 | ]); 25 | 26 | $this->comment('Done!'); 27 | 28 | return self::SUCCESS; 29 | } 30 | 31 | private function usingImportmap(): bool 32 | { 33 | return File::exists(base_path('routes/importmap.php')); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) tonysm 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/Commands/PublishBoostCommand.php: -------------------------------------------------------------------------------- 1 | option('bridge') || $this->option('all') ? 'stimulus-bridge.blade.php' : null, 19 | ])); 20 | 21 | foreach ($guidelines as $guideline) { 22 | $from = dirname(__DIR__, levels: 2).DIRECTORY_SEPARATOR.'.ai'.DIRECTORY_SEPARATOR.$guideline; 23 | 24 | File::ensureDirectoryExists(base_path(implode(DIRECTORY_SEPARATOR, ['.ai', 'guidelines'])), recursive: true); 25 | File::copy($from, base_path(implode(DIRECTORY_SEPARATOR, ['.ai', 'guidelines', $guideline]))); 26 | } 27 | 28 | $this->info('Boost guideline was published!'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/ManifestCommand.php: -------------------------------------------------------------------------------- 1 | components->info('Regenerating Manifest'); 18 | 19 | $this->components->task('regenerating manifest', function () use ($generator): true { 20 | $manifest = $generator->generateFrom(config('stimulus-laravel.controllers_path'))->join(PHP_EOL); 21 | $manifestFile = resource_path('js/controllers/index.js'); 22 | 23 | File::ensureDirectoryExists(dirname($manifestFile)); 24 | 25 | if (File::exists($manifestFile)) { 26 | File::delete($manifestFile); 27 | } 28 | 29 | File::put($manifestFile, <<newLine(); 43 | $this->components->info('Done'); 44 | 45 | return self::SUCCESS; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Manifest.php: -------------------------------------------------------------------------------- 1 | filter(fn (SplFileInfo $file): bool => str_contains($file->getFilename(), '_controller')) 16 | ->values() 17 | ->map(function (SplFileInfo $file) use ($controllersPath): string { 18 | $controllerPath = $this->relativePathFrom($file->getRealPath(), $controllersPath); 19 | $modulePath = Str::of($controllerPath)->before('.')->replace(DIRECTORY_SEPARATOR, '/')->toString(); 20 | $controllerClassName = Str::of($modulePath) 21 | ->explode('/') 22 | ->map(fn ($piece) => Str::studly($piece)) 23 | ->join('__'); 24 | $tagName = Str::of($modulePath)->before('_controller')->replace('_', '-')->replace('/', '--')->toString(); 25 | 26 | $join = (fn ($paths): string => implode('/', $paths)); 27 | 28 | return <<name('stimulus-laravel') 20 | ->hasAssets() 21 | ->hasConfigFile() 22 | ->hasCommands([ 23 | Commands\InstallCommand::class, 24 | Commands\MakeCommand::class, 25 | Commands\CoreMakeCommand::class, 26 | Commands\PublishCommand::class, 27 | Commands\PublishBoostCommand::class, 28 | Commands\ManifestCommand::class, 29 | ]); 30 | } 31 | 32 | public function packageBooted(): void 33 | { 34 | $this->bindDirectivesIfEnabled(); 35 | } 36 | 37 | private function bindDirectivesIfEnabled(): void 38 | { 39 | if (! Features::enabled(Features::directives())) { 40 | return; 41 | } 42 | 43 | Blade::directive('controller', fn ($expression): string => ""); 44 | 45 | Blade::directive('target', fn ($expression): string => ""); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Commands/MakeCommand.php: -------------------------------------------------------------------------------- 1 | components->info($this->option('bridge') ? 'Making a Stimulus Bridge Component' : 'Making Stimulus Controller'); 19 | 20 | $this->components->task('creating file', function () use ($generator): true { 21 | $generator->create($this->argument('name'), bridge: $this->option('bridge')); 22 | 23 | return true; 24 | }); 25 | 26 | if (! File::exists(base_path('routes/importmap.php'))) { 27 | $this->components->task('regenerating manifest', fn () => $this->callSilently(ManifestCommand::class)); 28 | 29 | if (file_exists(base_path('pnpm-lock.yaml'))) { 30 | Process::forever()->path(base_path())->run(['pnpm', 'run', 'build']); 31 | } elseif (file_exists(base_path('yarn.lock'))) { 32 | Process::forever()->path(base_path())->run(['yarn', 'run', 'build']); 33 | } else { 34 | Process::forever()->path(base_path())->run(['npm', 'run', 'build']); 35 | } 36 | } 37 | 38 | $this->newLine(); 39 | $this->components->info('Done'); 40 | 41 | return self::SUCCESS; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/StimulusGenerator.php: -------------------------------------------------------------------------------- 1 | targetFolder ??= rtrim(resource_path('js/controllers'), '/'); 13 | } 14 | 15 | public function create( 16 | string $name, 17 | ?string $stub = null, 18 | ?callable $replacementsCallback = null, 19 | ?string $bridge = null, 20 | ): array { 21 | $replacementsCallback ??= fn ($replacements) => $replacements; 22 | $controllerName = $this->controllerName($name); 23 | $targetFile = $this->targetFolder.'/'.$controllerName.'_controller.js'; 24 | 25 | File::ensureDirectoryExists(dirname($targetFile)); 26 | 27 | $replacements = $replacementsCallback([ 28 | '[attribute]' => $attributeName = $this->attributeName($name), 29 | '[component]' => $bridge ?? '', 30 | ]); 31 | 32 | File::put( 33 | $targetFile, 34 | str_replace(array_keys($replacements), array_values($replacements), File::get($stub ?: $this->getDefaultStub(boolval($bridge)))), 35 | ); 36 | 37 | return [ 38 | 'file' => $targetFile, 39 | 'controller_name' => $controllerName, 40 | 'attribute_name' => $attributeName, 41 | ]; 42 | } 43 | 44 | private function controllerName(string $name): string 45 | { 46 | return Str::of($name)->replace('_controller', '')->snake('_'); 47 | } 48 | 49 | private function attributeName(string $name): string 50 | { 51 | return Str::of($this->controllerName($name))->replace('/', '--')->replace('_', '-'); 52 | } 53 | 54 | private function getDefaultStub(bool $bridge): string 55 | { 56 | if ($bridge) { 57 | return __DIR__.'/../stubs/bridge.stub'; 58 | } 59 | 60 | return __DIR__.'/../stubs/controller.stub'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotwired-laravel/stimulus-laravel", 3 | "description": "Use Stimulus in your Laravel app", 4 | "keywords": [ 5 | "hotwired", 6 | "hotwire", 7 | "laravel", 8 | "stimulus", 9 | "stimulus-laravel" 10 | ], 11 | "homepage": "https://github.com/hotwired-laravel/stimulus-laravel", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Tony Messias", 16 | "email": "tonysm@hey.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "spatie/laravel-package-tools": "^1.9.2", 23 | "illuminate/contracts": "^11.0|^12.0" 24 | }, 25 | "require-dev": { 26 | "laravel/pint": "^1.21", 27 | "nunomaduro/collision": "^8.1|^9.0", 28 | "orchestra/testbench": "^9.1|^10.0", 29 | "phpunit/phpunit": "^10.5|^11.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "HotwiredLaravel\\StimulusLaravel\\": "src", 34 | "HotwiredLaravel\\StimulusLaravel\\Database\\Factories\\": "database/factories" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "HotwiredLaravel\\StimulusLaravel\\Tests\\": "tests" 40 | } 41 | }, 42 | "scripts": { 43 | "analyse": "vendor/bin/phpstan analyse", 44 | "test": "vendor/bin/pest", 45 | "test-coverage": "vendor/bin/pest --coverage", 46 | "format": "vendor/bin/pint" 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "pestphp/pest-plugin": true, 52 | "phpstan/extension-installer": true 53 | } 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "HotwiredLaravel\\StimulusLaravel\\StimulusLaravelServiceProvider" 59 | ], 60 | "aliases": { 61 | "StimulusLaravel": "HotwiredLaravel\\StimulusLaravel\\Facades\\StimulusLaravel" 62 | } 63 | } 64 | }, 65 | "minimum-stability": "dev", 66 | "prefer-stable": true 67 | } 68 | -------------------------------------------------------------------------------- /src/StimulusLaravel.php: -------------------------------------------------------------------------------- 1 | map(function ($configs, $controller): array { 15 | if (is_numeric($controller)) { 16 | $controller = $configs; 17 | $configs = []; 18 | } 19 | 20 | return [ 21 | 'controller' => $controller, 22 | 'target' => $configs['target'] ?? null, 23 | 'value' => $configs['value'] ?? null, 24 | 'class' => $configs['class'] ?? null, 25 | ]; 26 | })->reduce(function ($acc, array $configs) { 27 | $acc['data-controller'] = array_merge($acc['data-controller'] ?? [], [$configs['controller']]); 28 | 29 | foreach (['target', 'value', 'class'] as $attribute) { 30 | if ($configs[$attribute]) { 31 | foreach ($configs[$attribute] as $key => $val) { 32 | $acc['data-'.$configs['controller'].'-'.$key.'-'.$attribute] = $val; 33 | } 34 | } 35 | } 36 | 37 | return $acc; 38 | }, collect())->map(function ($value, $attr): string { 39 | $attr = e($attr); 40 | $controllers = e(is_array($value) ? implode(' ', $value) : $value); 41 | 42 | return "{$attr}=\"{$controllers}\""; 43 | })->join(' '); 44 | } 45 | 46 | public function target($targets) 47 | { 48 | $targets = collect(Arr::wrap($targets)); 49 | 50 | return $targets->reduce(function (Collection $acc, $targetName, string $controller) { 51 | $acc['data-'.$controller.'-target'] = $targetName; 52 | 53 | return $acc; 54 | }, collect())->map(function ($value, $attr): string { 55 | $attr = e($attr); 56 | $value = e($value); 57 | 58 | return "{$attr}=\"{$value}\""; 59 | })->join(' '); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Commands/Concerns/InstallsForImportmap.php: -------------------------------------------------------------------------------- 1 | publishJsFilesForImportmaps(); 16 | $this->registerImportmapPins(); 17 | } 18 | 19 | protected function publishJsFilesForImportmaps() 20 | { 21 | File::ensureDirectoryExists(resource_path('js/controllers')); 22 | File::ensureDirectoryExists(resource_path('js/libs')); 23 | 24 | File::copy(__DIR__.'/../../../stubs/resources/js/libs/stimulus.js', resource_path('js/libs/stimulus.js')); 25 | File::copy(__DIR__.'/../../../stubs/resources/js/controllers/index-importmap.js', resource_path('js/controllers/index.js')); 26 | 27 | $libsIndexFile = resource_path('js/libs/index.js'); 28 | $libsIndexSourceFile = __DIR__.'/../../../stubs/resources/js/libs/index-importmap.js'; 29 | 30 | if (File::exists($libsIndexFile)) { 31 | $importLine = trim(File::get($libsIndexSourceFile)); 32 | 33 | if (! str_contains(File::get($libsIndexFile), $importLine)) { 34 | File::append($libsIndexFile, PHP_EOL.$importLine.PHP_EOL); 35 | } 36 | } else { 37 | File::copy($libsIndexSourceFile, $libsIndexFile); 38 | } 39 | } 40 | 41 | protected function registerImportmapPins() 42 | { 43 | $dependencies = collect($this->jsPackages()) 44 | ->map(fn ($version, $package): string => "{$package}@{$version}") 45 | ->values() 46 | ->all(); 47 | 48 | Process::forever()->run(array_merge([ 49 | $this->phpBinary(), 50 | 'artisan', 51 | 'importmap:pin', 52 | ], $dependencies), function ($_type, $output): void { 53 | $this->output->write($output); 54 | }); 55 | 56 | // Publishes the `@hotwired/stimulus-loading` package to public/vendor 57 | Process::forever()->run([ 58 | $this->phpBinary(), 59 | 'artisan', 60 | 'vendor:publish', 61 | '--tag', 62 | 'stimulus-laravel-assets', 63 | ], function ($_type, $output): void { 64 | $this->output->write($output); 65 | }); 66 | 67 | File::append($this->importmapsFile(), <<<'IMPORTMAP' 68 | Importmap::pin("@hotwired/stimulus-loading", to: "vendor/stimulus-laravel/stimulus-loading.js", preload: true); 69 | IMPORTMAP); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Commands/Concerns/InstallsForNode.php: -------------------------------------------------------------------------------- 1 | publishJsFilesForNode(); 42 | $this->updateNpmPackagesForNode(); 43 | } 44 | 45 | protected function publishJsFilesForNode() 46 | { 47 | File::ensureDirectoryExists(resource_path('js/controllers')); 48 | File::ensureDirectoryExists(resource_path('js/libs')); 49 | 50 | File::copy(__DIR__.'/../../../stubs/resources/js/libs/stimulus.js', resource_path('js/libs/stimulus.js')); 51 | File::copy(__DIR__.'/../../../stubs/resources/js/controllers/index-node.js', resource_path('js/controllers/index.js')); 52 | 53 | $libsIndexFile = resource_path('js/libs/index.js'); 54 | $libsIndexSourceFile = __DIR__.'/../../../stubs/resources/js/libs/index-node.js'; 55 | 56 | if (File::exists($libsIndexFile)) { 57 | $importLine = trim(File::get($libsIndexSourceFile)); 58 | 59 | if (! str_contains(File::get($libsIndexFile), $importLine)) { 60 | File::append($libsIndexFile, $importLine.PHP_EOL); 61 | } 62 | } else { 63 | File::copy($libsIndexSourceFile, $libsIndexFile); 64 | } 65 | 66 | if (! str_contains(File::get(resource_path('js/app.js')), "import './libs';")) { 67 | File::append(resource_path('js/app.js'), <<<'JS' 68 | import './libs'; 69 | 70 | JS); 71 | } 72 | } 73 | 74 | protected function updateNpmPackagesForNode() 75 | { 76 | $this->updateNodePackages(fn ($packages): array => array_merge( 77 | $packages, 78 | $this->jsPackages(), 79 | )); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | usingImportmaps()) { 25 | $this->installsForImportmaps(); 26 | } else { 27 | $this->installsForNode(); 28 | 29 | if (file_exists(base_path('pnpm-lock.yaml'))) { 30 | $this->runCommands(['pnpm install', 'pnpm run build']); 31 | } elseif (file_exists(base_path('yarn.lock'))) { 32 | $this->runCommands(['yarn install', 'yarn run build']); 33 | } else { 34 | $this->runCommands(['npm install', 'npm run build']); 35 | } 36 | } 37 | 38 | $this->newLine(); 39 | 40 | $this->components->info('Stimulus Laravel was installed successfully.'); 41 | 42 | return self::SUCCESS; 43 | } 44 | 45 | protected function jsPackages(): array 46 | { 47 | return array_merge( 48 | ['@hotwired/stimulus' => '^3.2'], 49 | $this->hasOption('strada') ? ['@hotwired/hotwire-native-bridge' => '^1.1'] : [], 50 | ); 51 | } 52 | 53 | /** 54 | * Run the given commands. 55 | * 56 | * @param array $commands 57 | * @return void 58 | */ 59 | protected function runCommands($commands) 60 | { 61 | $process = Process::fromShellCommandline(implode(' && ', $commands), null, null, null, null); 62 | 63 | if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { 64 | try { 65 | $process->setTty(true); 66 | } catch (RuntimeException $e) { 67 | $this->output->writeln(' WARN '.$e->getMessage().PHP_EOL); 68 | } 69 | } 70 | 71 | $process->run(function ($type, string $line): void { 72 | $this->output->write(' '.$line); 73 | }); 74 | } 75 | 76 | protected function phpBinary(): string 77 | { 78 | return (new PhpExecutableFinder)->find(false) ?: 'php'; 79 | } 80 | 81 | private function usingImportmaps(): bool 82 | { 83 | return File::exists($this->importmapsFile()); 84 | } 85 | 86 | private function importmapsFile(): string 87 | { 88 | return base_path('routes/importmap.php'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.ai/stimulus-bridge.blade.php: -------------------------------------------------------------------------------- 1 | ## Hotwired Native Bridge Components Guidelines 2 | 3 | ### BridgeComponent Class 4 | - `BridgeComponent` extends Stimulus `Controller` with native bridge functionality 5 | - Always set `static component` property that matches your native component name 6 | - Place bridge components in `/bridge` subdirectory for easy identification 7 | 8 | @verbatim 9 | ```javascript 10 | // resources/js/controllers/bridge/form_controller.js 11 | import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge" 12 | 13 | export default class extends BridgeComponent { 14 | static component = "form" // Must match native component name 15 | static targets = [ "submit" ] 16 | 17 | submitTargetConnected(target) { 18 | const submitButton = new BridgeElement(target) 19 | const submitTitle = submitButton.title 20 | 21 | this.send("connect", { submitTitle }, () => { 22 | target.click() 23 | }) 24 | } 25 | } 26 | ``` 27 | @endverbatim 28 | 29 | ### BridgeComponent Properties 30 | - `this.platformOptingOut`: Whether controller is opted out for current platform 31 | - `this.enabled`: Whether component is supported by the native app 32 | - `this.bridgeElement`: Provides `this.element` wrapped in a BridgeElement 33 | - `this.send(event, data, callback)`: Send message to native component 34 | 35 | ### BridgeElement Features 36 | - `title`: Gets title from `data-bridge-title`, `aria-label`, or `textContent`/`value` 37 | - `disabled`/`enabled`: Check/set disabled state 38 | - `enableForComponent(component)`: Remove `data-bridge-disabled` attribute 39 | - `bridgeAttribute(name)`: Get `data-bridge-{name}` attribute value 40 | - `setBridgeAttribute(name, value)`: Set `data-bridge-{name}` attribute 41 | - `removeBridgeAttribute(name)`: Remove `data-bridge-{name}` attribute 42 | 43 | ### HTML Structure 44 | 45 | @verbatim 46 | ```html 47 |
48 | 49 | 56 |
57 | ``` 58 | @endverbatim 59 | 60 | ### Data Attributes 61 | - `data-bridge-title="My Title"`: Custom bridge title 62 | - `data-bridge-disabled="true|false|ios|android"`: Control element availability 63 | - `data-bridge-*`: Custom attributes accessible via BridgeElement 64 | - `data-controller-optout-ios`: Opt-out component for iOS 65 | - `data-controller-optout-android`: Opt-out component for Android 66 | 67 | ### Bridge Communication Pattern 68 | 1. Use target connection callbacks to initialize bridge elements 69 | 2. Send messages to native components with `this.send(event, data, callback)` 70 | 3. Always check `this.enabled` before bridge operations 71 | 4. Provide callback functions to handle native responses 72 | -------------------------------------------------------------------------------- /resources/dist/stimulus-loading.js: -------------------------------------------------------------------------------- 1 | // FIXME: es-module-shim won't shim the dynamic import without this explicit import 2 | import "@hotwired/stimulus" 3 | 4 | const controllerAttribute = "data-controller" 5 | 6 | // Eager load all controllers registered beneath the `under` path in the import map to the passed application instance. 7 | export function eagerLoadControllersFrom(under, application) { 8 | const paths = Object.keys(parseImportmapJson()).filter(path => path.match(new RegExp(`^${under}/.*_controller$`))) 9 | paths.forEach(path => registerControllerFromPath(path, under, application)) 10 | } 11 | 12 | function parseImportmapJson() { 13 | return JSON.parse(document.querySelector("script[type=importmap]").text).imports 14 | } 15 | 16 | function registerControllerFromPath(path, under, application) { 17 | const name = path 18 | .replace(new RegExp(`^${under}/`), "") 19 | .replace("_controller", "") 20 | .replace(/\//g, "--") 21 | .replace(/_/g, "-") 22 | 23 | if (canRegisterController(name, application)) { 24 | import(path) 25 | .then(module => registerController(name, module, application)) 26 | .catch(error => console.error(`Failed to register controller: ${name} (${path})`, error)) 27 | } 28 | } 29 | 30 | 31 | // Lazy load controllers registered beneath the `under` path in the import map to the passed application instance. 32 | export function lazyLoadControllersFrom(under, application, element = document) { 33 | lazyLoadExistingControllers(under, application, element) 34 | lazyLoadNewControllers(under, application, element) 35 | } 36 | 37 | function lazyLoadExistingControllers(under, application, element) { 38 | queryControllerNamesWithin(element).forEach(controllerName => loadController(controllerName, under, application)) 39 | } 40 | 41 | function lazyLoadNewControllers(under, application, element) { 42 | new MutationObserver((mutationsList) => { 43 | for (const { attributeName, target, type } of mutationsList) { 44 | switch (type) { 45 | case "attributes": { 46 | if (attributeName == controllerAttribute && target.getAttribute(controllerAttribute)) { 47 | extractControllerNamesFrom(target).forEach(controllerName => loadController(controllerName, under, application)) 48 | } 49 | } 50 | 51 | case "childList": { 52 | lazyLoadExistingControllers(under, application, target) 53 | } 54 | } 55 | } 56 | }).observe(element, { attributeFilter: [controllerAttribute], subtree: true, childList: true }) 57 | } 58 | 59 | function queryControllerNamesWithin(element) { 60 | return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).map(extractControllerNamesFrom).flat() 61 | } 62 | 63 | function extractControllerNamesFrom(element) { 64 | return element.getAttribute(controllerAttribute).split(/\s+/).filter(content => content.length) 65 | } 66 | 67 | function loadController(name, under, application) { 68 | if (canRegisterController(name, application)) { 69 | import(controllerFilename(name, under)) 70 | .then(module => registerController(name, module, application)) 71 | .catch(error => console.error(`Failed to autoload controller: ${name}`, error)) 72 | } 73 | } 74 | 75 | function controllerFilename(name, under) { 76 | return `${under}/${name.replace(/--/g, "/").replace(/-/g, "_")}_controller` 77 | } 78 | 79 | function registerController(name, module, application) { 80 | if (canRegisterController(name, application)) { 81 | application.register(name, module.default) 82 | } 83 | } 84 | 85 | function canRegisterController(name, application){ 86 | return !application.router.modulesByIdentifier.has(name) 87 | } 88 | -------------------------------------------------------------------------------- /.ai/stimulus.blade.php: -------------------------------------------------------------------------------- 1 | ## Stimulus Core Philosophy 2 | - Make Stimulus Controllers using the `artisan make:stimulus {name}` command 3 | - Make Stimulus Bridge Controllers using the `artisan make:stimulus --bridge {name}` command 4 | - Stimulus enhances static or server-rendered HTML with JavaScript controllers 5 | - Store state in HTML data attributes, not JavaScript objects 6 | - Design for progressive enhancement - work without JavaScript, enhance with it 7 | - Build small, reusable controllers that connect to DOM elements 8 | 9 | ### Best Practices 10 | - One responsibility per controller 11 | - Design for multiple instances on same page 12 | - Always cleanup in `disconnect()` 13 | - Use semantic HTML first, enhance with Stimulus 14 | 15 | #### Error Handling 16 | ```javascript 17 | // Global 18 | Stimulus.handleError = (error, message, detail) => { 19 | console.warn(message, detail) 20 | // Send to error tracking 21 | } 22 | 23 | // Controller method 24 | async fetchData() { 25 | try { 26 | // async operation 27 | } catch (error) { 28 | this.showError(error.message) 29 | } 30 | } 31 | ``` 32 | 33 | #### Default Events by Element 34 | - `a`, `button`: `click` 35 | - `form`: `submit` 36 | - `input`, `textarea`: `input` 37 | - `select`: `change` 38 | - `details`: `toggle` 39 | 40 | ### Controller Structure 41 | 42 | #### Basic Controller Template 43 | 44 | @verbatim 45 | ```javascript 46 | // resources/js/controllers/example_controller.js 47 | import { Controller } from "@hotwired/stimulus" 48 | 49 | export default class extends Controller { 50 | static targets = [ "targetName" ] 51 | static values = { propertyName: Type } 52 | static classes = [ "className" ] 53 | 54 | connect() { /* When connected to DOM */ } 55 | disconnect() { /* When disconnected - cleanup here */ } 56 | 57 | actionMethod() { /* Handle events */ } 58 | propertyNameValueChanged() { /* When value changes */ } 59 | } 60 | ``` 61 | @endverbatim 62 | 63 | ### Naming Conventions 64 | - **Files**: `hello_controller.js` → identifier `hello` 65 | - **Nested**: `admin/users_controller.js` → identifier `admin--users` 66 | - **Underscores**: become dashes in identifiers 67 | 68 | ### HTML Data Attributes 69 | 70 | #### Controller Binding 71 | 72 | @verbatim 73 | ```html 74 |
75 | 76 |
77 | ``` 78 | @endverbatim 79 | 80 | #### Actions (Event Handling) 81 | 82 | @verbatim 83 | ```html 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ``` 93 | @endverbatim 94 | 95 | #### Targets 96 | 97 | @verbatim 98 | ```html 99 | 100 | 101 | 102 | 103 | static targets = [ "slide" ] 104 | // Creates: this.slideTarget, this.slideTargets, this.hasSlideTarget 105 | ``` 106 | @endverbatim 107 | 108 | #### Values (State Management) 109 | 110 | @verbatim 111 | ```html 112 | 113 |
114 | 115 | 116 | static values = { 117 | index: Number, 118 | autoplay: Boolean, 119 | delay: { type: Number, default: 5000 } 120 | } 121 | // Creates: this.indexValue, this.autoplayValue, etc. 122 | ``` 123 | @endverbatim 124 | 125 | ### Common Patterns 126 | 127 | #### Lifecycle Management 128 | 129 | @verbatim 130 | ```javascript 131 | connect() { 132 | this.startTimer() 133 | } 134 | 135 | disconnect() { 136 | this.stopTimer() // Always cleanup external resources 137 | } 138 | 139 | startTimer() { 140 | this.timer = setInterval(() => this.refresh(), 1000) 141 | } 142 | 143 | stopTimer() { 144 | if (this.timer) { 145 | clearInterval(this.timer) 146 | } 147 | } 148 | ``` 149 | @endverbatim 150 | 151 | #### Progressive Enhancement 152 | 153 | @verbatim 154 | ```javascript 155 | static classes = [ "supported" ] 156 | 157 | connect() { 158 | if ("clipboard" in navigator) { 159 | this.element.classList.add(this.supportedClass) 160 | } 161 | } 162 | ``` 163 | @endverbatim 164 | 165 | #### Value Change Callbacks 166 | 167 | @verbatim 168 | ```javascript 169 | static values = { index: Number } 170 | 171 | indexValueChanged() { 172 | // Called on initialization and value changes 173 | this.showCurrentSlide() 174 | } 175 | ``` 176 | @endverbatim 177 | 178 | #### Action Parameters 179 | 180 | @verbatim 181 | ```html 182 | Load 183 | ``` 184 | 185 | ```javascript 186 | load({ params: { url } }) { 187 | fetch(url).then(/* handle response */) 188 | } 189 | ``` 190 | @endverbatim 191 | 192 | ### Manual Registration 193 | 194 | @verbatim 195 | ```javascript 196 | import { Application } from "@hotwired/stimulus" 197 | import HelloController from "./controllers/hello_controller" 198 | 199 | window.Stimulus = Application.start() 200 | Stimulus.register("hello", HelloController) 201 | ``` 202 | @endverbatim 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Stimulus Laravel

2 | 3 |

4 | 5 | Latest Stable Version 6 | 7 | 8 | License 9 | 10 |

11 | 12 | 13 | ## Introduction 14 | 15 | [Stimulus](https://stimulus.hotwired.dev/) is a JavaScript framework with modest ambitions. It doesn’t seek to take over your entire front-end in fact, it’s not concerned with rendering HTML at all. Instead, it’s designed to augment your HTML with just enough behavior to make it shine. Stimulus pairs beautifully with Turbo to provide a complete solution for fast, compelling applications with a minimal amount of effort. Together they form the core of [Hotwire](https://hotwired.dev/). 16 | 17 | Stimulus for Laravel makes it easy to use this modest framework with both import-mapped and JavaScript-bundled apps. It relies on either [Importmap Laravel](https://github.com/tonysm/importmap-laravel) to make Stimulus available via ESM or a Node-capable [Laravel using Vite](https://laravel.com/docs/9.x/vite) to include Stimulus in the bundle. Make sure to install one of these first! 18 | 19 | #### Inspiration 20 | 21 | This package was inspired by the [stimulus-rails gem](https://github.com/hotwired/stimulus-rails). 22 | 23 | ## Installation Steps 24 | 25 | Stimulus Laravel may be installed via composer: 26 | 27 | ```bash 28 | composer require hotwired-laravel/stimulus-laravel 29 | ``` 30 | 31 | Next, if you're on a fresh Laravel app (see the [#manual-installation](#manual-installation) if you're not), you may run install command: 32 | 33 | ```bash 34 | php artisan stimulus:install 35 | ``` 36 | 37 | That's it. The install command will automatically detect if you're using [Importmap Laravel](https://github.com/tonysm/importmap-laravel) or [Vite](https://vitejs.dev/) to manage your JavaScript dependencies. If you're using Importmap Laravel, we're pinning the Stimulus dependency and publishing a local dependency to your `public/vendor` folder and pinning it so you don't have to register Stimulus controllers. If you're using Vite, we'll add the Stimulus dependecy to your `package.json`. 38 | 39 | The install command generates a `resources/js/libs/stimulus.js` file that installs Stimulus. It will also create a `resources/js/libs/index.js` that ensures the `resources/js/controllers/index.js` module is imported. 40 | 41 | When using Importmap Laravel, the `resources/js/controllers/index.js` will use the published `stimulus-loading` dependency to either eager load or lazy load your Stimulus controller registrations automatically, so you don't have to manually register them. When using Vite, that file will be auto-generated whenever you make a new Stimulus controller or whenever you run the `php artisan stimulus:manifest` manually. 42 | 43 | ### Making a New Controller 44 | 45 | To make a new Stimulus controller, run: 46 | 47 | ```bash 48 | php artisan stimulus:make hello_controller 49 | ``` 50 | 51 | This should create the controller for you. When using Vite, it will also regenerate the `resources/js/controllers/index.js` file to register your newly created Stimulus controller automatically. 52 | 53 | There's also a hint comment on how you may use the controller in the DOM, something like this: 54 | 55 | ```js 56 | import { Controller } from "@hotwired/stimulus" 57 | 58 | // Connects to data-controller="hello" 59 | export default class extends Controller { 60 | connect() { 61 | } 62 | } 63 | ``` 64 | 65 | ### Making a new Hotwire Native Bridge Component 66 | 67 | You may use the same `stimulus:make` command to generate a Hotwire Native Bridge component by passing the `--bridge=` option with the name of the native component. For instance, if you're working on a native Toast component, you may create it like: 68 | 69 | ```bash 70 | php artisan stimulus:make bridge/toast_controller --bridge=toast 71 | ``` 72 | 73 | This should create a file for you using the bridge scaffolding. When using Vite, it will also generate the `resources/js/controllers/index.js` file to register your newly created Stimulus Bridge Component automatically. 74 | 75 | Like regular Stimulus controllers, there's also a hint comment on how you may use the controller in the DOM: 76 | 77 | ```js 78 | import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge" 79 | 80 | // Connects to data-controller="bridge--toast" 81 | export default class extends BridgeComponent { 82 | static component = "toast" 83 | 84 | // 85 | } 86 | ``` 87 | 88 | ### Regenerate the Manifest 89 | 90 | The `stimulus:make` command will regenerate the `resources/js/controllers/index.js` file for you, registering all your controllers. If you want to manually trigger a regeneration, you may run: 91 | 92 | ```bash 93 | php artisan stimulus:manifest 94 | ``` 95 | 96 | ## Manual Installation 97 | 98 | If you're installing the package on an pre-existing Laravel app, it may be useful to manually install it step by step. 99 | 100 | If you're using Importmap Laravel, follow the [Importmap Steps](#importmap-steps), otherwise follow the [Vite steps](#vite-steps). 101 | 102 | 1. Either way, you need to install the lib via composer first: 103 | 104 | ```bash 105 | composer require hotwired-laravel/stimulus-laravel 106 | ``` 107 | 108 | ### Importmap Steps 109 | 110 | 2. Create `resources/js/controllers/index.js` and load your controllers like this: 111 | 112 | ```js 113 | import { Stimulus } from 'libs/stimulus' 114 | 115 | // Eager load all controllers defined in the import map under controllers/**/*_controller 116 | import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading' 117 | eagerLoadControllersFrom('controllers', Stimulus) 118 | ``` 119 | 120 | 3. Create a `resources/js/libs/stimulus.js` with the following content: 121 | 122 | ```js 123 | import { Application } from '@hotwired/stimulus' 124 | 125 | const Stimulus = Application.start() 126 | 127 | // Configure Stimulus development experience 128 | Stimulus.debug = false 129 | 130 | window.Stimulus = Stimulus 131 | 132 | export { Stimulus } 133 | ``` 134 | 135 | 4. Create a `resources/js/libs/index.js` file with the following content (or add it to an existing file if you have one): 136 | 137 | ```js 138 | import 'controllers' 139 | ``` 140 | 141 | 5. Add the following line to your `resources/js/app.js` file: 142 | 143 | ```js 144 | import 'libs' 145 | ``` 146 | 147 | 6. Publish the vendor dependencies: 148 | 149 | ```bash 150 | php artisan vendor:publish --tag=stimulus-laravel-assets 151 | ``` 152 | 153 | 7. Pin the Stimulus dependency: 154 | 155 | ```bash 156 | php artisan importmap:pin @hotwired/stimulus 157 | ``` 158 | 159 | 8. Finally, pin the `stimulus-loading` dependency on your `routes/importmap.php` file: 160 | 161 | ```php 162 | Importmap::pin("@hotwired/stimulus-loading", to: "vendor/stimulus-laravel/stimulus-loading.js", preload: true); 163 | ``` 164 | 165 | ### Vite Steps 166 | 167 | 1. Create a `resources/js/controllers/index.js` and chose if you want to register your controllers manually or not: 168 | 169 | #### Register controllers manually 170 | 171 | ```js 172 | // This file is auto-generated by `php artisan stimulus:install` 173 | // Run that command whenever you add a new controller or create them with 174 | // `php artisan stimulus:make controllerName` 175 | 176 | import { Stimulus } from '../libs/stimulus' 177 | 178 | import HelloController from './hello_controller' 179 | Stimulus.register('hello', HelloController) 180 | ``` 181 | 182 | #### Register controllers automatically 183 | 184 | If you prefer to automatially register your controllers you can use the [`stimulus-vite-helpers`](https://www.npmjs.com/package/stimulus-vite-helpers) NPM package. 185 | 186 | ```js 187 | // resources/js/controllers/index.js 188 | 189 | import { Stimulus } from '../libs/stimulus' 190 | import { registerControllers } from 'stimulus-vite-helpers' 191 | 192 | const controllers = import.meta.glob('./**/*_controller.js', { eager: true }) 193 | 194 | registerControllers(Stimulus, controllers) 195 | ``` 196 | 197 | And install the NPM package: 198 | 199 | ```bash 200 | npm install stimulus-vite-helpers 201 | ``` 202 | 203 | 2. Create `resources/js/libs/stimulus.js` with the following content: 204 | 205 | ```js 206 | import { Application } from '@hotwired/stimulus' 207 | 208 | const Stimulus = Application.start() 209 | 210 | // Configure Stimulus development experience 211 | Stimulus.debug = false 212 | 213 | window.Stimulus = Stimulus 214 | 215 | export { Stimulus } 216 | ``` 217 | 218 | 3. Create a `resources/js/libs/index.js` file (if it doesn't exist) and add the following line to it: 219 | 220 | ```js 221 | import '../controllers' 222 | ``` 223 | 224 | 4. Add the following line to your `resources/js/app.js` file: 225 | 226 | ```js 227 | import './libs'; 228 | ``` 229 | 230 | 5. Finally, add the Stimulus package to NPM: 231 | 232 | ```bash 233 | npm install @hotwired/stimulus 234 | ``` 235 | 236 | ## Changelog 237 | 238 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 239 | 240 | ## Contributing 241 | 242 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 243 | 244 | ## Security Vulnerabilities 245 | 246 | Drop me an email at [tonysm@hey.com](mailto:tonysm@hey.com?subject=Security%20Vulnerability) if you want to report 247 | security vulnerabilities. 248 | 249 | ## License 250 | 251 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 252 | 253 | ## Credits 254 | 255 | - [Tony Messias](https://github.com/tonysm) 256 | - [All Contributors](./CONTRIBUTORS.md) 257 | --------------------------------------------------------------------------------