[6.x] Customize sources modal - #19474
Conversation
`craft-checkbox-group` announces a model value as its children register, before the group has applied ours. Treating that as a user change emitted an empty selection over whatever the caller passed in, so a group seeded with a value silently cleared itself on mount. Route the handler through the existing `ignoreModelValueInitialization` helper, as the form controls already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two affordances the customize-sources source settings need, and which a
plain checkbox list can't express:
- `sortable()` lets the selected options be reordered, with the value
stored in display order — what a source's table columns are.
- `allowAll()` adds an "All" option posting the `*` sentinel, which
checks and disables every other option. This matches
`Craft.ui.createCheckboxSelect({showAllOption: true})` and
`Garnish.CheckboxSelect`, including clearing the selection when All is
unchecked rather than restoring the previous one.
Both imply a multi-select checkbox list, and are only emitted in
`props()` when set so ordinary Choice payloads are unchanged. The PHP
renderer routes them through `Cp\Components\CheckboxSelect`, which
already implements this shape; the Vue side routes to the CP's
`CheckboxGroup`.
Also gives a `labelHtml`-only button an accessible name, which the
buttons presentation was missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A custom element source's criteria builder labels its add button "Add a filter" rather than the condition's default "Add a rule". Only emitted when set, so an unset label leaves the condition's own default in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ElementSourcesController::show()` returned raw config — view modes, sort
options, available table attributes, a condition builder template with
`__SOURCE_KEY__` placeholders — so that the customize-sources modal could
hand-build every field with `Craft.ui.*`. That duplicated, in ~1,800
lines of JS, what the Form system already describes declaratively.
Each source now carries a `form` key holding a standard FormPayload,
built by the new `ElementSourceForm` and namespaced at `sources.<key>`,
so a Control at `label` posts as `sources[<key>][label]` — the shape
`store()` already reads. Everything the modal used to assemble
client-side is gone from the response.
A new `form()` endpoint serves the payload for a source the client just
added, and doubles as the FormRenderer refresh target.
`store()` gains three fixes this shakes out:
- `defaultSort` arrives as `{attr, dir}`; the legacy 2-tuple is still
accepted. Numeric path segments do survive the round trip, but only
through PHP's numeric-string key casting on both ends, and
`currentValues()` would yield an object rather than a list.
- An empty `sites`/`userGroups` selection means "none", which project
config stores as `false`. The legacy modal posted nothing at all in
that case — jQuery skips disabled items and the unchecked All box —
so the setting silently reverted to "all" on every save.
- A heading with no posted value, and a missing page, are no longer
unguarded index reads.
A source with no key gets no Form: `ElementSources` synthesizes a keyless
blank heading to separate customized sources from the rest, regenerating
it on every read, so nothing can address it and it must not be saved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two in-flight CP chrome tweaks: the modal's default submit button uses the `primary` variant, and the global sidebar drops to `z-index: 10` so it sits under modals rather than over them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports Garnish's resizable Modal onto the Vue one, behind a `resizable` prop: a `BaseDrag` on a corner handle growing the modal by twice the pointer delta, since it stays centered and both edges move. The upper bound comes from the resolved max-width/max-height rather than a hard-coded gutter — the spacing token behind them is a `calc()` and can't be read back — so clamping keeps a drag away from the edge responsive instead of unwinding invisible overshoot. Arrow keys resize too, and Enter or a double-click hands the size back to CSS. Two stacking fixes this needs: - `.cp-modal` had a content-sized column but a row stretched to the viewport, so the handle's `align-self: end` landed on the viewport's edge rather than the content's. It now centers both axes. - `.content` was positioned but not a stacking context, so slotted content's own z-indexes — a sticky pane footer — painted over the handle and swallowed the pointer. It now isolates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces `Craft.CustomizeSourcesModal` and its TypeScript port with a native Vue master/detail: sources on the left, the selected source's settings on the right, rendered by a FormRenderer per source over the payloads the controller now returns. Settings are built on a source's first selection and stay mounted, as the legacy modal did, so unsaved edits and server-rendered condition builders survive switching between sources. A source that was never selected falls through to the payload the server sent, so it round-trips unchanged without having to be mounted at all. Ports the structural behavior the old modal owned: drag and keyboard reordering, the per-source action menu, adding and removing headings and custom sources, and the multi-page sidebar with its page settings, rename and delete. Reordering reuses `useReorderableItems`. Retires the legacy module, its cp/legacy entry points, its global typing, and the "Customize sources" action on the Garnish element index, which would otherwise construct a class that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The view-mode and sort-direction pickers inlined `Icons::svg()` output into each option's `labelHtml`. Options now carry an icon *name* instead, which `<craft-button>`'s own `icon` attribute resolves and renders as a `<craft-icon>`. This fixes a silent failure: `Icons::svg()` only accepts `[\da-z-]+`, so the Cards view mode's `custom-icons/element-cards` fell through to `Html::svg()`, threw, and returned an empty string — the button rendered its title as text instead of an icon. Both renderers now hand the family-prefixed name to the web component, which resolves it client-side. An icon option slots nothing, since `<craft-button>` keys its square icon-only treatment on having an empty light DOM, and the option's label becomes the button's `aria-label` — an icon with no label of its own is aria-hidden. The Vue side binds `icon` as an attribute because `craft-button` doesn't reflect the property, and that CSS selector needs the attribute. Payloads also shrink: an icon name rather than ~700 bytes of SVG per option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pages and sources sidebars had each grown their own copy of the same reorderable list: the `cs-list` markup, the `useReorderableItems` wiring, a bounds-checked reorder, and ~50 lines of near-identical styles. The two copies had already drifted — only the sources list truncated a long label or showed a grab cursor mid-drag. CustomSourceList owns the row: markup, drag and keyboard reordering, selected and dragging states. Callers keep what actually differs — how a row is identified, what its label looks like, and what its action menu offers — through `itemId`/`disabled` props and `label`/`actions` slots. It's generic over the item type, so both slots stay typed. The sources list still reorders against indexes into the unfiltered list, since it only ever renders one page's worth; that translation moved to a handler rather than being folded into the shared component. Also fixes a stray `actions(source, …)` in the pages list, where the loop variable is `page` — the shared row passes the item into the slot, so the reference can't drift again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The actions slot left one line duplicated in both sidebars, and that line
invoked the callback twice per row per render — once for the `v-if`, once
for the binding.
CustomSourceList now takes an `actions` callback and renders the menu
itself, alongside the id it already resolved. Both are computed once per
row rather than per binding, and the caller's own reactive reads are
tracked through the computed, so a source's "Move to {page}" list still
updates when a page is added or renamed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sidebars now describe a row entirely through callbacks — `label` and an optional `icon` join `itemId`/`disabled`/`actions` — and neither passes a slot at all. The blank-label fallback lives in one place rather than only in the sources list, and the truncation styles no longer have to reach through `:slotted()`. Fixes styling that never applied. These components were authored against token names that don't exist — `--c-bg-subtle`, `--c-bg-selected`, `--c-border-radius-md`, `--c-spacing-m/-s`, `--c-font-size-sm` — so the rows had no radius, no margin, and a selected state that painted nothing. It went unnoticed because the retired legacy stylesheet still ships an unscoped `.cs-item` rule that was supplying the background; the row now sets its own so it doesn't depend on which stylesheet wins. The icon also sat flush against the label, since the row gap is 2px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modal now holds the CP's existing `body.no-scroll` lock while it's open, the same convention the slideout panel stack and the legacy Garnish modal use. The lock is ref-counted, because CP modals nest — customize sources opens page settings, which opens the icon picker — and a naive add/remove would unlock the body as soon as the innermost one closed. It's also released when the owning scope is disposed, so a modal unmounted while still open can't strand the page unscrollable. `body.no-scroll` was only defined in the legacy stylesheet and in panel-stack.css, which loads with the slideouts; cp.css declares it too so a page with a modal and neither of those still locks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swapping a source's settings for a new heading's single field dropped the customize-sources modal from ~918px to ~539px, resizing the box out from under whoever was reading it. Modal now remembers the tallest it has been while open and holds that as a floor. The floor only ever rises, and resets on close, so reopening sizes itself to the new content rather than inheriting the last session's. It stays clamped to the same cap a drag-resize uses, since min-height beats max-height and a shrinking viewport would otherwise leave the modal taller than the screen — and it steps aside entirely when the height is already explicit, from the `height` prop or a drag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The floor kept `.content` from collapsing, but the form inside it stayed at its natural height — dropping from 1514px to 537px and leaving the pane, and its Cancel/Save footer, stranded partway up an otherwise full-height modal. `.content` is now a column flex box, and the form, the pane and the pane's own surface each grow to fill it. They grow but never shrink: `craft-pane` clips rather than scrolls, so constraining it below its content would hide a long form instead of scrolling it. `flex: 1 0 auto` keeps a tall form overflowing into `.content`'s scroll, exactly as before. The pane stacks its regions as blocks and its footer is only sticky while something scrolls, so a short body would still strand the footer. Its `base` and `body` parts are reached through `::part()`, keeping the change to modals built on ModalForm rather than every pane in the CP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five stories covering what the component actually decides: the default sources list, the icon-led pages variant, a blank label falling back to "(blank)" alongside an unselectable row, a single row losing its drag handles, and long labels truncating. The component is controlled — it emits `select` and `reorder` and the caller owns `items` and `selected` — so each story holds that state rather than passing static args. Otherwise the rows would render but nothing would answer a click or a drag. Also serves the icon directory Storybook was missing: `craft-icon` fetches `/vendor/craft/icons/<family>/<name>.svg`, which is a symlink to `cms-assets/resources` in the CP and simply 404'd here, so every icon in every story rendered nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`active` styles on `:host([active])` but the property wasn't reflected, so setting it from a framework binding — Vue assigns the property on a custom element, not the attribute — left the selector unmatched and the active state invisible. The component's own stories bind `?active`, an attribute, which is why it looked fine there. Also renders a `prefix` slot, so a row can lead with its own control ahead of the icon.
The header carried its spacing only on top and ran into the body with no separator, which reads as unfinished once the pane is tall enough for the header to sit against content.
Escape and the overlay already closed a modal, but nothing did so by pointer from the modal itself.
The rows were hand-built markup — a button, a handle span and a menu wrapped in a list item. `craft-action-item` already covers that shape, so the list renders one per row and slots the reorder button, icon, label and action menu into it. CustomSourceList also takes an `itemType` callback now, resolved onto the row so a heading can be styled apart via `cs-item--heading`. It stays an opaque string: the sources sidebar is the only place that knows what a source's type means, and this list shouldn't have to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its `.cs-*` rules outlived the jQuery modal they were written for, and being unscoped they were still reaching into the Vue rows that replaced it — quietly supplying a background the new component thought it was setting itself.
`form()` drained HtmlStack into `headHtml`/`bodyHtml`, copying the shape of
the field-layout settings endpoint. But that endpoint renders HTML, and
this one doesn't — it resolves a Form payload, which is pure JSON. So what
it actually returned was 16KB of the whole CP's asset bootstrap, and the
modal fed it to `appendBodyHtml`, which ran it.
Among those initializers is the selectize one, which does
`$('#id').data('selectize').$wrapper` against an id that only exists on a
full page render. On an empty jQuery set `.selectize()` is a no-op, so the
data is never set and it threw on every custom source added:
Uncaught TypeError: Cannot read properties of undefined (reading '$wrapper')
Nothing is lost by dropping them: a Control that renders server-side HTML
fetches its own assets when it renders, which is how the condition builder
already worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`formChangeFromEvent` returned any CustomEvent's `detail`, so anything
bubbling through a form was forwarded as a FormChange. htmx's request
lifecycle puts `{elt, xhr, requestConfig}` there — truthy, so it sailed
past the guard and reached listeners as a change with no `path`.
`recordChange` survived it, since `JSON.stringify(undefined)` is harmless,
and the failure surfaced one level out as an unhandled error in whichever
`change` listener touched `change.path` — in the customize-sources modal,
the one keeping a source's sidebar label in step with its input.
It also broke the condition builder outright: adding a rule swapped the
new markup in and then tore the whole builder back out of the DOM, so the
initializers htmx appended ran against elements that were no longer there
and threw reading `$wrapper`. With the bogus change no longer propagating,
the rule lands and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modal had grown ~180 lines of sizing: bounds from computed CSS, a clamp, a BaseDrag on the corner handle, arrow-key nudging, and the height floor. It now composes that and is left with what a modal is actually about — transitions, focus, the overlay, and turning the numbers into a style. Deliberately a sibling of `useResizable` rather than part of it. That one resizes a layout column: one axis, anchored to an edge, persisted, exposed to assistive tech as a splitter. A centered box grows from both edges at once, has no meaningful side, and is capped by whatever max-width and max-height CSS already gives it. Folding the two together would have meant one composable with two disjoint halves. The height floor came along because it answers the same question — how big is this box — and clamps against the same cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📚 Storybook previews@craftcms/ui — open Storybook Changed components: resources/js — open Storybook No changed components detected in this Storybook. |
Its header-actions block landed unformatted; `vp fmt --check` flags it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composable handed back three numbers and left every consumer to turn them into CSS. That put a rule in the wrong place: whether the height floor applies is a fact about resizing — it is meaningless once the height is fixed — but it lived in Modal as `!style.height` on a locally built object, where a second consumer would have had to rediscover it. It now returns `style`, named to match the same output `useResizable` already had. The one thing it cannot see for itself is a consumer that fixes the height by other means, so that arrives as the `fixedHeight` option. Both paths matter because min-height beats max-height: a floor under a fixed height is either redundant or actively wrong, and a drag that shrinks the box past the floor has to be able to. Modal is left owning only the viewport cap it applies to its own props, and composes the two through `:style`, where later entries win — so a dragged size still beats the width class and the height prop. The corner grip becomes CornerResizeHandle rather than an inline button, so it carries its own appearance and Modal keeps only placement. It is a sibling of ResizeHandle, not a replacement: that one is an edge divider reporting a width as a WAI-ARIA splitter, while a corner grip drives both axes at once, so there is no single value to report and no separator role that fits. What the two do share is how a handle talks to whatever it drives, so `setHandle`/`onKeydown`/`reset` are named once as ResizeHandleControls and both returns extend it. Their keyboard handling stays separate — one axis with Home/End against stored bounds versus two axes against a CSS cap would have made one handler out of two disjoint halves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mount() helper typed its value parameter as `unknown`, but ChoiceControl declares `value: FormValue`, so the h() call failed every overload and TS2769 smeared across all six prop lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6YURj8Rv6LezuXifCSzq3
…sources-modal
diff --git a/.agents/skills/artisan-command-authoring/SKILL.md b/.agents/skills/artisan-command-authoring/SKILL.md
deleted file mode 100644
index a6c6d73128..0000000000
--- a/.agents/skills/artisan-command-authoring/SKILL.md
+++ /dev/null
@@ -1,64 +0,0 @@
----
-name: artisan-command-authoring
-description: Write and review Craft CMS Laravel Artisan commands using repository conventions. Use when adding or refactoring command classes in `src/**/Commands`, porting legacy Yii console controllers, or addressing review feedback on command signatures, aliases, prompts, output formatting, and Laravel service/facade usage.
----
-
-# Artisan Command Authoring
-
-## Overview
-Implement or refactor commands so they match Craft CMS 6 Laravel patterns and pass common review checks the first time.
-
-This skill now includes a Laravel 13 Prompts reference in `references/laravel-prompts-reference.md`.
-
-## Workflow
-1. Inspect neighboring command classes in the same domain and the domain service provider registration.
-2. Define signature, aliases, arguments/options, injected services, and interactive behavior.
-3. If the command is interactive, load `references/laravel-prompts-reference.md` and choose from the full prompt surface, not just basic text/confirm/select helpers.
-4. Implement using Laravel-first APIs (services/facades/components/prompts), then preserve required backward-compatible aliases.
-5. Run format + targeted tests and address findings.
-
-## Command Construction Rules
-- Use `final class ... extends Command` plus `use CraftCommand;`.
-- Define `protected $signature`, `protected $description`, and `protected $aliases` explicitly.
-- Read CLI inputs via `$this->argument()` / `$this->option()` inside `handle()`.
-- Inject services into `handle()` (or constructor when needed) instead of pulling from globals.
-- Prefer Laravel APIs over legacy Yii/Craft app APIs where equivalent exists.
-- Keep compatibility aliases when replacing legacy commands.
-
-## Output And Prompt Rules
-- Use Laravel Prompts for interactive input and interactive command UX, including `search()`, `multisearch()`, `autocomplete()`, `form()`, `progress()`, `task()`, `stream()`, `title()`, and `clear()` when they fit.
-- Prefer `$this->components->info|warn|error|success|task` for conventional Artisan status output in non-interactive or mixed flows.
-- Prompt-side informational helpers (`info()`, `warning()`, `error()`, `alert()`, `note()`, `intro()`, `outro()`, `table()`) are valid when the command is intentionally using a Prompts-driven experience; avoid mixing both styles line-by-line without a reason.
-- Avoid ad-hoc `output->write()`/`line()` for status messaging when components or prompt helpers fit.
-- Gate interactive prompts with `$this->input->isInteractive()` and provide non-interactive fallbacks.
-- Normalize prompt input with `transform:` before `validate:` when trimming or coercion would otherwise leak into validation logic.
-- Keep labels, option text, and validation messages short enough for narrow terminals.
-- In task closures, do not add unnecessary `return true;` values.
-- Do not add manual blank-line spacing after component calls unless behavior requires it.
-
-## When To Load References
-- You need any prompt type beyond `text()`, `confirm()`, `select()`, or `multiselect()`.
-- You need prompt validation/transform behavior or searchable prompt patterns.
-- You need form builder chaining (`form()->...->submit()`) or conditional form steps.
-- You need long-running interactive UX (`spin()`, `progress()`, `task()`, `stream()`) or terminal helpers (`title()`, `clear()`).
-- You need prompt-specific testing expectations or fallback behavior details.
-
-## Interactive Command Guidance
-- Prefer `search()` / `multisearch()` over large static selects when options come from the database or are too numerous to scan.
-- Prefer `autocomplete()` or `suggest()` when users benefit from completion but may still need freeform values.
-- Prefer `form()` for grouped setup flows where users may need to revisit earlier answers (`CTRL + U` in supported terminals).
-- Prefer `task()` for long-running work that benefits from a live log area, status messages, dynamic labels, or partial streamed output.
-- Prefer `progress()` for bounded loops and `spin()` for single opaque operations.
-- Assume unsupported environments and non-interactive runs still exist even when using Prompts; Laravel configures fallbacks automatically, but command behavior must remain safe and predictable.
-
-## Testing And Fallbacks
-- When prompt helpers produce informational output, assert it with prompt-aware expectations such as `expectsPromptsInfo()`, `expectsPromptsWarning()`, `expectsPromptsError()`, `expectsPromptsAlert()`, `expectsPromptsIntro()`, `expectsPromptsOutro()`, and `expectsPromptsTable()`.
-- If a command has both interactive and non-interactive branches, test both paths.
-- If reviewing custom prompt fallback behavior, prefer Laravel's built-in fallbacks unless the command genuinely needs `Prompt::fallbackWhen(...)` or prompt-class `fallbackUsing(...)` customization.
-
-## Validation
-Run at minimum:
-```bash
-./vendor/bin/pint <touched files>
-./vendor/bin/pest --compact <relevant tests>
-```
diff --git a/.agents/skills/artisan-command-authoring/references/laravel-prompts-reference.md b/.agents/skills/artisan-command-authoring/references/laravel-prompts-reference.md
deleted file mode 100644
index f262d90dc5..0000000000
--- a/.agents/skills/artisan-command-authoring/references/laravel-prompts-reference.md
+++ /dev/null
@@ -1,260 +0,0 @@
-# Laravel Prompts Reference
-
-## Source And Scope
-
-- Official Laravel 13 docs: <https://laravel.com/docs/13.x/prompts>
-- Prompt helper signatures: <https://github.com/laravel/prompts/blob/main/src/helpers.php>
-- Form builder signatures: <https://github.com/laravel/prompts/blob/main/src/FormBuilder.php>
-- Prompt test expectations in Artisan tests: `vendor/laravel/framework/src/Illuminate/Testing/PendingCommand.php`
-- Last synced: 2026-03-24
-
-This reference is for authoring and reviewing interactive Artisan commands in Craft CMS.
-
-## Import Patterns
-
-Import only the helpers you use:
-
-```php
-use function Laravel\Prompts\confirm;
-use function Laravel\Prompts\multiselect;
-use function Laravel\Prompts\search;
-use function Laravel\Prompts\text;
-```
-
-Or import many in one statement:
-
-```php
-use function Laravel\Prompts\{confirm, error, info, multiselect, search, text};
-```
-
-## Global Prompt Helpers
-
-### Input Helpers
-
-| Helper | Return | Key arguments | Notes |
-| --- | --- | --- | --- |
-| `text()` | `string` | `label`, `placeholder`, `default`, `required`, `validate`, `hint`, `transform` | Single line text input. |
-| `textarea()` | `string` | `label`, `placeholder`, `default`, `required`, `validate`, `hint`, `rows`, `transform` | Multiline input. |
-| `number()` | `int|string` | `label`, `placeholder`, `default`, `required`, `validate`, `hint`, `min`, `max`, `step` | Numeric input with arrow key adjustments. |
-| `password()` | `string` | `label`, `placeholder`, `required`, `validate`, `hint`, `transform` | Input is masked. |
-| `confirm()` | `bool` | `label`, `default`, `yes`, `no`, `required`, `validate`, `hint`, `transform` | Yes/no selection. |
-| `select()` | `int|string` | `label`, `options`, `default`, `scroll`, `validate`, `hint`, `required`, `transform` | Single option from fixed list. |
-| `multiselect()` | `array<int|string>` | `label`, `options`, `default`, `scroll`, `required`, `validate`, `hint`, `transform` | Multi option selection with space bar. |
-| `suggest()` | `string` | `label`, `options` (array or closure), `placeholder`, `default`, `scroll`, `required`, `validate`, `hint`, `transform` | Autocomplete, freeform value still allowed. |
-| `search()` | `int|string` | `label`, `options` (closure), `placeholder`, `scroll`, `validate`, `hint`, `required`, `transform` | Search first, then select one option. |
-| `multisearch()` | `array<int|string>` | `label`, `options` (closure), `placeholder`, `scroll`, `required`, `validate`, `hint`, `transform` | Search first, then select multiple options. |
-| `pause()` | `bool` | `message` | Waits for enter/return confirmation. |
-| `autocomplete()` | `string` | `label`, `options` (array or closure), `placeholder`, `default`, `required`, `validate`, `hint` | Inline type-ahead completion via tab/right arrow. |
-
-### Output And Utility Helpers
-
-| Helper | Return | Purpose |
-| --- | --- | --- |
-| `info()` | `void` | Informational message. |
-| `warning()` | `void` | Warning message. |
-| `error()` | `void` | Error message. |
-| `alert()` | `void` | Alert message. |
-| `note()` | `void` | Generic note style. |
-| `intro()` | `void` | Introductory message. |
-| `outro()` | `void` | Closing message. |
-| `table()` | `void` | Render rows and headers as a table. |
-| `grid()` | `void` | Render items in a grid. |
-| `spin()` | `mixed` | Spinner while callback executes; returns callback result. |
-| `progress()` | `array|Progress` | Progress bar for iterable or fixed step count. |
-| `task()` | `mixed` | Spinner + scrolling live log area for long-running work. |
-| `stream()` | `Stream` | Stream incremental text into the terminal. |
-| `title()` | `void` | Update the terminal window/tab title. |
-| `clear()` | `void` | Clear terminal. |
-| `form()` | `FormBuilder` | Build a multi-step prompt flow with backtracking. |
-
-## Shared Argument Behavior
-
-- `required`:
- - `false` means optional.
- - `true` enforces input.
- - `string` enforces input with a custom message.
-- `validate`:
- - Closure returning `null` or an error message.
- - For many text-like prompts, validation rule arrays are also accepted (for example `['name' => 'required|max:255']`).
-- `transform`:
- - Runs before validation.
- - Use for normalization, such as `trim`, lowercase conversion, or casting.
-- `hint`:
- - Help text shown under the prompt.
-- `scroll`:
- - Visible option count before scrolling.
-- `options` return value:
- - Associative arrays return keys.
- - Indexed arrays return values.
-
-## Search Prompt Notes
-
-- `search()` and `multisearch()` expect an `options` closure that receives current input and returns options.
-- For value-based filtering, ensure the array is reindexed (`values()->all()` or `array_values(...)`) so it is not treated as associative.
-
-## Form Builder Reference
-
-Use `form()` when prompts are a sequence and users may need to go back (`CTRL + U` in supported terminals).
-
-- `submit()` returns indexed responses unless prompt steps are given `name:`.
-- Named steps return associative responses.
-- Use `add(...)` when later prompts depend on earlier responses.
-- `FormBuilder` also supports conditional chaining such as `when(...)` because it is conditionable.
-
-### Core Methods
-
-| Method | Purpose |
-| --- | --- |
-| `add(Closure $step, ?string $name = null, bool $ignoreWhenReverting = false)` | Add custom step closure. |
-| `addIf(Closure|bool $condition, Closure $step, ?string $name = null, bool $ignoreWhenReverting = false)` | Add conditional custom step. |
-| `submit(): array` | Execute steps and return responses. |
-
-### Built-in Prompt Methods On `FormBuilder`
-
-- `text(...)`
-- `textarea(...)`
-- `password(...)`
-- `confirm(...)`
-- `select(...)`
-- `multiselect(...)`
-- `suggest(...)`
-- `search(...)`
-- `multisearch(...)`
-- `pause(...)`
-- `spin(...)`
-- `note(...)`
-- `info(...)`
-- `warning(...)`
-- `error(...)`
-- `alert(...)`
-- `intro(...)`
-- `outro(...)`
-- `table(...)`
-- `progress(...)`
-
-Most form methods accept `name: 'key'` so `submit()` returns named responses.
-For numeric input in a form flow, use `add(...)` and call `number(...)` inside the closure.
-
-## Transform Before Validation
-
-Use `transform` when users might include formatting you do not want to validate directly:
-
-```php
-$slug = text(
- label: 'Slug',
- transform: fn (string $value) => trim(strtolower($value)),
- validate: ['slug' => 'required|alpha_dash']
-);
-```
-
-## Informational Messages In Commands
-
-Prefer one style per command path and keep message noise low:
-
-- Prompt helpers for rich, prompt-themed messaging (`info()`, `warning()`, etc.).
-- `$this->components->...` for conventional Artisan status output.
-
-Use one approach intentionally and avoid mixing styles in every line unless there is a reason.
-
-## Task And Stream Patterns
-
-Use `task()` when a command should show live status while work is running.
-
-- `task()` callback receives a logger.
-- Logger methods include `line()`, `success()`, `warning()`, `error()`, `label()`, `partial()`, and `commitPartial()`.
-- Use `limit:` to control how many scrolling log lines remain visible.
-- `task()` and `spin()` animate when `ext-pcntl` is available; otherwise they degrade to static output.
-
-Use `stream()` when output itself should arrive incrementally rather than as task log lines:
-
-```php
-use function Laravel\Prompts\stream;
-
-$stream = stream();
-
-foreach ($chunks as $chunk) {
- $stream->append($chunk);
-}
-
-$stream->close();
-```
-
-Use `title()` for long-running commands where terminal/tab context helps, and reset with `title('')` when appropriate.
-
-## Progress Patterns
-
-Map-style progress:
-
-```php
-use function Laravel\Prompts\progress;
-
-$results = progress(
- label: 'Processing users',
- steps: $users,
- callback: fn ($user, $progress) => handleUser($user),
-);
-```
-
-Manual progress:
-
-```php
-$progress = progress(label: 'Processing users', steps: count($users));
-$progress->start();
-
-foreach ($users as $user) {
- handleUser($user);
- $progress->advance();
-}
-
-$progress->finish();
-```
-
-## Fallbacks And Unsupported Environments
-
-- Laravel Prompts supports macOS, Linux, and Windows under WSL.
-- Laravel framework configures fallbacks automatically in unsupported environments.
-- For non-Laravel or custom behavior:
- - `Laravel\Prompts\Prompt::fallbackWhen(bool)`
- - Per prompt class: `SomePrompt::fallbackUsing(Closure $fallback)`
-
-## Terminal Constraints
-
-- Keep labels/options/validation messages short enough for narrow terminals.
-- A safe target is about 74 characters for 80-column terminals.
-- Prompts with `scroll` automatically clamp to terminal height.
-
-## Testing Prompt Output In Artisan Tests
-
-Available prompt message assertions on `PendingCommand`:
-
-- `expectsPromptsInfo(string $message)`
-- `expectsPromptsWarning(string $message)`
-- `expectsPromptsError(string $message)`
-- `expectsPromptsAlert(string $message)`
-- `expectsPromptsIntro(string $message)`
-- `expectsPromptsOutro(string $message)`
-- `expectsPromptsTable(array|Collection $headers, array|Collection|null $rows)`
-
-## Craft Command Integration Pattern
-
-Always gate prompts in case the command is running non-interactively:
-
-```php
-use function Laravel\Prompts\confirm;
-use function Laravel\Prompts\text;
-
-$name = $this->argument('name');
-
-if (!$name) {
- if (!$this->input->isInteractive()) {
- $this->components->error('The name argument is required in non-interactive mode.');
- return self::FAILURE;
- }
-
- $name = text(label: 'Name', required: true);
-}
-
-$shouldProceed = $this->input->isInteractive()
- ? confirm('Continue?', default: true)
- : (bool)$this->option('force');
-```
diff --git a/.agents/skills/inertia-vue-development/SKILL.md b/.agents/skills/inertia-vue-development/SKILL.md
new file mode 100644
index 0000000000..2813e8cd90
--- /dev/null
+++ b/.agents/skills/inertia-vue-development/SKILL.md
@@ -0,0 +1,575 @@
+---
+name: inertia-vue-development
+description: "Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Inertia Vue Development
+
+## When to Apply
+
+Activate this skill when:
+
+- Creating or modifying Vue page components for Inertia
+- Working with forms in Vue (using `<Form>`, `useForm`, or `useHttp`)
+- Implementing client-side navigation with `<Link>` or `router`
+- Using v3 features: deferred props, prefetching, optimistic updates, instant visits, layout props, HTTP requests, WhenVisible, InfiniteScroll, once props, flash data, or polling
+- Building Vue-specific features with the Inertia protocol
+
+## Documentation
+
+Use `search-docs` for detailed Inertia v3 Vue patterns and documentation.
+
+## Basic Usage
+
+### Page Components Location
+
+Vue page components should be placed in the `resources/js/pages` directory.
+
+### Page Component Structure
+
+<!-- Basic Vue Page Component -->
+```vue
+<script setup>
+defineProps({
+ users: Array
+})
+</script>
+
+<template>
+ <div>
+ <h1>Users</h1>
+ <ul>
+ <li v-for="user in users" :key="user.id">
+ {{ user.name }}
+ </li>
+ </ul>
+ </div>
+</template>
+```
+
+## Client-Side Navigation
+
+### Basic Link Component
+
+Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
+
+<!-- Inertia Vue Navigation -->
+```vue
+<script setup>
+import { Link } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <div>
+ <Link href="/">Home</Link>
+ <Link href="/users">Users</Link>
+ <Link :href="`/users/${user.id}`">View User</Link>
+ </div>
+</template>
+```
+
+### Link with Method
+
+<!-- Link with POST Method -->
+```vue
+<script setup>
+import { Link } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Link href="/logout" method="post" as="button">
+ Logout
+ </Link>
+</template>
+```
+
+### Prefetching
+
+Prefetch pages to improve perceived performance:
+
+<!-- Prefetch on Hover -->
+```vue
+<script setup>
+import { Link } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Link href="/users" prefetch>
+ Users
+ </Link>
+</template>
+```
+
+### Programmatic Navigation
+
+<!-- Router Visit -->
+```vue
+<script setup>
+import { router } from '@inertiajs/vue3'
+
+function handleClick() {
+ router.visit('/users')
+}
+
+// Or with options
+function createUser() {
+ router.visit('/users', {
+ method: 'post',
+ data: { name: 'John' },
+ onSuccess: () => console.log('Done'),
+ })
+}
+</script>
+
+<template>
+ <Link href="/users">Users</Link>
+ <Link href="/logout" method="post" as="button">Logout</Link>
+</template>
+```
+
+## Form Handling
+
+### Form Component (Recommended)
+
+The recommended way to build forms is with the `<Form>` component:
+
+<!-- Form Component Example -->
+```vue
+<script setup>
+import { Form } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
+ <input type="text" name="name" />
+ <div v-if="errors.name">{{ errors.name }}</div>
+
+ <input type="email" name="email" />
+ <div v-if="errors.email">{{ errors.email }}</div>
+
+ <button type="submit" :disabled="processing">
+ {{ processing ? 'Creating...' : 'Create User' }}
+ </button>
+
+ <div v-if="wasSuccessful">User created!</div>
+ </Form>
+</template>
+```
+
+### Form Component With All Props
+
+<!-- Form Component Full Example -->
+```vue
+<script setup>
+import { Form } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Form
+ action="/users"
+ method="post"
+ #default="{
+ errors,
+ hasErrors,
+ processing,
+ progress,
+ wasSuccessful,
+ recentlySuccessful,
+ setError,
+ clearErrors,
+ resetAndClearErrors,
+ defaults,
+ isDirty,
+ reset,
+ submit
+ }"
+ >
+ <input type="text" name="name" :value="defaults.name" />
+ <div v-if="errors.name">{{ errors.name }}</div>
+
+ <button type="submit" :disabled="processing">
+ {{ processing ? 'Saving...' : 'Save' }}
+ </button>
+
+ <progress v-if="progress" :value="progress.percentage" max="100">
+ {{ progress.percentage }}%
+ </progress>
+
+ <div v-if="wasSuccessful">Saved!</div>
+ </Form>
+</template>
+```
+
+### Form Component Reset Props
+
+The `<Form>` component supports automatic resetting:
+
+- `resetOnError` - Reset form data when the request fails
+- `resetOnSuccess` - Reset form data when the request succeeds
+- `setDefaultsOnSuccess` - Update default values on success
+
+Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
+
+<!-- Form with Reset Props -->
+```vue
+<script setup>
+import { Form } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Form
+ action="/users"
+ method="post"
+ reset-on-success
+ set-defaults-on-success
+ #default="{ errors, processing, wasSuccessful }"
+ >
+ <input type="text" name="name" />
+ <div v-if="errors.name">{{ errors.name }}</div>
+
+ <button type="submit" :disabled="processing">
+ Submit
+ </button>
+ </Form>
+</template>
+```
+
+Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
+
+### `useForm` Composable
+
+For more programmatic control or to follow existing conventions, use the `useForm` composable:
+
+<!-- useForm Composable Example -->
+```vue
+<script setup>
+import { useForm } from '@inertiajs/vue3'
+
+const form = useForm({
+ name: '',
+ email: '',
+ password: '',
+})
+
+function submit() {
+ form.post('/users', {
+ onSuccess: () => form.reset('password'),
+ })
+}
+</script>
+
+<template>
+ <form @submit.prevent="submit">
+ <input type="text" v-model="form.name" />
+ <div v-if="form.errors.name">{{ form.errors.name }}</div>
+
+ <input type="email" v-model="form.email" />
+ <div v-if="form.errors.email">{{ form.errors.email }}</div>
+
+ <input type="password" v-model="form.password" />
+ <div v-if="form.errors.password">{{ form.errors.password }}</div>
+
+ <button type="submit" :disabled="form.processing">
+ Create User
+ </button>
+ </form>
+</template>
+```
+
+## Inertia v3 Features
+
+### HTTP Requests
+
+Use the `useHttp` hook for standalone HTTP requests that do not trigger Inertia page visits. It provides the same developer experience as `useForm`, but for plain JSON endpoints.
+
+<!-- useHttp Example -->
+```vue
+<script setup>
+import { useHttp } from '@inertiajs/vue3'
+
+const http = useHttp({
+ query: '',
+})
+
+function search() {
+ http.get('/api/search', {
+ onSuccess: (response) => {
+ console.log(response)
+ },
+ })
+}
+</script>
+
+<template>
+ <input v-model="http.query" @input="search" />
+ <div v-if="http.processing">Searching...</div>
+</template>
+```
+
+### Optimistic Updates
+
+Apply data changes instantly before the server responds, with automatic rollback on failure:
+
+<!-- Optimistic Update with Router -->
+```vue
+<script setup>
+import { router } from '@inertiajs/vue3'
+
+function like(post) {
+ router.optimistic((props) => ({
+ post: {
+ ...props.post,
+ likes: props.post.likes + 1,
+ },
+ })).post(`/posts/${post.id}/like`)
+}
+</script>
+```
+
+Optimistic updates also work with `useForm` and the `<Form>` component:
+
+<!-- Optimistic Update with Form Component -->
+```vue
+<template>
+ <Form
+ action="/todos"
+ method="post"
+ :optimistic="(props, data) => ({
+ todos: [...props.todos, { id: Date.now(), name: data.name, done: false }],
+ })"
+ >
+ <input type="text" name="name" />
+ <button type="submit">Add Todo</button>
+ </Form>
+</template>
+```
+
+### Instant Visits
+
+Navigate to a new page immediately without waiting for the server response. The target component renders right away with shared props, while page-specific props load in the background.
+
+<!-- Instant Visit with Link -->
+```vue
+<script setup>
+import { Link } from '@inertiajs/vue3'
+</script>
+
+<template>
+ <Link href="/dashboard" component="Dashboard">Dashboard</Link>
+
+ <Link
+ href="/posts/1"
+ component="Posts/Show"
+ :page-props="{ post: { id: 1, title: 'My Post' } }"
+ >
+ View Post
+ </Link>
+</template>
+```
+
+### Layout Props
+
+Share dynamic data between pages and persistent layouts:
+
+<!-- Layout Props in Layout -->
+```vue
+<script setup>
+withDefaults(defineProps({
+ title: String,
+ showSidebar: Boolean,
+}), {
+ title: 'My App',
+ showSidebar: true,
+})
+</script>
+
+<template>
+ <header>{{ title }}</header>
+ <aside v-if="showSidebar">Sidebar</aside>
+ <main>
+ <slot />
+ </main>
+</template>
+```
+
+<!-- Setting Layout Props from Page -->
+```vue
+<script setup>
+import { setLayoutProps } from '@inertiajs/vue3'
+
+setLayoutProps({
+ title: 'Dashboard',
+ showSidebar: false,
+})
+</script>
+
+<template>
+ <h1>Dashboard</h1>
+</template>
+```
+
+### Deferred Props
+
+Use deferred props to load data after initial page render:
+
+<!-- Deferred Props with Empty State -->
+```vue
+<script setup>
+defineProps({
+ users: Array
+})
+</script>
+
+<template>
+ <div>
+ <h1>Users</h1>
+ <div v-if="!users" class="animate-pulse">
+ <div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
+ <div class="h-4 bg-gray-200 rounded w-1/2"></div>
+ </div>
+ <ul v-else>
+ <li v-for="user in users" :key="user.id">
+ {{ user.name }}
+ </li>
+ </ul>
+ </div>
+</template>
+```
+
+### Polling
+
+Use the `usePoll` composable to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
+
+<!-- Basic Polling -->
+```vue
+<script setup>
+import { usePoll } from '@inertiajs/vue3'
+
+defineProps({
+ stats: Object
+})
+
+usePoll(5000)
+</script>
+
+<template>
+ <div>
+ <h1>Dashboard</h1>
+ <div>Active Users: {{ stats.activeUsers }}</div>
+ </div>
+</template>
+```
+
+<!-- Polling With Request Options and Manual Control -->
+```vue
+<script setup>
+import { usePoll } from '@inertiajs/vue3'
+
+defineProps({
+ stats: Object
+})
+
+const { start, stop } = usePoll(5000, {
+ only: ['stats'],
+ onStart() {
+ console.log('Polling request started')
+ },
+ onFinish() {
+ console.log('Polling request finished')
+ },
+}, {
+ autoStart: false,
+ keepAlive: true,
+})
+</script>
+
+<template>
+ <div>
+ <h1>Dashboard</h1>
+ <div>Active Users: {{ stats.activeUsers }}</div>
+ <button @click="start">Start Polling</button>
+ <button @click="stop">Stop Polling</button>
+ </div>
+</template>
+```
+
+- `autoStart` (default `true`) - set to `false` to start polling manually via the returned `start()` function
+- `keepAlive` (default `false`) - set to `true` to prevent throttling when the browser tab is inactive
+
+### WhenVisible
+
+Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
+
+<!-- WhenVisible Example -->
+```vue
+<script setup>
+import { WhenVisible } from '@inertiajs/vue3'
+
+defineProps({
+ stats: Object
+})
+</script>
+
+<template>
+ <div>
+ <h1>Dashboard</h1>
+
+ <WhenVisible data="stats" :buffer="200">
+ <template #fallback>
+ <div class="animate-pulse">Loading stats...</div>
+ </template>
+
+ <template #default="{ fetching }">
+ <div>
+ <p>Total Users: {{ stats.total_users }}</p>
+ <p>Revenue: {{ stats.revenue }}</p>
+ <span v-if="fetching">Refreshing...</span>
+ </div>
+ </template>
+ </WhenVisible>
+ </div>
+</template>
+```
+
+### InfiniteScroll
+
+Automatically load additional pages of paginated data as users scroll:
+
+<!-- InfiniteScroll Example -->
+```vue
+<script setup>
+import { InfiniteScroll } from '@inertiajs/vue3'
+
+defineProps({
+ users: Object
+})
+</script>
+
+<template>
+ <InfiniteScroll data="users">
+ <div v-for="user in users.data" :key="user.id">
+ {{ user.name }}
+ </div>
+ </InfiniteScroll>
+</template>
+```
+
+The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
+
+## Server-Side Patterns
+
+Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
+
+## Common Pitfalls
+
+- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
+- Forgetting that Vue components must have a single root element
+- Forgetting to add loading states (skeleton screens) when using deferred props
+- Not handling the `undefined` state of deferred props before data loads
+- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
+- Forgetting to check if `<Form>` component is available in your Inertia version
+- Using `router.cancel()` instead of `router.cancelAll()` (v3 breaking change)
+- Using `router.on('invalid', ...)` or `router.on('exception', ...)` instead of the renamed `httpException` and `networkError` events
diff --git a/.agents/skills/infer-conventions/SKILL.md b/.agents/skills/infer-conventions/SKILL.md
new file mode 100644
index 0000000000..11a9327530
--- /dev/null
+++ b/.agents/skills/infer-conventions/SKILL.md
@@ -0,0 +1,104 @@
+---
+name: infer-conventions
+description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Infer Conventions
+
+Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
+
+## Ground Rules (read before you start)
+
+- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
+- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
+- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
+- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
+- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
+- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
+- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
+
+## Process
+
+Each step ends on a checkable completion criterion. Do not advance until it holds.
+
+Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
+
+### Step 0: Orient
+
+Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
+
+This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
+
+Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
+
+### Step 1: Predefined sweep
+
+Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
+
+- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
+- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
+- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
+- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
+- Tooling-owned or Already-recorded. Skip per the ground rules.
+
+Done when: every applicable dimension carries exactly one of those verdicts.
+
+### Step 2: Open-ended pass
+
+First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
+
+Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
+
+Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
+
+### Step 3: Confirm
+
+Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
+
+Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
+
+Done when: every candidate is approved, rejected, or (conflicts) decided.
+
+### Step 4: Record
+
+Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
+
+Record this:
+
+> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
+
+Not this:
+
+> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
+
+Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
+
+### Step 5: Summarize
+
+List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
+
+## Glob mapping
+
+Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
+
+Examples:
+
+- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
+- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
+- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
+- Tests: `tests/**`.
+- Migrations and database: `database/migrations/**`.
+- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
+
+`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
+
+## Edge cases
+
+- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
+- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
+- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
+- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
+- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
diff --git a/.agents/skills/infer-conventions/references/checklist.md b/.agents/skills/infer-conventions/references/checklist.md
new file mode 100644
index 0000000000..f8d1b1441f
--- /dev/null
+++ b/.agents/skills/infer-conventions/references/checklist.md
@@ -0,0 +1,137 @@
+# Detection Checklist
+
+Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
+
+Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
+
+---
+
+## A. Validation & HTTP input
+
+1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
+ - Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
+2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
+ - Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
+3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
+ - Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
+4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
+ - Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
+
+## B. Controllers & routing
+
+5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
+ - Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
+6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
+ - Hint: read a few controller methods; `ls app/Actions app/Services`.
+7. Route handler style: closures in `routes/*.php` vs controller classes.
+ - Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
+8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
+ - Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
+9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
+ - Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
+10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
+ - Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
+
+## C. Authorization
+
+11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
+ - Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
+12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
+ - Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
+
+## D. Eloquent & models
+
+13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
+ - Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
+14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
+ - Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
+15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
+ - Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
+16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
+ - Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
+17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
+ - Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
+18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
+ - Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
+19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
+ - Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
+20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
+ - Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
+
+## E. Architecture & organization
+
+21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
+ - Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
+22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
+ - Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
+23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
+ - Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
+24. Decoupling: events + listeners vs direct service calls.
+ - Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
+25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
+ - Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
+26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
+ - Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
+27. Enums: backed vs pure; case naming; where they live.
+ - Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
+
+## F. Frontend & views
+
+This app ships a frontend stack, so the items below apply.
+
+28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
+ - Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
+29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
+ - Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
+32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
+ - Hint: `ls lang`; grep dotted `__('` vs sentence keys.
+
+## G. Database & migrations
+
+33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
+ - Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
+34. `down()` methods: real reverse logic vs omitted / one-way migrations.
+ - Hint: grep `function down` vs the migration count.
+35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
+ - Hint: grep `->enum(` in migrations vs string columns cast to enums.
+36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
+ - Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
+37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
+ - Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
+
+## H. Testing
+
+38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
+ - Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
+39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
+ - Hint: grep those trait names in `tests/`.
+40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
+ - Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
+41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
+ - Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
+42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
+ - Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
+
+## I. Responses & API resources
+
+43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
+ - Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
+44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
+ - Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
+45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
+ - Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
+46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
+ - Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
+
+## J. Strings, collections & dates
+
+47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
+ - Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
+48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
+ - Hint: grep `Str::of(` vs `Str::` vs native string funcs.
+49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
+ - Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
+
+---
+
+Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 0000000000..311ab84437
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## How to Apply
+
+1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
+2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
+3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
+4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
+5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
+6. Re-read the diff against every mapped rule before finishing.
+
+## Rule Index
+
+Cross-cutting changes often need more than one rule file.
+
+| Concern | Read |
+| --- | --- |
+| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
+| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
+| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
+| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
+| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
+| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
+| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
+| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
+| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
+| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
+| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
+| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
+| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
+| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
+| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
+| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
+| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
+| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill |
+| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
+| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
+
+## Decision Rules
+
+- Prefer framework features and existing application abstractions over new helpers or dependencies.
+- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
+- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 0000000000..f12876e4c1
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 0000000000..b65e3b566f
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,206 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function handle(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```ph…
…ces-modal # Conflicts: # resources/translations/de-CH/app.php
|
This PR had conflicts with Claude's response
|
Modernizes the customize sources model into Vue / Inertia and uses the form builder to create the form for each source in the modal.