hotwired-laravel/stimulus-laravel
Stimulus-Laravel integrates Hotwired Stimulus with Laravel, helping you register and load Stimulus controllers and attach them in Blade via data-controller attributes, adding lightweight interactive UI without a full SPA or heavy JavaScript framework.
composer require hotwired-laravel/stimulus-laravel — 1.5.0 is a compatibility-only release (Laravel 13.x support), so the install flow is unchanged from 1.4.0. If you're pinned to 1.4.* you'll need to bump the constraint; ^1.4 already resolves to 1.5.0.php artisan stimulus:install — publishes the JS wiring into resources/js/controllers/ (an index.js that registers your controllers and a boot.js that starts the Stimulus Application) and adds the import to resources/js/app.js.npm install && npm run dev (or npm run build before deploying).php artisan stimulus:make Toggle
# -> resources/js/controllers/toggle_controller.js
<div data-controller="toggle">
<button data-action="toggle#toggle">Menu</button>
<nav data-toggle-target="menu" hidden>…</nav>
</div>
Write the toggle() method, save, and Vite hot-reloads it.Where to look first: resources/js/controllers/index.js (how controllers are discovered and registered), resources/js/app.js (the single import that boots everything), and the published config/stimulus.php if your controllers live somewhere other than the default path. Skim php artisan stimulus:make --help before you create your first ten controllers — the naming convention it enforces is what makes the registration work.
Naming drives everything. resources/js/controllers/date_picker_controller.js becomes data-controller="date-picker"; nested folders use the -- separator (admin--date-picker). Let stimulus:make create the file so PHP and JS agree on the identifier, and treat the filename as the public API of that controller.
Server → client data via values and targets, never inline JS. Keep Blade as the only place that knows about your Eloquent models and pass primitives down:
<div data-controller="search"
data-search-url-value="{{ route('users.search') }}"
data-search-delay-value="300">
<input data-search-target="input" data-action="input->search#query">
<ul data-search-target="results"></ul>
</div>
export default class extends Controller {
static targets = ['input', 'results']
static values = { url: String, delay: Number }
// …
}
This keeps controllers reusable across pages and makes the Blade partial the thing you review in a PR.
Wrap controllers in Blade components. A <x-date-picker :value="$model->starts_at" /> that emits the data-controller, data-* values, and targets gives you one place to change markup and keeps controllers dumb. This is the highest-leverage pattern in a real Laravel app — it's the analogue of view components for your JS behaviour.
Compose, don't nest logic. Small controllers, one behaviour each; use Stimulus outlets (static outlets = ['modal']) when two controllers need to talk instead of firing global window events. Outlets are also the cleanest bridge when you're mixing in Alpine or Livewire on the same markup.
Turbo/Livewire lifecycle. With turbo-laravel in the same app, controllers connect()/disconnect() on every navigation — bind listeners in connect() and remove them in disconnect(). Never use DOMContentLoaded inside a controller; it will only fire once and silently break after the first Turbo visit.
Keep controllers lean and page-scoped. Because controllers are loaded through Vite's module graph, an unused controller costs nothing on a page that doesn't render its data-controller. Resist a app_controller.js grab-bag; split by feature so code-splitting stays effective and a failing controller can't take down unrelated pages.
Forms and fetch. Post with fetch + the CSRF token from <meta name="csrf-token">, set this.element.setAttribute('aria-busy', 'true') while in flight, and always reset it in a finally block — otherwise a 422 leaves the button disabled forever.
npm run build wasn't run before deploy, nothing throws — the data-action simply does nothing. First debugging step: console.log(this.application.controllers) in boot.js and confirm your identifier is registered. Second: check the built manifest in public/build/.date_picker_controller.js is data-controller="date-picker", not date_picker. Mismatches here are the single most common "it works locally" bug.this.fooTarget without adding foo to static targets throws only when the element is missing; referencing an undeclared value gives you undefined rather than an error. Declare them first, write the markup second.stimulus:make vs. a customised path. If you changed the controllers path in config/stimulus.php, the generator and the registration code must agree — regenerate index.js (or re-run stimulus:install) rather than hand-editing one side.stimulus:install casually. It touches resources/js/app.js; commit before running it on an app that already has a hand-tuned entrypoint so you can diff and discard the noise.config:cache after config edits. A stale cached config pointing at the old controllers path produces a build that looks fine and registers nothing.composer why-not laravel/framework 13.* set before bumping, and if you maintain a fork or downstream package, update its testbench/CI matrix in the same PR. Running Laravel ≤12? 1.4.0 and 1.5.0 are functionally identical; stay on 1.4.x if that simplifies your dependency graph.node_modules) belongs in resources/js/controllers/index.js or boot.js — those two files are yours to edit and are the supported seam. Keep custom registrations below the generated/discovered ones so a stimulus:make re-run only ever appends.npm run build after the test step, or not at all. Build first, then test.How can I help you explore Laravel packages today?