The complete Hotwire stack for Laravel — Turbo Drive, Turbo Streams, Stimulus controllers and Blade components.
php artisan boost:add-skill emaia/laravel-hotwire
Save this content to: AGENTS.md
# Laravel Hotwire
The complete Hotwire stack for Laravel — Turbo Drive, Turbo Streams, Stimulus controllers and Blade components.
## Package Structure
- `src/` — PHP source (commands, components, service provider)
- `src/Components/Concerns/` — shared component traits (e.g. `StripsNullProps` for omitting null props from rendered
attributes)
- `src/Support/` — framework-agnostic helpers (`FieldKey` for id/errorKey derivation, `MaskPresets`, `ControllerImports`
for resolving a controller's shared JS deps, doc search/render/paging, installer)
- `src/Registry/catalog.php` — single source of truth mapping every component and controller to its class/source, view,
docs, category and dependencies
- `src/Registry/styles.php` — preset-neutral visual module ownership, dependencies and canonical source order
- `resources/js/controllers/` — Stimulus controllers shipped with the package (published to user's app)
- `resources/views/component-views/` — Blade component views
- `stubs/resources/` — Scaffolding files copied by `hotwire:install`
- `config/hotwire.php` — Package configuration (component prefix)
- `tests/` — Pest PHP tests with Orchestra Testbench
- `tests/Controllers/` — Bun JS tests for Stimulus controllers
- `docs/` — Documentation
## Artisan Commands
| Command | Description |
|---------------------------|------------------------------------------------------------------------------------------------------------------------|
| `hotwire:install` | Scaffold JS/CSS setup, add npm deps to package.json |
| `hotwire:make-controller` | Create a new Stimulus controller (interactive scaffolding) |
| `hotwire:make-preset` | Generate a complete custom CSS preset scaffold or clone a shipped preset |
| `hotwire:bundle-preset` | Generate or regenerate a selective preset bundle from explicit component selections |
| `hotwire:controllers` | Publish package Stimulus controllers to the app for customization (`--outdated` to update only published+changed ones) |
| `hotwire:components` | List available Blade components and their controller dependencies |
| `hotwire:check` | Verify npm/controller drift, generated CSS and complete application preset contracts (CI-friendly) |
| `hotwire:docs` | Browse and read controller/component docs in the terminal |
| `hotwire:ide-json` | Generate Laravel Idea metadata for package and application Stimulus controllers |
## Conventions
### Stimulus Controllers
- **Flat layout** at the top level. File naming: `{name}_controller.js` (snake_case). The user can generate `.ts`
controllers via `hotwire:make-controller --ts`; the package ships `.js` only.
- **Identifier** matches the file name converted to kebab-case: `auto_submit_controller.js` →
`data-controller="auto-submit"`.
- **Substrate folders** (`turbo/`, `optimistic/`, `dev/`) group controllers tied to a specific technical layer. Files
inside keep Stimulus' `--` separator in the identifier: `turbo/progress_controller.js` →
`data-controller="turbo--progress"`.
- **No UI-role folders** (no `form/`, `modal/`, `utils/`, `lib/`, `media/`, `notification/`). Names themselves describe
intent — prefer compound names (`copy-to-clipboard`, `lazy-image`, `input-mask`) over namespace buckets.
- **Internal helpers** prefixed with `_` (e.g. `_focus_trap.js`, `_presence.js`, `_form_errors.js`) are shared utility
modules imported by controllers. They are **not** Stimulus controllers and are never registered via
`data-controller`.
- **Package marker.** Every controller, helper, and shared dependency shipped by the package begins with
`// @hotwire-package` on its first non-empty line (or `/* @hotwire-package */` for `.css`). The marker lets
`hotwire:controllers` (with `--force` or `--outdated --force`) and `hotwire:check --fix` distinguish files that came
from the package from files written by the user — without the marker, those commands refuse to overwrite.
`hotwire:make-controller` deliberately does **not** emit the marker, so generated user controllers stay protected.
Drop the marker when reading `Support\PackageMarker`.
- Loaded via `@emaia/stimulus-lazy-loader` with Vite's `import.meta.glob`.
- **Controllers must be mutually compatible.** Blade components stack several controllers on the same element
(`<hw:form>` → `auto-submit unsaved-changes clean-query-params`; `<hw:file>` →
`file-preserve reset-files` plus the user's own `data-controller`). A controller must therefore never assume it owns
the element exclusively:
- Scope DOM reads/writes to `this.element` (and prefer `this.targets`); don't clobber attributes other controllers
set, especially shared `data-controller`.
- Multiple controllers commonly listen to the same Turbo events (`turbo:submit-end`, `turbo:morph`,
`turbo:before-render`/`turbo:render`). Coordinate on shared semantics (e.g. submit `success`) and make each
handler idempotent and order-independent rather than mutating state another controller relies on.
- Clean up every listener and timer in `disconnect()` so re-renders/morphs don't leave duplicates behind.
- When a controller is activated by a component prop, expose it as an explicit prop and let the component merge the
identifier — see the Blade Components `data-*` filtering rule below.
### Blade Components
- Registered with configurable prefix (default: `hw`)
- See all available components with `php artisan hotwire:components`
- Components that encapsulate a Stimulus controller merge user-provided `data-controller` with internal controllers on
the element. User-provided `data-{identifier}-*` for internal controllers active via props is filtered to prevent
conflicts; for other controllers these pass through freely. Expose supported controller configuration as explicit
Blade props instead of relying on user-provided `data-*` attributes.
- **Exposing a `controller` prop.** A component exposes `controller` — letting the app swap the Stimulus identifier for
its own subclass — only when the controller encapsulates configuration the package cannot proxy through props: ECharts
`option`, Leaflet layers, Tiptap extensions, Embla plugins, the File Upload pipeline. Where the controller's surface is
closed (overlays sharing `_overlay.js`, the Floating UI positioners, satellites like `char-counter`, `clear-input` or
`timeago`), the extension path is composition — the merge rule above already lets the app stack its own
`data-controller` on the element and react to the component's events. Every `controller` prop is permanent public API,
one more entry point to validate, and one more identifier flowing through `@aware`, so open one on demand rather than
for symmetry: reach for an event or a specific prop first.
- Components that derive field ids/error keys (Input, File, Select, etc.) use `Support\FieldKey`; components that omit
null props from the rendered tag use the `StripsNullProps` concern. Reuse these instead of reimplementing.
### Registry
`src/Registry/catalog.php` is the public component and controller contract — `hotwire:components`,
`hotwire:controllers`, `hotwire:check` and `hotwire:docs` all read from it. `src/Registry/styles.php` separately owns
visual CSS modules, their dependencies and source order. **Any new component or controller must be registered in the
catalog, and any visual ownership must also be registered in styles.php**, or the commands won't see it.
- `components` entries: `class`, `view`, `docs`, `category`, `description`, and `controllers` (the list of Stimulus
identifiers the component depends on — keep it in sync with what the Blade view actually mounts, since
`hotwire:check` verifies these are published). A family's root component declares its slots once in a public `SLOTS`
constant as local key => public name + `visual|structural`; child classes and views reference those local keys. The
catalog projects the declaration through `styling.slots` class references, optionally narrowed with `only`.
- `controllers` entries: `source` (path to the `.js`/`.ts` file), `docs`, `category`, `description`, and optional
`npm` (a `package => version` map for third-party deps like `@floating-ui/dom`, `maska`, `echarts`). Controllers that
create `data-slot` values in JavaScript declare them in `slots` too.
- Add every new package-emitted `data-slot` to the owning family declaration and reference that declaration from the
catalog. Do not recover this metadata by parsing Blade, PHP or CSS at runtime; the projected registry is the contract
used by preset generation and coverage tests.
- Identifiers follow the Stimulus naming rules above — substrate-folder controllers use the `--` separator
(`turbo--progress`, `optimistic--form`, `dev--log`).
- Every registered component/controller should ship a matching doc file under `docs/` at the path given in the entry.
- **`description` is public presentation.** It reaches an app developer through `hotwire:docs` and
`hotwire:components` who has never read the source and is deciding whether the entry solves their problem, so it
says what the entry gives them and where it applies — [`docs/registry.md`](docs/registry.md#descriptions) covers
the form:
- Name the thing, then the capabilities that decide adoption: modes, Laravel and Turbo integration,
accessibility, limits.
- Name a library the app itself adopts (Embla, ECharts, Tiptap, Leaflet, Maska, Lucide); keep the package's own
plumbing out (Presence, outlets, Floating UI, header names, morph internals) and describe its effect instead —
`X-Turbo-Frame-Src` became "Sends the Turbo Frame source URL", `IntersectionObserver` became "enters view".
- Drop in-house shorthand — "items shortcut", "sticky sugar", "composed subcomponents", "state-driven motion" —
and empty restatements like "Displays a button or a component that looks like a button".
- A description that outlived its implementation is wrong, not merely dated; revisit it whenever a prop or option
changes what the entry does.
### Turbo
- Provided by `emaia/laravel-hotwire-turbo` dependency
- Fluent stream builder: `turbo_stream()->append(...)` (the builder is `Responsable` — return it directly from the
controller; use `->withResponse(...)` when you need a custom status or headers)
- Request detection: `request()->wantsTurboStream()`, `request()->wasFromTurboFrame()`, `request()->turboFrameId()`,
`request()->turboFrameSource()`
- DOM helpers: `dom_id($model)`, `dom_class($model)`
## Collaboration Rules
- Do not expose local absolute paths in user-facing messages.
- Before committing, pushing, opening a PR, creating a tag or publishing a release, get the user's explicit confirmation of the action. One confirmation may cover a clearly specified sequence; ask again if its scope changes. Approval of the exact wording is only required if the user requests it.
## Release Workflow
### Commits
- **Subject**: Imperative mood, no period at end (e.g. `Add progress bar and counter targets to carousel`)
- **Body**: Optional. Explain rationale, tradeoffs or technical details when they help understand the change; keep it clear and avoid narrating what the diff already shows. Use `-` bullets only for distinct, relevant points, not to meet a quota
- **PR reference**: Appended as `(#N)` in the subject when applicable
- Always signed (GPG)
### Pull Requests
- Push feature branch, open PR on GitHub
- Branch naming: descriptive kebab-case (e.g. `radio-group`, `alert-dialog`)
- PR title matches commit subject convention; body summarizes the outcome and why it matters
- Review required; merge from the GitHub UI (not the CLI)
- Remote merge (GitHub UI):
- **Default: squash-merge** — for PRs where iterative review/fixup commits should collapse into a single clean
commit reflecting the deliverable
- **Merge commit (no squash)** — for PRs that bundle multiple isolated changes that each deserve their own commit in
history
- **PR body template** — `## Summary` (a short paragraph or bullets for distinct principal changes) + `## Test plan`.
Include only applicable automated checks and manual smokes for what tests cannot verify (visual, browser-specific
behavior, real interaction). Use checkboxes for actual verification steps; do not add filler to reach a bullet count.
- Write the PR Summary in concise, natural language for a reviewer: focus on the deliverable, relevant decisions and
limitations. Add technical details only when they clarify behavior, impact or review; don't narrate files or obvious
diff details. Omit supporting implementation details such as catalog registration, IDE metadata, docs, examples, and
test coverage unless they are themselves the purpose of the PR.
Example (include only checks that apply):
```markdown
## Test plan
- [x] `composer test` — N/N passing
- [ ] Manual smoke: <exercise the changed interaction and expected result, when relevant>
```
Skip the manual lines that don't apply (a pure internal refactor with full test coverage may legitimately have only
the automated lines). For Stimulus controllers and Blade components, default to manual smokes for browser-observable
behavior, interaction, accessibility, and structural CSS. Include a pure visual smoke only when the change
intentionally affects appearance.
### Tag and Release
- Tags are created on `main` after the PR is merged remotely
- Versioning follows `X.Y.Z` semver:
- Patch (`X.Y.Z+1`): bugfixes
- Minor (`X.Y+1.Z`): new features
- Annotated tag: `git tag -a X.Y.Z -m "X.Y.Z"`
- Release created via `gh release create X.Y.Z --title "X.Y.Z" --notes-file /tmp/release-notes.md`
- Release notes format (following the `X.Y.Z` template):
- Markdown title with feature name (e.g. `## Carousel progress bar and slide counter`)
- One-sentence summary
- Section per feature and referer docs for examples
- `**Full Changelog**: https://github.com/emaia/laravel-hotwire/compare/<prev>...<version>` at the end
- CHANGELOG.md is updated automatically by the release workflow; do not edit manually
## Development
```bash
composer test # Run Pest tests
composer analyse # Run PHPStan
bun run test # Run JS unit tests (Bun + happy-dom). Use `bun run test`, not `bun test` — the npm script wires up --isolate so mocks don't leak across files
bun run test:browser # Run browser tests (Playwright)
composer format # Run Pint code formatter
```
### Testing (TDD)
Follow test-driven development for testable behavior and contracts: write tests first, then implement. Purely visual CSS
changes follow the CSS test scope below.
There are three test suites:
- **PHP** (`composer test`) — Pest + Orchestra Testbench. Covers commands, components, registry.
- **JS** (`bun run test`) — Bun test runner + happy-dom. Covers Stimulus controllers in `tests/Controllers/*.test.js`.
- **Browser JS** (`bun run test:browser`) — Playwright. Covers browser-dependent Stimulus behavior in
`tests/Browser/*.pw.js`.
#### CSS test scope
Automate CSS coverage when a rule provides a structural, behavioral, accessibility, compilation, or documented public
contract. Examples include required visibility, scrolling, clipping, positioning, interaction state, focus visibility,
contrast, reduced motion, forced-colors support, and public values emitted through CSS custom properties.
Do not add source snapshots or exact computed-style assertions solely to freeze visual choices such as colors, shadows,
border radii, typography, decorative motion, or non-functional spacing. Validate intentional appearance changes with a
focused manual smoke; they do not require a failing automated test. Stylesheet imports, generated output, slot and axis
coverage, catalog ownership, and structural browser behavior remain subject to TDD.
TDD flow:
1. **Write the failing test** in the appropriate suite
2. **Run only that test** to confirm it fails:
- PHP: `vendor/bin/pest --filter='test name'`
- JS: `bun test --isolate tests/Controllers/<name>_controller.test.js`
- Browser JS: `bun run test:browser -- tests/Browser/<name>.pw.js`
3. **Implement** the minimum code to make it pass
4. **Run the test again** to confirm it passes
5. **Repeat** for the next behavior
PHP conventions:
- Test files mirror `src/` structure: `src/Commands/FooCommand.php` → `tests/Commands/FooCommandTest.php`
- Use Pest syntax (`it()`, `test()`, `expect()`) — no PHPUnit classes
- Group related tests with comment headers: `// --- Section name ---`
- Use `beforeEach`/`afterEach` for shared setup and cleanup (temp files, directories)
- For artisan commands: use `$this->artisan('command')->assertSuccessful()` and `expectsQuestion`/`expectsChoice`/
`expectsOutput` for interactive flows
- Always run `composer test` at the end to ensure nothing else broke
JS conventions:
- One test file per controller: `tests/Controllers/<name>_controller.test.js`
- Use `mountController` from `resources/js/helpers/test_stimulus.js` to set up the DOM and Stimulus
- Always call `mounted?.cleanup()` in `afterEach`
- Always run `bun run test` at the end to ensure nothing else broke (the script applies `--isolate`)
- The suite runs with `bun test --isolate` (Bun ≥1.3.10): each file gets its own JSGlobalObject, so
`mock.module` registrations don't leak between files. Drop the flag once Bun 1.4 makes isolation the default.
- **Do not add `--parallel`.** Bare, it defaults to the CPU core count (32 on the maintainer's machine), which drove a
single worker to 11.8 GB and OOM-killed the entire WSL VM. Pinned low it is still a bad trade: measured on 32 cores,
`--parallel=4` failed 4 runs in 8 (~7s) and `--parallel=2` 2 in 4 (~14s), against ~3 in 5 serial (~31s) — and under
any worker count the flaky tests below stop responding entirely rather than merely running late.
- **Behaviour that hinges on MutationObserver delivery belongs in Playwright, not here.** happy-dom drains those
callbacks unreliably once many files share a process: failure probability scaled with how many files ran first
(5 files → 0 of 6 runs, 15 → 2 of 6, 40 → 3 of 6), and in a failing run the expected transition never arrived at all,
so widening poll budgets did not help — Bun caps a single test at 5s regardless. Two mitigations are in place, and
the Bun suite has been green 10 runs running since:
- `resources/js/helpers/test_stimulus.js` drops its `globalThis` bindings in an `afterAll`, so each file releases
its happy-dom `Window` instead of pinning one for the life of the process. It must stay in `afterAll`, not in
`cleanup()` — many `afterEach` blocks still touch `document` after cleaning up.
- The cases that turned on target removal or an external value change moved to
`tests/Browser/read_more_controller.pw.js` and `tests/Browser/side_panel_controller.pw.js`.
- Use Playwright (`tests/Browser/*.pw.js`) for controller behavior that depends on real browser semantics:
`MutationObserver`, focus, `requestAnimationFrame`, layout required by a behavioral, accessibility, or documented
public contract, Turbo frame-like DOM changes, or other complex event timing. Do not use Playwright solely to assert
decorative spacing or appearance.
- Keep Playwright tests focused and few; prefer `bun run test` for deterministic controller unit behavior.
- Run `bun run test:browser` after changing browser-dependent behavior.
## Dependencies
- PHP: `emaia/laravel-hotwire-turbo`, `spatie/laravel-package-tools`
- JS: `@hotwired/stimulus`, `@hotwired/turbo`, `@emaia/stimulus-lazy-loader`
- Optional JS: third-party libs required by specific controllers — the `npm` maps in `src/Registry/catalog.php` are the
source of truth (don't list them here)
## Docblock convention
**Selective documentation** — docblocks must add information; if they don't, they're noise. Default to skipping;
document deliberately.
### Decision tree
```
1. Is this method part of the public API that app code calls?
(Facades, fluent builders, model accessors/scopes, trait methods exposed to consumers,
interface contracts, public methods on PHP classes)
→ YES → docblock required (single-line summary at minimum)
→ NO → step 2
2. Is the purpose non-obvious from name + parameter types + return type?
(Cross-class interaction, hidden constraint, unusual side effect, surprising return semantics)
→ YES → docblock focused on WHY, not what
→ NO → skip — let the code speak
```
### What to write
- **Imperative mood**, single sentence ending in a period: `Persist the upload and return the new Media.`
- **Multi-line only** when 2-3 sentences are required to capture a non-obvious constraint or rationale. If you need
more, the docblock is masking a design smell — refactor or move the explanation to `docs/*.md`.
- **WHY over what** when explaining: "Extracted so the per-iteration try/catch covers every step" — not "Encodes and
persists a conversion" (that's the name).
### Native types first
Always declare native PHP types on properties, parameters, and return values when possible. Drop redundant `@var` /
`@param` / `@return` once the native type carries the contract. Untyped signatures combined with a docblock that names
the type are an anti-pattern (the type system can't enforce what the docblock says).
Constructors don't need a return type. PHP's `resource` pseudo-type has no native equivalent — use
`@param resource $stream` / `@return resource` there.
### When to add `@param` / `@return` / `@throws`
- **`@param` / `@return`**: only when they carry information **beyond** the native type (`array<string, callable>`,
`string[]`, `Collection<int, Media>`, `array{conversion: string, exception: \Throwable}`, semantic constraint like
"empty array returns all"). Never add when they merely repeat the type.
- **`@throws`**: list exceptions that are part of the method's contract (caller is expected to handle them). Skip
generic `RuntimeException` of the "if it breaks it broke" variety.
- **Property `@var`**: only when the native type loses information
(`array<int, array{conversion: string, exception: \Throwable}>`, `MediaChannel[]`,
`array<string, Collection<int, Media>>`). Plain typed properties don't need it.
### What to always remove
- Auto-generated IDE docblocks: `/** Get the X. */` on `getX()` — pure noise
- `@author`, `@version`, `@since`, `@package` — git/Composer resolve these
- Multi-paragraph essays — move to `docs/*.md` and link
- `@param` / `@return` that just repeat the type-hint
- Comments inside method bodies explaining WHAT the next line does (rename a variable instead); keep only WHY when
non-obvious
- Stale TODO/FIXME — convert to an issue or delete
## Roadmap
Operational roadmap and execution notes are maintained outside this repository. Treat local planning exports as
temporary context that may be stale.
## CSS / Theming
- **Semantic tokens only.** Component views use `bg-background`, `text-foreground`, `border-border`, etc. Never hardcode
raw colors (`bg-white`, `text-gray-700`, `bg-zinc-900`). Tokens are defined in
`resources/css/tokens.css` via `@theme inline` with OKLCH values for light and dark mode.
- **Dark mode via `data-theme`** on `<html>`, not `class="dark"`. Default is `:root` (light);
`[data-theme="dark"]` activates the dark palette.
- **Tailwind v4 scanner constraint.** Imported presets are processed directly and must not be scanned as content with
`@source`. Do not add Tailwind utility defaults back to package Blade/PHP unless they are also represented by preset
CSS or the `@source inline(...)` safelist in
`resources/css/structural.css`, which every preset imports.
- **Structural vs visual CSS.** Mechanics live in `resources/css/structural.css` — the rules whose absence leaves a
component broken rather than restyled (the carousel track, the Accordion's `::details-content` collapse). Every preset
imports that file, so they compile into the application stylesheet and hold on the first paint instead of landing once
the bundle runs, and no new preset has to rediscover them. Visual styling lives in presets and uses `data-slot`. Mark
presentation-free or controller-owned slots `structural` in the catalog; `hotwire:make-preset` scaffolds only visual
slots.
- **`Variants` helper is for application code, not for package components.** `Support\Variants` (CVA-equivalent in PHP)
stays exported so apps can build their own variant matrices; the Tailwind scanner reaches it there because it scans
the app's own PHP. Package components must not use it — they emit no classes at all, only `data-slot`,
`data-variant` and `data-size`, and the preset does the styling. `Support\PresetAxes` reports the attributes a
stylesheet differentiates — `data-*`, `aria-*` and native attributes alike — as lexical diagnostics only. Presets
may cover values with base rules or equivalent selectors; conformance must not require identical extracted axes.
- Theming docs for app developers: `docs/theming.md` — token reference, override instructions, colour space notes.
Upgrade notes for existing apps: `docs/upgrade.md` (deliverable of `0.32.0`).
## PR Checklist
Every PR that introduces a new component or controller must include:
- [ ] **Manual smoke matrix.** Component verified: (a) in isolation, (b) inside a Modal, (c) inside a Dropdown, (d)
inside a Turbo Frame. Overlay components additionally verified nested inside each other.
- [ ] **Listeners and timers** verified cleaned up in `disconnect()` — Turbo morph re-connects controllers; duplicate
listeners are a recurring bug source.
- [ ] **Integration opt-in evaluation.** For each new component, assess whether exposing a prop that activates an
existing controller turns a common user setup into a one-liner. Model: Button + `hotkey`
(`hotkey="cmd+s"`). Document the decision (adopted or discarded, with rationale) in the PR body.
## New internal helpers
- `_overlay.js` — shared Presence-driven overlay lifecycle (`data-state`, `hidden`, `inert`, FocusTrap, body scroll
lock, Escape key, focus return and cancelable CSS motion). Consumed by Modal, Alert Dialog, Drawer, Sheet and Sidebar.
Exports `createOverlay(controller, options)` with animated and immediate open/close lifecycle methods.
- `_overlay_host.js` — create and retire per-opening instances from reusable hosts; correlate Frame requests and
parent/child ownership. Never resolve a missing origin through the top overlay or let a late response affect a new
opening.
- `_overlay_return.js` — capture the exact initiating form/region and apply returned HTML; fail safely if that
destination changes. The origin identifying the child to close and the region receiving its return are separate
identities.
- `_overlay_presentation.js` — apply size/side per opening, preserve only managed presentation during morph and restore
host defaults without overwriting unrelated styles.
## Controller auto-loading
All package controllers are loaded directly from the vendor directory via `import.meta.glob` in
`resources/js/controllers/index.js`. No `hotwire:controllers` step is required — controllers work out of the box.
The loader index is a static file copied from `stubs/resources/js/controllers/index.js` by
`hotwire:install`. There is no dynamic generation or version tracking — the stub is the single source of truth.
`hotwire:check` verifies npm dependencies (declared in the catalog `npm` key) and reports outdated/diverged published
controllers. It no longer flags "not published" as a problem since controllers auto-load from vendor.
`hotwire:controllers` remains available for users who want to publish a controller to customize it.
## Workflow
### Teamwork
- Project: Laravel Hotwire
- Operational notebook: `task-workflow.md` (always read before changing Teamwork tasks; contains project-specific rules)
The complete Hotwire stack for Laravel — Turbo Drive, Turbo Streams, Stimulus controllers and Blade components.
src/ — PHP source (commands, components, service provider)src/Components/Concerns/ — shared component traits (e.g. StripsNullProps for omitting null props from rendered
attributes)src/Support/ — framework-agnostic helpers (FieldKey for id/errorKey derivation, MaskPresets, ControllerImports
for resolving a controller's shared JS deps, doc search/render/paging, installer)src/Registry/catalog.php — single source of truth mapping every component and controller to its class/source, view,
docs, category and dependenciessrc/Registry/styles.php — preset-neutral visual module ownership, dependencies and canonical source orderresources/js/controllers/ — Stimulus controllers shipped with the package (published to user's app)resources/views/component-views/ — Blade component viewsstubs/resources/ — Scaffolding files copied by hotwire:installconfig/hotwire.php — Package configuration (component prefix)tests/ — Pest PHP tests with Orchestra Testbenchtests/Controllers/ — Bun JS tests for Stimulus controllersdocs/ — Documentation| Command | Description |
|---|---|
hotwire:install |
Scaffold JS/CSS setup, add npm deps to package.json |
hotwire:make-controller |
Create a new Stimulus controller (interactive scaffolding) |
hotwire:make-preset |
Generate a complete custom CSS preset scaffold or clone a shipped preset |
hotwire:bundle-preset |
Generate or regenerate a selective preset bundle from explicit component selections |
hotwire:controllers |
Publish package Stimulus controllers to the app for customization (--outdated to update only published+changed ones) |
hotwire:components |
List available Blade components and their controller dependencies |
hotwire:check |
Verify npm/controller drift, generated CSS and complete application preset contracts (CI-friendly) |
hotwire:docs |
Browse and read controller/component docs in the terminal |
hotwire:ide-json |
Generate Laravel Idea metadata for package and application Stimulus controllers |
{name}_controller.js (snake_case). The user can generate .ts
controllers via hotwire:make-controller --ts; the package ships .js only.auto_submit_controller.js →
data-controller="auto-submit".turbo/, optimistic/, dev/) group controllers tied to a specific technical layer. Files
inside keep Stimulus' -- separator in the identifier: turbo/progress_controller.js →
data-controller="turbo--progress".form/, modal/, utils/, lib/, media/, notification/). Names themselves describe
intent — prefer compound names (copy-to-clipboard, lazy-image, input-mask) over namespace buckets._ (e.g. _focus_trap.js, _presence.js, _form_errors.js) are shared utility
modules imported by controllers. They are not Stimulus controllers and are never registered via
data-controller.// @hotwire-package on its first non-empty line (or /* @hotwire-package */ for .css). The marker lets
hotwire:controllers (with --force or --outdated --force) and hotwire:check --fix distinguish files that came
from the package from files written by the user — without the marker, those commands refuse to overwrite.
hotwire:make-controller deliberately does not emit the marker, so generated user controllers stay protected.
Drop the marker when reading Support\PackageMarker.@emaia/stimulus-lazy-loader with Vite's import.meta.glob.<hw:form> → auto-submit unsaved-changes clean-query-params; <hw:file> →
file-preserve reset-files plus the user's own data-controller). A controller must therefore never assume it owns
the element exclusively:
this.element (and prefer this.targets); don't clobber attributes other controllers
set, especially shared data-controller.turbo:submit-end, turbo:morph,
turbo:before-render/turbo:render). Coordinate on shared semantics (e.g. submit success) and make each
handler idempotent and order-independent rather than mutating state another controller relies on.disconnect() so re-renders/morphs don't leave duplicates behind.data-* filtering rule below.hw)php artisan hotwire:componentsdata-controller with internal controllers on
the element. User-provided data-{identifier}-* for internal controllers active via props is filtered to prevent
conflicts; for other controllers these pass through freely. Expose supported controller configuration as explicit
Blade props instead of relying on user-provided data-* attributes.controller prop. A component exposes controller — letting the app swap the Stimulus identifier for
its own subclass — only when the controller encapsulates configuration the package cannot proxy through props: ECharts
option, Leaflet layers, Tiptap extensions, Embla plugins, the File Upload pipeline. Where the controller's surface is
closed (overlays sharing _overlay.js, the Floating UI positioners, satellites like char-counter, clear-input or
timeago), the extension path is composition — the merge rule above already lets the app stack its own
data-controller on the element and react to the component's events. Every controller prop is permanent public API,
one more entry point to validate, and one more identifier flowing through @aware, so open one on demand rather than
for symmetry: reach for an event or a specific prop first.Support\FieldKey; components that omit
null props from the rendered tag use the StripsNullProps concern. Reuse these instead of reimplementing.src/Registry/catalog.php is the public component and controller contract — hotwire:components,
hotwire:controllers, hotwire:check and hotwire:docs all read from it. src/Registry/styles.php separately owns
visual CSS modules, their dependencies and source order. Any new component or controller must be registered in the
catalog, and any visual ownership must also be registered in styles.php, or the commands won't see it.
components entries: class, view, docs, category, description, and controllers (the list of Stimulus
identifiers the component depends on — keep it in sync with what the Blade view actually mounts, since
hotwire:check verifies these are published). A family's root component declares its slots once in a public SLOTS
constant as local key => public name + visual|structural; child classes and views reference those local keys. The
catalog projects the declaration through styling.slots class references, optionally narrowed with only.controllers entries: source (path to the .js/.ts file), docs, category, description, and optional
npm (a package => version map for third-party deps like @floating-ui/dom, maska, echarts). Controllers that
create data-slot values in JavaScript declare them in slots too.data-slot to the owning family declaration and reference that declaration from the
catalog. Do not recover this metadata by parsing Blade, PHP or CSS at runtime; the projected registry is the contract
used by preset generation and coverage tests.-- separator
(turbo--progress, optimistic--form, dev--log).docs/ at the path given in the entry.description is public presentation. It reaches an app developer through hotwire:docs and
hotwire:components who has never read the source and is deciding whether the entry solves their problem, so it
says what the entry gives them and where it applies — docs/registry.md covers
the form:
X-Turbo-Frame-Src became "Sends the Turbo Frame source URL", IntersectionObserver became "enters view".emaia/laravel-hotwire-turbo dependencyturbo_stream()->append(...) (the builder is Responsable — return it directly from the
controller; use ->withResponse(...) when you need a custom status or headers)request()->wantsTurboStream(), request()->wasFromTurboFrame(), request()->turboFrameId(),
request()->turboFrameSource()dom_id($model), dom_class($model)Add progress bar and counter targets to carousel)- bullets only for distinct, relevant points, not to meet a quota(#N) in the subject when applicableradio-group, alert-dialog)## Summary (a short paragraph or bullets for distinct principal changes) + ## Test plan.
Include only applicable automated checks and manual smokes for what tests cannot verify (visual, browser-specific
behavior, real interaction). Use checkboxes for actual verification steps; do not add filler to reach a bullet count.Example (include only checks that apply):
## Test plan
- [x] `composer test` — N/N passing
- [ ] Manual smoke: <exercise the changed interaction and expected result, when relevant>
Skip the manual lines that don't apply (a pure internal refactor with full test coverage may legitimately have only the automated lines). For Stimulus controllers and Blade components, default to manual smokes for browser-observable behavior, interaction, accessibility, and structural CSS. Include a pure visual smoke only when the change intentionally affects appearance.
main after the PR is merged remotelyX.Y.Z semver:
X.Y.Z+1): bugfixesX.Y+1.Z): new featuresgit tag -a X.Y.Z -m "X.Y.Z"gh release create X.Y.Z --title "X.Y.Z" --notes-file /tmp/release-notes.mdX.Y.Z template):
## Carousel progress bar and slide counter)**Full Changelog**: https://github.com/emaia/laravel-hotwire/compare/<prev>...<version> at the endcomposer test # Run Pest tests
composer analyse # Run PHPStan
bun run test # Run JS unit tests (Bun + happy-dom). Use `bun run test`, not `bun test` — the npm script wires up --isolate so mocks don't leak across files
bun run test:browser # Run browser tests (Playwright)
composer format # Run Pint code formatter
Follow test-driven development for testable behavior and contracts: write tests first, then implement. Purely visual CSS changes follow the CSS test scope below.
There are three test suites:
composer test) — Pest + Orchestra Testbench. Covers commands, components, registry.bun run test) — Bun test runner + happy-dom. Covers Stimulus controllers in tests/Controllers/*.test.js.bun run test:browser) — Playwright. Covers browser-dependent Stimulus behavior in
tests/Browser/*.pw.js.Automate CSS coverage when a rule provides a structural, behavioral, accessibility, compilation, or documented public contract. Examples include required visibility, scrolling, clipping, positioning, interaction state, focus visibility, contrast, reduced motion, forced-colors support, and public values emitted through CSS custom properties.
Do not add source snapshots or exact computed-style assertions solely to freeze visual choices such as colors, shadows, border radii, typography, decorative motion, or non-functional spacing. Validate intentional appearance changes with a focused manual smoke; they do not require a failing automated test. Stylesheet imports, generated output, slot and axis coverage, catalog ownership, and structural browser behavior remain subject to TDD.
TDD flow:
vendor/bin/pest --filter='test name'bun test --isolate tests/Controllers/<name>_controller.test.jsbun run test:browser -- tests/Browser/<name>.pw.jsPHP conventions:
src/ structure: src/Commands/FooCommand.php → tests/Commands/FooCommandTest.phpit(), test(), expect()) — no PHPUnit classes// --- Section name ---beforeEach/afterEach for shared setup and cleanup (temp files, directories)$this->artisan('command')->assertSuccessful() and expectsQuestion/expectsChoice/
expectsOutput for interactive flowscomposer test at the end to ensure nothing else brokeJS conventions:
tests/Controllers/<name>_controller.test.jsmountController from resources/js/helpers/test_stimulus.js to set up the DOM and Stimulusmounted?.cleanup() in afterEachbun run test at the end to ensure nothing else broke (the script applies --isolate)bun test --isolate (Bun ≥1.3.10): each file gets its own JSGlobalObject, so
mock.module registrations don't leak between files. Drop the flag once Bun 1.4 makes isolation the default.--parallel. Bare, it defaults to the CPU core count (32 on the maintainer's machine), which drove a
single worker to 11.8 GB and OOM-killed the entire WSL VM. Pinned low it is still a bad trade: measured on 32 cores,
--parallel=4 failed 4 runs in 8 (~7s) and --parallel=2 2 in 4 (~14s), against ~3 in 5 serial (~31s) — and under
any worker count the flaky tests below stop responding entirely rather than merely running late.resources/js/helpers/test_stimulus.js drops its globalThis bindings in an afterAll, so each file releases
its happy-dom Window instead of pinning one for the life of the process. It must stay in afterAll, not in
cleanup() — many afterEach blocks still touch document after cleaning up.tests/Browser/read_more_controller.pw.js and tests/Browser/side_panel_controller.pw.js.tests/Browser/*.pw.js) for controller behavior that depends on real browser semantics:
MutationObserver, focus, requestAnimationFrame, layout required by a behavioral, accessibility, or documented
public contract, Turbo frame-like DOM changes, or other complex event timing. Do not use Playwright solely to assert
decorative spacing or appearance.bun run test for deterministic controller unit behavior.bun run test:browser after changing browser-dependent behavior.emaia/laravel-hotwire-turbo, spatie/laravel-package-tools@hotwired/stimulus, @hotwired/turbo, @emaia/stimulus-lazy-loadernpm maps in src/Registry/catalog.php are the
source of truth (don't list them here)Selective documentation — docblocks must add information; if they don't, they're noise. Default to skipping; document deliberately.
1. Is this method part of the public API that app code calls?
(Facades, fluent builders, model accessors/scopes, trait methods exposed to consumers,
interface contracts, public methods on PHP classes)
→ YES → docblock required (single-line summary at minimum)
→ NO → step 2
2. Is the purpose non-obvious from name + parameter types + return type?
(Cross-class interaction, hidden constraint, unusual side effect, surprising return semantics)
→ YES → docblock focused on WHY, not what
→ NO → skip — let the code speak
Persist the upload and return the new Media.docs/*.md.Always declare native PHP types on properties, parameters, and return values when possible. Drop redundant @var /
@param / @return once the native type carries the contract. Untyped signatures combined with a docblock that names
the type are an anti-pattern (the type system can't enforce what the docblock says).
Constructors don't need a return type. PHP's resource pseudo-type has no native equivalent — use
@param resource $stream / @return resource there.
@param / @return / @throws@param / @return: only when they carry information beyond the native type (array<string, callable>,
string[], Collection<int, Media>, array{conversion: string, exception: \Throwable}, semantic constraint like
"empty array returns all"). Never add when they merely repeat the type.@throws: list exceptions that are part of the method's contract (caller is expected to handle them). Skip
generic RuntimeException of the "if it breaks it broke" variety.@var: only when the native type loses information
(array<int, array{conversion: string, exception: \Throwable}>, MediaChannel[],
array<string, Collection<int, Media>>). Plain typed properties don't need it./** Get the X. */ on getX() — pure noise@author, @version, @since, @package — git/Composer resolve thesedocs/*.md and link@param / @return that just repeat the type-hintOperational roadmap and execution notes are maintained outside this repository. Treat local planning exports as temporary context that may be stale.
bg-background, text-foreground, border-border, etc. Never hardcode
raw colors (bg-white, text-gray-700, bg-zinc-900). Tokens are defined in
resources/css/tokens.css via @theme inline with OKLCH values for light and dark mode.data-theme on <html>, not class="dark". Default is :root (light);
[data-theme="dark"] activates the dark palette.@source. Do not add Tailwind utility defaults back to package Blade/PHP unless they are also represented by preset
CSS or the @source inline(...) safelist in
resources/css/structural.css, which every preset imports.resources/css/structural.css — the rules whose absence leaves a
component broken rather than restyled (the carousel track, the Accordion's ::details-content collapse). Every preset
imports that file, so they compile into the application stylesheet and hold on the first paint instead of landing once
the bundle runs, and no new preset has to rediscover them. Visual styling lives in presets and uses data-slot. Mark
presentation-free or controller-owned slots structural in the catalog; hotwire:make-preset scaffolds only visual
slots.Variants helper is for application code, not for package components. Support\Variants (CVA-equivalent in PHP)
stays exported so apps can build their own variant matrices; the Tailwind scanner reaches it there because it scans
the app's own PHP. Package components must not use it — they emit no classes at all, only data-slot,
data-variant and data-size, and the preset does the styling. Support\PresetAxes reports the attributes a
stylesheet differentiates — data-*, aria-* and native attributes alike — as lexical diagnostics only. Presets
may cover values with base rules or equivalent selectors; conformance must not require identical extracted axes.docs/theming.md — token reference, override instructions, colour space notes.
Upgrade notes for existing apps: docs/upgrade.md (deliverable of 0.32.0).Every PR that introduces a new component or controller must include:
disconnect() — Turbo morph re-connects controllers; duplicate
listeners are a recurring bug source.hotkey
(hotkey="cmd+s"). Document the decision (adopted or discarded, with rationale) in the PR body._overlay.js — shared Presence-driven overlay lifecycle (data-state, hidden, inert, FocusTrap, body scroll
lock, Escape key, focus return and cancelable CSS motion). Consumed by Modal, Alert Dialog, Drawer, Sheet and Sidebar.
Exports createOverlay(controller, options) with animated and immediate open/close lifecycle methods._overlay_host.js — create and retire per-opening instances from reusable hosts; correlate Frame requests and
parent/child ownership. Never resolve a missing origin through the top overlay or let a late response affect a new
opening._overlay_return.js — capture the exact initiating form/region and apply returned HTML; fail safely if that
destination changes. The origin identifying the child to close and the region receiving its return are separate
identities._overlay_presentation.js — apply size/side per opening, preserve only managed presentation during morph and restore
host defaults without overwriting unrelated styles.All package controllers are loaded directly from the vendor directory via import.meta.glob in
resources/js/controllers/index.js. No hotwire:controllers step is required — controllers work out of the box.
The loader index is a static file copied from stubs/resources/js/controllers/index.js by
hotwire:install. There is no dynamic generation or version tracking — the stub is the single source of truth.
hotwire:check verifies npm dependencies (declared in the catalog npm key) and reports outdated/diverged published
controllers. It no longer flags "not published" as a problem since controllers auto-load from vendor.
hotwire:controllers remains available for users who want to publish a controller to customize it.
task-workflow.md (always read before changing Teamwork tasks; contains project-specific rules)How can I help you explore Laravel packages today?