hotwired/stimulus-laravel
Stimulus Laravel brings the Hotwired Stimulus JavaScript framework to Laravel, letting you register controllers from your resources/js folder and attach behavior to Blade markup with simple data attributes—no extra wiring required.
Install the package and its JS peer. The package is a thin Laravel-side wrapper; you still need Stimulus itself.
composer require hotwired-laravel/stimulus-laravel
php artisan stimulus:install # publishes config + scaffolds resources/js/controllers
npm install @hotwired/stimulus
Version note (1.5.0). The current release is a compatibility-only bump: it adds Laravel 13.x support and changes no public API. If you are on 1.4.x and Laravel 12 or below, there is no reason to rush — and if you are moving to Laravel 13, 1.5.0 is the release you need. The constraint is set by the package's composer.json, so check it before assuming a given Laravel version is supported; older tags will hard-fail on install against a newer framework.
If the install command isn't present in your version, publish manually:
php artisan vendor:publish --tag=stimulus-config
Look here first, in this order:
config/stimulus.php — controller paths, the Blade directive toggle, and auto-registration behaviour.resources/js/controllers/ — where the installer expects your controllers to live. Everything downstream assumes this convention.@stimulus) that injects the bootstrap script. Confirm the exact directive name against the README, since this is the one part of the API that varies between minor releases.resources/js/app.js — check whether the installer wired Stimulus into your existing Vite entrypoint or created a separate one. This determines whether @vite(['resources/js/app.js']) is enough or you need a second entry.First use case: the disposable toggle. Don't start with outlets or values. Write the smallest possible controller and confirm the wiring end-to-end before layering on features.
// resources/js/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["panel"]
static classes = ["open"]
toggle() {
this.panelTarget.classList.toggle(this.openClass)
}
}
<div data-controller="toggle"
data-toggle-open-class="is-open">
<button data-action="toggle#toggle">Toggle</button>
<div data-toggle-target="panel">…</div>
</div>
If the toggle fires, your registration path is correct and everything else is incremental. If it doesn't, the problem is almost always the controller file not being picked up (naming, path, or Vite not rebuilt) — not the controller code.
Controller-per-behaviour, not per-page. The whole point of Stimulus is that controllers are attached to markup, not routes. Resist the urge to make a users_index_controller.js. Make a dropdown_controller.js, a clipboard_controller.js, a confirm_controller.js, and compose them on the page. A typical Laravel index view ends up with three or four tiny controllers on one <table> rather than one large page controller.
Follow the filename convention religiously. resources/js/controllers/date_picker_controller.js becomes data-controller="date-picker". Underscores in the filename become dashes in the attribute; dashes inside a name become --. Mismatches here produce silent failures with no console error, which is the single most common source of "it's not working" tickets.
Use the generator for new controllers. The package's make-style command (typically php artisan stimulus:controller date-picker) creates the file in the configured path with the right naming and a boilerplate class. It's a small convenience, but it eliminates the convention errors above and keeps the team consistent. Wire it into your onboarding docs.
Prefer the Values API over reading data-* attributes manually. Values give you type coercion and change callbacks, and they're the cleanest boundary between Blade-rendered server state and JS state:
<div data-controller="poll"
data-poll-url-value="{{ route('polls.show', $poll) }}"
data-poll-interval-value="5000"
data-action="visibilitychange@document->poll#pause">
static values = { url: String, interval: Number }
This means your Blade templates carry configuration and your controllers stay generic and testable.
Integrate with Vite, not with mix or inline script tags. Register the controllers in the same entrypoint as the rest of your app:
import { Application } from "@hotwired/stimulus"
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
const app = Application.start()
const context = require.context("./controllers", true, /_controller\.js$/)
app.load(definitionsFromContext(context))
Then @vite(['resources/js/app.js']) in your layout covers it. Keep a single entrypoint; multiple Stimulus entrypoints cause double-registration and duplicate event handlers that are painful to diagnose.
Use outlets for cross-controller communication instead of custom DOM events. When a modal needs to close a dropdown, static outlets = ["dropdown"] and this.dropdownOutlet.close() is far more legible than document.dispatchEvent(new CustomEvent('dropdown:close')). Reserve custom events for genuinely decoupled, app-wide concerns (toasts, analytics).
Layer on Turbo or Livewire deliberately. If you use Turbo, controllers must be idempotent across turbo:load, because the body is replaced without a full page reload. If you use Livewire, note that DOM morphing can preserve a data-controller element while replacing its children — connect() will not re-fire. Use this.dispatch from Livewire hooks, or listen to livewire:navigated/turbo:load in the affected controller to re-sync. This is the biggest real-world integration cost of the package and it isn't solvable at the Laravel layer.
Testing. For request-level coverage, Laravel feature tests can assert the presence of data-controller attributes, which catches Blade-side wiring regressions cheaply. For behaviour, use Laravel Dusk — it drives real Stimulus. Jest/Vitest against controllers in isolation is possible but requires stubbing the Stimulus Controller base class, and the ROI is usually poor for small controllers.
Treat framework-compatibility bumps as your upgrade gate. 1.5.0's entire content is Laravel 13.x support, which tells you the package version and the framework version are coupled by composer.json constraints rather than feature flags. When you bump Laravel majors, check for a matching package tag first; when you bump the package, read the diff (it's tiny) to confirm nothing else moved. Renovate/Dependabot will surface these, but the Laravel-major half of the pair won't be automatic.
Silent registration failures are the default failure mode. Stimulus logs nothing when a data-controller="foo" has no matching definition — it just does nothing. When debugging, first confirm the controller file appears in the generated output of npm run build, then check the console for Application errors at boot, then verify the filename-to-attribute mapping. Add app.debug = true in development to get Stimulus' own activity logging.
Vite manifest staleness. Running php artisan serve does not rebuild assets. New controllers require npm run dev to be running or a fresh npm run build. A missing controller after adding a file is a build problem roughly 90% of the time.
Config caching bites in CI and production. If config/stimulus.php changes and you've run php artisan config:cache, the change won't take effect until you re-cache. Add php artisan config:cache to your deploy script explicitly and don't rely on it being implicit. Similarly, php artisan optimize:clear before debugging a "config change did nothing" issue.
The Blade directive is not a bundler. Whatever script tag the package injects, it can only point at an already-built asset. If you disable the directive and include Stimulus manually via @vite, make sure you don't end up with both — duplicate Application.start() calls produce controllers which fire their actions twice, and the symptom (a toggle that opens and immediately closes) looks like a CSS bug.
data-action syntax errors fail silently too. The format is event->controller#method, with modifiers like keydown.esc->modal#close or click@window->menu#closeOnClickOutside. A typo in the event name registers nothing. When an action doesn't fire, check the attribute string character by character before touching the controller body.
Lifecycle asymmetry with Turbo/Livewire. connect() fires when the element enters the DOM; disconnect() fires on removal. With Turbo Drive, elements are cached rather than removed on navigation — disconnect() fires, but they're restored from cache later, so any state you set up in connect() must be re-establishable. Don't hold references to DOM nodes outside the controller; re-query via targets.
Extension points worth knowing:
resources/js/controllers — useful in multi-entrypoint apps or when colocating JS with Blade components.dispatchToast method, rather than a utility module imported everywhere.@stimulus-components/*) can be registered alongside generated ones — add a manual application.register() block after the auto-loader for anything outside the controller directory.Keep the package thin. The 1.5.0 release is a good signal — the maintainer is still tracking Laravel majors, which is the main thing that would otherwise force you off the package. But the ecosystem around it remains tiny, so you are still the primary support channel. Treat it as a convenience wrapper, not a framework: read its source (it's small) before depending on undocumented behaviour, pin the version in composer.json, and be prepared to replace it with a dozen lines of Vite config and a Blade @once block if it ever falls out of maintenance. The migration cost is negligible, which is also the reason to adopt it — the lock-in is near zero.
How can I help you explore Laravel packages today?