Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
alfakit-development
alfasix/alfakit-workflows
claude-code
workflow
Audit
laravel
state-machine
approvals
alfakit

Build AlfaKit admin panels in Laravel — resources, forms, tables, filters, actions, relation managers, infolists, widgets, custom pages, notifications and authorization. Use when creating or changing anything under app/Alfakit or a PanelProvider.

Install
php artisan boost:add-skill alfasix/alfakit-workflows

Save this content to: .claude/skills/alfakit-development/SKILL.md

# AlfaKit development

## When to use this skill

Use it when adding or changing panels, resources, forms, tables, actions, relation managers, infolists, widgets or custom pages in an app that uses `alfasix/alfakit`. Everything is defined in PHP; the React/Vue/Svelte renderer only displays the schema.

## Workflow

0. New app: `php artisan alfakit:setup` is the guided install (`--mode=panel|starter-kit|standalone`, `--users`, `--roles` (Shield), `--ai=openai,anthropic`, `--realtime`, `--plugins=activity-log,comments` (or `all`), `--bundle=saas|content|backoffice|support|ops` (comma-separated or `all`: a curated set of plugins registered with default navigation groups), `--brand-color="#2563eb"`, `--logo=images/logo.svg`, `--font=Inter`, `--demo`, `--migrate`, `--admin-email=` / `--admin-name=` / `--admin-password=` to create the user you sign in with (panel mode; interactively it offers `make:alfakit-user`), `--no-install` to never run composer/npm, `--no-interaction`); re-running skips finished steps; it defaults to a minimal install (every optional step no, no plugins). Add first-party plugins later with `php artisan alfakit:add comments activity-log [--panel=admin] [--migrate] [--no-install]` (composer require after a confirmation, `->plugin(...)` on the panel provider, migrations), list them with `alfakit:add --list` (`alfakit:add --list kanban` prints a plugin's highlights, its live-demo page from `config('alfakit.demo_url')` / `ALFAKIT_DEMO_URL` with a short tour, and the plugins it works with), and remove them with `alfakit:remove comments` (unregisters, composer remove; tables are kept). Its parts run on their own: `php artisan alfakit:users [--panel=] [--model=] [--roles] [--no-migrations] [--no-provider] [--force]` generates `App\Alfakit\Resources\UserResource` (invite with a signed link → `Users\Pages\AcceptInvitation`, `ImpersonateAction`, suspend/ban, require a new password → `Users\Pages\ChangePassword`, reset 2FA, last sign-in via `App\Listeners\RecordUserSignIn`, sessions with revoke on the database session driver) plus `App\Alfakit\Users\UserAccounts` helpers (`status()`, `suspend()`, `ban()`, `requirePasswordChange()`, `resetTwoFactor()`, `sessions()`, `revokeSessions()`, `invite(User::class, $data, $panel)`), the `EnsureAccountIsActive`/`RequirePasswordChange` auth middleware (for a `->starterKit()` panel `EnsureAccountIsActive` is also appended to the `web` group in `bootstrap/app.php`, so blocked users are signed out of the app's own pages and its login page shows the `status` message) and a columns migration; `php artisan alfakit:ai-setup [--providers=openai,anthropic] [--default=] [--model=] [--check] [--no-test]` writes laravel/ai keys to `.env` (hidden prompt; without interaction from the process environment), publishes `config/ai.php`, sets the default provider/model and live-tests each key (`--check` only reports and tests, never installs, and exits 1 on a failing key or a missing laravel/ai); `php artisan alfakit:realtime [--no-install]` installs Reverb (`install:broadcasting --reverb`, `reverb:install`), writes `REVERB_*`/`VITE_REVERB_*`, creates `resources/js/alfakit/echo.ts` (a factory that sets up `window.Echo` when called, browser only, importing laravel-echo and pusher-js inside it; the panel entry passes it as `createAlfakit({ echo: () => import('./echo').then((module) => module.default()) })`, a starter kit or standalone entry as `configureRealtime(null, { echo: () => import('./alfakit/echo').then((module) => module.default()) })` from the renderer package, so Echo only connects on pages with `->realtime()`) and enables `->realtime()` (real-time collaboration, see below).
1. Generate, don't hand-write: `php artisan make:alfakit-resource Post --generate` (`--view` adds a view page, `--simple` gives modal CRUD on one page, `--soft-deletes` adds trash support, `--model --migration --factory` create them, `--model-namespace=App\Domain\Blog\Models`). `--generate` reads the database columns (literal column defaults become `->default(...)` on the generated fields: `default(true)` toggles, `'draft'` strings, numbers). To choose columns, filters, infolist entries and relation managers: `php artisan alfakit:generate Post` (or `make:alfakit-resource --wizard`); in scripts pass every choice: `alfakit:generate Post --form=title,body,author_id --table=title,author_id --filters=author_id,is_published --infolist=title,body --relation-managers=comments --no-soft-deletes --no-interaction` (`none` selects nothing; omitted lists use the type-based defaults). Also `make:alfakit-relation-manager PostResource comments body --soft-deletes --view`, `make:alfakit-widget PostStats --resource=Post`, `make:alfakit-page ViewPostStats --resource=Post --type=ViewRecord|EditRecord|ManageRelatedRecords`, `make:alfakit-form|table Name --model=Post --generate`, `make:alfakit-rich-content-custom-block Hero`, `make:alfakit-theme acme --pm=pnpm`, `make:alfakit-issue`. Frontend files the generators write (and `alfakit:eject` copies, `alfakit:realtime`'s `echo.ts`) are formatted with the app's own `vp fmt` / Oxfmt / Prettier when configured (`alfakit.file_generation.format_frontend`). Customise generated files by binding a subclass of `AlfaSix\Alfakit\Commands\Generators\ClassGenerator` (override `modify(string $stub, string $contents, array $replacements)`).
2. Edit the generated `form()`, `table()` and `infolist()`.
3. Register relation managers in `getRelations()`, widgets on the panel or a page.
4. Check the result in the browser and with a Pest HTTP test.
5. Coming from FilamentPHP: run `php artisan alfakit:migrate-from-filament --dry-run` first (writes `alfakit-migration-report.md` without changing files), then without `--dry-run` (`--adapter=react|vue|svelte`, which on a Blade/Livewire app without Inertia also sets up the Inertia toolchain; `--keep-filament` to leave the composer packages). It rewrites PHP (`Filament\Forms\Form` → `Schema`, `toolbarActions()`/`BulkActionGroup` → `headerActions()`/`bulkActions()`, Heroicon → Lucide names, `Color::Amber` → hex, `$this->form->getState()` on pages → `$this->getForm()->validate()`, `$view` pages → reported), moves `app/Filament` → `app/Alfakit` and registers `App\Providers\Alfakit\*PanelProvider`. Every manual step is marked `// TODO(alfakit-migration): …` in the code (search for it); the report's summary gives the share converted automatically and the `php artisan alfakit:add …` command for the first-party plugins that replace Filament plugins (shield, media-library, tags, settings, comments, kanban, calendar, activity-log, tree, …). Livewire components get scaffolds to port: `app/Alfakit/Components/<Name>.php` (`<Name>::make()` in any schema) and `resources/js/alfakit/components/<Name>.(tsx|vue|svelte)` (auto-registered). Then: Blade views become frontend components, themes `make:alfakit-theme`, render hooks slots, Livewire tests `alfakit()->testPage()`.

## Forms

```php
use AlfaSix\Alfakit\Forms\Components\{Builder, CodeEditor, DatePicker, FileUpload, ModalTableSelect, MoneyInput, Repeater, RichEditor, Select, Slider, TextInput, Textarea, Toggle, ToggleButtons};
use AlfaSix\Alfakit\Schemas\Components\{Grid, Section, Tabs, Wizard};
use AlfaSix\Alfakit\Schemas\Components\Utilities\{Get, Set};

public static function form(Schema $schema): Schema
{
    return $schema->components([
        Section::make('Details')->schema([
            TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)
                ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug((string) $state))),
            TextInput::make('slug')->required()->alphaDash()->unique(),
            Select::make('category_id')->relationship('category', 'name')->searchable()->preload(),
            Select::make('tags')->relationship('tags', 'name')->multiple(),
            RichEditor::make('body')->columnSpanFull(),
        ])->columns(2),
        Section::make('Pricing')->schema([
            Select::make('currency')->currencies()->default('USD')->live(),
            MoneyInput::make('price')->currency(fn (Get $get) => $get('currency'))->minorUnits(),
        ]),
        Repeater::make('items')->relationship()->schema([
            TextInput::make('name')->required(),
        ]),
    ]);
}
```

- Validation lives on the field (`required()`, `email()`, `numeric()`, `unique()`, `rule()`); it runs on the server.
- `$get('x')` / `$set('x', $v)` resolve from the component's container, as in Filament: inside a repeater item or builder block that is the item (`items.0`, `content.0.data`), for fields and layout components (`Text`, `Section`…) alike; each leading `../` goes up one level (`$get('../../currency')` reaches the root from an item); `isAbsolute: true` reads from the root. A dotted field (`make('settings.url')`) reads a sibling by its full name (`$get('settings.mode')`). Closure child schemas (`Section::make()->schema(fn (Get $get) => [...])`) are rebuilt whenever the state changes, so live updates see the browser's state.
- `unique()` ignores the bound record on edit forms (only when it checks the record's own table); `unique(ignoreRecord: false)` checks every row. `unique()`/`exists()` are tenant-scoped on tenant-owned tables. Laravel shortcuts: `alpha()`, `alphaDash()`, `alphaNum()`, `ascii()`, `string()`, `filled()`, `activeUrl()`, `hexColor()`, `ipv4()`, `ipv6()`, `macAddress()`, `enum(Status::class)`, `multipleOf(5)`, `notRegex()`, `gt/gte/lt/lte('other_field' or 10)`, `prohibitedIf()`, `prohibitedUnless()`, `prohibits()`, `requiredIfAccepted()`, `requiredWithAll()`, `requiredWithoutAll()`; field references resolve like `$get()` (siblings inside repeater items). `scopedUnique()` / `scopedExists(Post::class, 'id')` query through the Eloquent model (global scopes such as soft deletes apply, tenant-scoped) and take `modifyQueryUsing: fn (Builder $query) => $query->withoutGlobalScope(SoftDeletingScope::class)`; `scopedUnique()` has `ignorable:` / `ignoreRecord:` like `unique()`.
- `distinct()` on a field inside a Repeater or Builder block fails duplicates across items, with the error on each duplicate item path (`items.2.sku`).
- `Checkbox`/`Toggle`: `accepted()`, `declined()`. `CheckboxList::columns(3)` (alias `gridColumns()`) lays out the options, `->gridDirection('column')` fills column by column; `getOptionDescriptionFromRecordUsing(fn (Tag $record) => $record->summary)` for relationship options; `selectAllAction(fn (Action $action) => $action->label('…')->icon('…'))` / `deselectAllAction(...)` customise the bulk toggle links (label, icon, `hidden()`). `ToggleButtons::columns(3)->gridDirection('column')` lays buttons out in a grid.
- Select: `noOptionsMessage()`, `searchingMessage()`, `wrapOptionLabels(false)`; `allowHtml()` on `Select`/`CheckboxList` renders option labels as HTML, sanitised on the server. `prefixIconColor('danger')` / `suffixIconColor()` on `TextInput`, `Select` and the date pickers. Date pickers: `hoursStep()`, `minutesStep(15)`, `secondsStep()`, `weekStartsOnMonday()` / `weekStartsOnSunday()` (default: the panel locale's first day), `defaultFocusedDate(now()->addMonth())`. `Textarea::disableGrammarly()`.
- Content slots on fields and entries: `aboveLabel()`, `beforeLabel()`, `afterLabel()` (end-aligned next to the hint), `belowLabel()`, `aboveContent()`, `beforeContent()`, `afterContent()`, `belowContent()`, and on fields `aboveErrorMessage()` / `belowErrorMessage()` (shown with an error). Each takes a string, `HtmlString`, schema component (`Text`, `Icon`…), `Action`/`ActionGroup`, an array of these or a closure, plus an optional `Alignment::Start|End|Between`: `TextInput::make('slug')->belowContent(['Used in the URL.', Action::make('generate')->action(fn (Get $get, Set $set) => $set('slug', Str::slug($get('title'))))])`. Slot actions run like field actions. `extraFieldWrapperAttributes([...])` / `extraEntryWrapperAttributes([...])` set wrapper attributes (merged with `extraAttributes()`).
- `FusedGroup::make([Select::make('code')->placeholder('Code'), TextInput::make('phone')->placeholder('Number')->columnSpan(2)])->label('Phone')->columns(3)` (`AlfaSix\Alfakit\Schemas\Components\FusedGroup`) fuses fields into one bordered control; child labels become screen-reader text, child helper texts are hidden, errors are listed below.
- `saved(false)`: validated and kept in the form state, but left out of `getState()` and its relationship isn't saved (password confirmations); `disabled()->saved()` saves a disabled field's server value. `validatedWhenNotDehydrated(false)` skips the rules of non-dehydrated fields. `allowHtmlValidationMessages()` renders the field's messages as HTML (don't use `:input` in them).
- Private files: `FileUpload::make('doc')->disk('s3')->visibility('private')` serves previews/links via `temporaryUrl()` (`config('alfakit.temporary_file_url_expiry_minutes')`, 30, rounded up to the end of the hour; `->temporaryUrlExpiry(60)` per FileUpload/ImageColumn/ImageEntry/RichEditor; the disk must support it, e.g. S3 or a `local` disk with `serve`); `RichEditor::make('body')->fileAttachmentsVisibility('private')` saves plain URLs and signs them when the form is filled; sign for display with `RichContentRenderer::make($content)->fileAttachments('s3', 'posts', 'private')` (or `$post->renderRichContent('content')` with the model config). Visibility defaults to the disk's `visibility` config. `FileUpload::make('attachments')->multiple()->storeFileNamesIn('attachment_file_names')` saves original names to that sibling attribute (`[path => name]`, cast it to `array`; a single field stores the name) and shows them.
- `relationship()` on Select, Repeater and CheckboxList saves the relation for you.
- `disableOptionWhen(['archived'])`, a closure returning values, or a per-option closure `fn (string $value): bool => $value === 'archived'` (also `$label`) on Select, Radio, CheckboxList and ToggleButtons: disabled options render disabled and are refused on submit (a value the record already holds stays valid).
- Polymorphic relations: `MorphToSelect::make('commentable')->types([MorphToSelect\Type::make(Post::class)->titleAttribute('title'), MorphToSelect\Type::make(Product::class)->getOptionLabelFromRecordUsing(fn (Product $record) => $record->name)->modifyOptionsQueryUsing(fn (Builder $query) => $query->where('active', true))])->searchable()->preload()->required()` (`AlfaSix\Alfakit\Forms\Components\MorphToSelect`). It is a fieldset with a type `Select` (`typeSelectToggleButtons()` for buttons) and a key `Select`, filling and saving the record's `commentable_type` / `commentable_id` (make them fillable); the type is the morph class (morph map alias). The type must be one of `types()`, the key must exist in that type's tenant-scoped query. `modifyTypeSelectUsing()`, `modifyKeySelectUsing()`, `Type::label()`, `Type::searchColumns()`, `contained(false)`, `native()`, `optionsLimit()`.
- Repeater relationships: `Repeater::make('products')->relationship()` also takes a BelongsToMany (items are pivot rows: the item field named after the related pivot key, e.g. `Select::make('product_id')`, picks the record and is required + distinct; other item fields are pivot columns, attached/updated/detached), or use a HasMany to a pivot model (`orderProducts`). `relationship(modifyQueryUsing: fn (Builder $query) => ..., modifyRecordsUsing: fn (Collection $records) => ...)` limits the managed records (others are never touched); `afterCreate(fn (array $data, Model $record))`, `afterUpdate(...)`, `afterDelete(fn (Model $record))`; `mutateRelationshipDataBeforeCreateUsing()` / `...BeforeSaveUsing()` returning null skip the item.
- Inside a `relationship()` Repeater, item fields are scoped to the related model: `unique()`/`exists()` check its table, `$record` is the item's record, and rule references like `requiredIf('type', ...)` or `same('other')` mean sibling fields of the same item. `addable(false)`, `deletable(false)` and `reorderable(false)` are enforced on the server.
- Block content: `Builder::make('content')->blocks([Block::make('heading')->icon('heading')->schema([TextInput::make('text')->required()]), Block::make('image')->maxItems(1)->schema([...])])->blockPickerColumns(2)->collapsible()->cloneable()` (`AlfaSix\Alfakit\Forms\Components\Builder\Block`). The state is a list of `['type' => 'heading', 'data' => [...]]`; store it in a JSON column with an `array` cast. Block types, block `maxItems()` and every block field are validated on the server; `Block::label(fn (?array $state) => ...)` labels items after their data. `Block::summary(fn (array $state): ?string => $state['text'] ?? null)` adds a plain-text line after the label (HTML stripped, 160 characters; also gets `$index`), and `Builder::blockActionsMenu()` moves the item actions (move, duplicate, extra actions, delete) into one **More** menu per item header. The block picker flips above the add button when there is no room below and stays clear of the sticky top bar. Picker headings: `->blocks(['Content' => [Block::make('heading'), ...], 'Media' => [...]])` or `Block::make('x')->group('Media')` (ungrouped blocks first). No `relationship()`/`simple()` on Builder.
- `ToggleButtons::make('status')->options([...])->colors([...])->icons([...])->inline()` (or `->grouped()`, `->multiple()`, `->boolean()`, `->columns(3)`); `Slider::make('price')->range(minValue: 0, maxValue: 1000)->step(10)->default([200, 800])->tooltips()->pips()` (an array default makes a range; `minDifference()`/`maxDifference()`; `rangePadding([10, 20])`, `rtl()`, `nonLinearPoints(['20%' => 50, '50%' => [75, 5]])`, `pips(PipsMode::Count)->pipsValues(5)` with `AlfaSix\Alfakit\Forms\Components\Slider\Enums\PipsMode` (`Steps`, `Positions`, `Count`, `Values`), `steppedPips()`, `pipsFilter(fn (int|float $value): int => …)` run in PHP (1 large label, 2 small, 0 tick, -1 hidden), `behavior([Behavior::Tap, Behavior::Drag, Behavior::Fixed])` from `…\Slider\Enums\Behavior`, `behavior(null)` for none); `CodeEditor::make('snippet')->language(Language::Php)` (`AlfaSix\Alfakit\Forms\Components\CodeEditor\Enums\Language`; `wrap()`, `lineNumbers(false)`, `maxHeight()`).
- `ModalTableSelect::make('author_id')->relationship('author', 'name')->tableConfiguration(UserResource::class)` picks records from a table in a modal (search, filters, sort, pagination). `tableConfiguration()` takes a resource class, a class with `static configure(Table $table): Table`, or a closure receiving `$table`; add `->multiple()->maxItems(5)` for BelongsToMany. Without a relationship, the configuration sets `query()` and `recordTitleAttribute()` names the label. The list is tenant scoped and submitted keys are validated against it. `->selectAction(fn (Action $action) => $action->label('Pick')->icon('search')->modalHeading('Search')->modalDescription('…')->modalSubmitActionLabel('Use')->modalWidth(Width::FiveExtraLarge))` customises the button and modal; `->tableArguments(fn (Get $get): array => ['category_id' => $get('category_id')])` passes form values to a closure configuration as `$arguments` (`fn (Table $table, array $arguments) => …`; classes read `$table->getContext()->getTableArguments()`), display only.
- `->live(debounce: 500)` takes milliseconds or a duration string (`'750ms'`, `'1s'`). Without it, typed fields debounce 150 ms and choice fields (select, toggle, checkbox, radio, date pickers, uploads) refresh at once; custom fields override `getDefaultLiveDebounce(): int`. `$set('region', null, shouldCallUpdatedHooks: true)` also runs the target's `afterStateUpdated()`.
- `afterStateUpdated(fn ($state, $old) => ...)` receives the previous value as `$old`. Live refreshes answer with only the changed components and state keys; nothing to configure. To limit further: `->partiallyRenderComponentsAfterStateUpdated(['total'])` (sibling names, keys or state paths), `->partiallyRenderAfterStateUpdated()` (the field only), `->skipRenderAfterStateUpdated()` (no components; `$set()` state still arrives; without `afterStateUpdated()` no request at all).
- Field actions: `TextInput::make('slug')->suffixAction(Action::make('generate')->icon('sparkles')->iconButton()->action(fn (Get $get, Set $set) => $set('slug', Str::slug($get('title')))))`, also `prefixAction(s)()` and `hintAction(s)()` on fields and infolist entries. In their closures `$component` is the field, `$state` its state, `$get`/`$set` act on the field's form (the change reaches the browser), `$data` is the action's own modal data. Actions of hidden or disabled fields can't run.
- Select option modals: `Select::make('author_id')->relationship('author', 'name')->createOptionForm([TextInput::make('name')->required()])->editOptionForm([TextInput::make('name')->required()])` creates (policy `create`) or updates (policy `update`) the related record and selects it; `createOptionUsing(fn (array $data) => $key)` / `updateOptionUsing(fn (array $data, Model $record) => ...)` for custom saving (required without a relationship), `createOptionAction(fn (Action $action) => $action->modalWidth('lg'))`, `createOptionModalHeading()`.
- One related record in a layout component: `Section::make('SEO')->relationship('seo')->schema([TextInput::make('meta_title')])` (also `Group`, `Fieldset`; HasOne, MorphOne, BelongsTo, MorphTo with `relationship('attachable', relatedModel: Image::class)` to create one). State nests under the name (`seo.meta_title`), the data stays out of the owner's attributes and is saved after the owner; `relationship('address', condition: fn (array $state) => filled($state['street'] ?? null))` skips (and deletes or dissociates) when false; `mutateRelationshipDataBeforeFillUsing()`/`...BeforeCreateUsing()`/`...BeforeSaveUsing()`. Not inside repeater items.
- Long Repeaters and Builders render only the visible items above 50 (`virtualize(false)` turns it off, `virtualizeThreshold(200)` moves the threshold, `virtualizeEstimateSize(96)` tunes the pre-measurement row height in px); all three renderers keep keyboard navigation, drag reorder, collapse state and scroll the first errored item into view on validation. A multi-column `grid()` repeater is windowed by whole rows (the renderer counts the rendered grid's columns).
- Table repeaters: `Repeater::make('lines')->table([TableColumn::make('Product')->width('50%'), TableColumn::make('Qty')->alignment(Alignment::End)])->schema([...])` (`AlfaSix\Alfakit\Forms\Components\Repeater\TableColumn`; columns follow the schema order; `hiddenHeaderLabel()`, `markAsRequired()`, `wrapHeader()`). `RepeatableEntry::make('lines')->table([...])` uses `AlfaSix\Alfakit\Infolists\Components\RepeatableEntry\TableColumn`.
- Repeater / Builder item actions are Actions: `->deleteAction(fn (Action $action) => $action->requiresConfirmation())`, also `addAction()`, `cloneAction()`, `reorderAction()`, `moveUpAction()`, `moveDownAction()`, `collapseAction()`, `expandAction()`, `collapseAllAction()`, `expandAllAction()`; `->extraItemActions([Action::make('email')->action(fn (array $arguments, Repeater $component) => $component->getItemState($arguments['item']))])`. `$arguments['item']` is the item's position (`'0'`); reorder gets `$arguments['items']`, the Builder's add `$arguments['block']`. Restyled-only actions run in the browser; ones with a modal/handler/hooks run on the server. Layout: `reorderableWithButtons(false)`, `reorderableWithDragAndDrop(false)`, `addActionAlignment(Alignment::End)`, `itemNumbers()`. `KeyValue` has `addAction()`, `deleteAction()`, `reorderAction()`.
- `Select::make('product_id')->disableOptionsWhenSelectedInSiblingRepeaterItems()` inside a repeater item disables options other items picked (adds `distinct()` + `live()`).
- Builder previews: `->blockPreviews(areInteractive: false)` with `Block::make('hero')->preview('blocks.hero')` (Blade view, item data as variables; or a closure returning HTML) or `->previewComponent('hero')` (renderer registry key `builder-preview:hero`, props `data`, `block`, `index`). `blockPickerWidth(Width::Large)`. Tests: `Repeater::fake()` / `Builder::fake()` return an undo closure (keys are positional anyway).
- RichEditor: `->json()` stores a Tiptap document (cast to `array`); `->customBlocks([HeroBlock::class])` with `class HeroBlock extends RichContentCustomBlock` (`configureEditorAction(Action $action): Action` adds `->schema([...])`, `toPreviewHtml(array $config)`, `toHtml(array $config, array $data)`; escape config values); `->mentions([MentionProvider::make('@')->getSearchResultsUsing(fn (string $search): array => User::where('name', 'like', "%{$search}%")->limit(10)->pluck('name', 'id')->all())])` or `->items([...])`; `->mergeTags(['name', 'today' => 'Today'])`. Display with `RichContentRenderer::make($content)->mergeTags(['name' => $user->name])->customBlocks([HeroBlock::class])->toHtml()` (namespace `AlfaSix\Alfakit\Forms\Components\RichEditor\`); stored HTML is sanitised first. In an infolist return the renderer itself: `TextEntry::make('content')->html()->state(fn (Post $record) => RichContentRenderer::make($record->content))`.
- RichEditor toolbar extras (opt in via `->toolbarButtons([...])`; the default toolbar is unchanged): `subscript`, `superscript`, `code`, `highlight`, `textColor` (palette from `->textColors(['#dc2626' => 'Red', ...RichEditor::getDefaultTextColors()])`, `->customTextColors()` adds a free picker), `alignStart`/`alignCenter`/`alignEnd`/`alignJustify` (logical, stored as `style="text-align: …"`), `table` (plus `tableAddColumnBefore|After`, `tableDeleteColumn`, `tableAddRowBefore|After`, `tableDeleteRow`, `tableMergeCells`, `tableSplitCell`, `tableToggleHeaderRow|Cell`, `tableDelete`, shown in a row while the cursor is in a table), `details`, `h1`–`h6`, `paragraph`, `clearFormatting`; `->linkProtocols(['https', 'mailto'])` limits link schemes (editor, save and `RichContentRenderer::linkProtocols()`). Images pointing at another file on the attachments disk are dropped on save unless the stored content referenced it (`->preventFileAttachmentTampering(bool, allowFilePathUsing: fn (string $file): bool => ...)`, on by default). Custom storage: `->fileAttachmentProvider(new MyProvider)` implementing `RichEditor\FileAttachmentProviders\Contracts\FileAttachmentProvider` (`saveUploadedFileAttachment(TemporaryUpload $file): ?string` returns the URL to store, `getFileAttachmentUrl()`, `getDefaultFileAttachmentVisibility()`, `isExistingRecordRequiredToSaveNewFileAttachments()`, `cleanUpFileAttachments(array $exceptIds)`), also on `registerRichContent()` or from a plugin implementing `Plugins\Contracts\HasFileAttachmentProvider`; default `DiskFileAttachmentProvider`. `->fileAttachmentsAcceptedFileTypes(['image/png', 'image/jpeg'])` and `->fileAttachmentsMaxSize(2048)` (KB) are checked in the browser and again on save (validation error); SVG is never accepted. MarkdownEditor's `table` button stores GitHub tables. The sanitiser keeps only `color`, `background-color` and `text-align` in `style`.
- RichEditor layout: `ToolbarButtonGroup::make('Heading', ['h1', 'h2', 'h3'])->icon('heading')->textualButtons()` inside `toolbarButtons([...])` is a dropdown; `->floatingToolbars(['paragraph' => ['bold', 'link'], 'heading' => [...], 'table' => [...]])` shows buttons above the selection (text nodes while text is selected; `table`/`image`/`customBlock`… while inside or selected; a `table` one replaces the table row); `->stickyToolbar()`, `->stickyPanels()`, `->stickyOffset('4rem')`; `->resizableImages()` stores `<img width="320">`. Grids get a default floating toolbar (`gridAddColumnBefore`, `gridAddColumnAfter`, `gridDeleteColumn`, `gridDelete`). Custom block previews skip prose styles unless the block's `shouldApplyProseStylingToPreview(array $config): bool` returns true. Mentions render the provider's `getLabelsUsing()` label (else the stored one). The `customBlocks`/`mergeTags` buttons open side panels: `->customBlocks(['Marketing' => [HeroBlock::class], DividerBlock::class])` groups blocks, plus `->searchableCustomBlocks()`, `->customBlocksGrid()`, `->minimalCustomBlockControls()`, `->activePanel('customBlocks'|'mergeTags')`; `->mergeTags(['name'])->mergeTagLabels(['name' => 'Full name'])`.
- Rich content models: `class Post extends Model implements HasRichContent { use InteractsWithRichContent; public function setUpRichContent(): void { $this->registerRichContent('content')->fileAttachmentsDisk('s3')->fileAttachmentsVisibility('private')->customBlocks([...])->mergeTags(['name' => fn () => auth()->user()?->name])->mergeTagLabels([...])->plugins([...]); } }` (`RichEditor\Models\Contracts\HasRichContent`, `RichEditor\Models\Concerns\InteractsWithRichContent`). `RichEditor::make('content')` uses it for unset settings; `$post->renderRichContent('content')` renders (sanitised, blocks, merge tag values, private images signed).
- RichEditor plugins: `->plugins([MyPlugin::make()])` with `class MyPlugin extends RichEditor\Plugins\RichContentPlugin` overriding `getTipTapJsExtensions()` (registry keys; register the JS with `createAlfakit({ richEditorExtensions: { key: () => import('./ext') } })`), `getEditorTools()` (`RichEditorTool::make('name')->label()->icon()->command('toggleMark', 'x')` or `->action()` + `->activeWhen('x')`), `getEditorActions()` (actions named like the tool; return `$component->runCommands([EditorCommand::make('setMark', 'x', $attrs)], editorSelection: $arguments['editorSelection'] ?? null)`), `getEnabledToolbarButtons()`/`getDisabledToolbarButtons()`, `configureHtmlSanitizer(HtmlSanitizerConfig $config)` (allow the plugin's tags only) and `renderJson(array $node, string $content): ?string`. No `jsHandler()`. `RichContentRenderer::make($c)->plugins([...])`.
- Fake content: `RichContentFaker::make()->heading()->paragraphs(3, links: true)->bulletList()->image()->toHtml()` (`toArray()`/`toJson()` for `json()`, `toMarkdown()`, `toRenderedHtml()`, `toText()`; `mergeTag()`, `mention()`, `customBlock(HeroBlock::class, $config)`, `table()`, `details()`), `MarkdownEditor\MarkdownFaker::make()->heading()->paragraphs(2)->codeBlock('php')->toMarkdown()`; on Faker as `fake()->alfakitRichContent($attribute = null)` / `fake()->alfakitMarkdown()` (aliases `filamentRichContent()` / `filamentMarkdown()`). `customBlock()` without arguments picks an attribute block implementing `RichEditor\Contracts\CanGenerateFakeConfiguration` (`generateFakeConfiguration(Generator $faker): array`); `toValue()` follows the attribute's `json()`. Needs fakerphp/faker.
- FileUpload images: `->imageEditor()` (crop/zoom/rotate/flip dialog with X/Y/width/height/rotation inputs before upload, plus an "Edit image" button on uploaded images that uploads the edited copy in the old file's place; `->imageEditorAspectRatios(['16:9', null])`, alias `imageEditorAspectRatioOptions()`, `null` = free), `->circleCropper()` (1:1, round viewport), `->imageAspectRatio('16:9')` / `->imageDimensions(minWidth: 800, maxHeight: 4000)` validated on the server against the real pixels, `->orientImagesFromExif()` (off by default; GD + exif on the server, else the browser re-encodes JPEGs), `->pasteable(false)` to stop clipboard pastes into the focused drop zone (on by default). `->imageEditorMode(0)` lets the crop leave the image (`1`–`3` keep it inside; `2` can't zoom out below fitting, `3` always fills the editor, like Cropper.js view modes), `->imageEditorEmptyFillColor('#000000')` paints the empty space, `->imageEditorViewportWidth(1920)->imageEditorViewportHeight(1080)` opens with that crop ratio. Automatic crop/resize in the browser: `imageCropAspectRatio('16:9')`, `imageResizeMode('cover')`, `imageResizeTargetWidth(1920)`, `imageResizeTargetHeight(1080)` (Filament aliases `automaticallyCropImagesToAspectRatio()`, `automaticallyResizeImagesMode()`, `automaticallyResizeImagesToWidth()/ToHeight()`). Also `minFiles()`, `previewable(false)`, `storeFiles(false)` (state keeps the `tmp:` key), `uploadingMessage('Uploading…')`. Files over `config('alfakit.uploads.chunk_size')` (5 MB) upload in resumable chunks automatically (FileUpload, rich editor attachments, file manager); `->maxSize(512000)` may exceed `alfakit.uploads.max_size_kb` because the field sends an encrypted upload policy; schedule `alfakit:prune-uploads` to remove unfinished chunks. Custom frontends: `uploadTemporaryFile(panel.endpoints.upload, file, { ...panelUploadOptions(panel, component.uploadPolicy), onProgress })` from `@alfakit/core`, wrapped in `form.trackUpload(promise)` (the field's `FormStore`) so a save made while it runs waits for it instead of dropping the file (`form.hasPendingUploads`); when it fails, call `form.failUpload(id, component.statePath, message)` so saves stop with `message` under the field, and `form.forgetFailedUpload(id)` when the user removes the file.
- More field options: `TextInput::autocapitalize('words')` (`'none'`, `'sentences'`, `'characters'`; `true`/`false` = `on`/`off`); `Textarea::length(4)`; `Select::make('tags')->multiple()->reorderable()` (chosen options reorder by drag or Alt + arrows); `Select::relationship('parent', 'name', ignoreRecord: true)` leaves the edited record out (and rejects it); `CheckboxList::getOptionLabelFromRecordUsing(fn (Tag $record) => …)`, `searchPrompt()`, `noSearchResultsMessage()`, `searchDebounce(300)`; date pickers `locale('fr')` and `datalist([...])` (native pickers only); `ColorPicker::rgba()` adds an opacity slider (an empty ColorPicker shows a struck-through `data-empty` swatch and a `#rrggbb` placeholder; pass `placeholder()` when empty has a meaning, e.g. "Default"); `IconPicker::make('icon')->sets(['lucide', 'mdi'])->allowedIcons([...])->exclude([...])->gridColumns(8)->previewSize('lg')->allowCustomSvg()` stores an icon name usable in any `icon()` (server-side paginated search, validated against the offered sets); `ToggleButtons::tooltips(['draft' => '…'])`, `size(Size::Small)` (`AlfaSix\Alfakit\Support\Enums\Size`), `fullWidth()`; `Slider::vertical(isTopToBottom: true)`, `tooltips([true, false])`, `fillTrack([false, true, false])`; `Repeater::table([...])->compact()`; `Repeater`/`Builder` `->deferLoading()` (sent as a skeleton, loaded when scrolled into view); `Builder::searchable()->searchDebounce(500)->searchPrompt('Find a block')->noSearchResultsMessage('No match')` (block picker search); `OneTimeCodeInput::make('code')->length(6)->submitOnCompletion()` (`autocomplete="one-time-code"`, digits only, used by the MFA forms). RichEditor toolbar `lead`, `small`, `grid`, `gridDelete`; `MentionProvider::url(fn (string $id, string $label) => route('users.show', $id))` renders mentions as links via `RichContentRenderer::make($content)->mentions([...])`. `Hidden` values are editable by the user: never trust them.
- FileUpload options: `->maxParallelUploads(3)` (default 1), `->appendFiles(false)` (new files first), `->panelLayout('integrated')` (`compact`, `grid`, `integrated`, `circle`), `->panelAspectRatio('2:1')`, `->imagePreviewHeight(250)`, `->removeUploadedFileButtonPosition('left')`, `->uploadProgressIndicatorPosition('left')` / `->loadingIndicatorPosition()`, `->uploadButtonPosition('center')`, `->getOpenableFileUrlUsing(fn (string $file): ?string => …)`, `->getDownloadableFileUrlUsing(fn (string $file): ?string => …)`, `->fetchFileInformation(false)` (skip disk size/existence checks), `->mimeTypeMap(['skp' => 'application/vnd.sketchup.skp'])`. Stored paths not filled into the field or on the record are always rejected; `->preventFilePathTampering(allowFilePathUsing: fn (string $file): bool => …)` allows more (message key `tampered`), `->preventFilePathTampering(false)` accepts any.
- `visible()`, `hidden()`, `disabled()` and `required()` accept closures receiving `$get`, `$record` and `$operation` (`create`, `edit`, `view`).
- Hidden fields (also inside a hidden Section/Tab/Step) are never validated, saved or sent to the browser. Disabled fields keep their server value unless you add `->dehydrated()`. `readOnly()` fields (and everything inside a read-only Repeater) ignore posted values and save the server value; values the server writes with `$set()` in a live update are kept.
- Don't add `->label()` just to repeat the column name. Labels are generated: `category_id` becomes "Category".
- Layout: `Flex::make([...])->from('md')` puts children side by side (`->grow(false)` on a child keeps it narrow; `Split` is an alias; `->alignment(Alignment::End)` / `->alignCenter()` aligns them horizontally). `Section::make()->headerActions([...])` / `footerActions([...])`, `Callout::actions()` / `controlActions()` and `EmptyState::actions()` accept `ActionGroup::make([...])` too. `Wizard::goToStep('payment')` (step `key()` or label slug) moves the browser to a step from `afterStateUpdated()` or a schema action; non-skippable wizards only move back. `hiddenOn()` / `visibleOn()` / `disabledOn()` accept `Operation::Edit` (`AlfaSix\Alfakit\Support\Enums\Operation`) or arrays of it. `numeric(locale: AppLocale::German)` / `money(locale: ...)` accept a backed enum. Prime components: `Callout::make('Heading')->description('…')->warning()->actions([...])`, `EmptyState::make('No orders')->description('…')->icon('inbox')->actions([...])`, `Icon::make('star')->color('warning')`, `Image::make($url, 'Alt')->imageHeight('8rem')->alignCenter()`, `UnorderedList::make(['One', Text::make('Two')])` (all in `AlfaSix\Alfakit\Schemas\Components`). Callout/EmptyState descriptions escape plain strings. Callouts: `->iconSize('sm'|'md'|'lg')`, `->footerActionsAlignment(Alignment::End)`, `->controls([Text::make('Beta')->badge()])` and `->controlActions([Action::make('dismiss')->iconButton()->icon('x')->action(...)])` (top end corner), `->danger()->color(null)` (no background, the status icon colour and `role="alert"` stay), `->iconColor('warning')`. `->footer([Text::make('…'), Action::make('fix')])` (components and actions) on `Section`, `Callout` and `EmptyState` (on EmptyState it is no longer an alias of `actions()`). `Fieldset::make('Address')->contained(false)` drops the border. `Text::make('ORD-1')->color('neutral')->fontFamily('mono')->tooltip('Order number')->copyable(copyMessage: 'Copied!', copyMessageDuration: 1500)`.
- Layout extras: `Section::make('Cart')->id('order-cart')->collapsible()->persistCollapsed()` (local storage, key from `id()` or heading), `->afterHeader([Text::make('Beta')->badge(), Action::make('help')])` (actions run like `headerActions()`), `->footerActionsAlignment(Alignment::End)`, `->headingLevel(4)`; sections are `h2` at the top and one level deeper per nesting, `Group::make([...])->rootHeadingLevel(3)` (and `$schema->rootHeadingLevel(3)`) changes the start. `Tabs::make()->id('order-tabs')->persistTab()` (`storage: 'session'` for session storage), `->persistTabInQueryString('tab')` (value: the tab's `id()`, explicit `key()` or label slug, `?tab=billing-address`) and `->scrollable(false)` (wrap the tab list). Any component: `->columnOrder(2)` / `->columnOrder(['default' => 1, 'lg' => 3])`; layout components: `->dense()` (half gap), `->gap(false)`. `->deferLoading()` on `Section`, `Fieldset`, `Group`, `Grid`, `Tabs`/`Tab`, `Wizard`/`Step` (forms and infolists): the page's first response sends a skeleton and the children load from `POST /_alfakit/schema/deferred` once in view (or at once when a field inside has an error); state, validation and saving work before that; modals and live updates render in full. `Tab::make('Orders')->badge(fn () => …)->deferBadge()` loads a schema tab's badge after the page renders; `Tab::make()->iconPosition(IconPosition::After)->badgeColor('danger')`.
- Container-query columns: `Grid::make(['default' => 1, '@md' => 2, '@xl' => 3])` and `->columnSpan(['@md' => 2])` follow the width of the surrounding container instead of the viewport.
- `columnSpan(2)` / `columnStart(2)` with a number apply from `lg` up (like `columns(3)`), so phones stack; `columnSpanFull()` / `'full'` apply everywhere; pass `['default' => 2]` to span on phones too.
- Inline labels: `->inlineLabel()` on a field or entry, a layout (`Section::make()->inlineLabel()`) or the whole `$schema->inlineLabel()`; children inherit it and `inlineLabel(false)` opts out. The label sits beside the control from `sm` up.
- Text inputs: `->trim()` (also `Textarea`, `TagsInput`), `->stripCharacters(',')` (string or array) apply to validation and the saved value, not the input; `->telRegex('/.../')` replaces the `tel()` pattern; `->copyable(copyMessage: 'Copied!', copyMessageDuration: 1500)` adds a copy button; `->autocomplete(false)` renders `off`. Any field: `->mutateStateForValidationUsing(fn ($state) => ...)`.
- Typed `Get`: `$get->string('name')`, `integer()`, `float()`, `boolean()`, `array()`, `date()` (Carbon), `enum('status', Status::class)`, `filled()`, `blank()`; `isNullable: true` returns null for blank values, `isAbsolute: true` reads from the root. Closures can take `$rawState` (entries: the attribute before casts).
- Page wizards: `Wizard::make([...])->nextAction(fn (Action $action) => $action->label('Continue'))->previousAction(fn (Action $action) => $action->hidden())->submitAction(Action::make('place')->action(...))` (or an `HtmlString` with `<button type="submit">`), `->completedIcon('circle-check')` (also per `Step`), `->persistStepInQueryString('checkout-step')`, `->startOnStep(2)`, `->skippable()`. Next/previous actions only style the buttons; the submit `Action` runs through the action endpoint and is authorised like section actions.
- Reusable schemas: `php artisan make:alfakit-schema AddressFields [--model=Post]` writes `app/Alfakit/Schemas/AddressFields.php` (with `--model` it starts from the resource generator's form fields for that model), a `class AddressFields extends Fragment` (`AlfaSix\Alfakit\Schemas\Fragment`) whose `define(): array` returns components. Use `AddressFields::make()` inside any `components([...])`; it is an invisible `Group`, so `->statePath('billing')` prefixes its state, `->relationship('address')` edits a related record and `->hidden()`/`->visible()` work. Constructor arguments go through `make()`. Not `components()`: that name is the Group setter.
- Fields from a model: `Schema::fromModel(Post::class, except: ['slug'])` (or `only: [...]`) returns a schema bound to the model with fields inferred from casts and columns (`*_id` + BelongsTo → relationship `Select`, enum cast → `Select`, boolean → `Toggle`, json → `KeyValue`, dates → pickers, text → `Textarea`; non-nullable columns without default are required; `$fillable` limits the list). Tweak it: `$schema->getComponent('title')?->maxLength(120)`, `$schema->components([...$schema->getComponents(), Toggle::make('featured')])`. `ModelSchema::fields(Post::class)` gives just the components.

## Tables

```php
public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('title')->searchable()->sortable()->limit(50),
            TextColumn::make('category.name'),                         // label "Category"
            TextColumn::make('status')->badge()->color(fn (string $state) => match ($state) {
                'published' => 'success', 'draft' => 'gray', default => 'warning',
            }),
            TextColumn::make('price')->money(fn (Post $r) => $r->currency, inMinorUnits: true)->alignEnd(),
            TextColumn::make('published_at')->since()->sortable()->toggleable(),
            ToggleColumn::make('is_featured'),
        ])
        ->defaultSort('published_at', 'desc')
        ->filters([
            SelectFilter::make('category')->relationship('category', 'name'),
            TernaryFilter::make('is_featured'),
            TrashedFilter::make(),
        ])
        ->recordActions([ViewAction::make(), EditAction::make(), DeleteAction::make()])
        ->bulkActions([DeleteBulkAction::make()]);
}
```

- Enums: implement `AlfaSix\Alfakit\Support\Contracts\{HasLabel, HasColor, HasIcon, HasDescription}` (`getLabel()`, `getColor()`, `getIcon()`, `getDescription()`). `options(Status::class)` on `Select`/`SelectColumn`/`SelectFilter` uses the labels, `Radio`/`CheckboxList` add the descriptions, `ToggleButtons` the colours and icons. `TextColumn::make('status')->badge()` / `TextEntry` / `IconColumn` / `IconEntry` show the label, colour and icon of an enum-cast attribute; for a stored raw value add `->enum(Status::class)`. Explicit `color()`/`icon()`/`formatStateUsing()` win and receive the case as `$state`. Navigation groups may be enum cases (`NavigationGroup::make(Section::Shop)`, `->group(Section::Shop)`). Helpers: `AlfaSix\Alfakit\Support\EnumState::resolve($value, Status::class)`, `::label()`, `::options()`.
- Relationship aggregates: name the column after Eloquent's attribute: `TextColumn::make('comments_count')->counts('comments')->sortable()->searchable()`, `->exists('comments')`, `->avg('comments', 'rating')` (also `sum`, `min`, `max`; constrain with `counts(['comments as approved_count' => fn (Builder $q) => $q->where('approved', true)])`; the column may be an expression: `->sum('items as order_total', DB::raw('price * quantity'))`, also on `TextEntry` and `ExportColumn`). Sorting uses the alias, searching the same sub-select (`exists()` is not searchable).
- Column breakpoints: `TextColumn::make('email')->visibleFrom('md')`, `->hiddenFrom('xl')` (`sm`–`2xl`, closures allowed) hide the header and cells (or the layout block) with responsive classes; the column stays searchable, sortable and in the column manager.
- Content layouts (`AlfaSix\Alfakit\Tables\Columns\Layout\{Split, Stack, Panel, Grid}`): `Split::make([ImageColumn::make('avatar')->circular()->grow(false), Stack::make([...])->space(1)])->from('md')`, `Panel::make([...])->collapsible()`; `->contentGrid(['md' => 2, 'xl' => 3])` shows cards; its breakpoints follow the table's width (container query: `md` ≈ a 24rem table, `xl` ≈ 56rem) and a count applies only once each card can be 15rem wide (2 columns from 36rem), so a narrow table shows fewer columns; container keys (`'@lg' => 2`) are used as given. Inside a record, `Split::from()`, `Grid` columns and `columnSpan()`/`columnStart()` follow the record's width (container query: `sm` 16rem, `md` 20rem, `lg` 28rem, `xl` 36rem, `2xl` 48rem), not the viewport; `visibleFrom()`/`hiddenFrom()` stay on the viewport. Header groups: `ColumnGroup::make('Visibility', [...])`.
- `->reorderable('sort', direction: 'desc')` enables drag-and-drop record ordering (`asc` by default). Reorder mode lists every record (pagination off, `?reordering=1`) unless `->paginatedWhileReordering()`; saved records swap their existing positions, unlisted ones keep theirs. `->beforeReordering(fn (array $order) => ...)` / `->afterReordering(fn (array $order) => ...)` get the moved keys in their new order. The bulk selection clears when filters, search or the list tab change; `->deselectAllRecordsWhenFiltered(false)` keeps it.
- `->reorderableColumns()` lets users reorder columns from the column manager by drag and drop or the move up / down buttons (stored per user in the session); `->recordActionsPosition(RecordActionsPosition::BeforeCells)` (`AlfaSix\Alfakit\Tables\Enums`).
- Pagination: `->paginationMode(PaginationMode::Simple | Cursor | LoadMore | InfiniteScroll)` (`AlfaSix\Alfakit\Tables\Enums\PaginationMode`); simple, cursor and appending modes skip the count query. Cursor tables sort by plain, relationship (`author.name`) and aggregate columns; a raw `sortable(query:)` expression throws a `LogicException` (order by a select alias instead). A failed load-more / infinite-scroll request shows a translated retry control. `->deferTotals()` keeps numbered pagination but loads the total, page numbers and summaries after the rows (`POST /_alfakit/table/totals`; placeholders meanwhile; paging and sorting never count again; inline for polled and `live()` tables). Hovering or focusing "next" prefetches the next page (`prefetchTablePage()` from `@alfakit/core`, cached `PREFETCH_CACHE_FOR` = 10s, dropped after any mutation).
- Summaries (`AlfaSix\Alfakit\Tables\Columns\Summarizers\{Sum, Average, Count, Range, Summarizer}`): `TextColumn::make('price')->summarize([Sum::make()->money('EUR'), Average::make()->label('Mean')])`; `->query(fn (Builder $q) => $q->where(...))` narrows, `Summarizer::make()->using(fn (\Illuminate\Database\Query\Builder $query, string $attribute) => $query->max($attribute))` is custom. Rows: all matching records, the current page (when there are more pages) and each group; hide the first two with `$table->summaries(pageCondition: false, allTableCondition: false)`. `->hiddenLabel()` keeps a summarizer label for screen readers only; `IconColumn::make('is_published')->boolean()->summarize(Count::make()->icons())` counts each icon. Relationship columns need `using()`.
- Grouping (`AlfaSix\Alfakit\Tables\Grouping\Group`): `->groups([Group::make('status')->collapsible(), Group::make('author.name')->label('Author'), Group::make('published_at')->date()])->defaultGroup('status')`; also `getTitleFromRecordUsing()`, `getDescriptionFromRecordUsing()`, `getKeyFromRecordUsing()` + `scopeQueryByKeyUsing()`, `orderQueryUsing()`, `titlePrefixedWithLabel(false)`; an enum-cast attribute is titled with the enum label (headlined case name without `HasLabel`) plus its `HasIcon` icon in its `HasColor` colour, `->enum(Status::class)` for raw stored values; table: `groupingSettingsHidden()`, `groupingDirectionSettingHidden()`, `collapsedGroupsByDefault()`, `groupsOnly()`. Works on `records()` arrays too (closures take `array $record`; no group summaries). State: `?group=` / `?groupDirection=`.
- Query builder filter (`AlfaSix\Alfakit\Tables\Filters\QueryBuilder` + `...\QueryBuilder\Constraints\{TextConstraint, NumberConstraint, DateConstraint, BooleanConstraint, SelectConstraint, RelationshipConstraint}`): `QueryBuilder::make()->constraints([TextConstraint::make('title'), TextConstraint::make('author.name'), NumberConstraint::make('rating')->integer(), SelectConstraint::make('status')->options([...])->multiple(), RelationshipConstraint::make('tags')->titleAttribute('name')->multiple()])->maxDepth(2)`; string keys group constraints in the picker: `->constraints([TextConstraint::make('title'), 'Dates' => [DateConstraint::make('created_at')]])` (`getGroupedConstraints()`). Rules are validated server-side; invalid ones are ignored.
- `->deferFilters()` adds an Apply button; `->filtersLayout(FiltersLayout::Modal)` (`AlfaSix\Alfakit\Tables\Enums\FiltersLayout`) opens filters in a dialog (always applied with Apply); `FiltersLayout::BeforeContent` / `AfterContent` (+ `BeforeContentCollapsible` / `AfterContentCollapsible`) put them in a sidebar. `->filtersFormWidth(Width::Large)`, `->filtersFormMaxHeight('400px')`, `->filtersFormSchema(fn (array $filters): array => [Section::make('Who')->schema([$filters['author']])])` (each filter is a group keyed by name), `->hiddenFilterIndicators()`.
- Toolbar and columns: `->pushColumns([...])` / `->pushFilters([...])` (add, e.g. in `Table::configureUsing()`), `->extremePaginationLinks()`, `->loadingSkeleton()`, `->defaultSortOptionLabel('Newest')`, `->defaultKeySort(false)` (sorts get a primary-key tiebreaker by default). Buttons take a closure receiving `AlfaSix\Alfakit\Actions\Action` (only its look is used): `->filtersTriggerAction(fn (Action $action) => $action->button()->label('Filter'))`, `filtersApplyAction()`, `filtersResetAction()`, `filtersRemoveAllAction()`, `columnManagerTriggerAction()`, `columnManagerApplyAction()`, `columnManagerResetAction()`, `groupRecordsTriggerAction()`, `reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => ...)`. Column manager: `->deferColumnManager()` (AlfaKit applies toggles at once by default), `->columnManagerLayout(ColumnManagerLayout::Modal)` (then `columnManagerTriggerAction(fn (Action $action) => $action->slideOver()->modalWidth(Width::Large)->modalHeading('Columns'))` applies to the modal), `->columnManagerColumns(2)`, `->columnManagerResetActionPosition(ColumnManagerResetActionPosition::Header)`; `->filtersResetActionPosition(FiltersResetActionPosition::Header)` (enums in `AlfaSix\Alfakit\Tables\Enums`); `->groupingSettingsInDropdownOnDesktop()` (desktop shows grouping selects inline by default); `Group::make('status')->groupQueryUsing(fn (Builder $q) => $q->groupBy('status'))` (groups-only tables). Columns: `headerTooltip()`, `disabledClick()`, `verticallyAlignStart|Center|End()`, closures inject `stdClass $rowLoop` / `?int $index`. `TextColumn`/`TextEntry`: `isoDate('LL')`, `isoDateTime()`, `isoTime()`, `dateTooltip()`, `dateTimeTooltip()`, `timeTooltip()`, `isoDateTooltip()`, `isoDateTimeTooltip()`, `isoTimeTooltip()`, `sinceTooltip()`, `limitList(3)->expandableLimitedList()`, `getCharacterLimit()` (`->tooltip(fn (TextColumn $column) => mb_strlen($column->getState()) > $column->getCharacterLimit() ? $column->getState() : null)`). `ImageColumn`/`ImageEntry`: `ring(4)`, `overlap(3)`, `extraImgAttributes(['loading' => 'lazy'])`, `limitedRemainingText(size: 'lg')`, `checkFileExistence()`; `ImageColumn::wrap()`. `TextInputColumn`: `prefix('$')`, `suffix()`, `prefixIcon()`, `prefixIconColor()`, `suffixIcon()`, `suffixIconColor()`. Formatting (`TextColumn`/`TextEntry`): `->dateTime()->timezone('Asia/Dubai')`, `numeric(decimalPlaces: 2, decimalSeparator: ',', thousandsSeparator: '.', maxDecimalPlaces: null, locale: null)`, `money('EUR', decimalPlaces: 0)`; copy: `copyable()->copyMessage('Copied')->copyMessageDuration(3000)` (also `ColorColumn`/`ColorEntry`). `IconColumn` with a list state shows one icon per item (`->wrap()`); `ColorColumn::wrap()`. `SelectColumn`: `optionsSearchDebounce(500)`, `wrapOptionLabels(false)`, `rememberOptions()` (no-op). Inside a layout `Grid`: `TextColumn::make('title')->columnSpan(2)`, `columnStart()`. `->defaultGroup('status', direction: 'desc')`; `->defaultSort(fn (Builder $query, string $direction) => $query->orderBy('position', $direction))` orders the query itself. `Range::make()->minimalDateTimeDifference()` / `minimalTextualDifference()`.
- Filter extras: `->modifyFormFieldUsing(fn (Select $field) => $field->native())` (built-in field only), `->baseQuery(fn (Builder $query, array $data) => ...)` (base query, also when resolving records; never a security boundary), `->excludeWhenResolvingRecord()` (accepted; filter queries never apply to record resolution), `SelectFilter::make('author')->relationship('author', 'name', hasEmptyOption: true)->emptyRelationshipOptionLabel('No author')`, `indicateUsing()` returning `AlfaSix\Alfakit\Tables\Filters\Indicator::make('From …')->removeField('from')` / `->removable(false)`. Query builder: `TextConstraint::make('email')->operators(['contains', 'equals'])->pushOperators([Operator::make('is_company')->label('is a company')->query(fn (Builder $query, string $column) => ...)])` / `unshiftOperators()` (`AlfaSix\Alfakit\Tables\Filters\QueryBuilder\Operators\Operator`; no value unless `->input('number'|'text'|'date'|'datetime'|'month'|'year'|'select')`, with `->options([...])` for selects and `$value` injected into `query()`; `->validateValueUsing(fn ($value) => ...)`), `RelationshipConstraint::make('tags')->emptyable(false)`, `->selectable(IsRelatedToOperator::make()->titleAttribute('name')->searchable()->multiple())` (`...\QueryBuilder\Operators\IsRelatedToOperator`), `SelectConstraint::make('status')->options([...])->searchable()`, `DateConstraint::make('published_at')->time()` (operators also include is month / is year), `->nullable(false)` removes is filled / is blank, `QueryBuilder::make()->constraintPickerColumns(2)->constraintPickerWidth('lg')` (constraint `icon()`s show in the picker).
- Actions: `BulkActionGroup::make([...])` (`AlfaSix\Alfakit\Actions\BulkActionGroup`, a labelled dropdown), `->groupedBulkActions([...])`, `->toolbarActions([...])` (bulk actions and bulk-only groups join `bulkActions()`, the rest `headerActions()`), `->modifyUngroupedRecordActionsUsing(fn (Action $action) => $action->iconButton())`. Custom data across pages: `->resolveSelectedRecordsUsing(fn (array $keys, bool $isTrackingDeselectedKeys): array => ...)` (rows keyed by key; select-all passes `$isTrackingDeselectedKeys = true`).
- Session persistence (per panel, tenant, user and table): `->persistInSession()` for everything, or `->persistFiltersInSession()`, `->persistSearchInSession()`, `->persistColumnSearchesInSession()`, `->persistSortInSession()`, `->persistRecordsPerPageInSession()`, `->persistColumnsInSession()`, `->persistGroupInSession()` (each takes `false` to override `persistInSession()`). Off by default.
- Saved views (opt-in): `->savedViews()` (or `$panel->tableSavedViews()`; publish `alfakit-table-views-migrations`) lets users save the current filters, search, sort, visible/ordered columns, grouping and per-page as "My views" and, with a `manageSharedTableViews` policy ability (or `->canManageSharedViews(fn () => ...)`), share them per tenant. Code views: `->views([TableView::make('overdue')->label('Overdue')->icon('heroicon-o-clock')->filters(['status' => ['value' => 'overdue']])->sort('due_at', 'desc')->columns(['number', 'due_at'])->group('status')->perPage(50)->default()])` (`AlfaSix\Alfakit\Tables\Views\TableView`); `->viewsLayout('dropdown')`. The URL names the view (`?view=slug`); stored state is sanitised against the table on read.
- Spreadsheet mode (opt-in, lazy-loaded): `->spreadsheet()` (or `->spreadsheet(atomic: false)` to save valid cells when others fail) adds "Edit as spreadsheet": keyboard grid, TSV copy/paste with a validated preview, Ctrl/Cmd+D fill down, undo, one batch save checked per cell with the column's `rules()` and per record with the `update` policy. Inline-editable columns are editable (`->editable(false)` opts one out); `TextColumn::make('stock')->editable()->rules(['integer'])` opts a text column in.
- Search: `->splitSearchTerms(false)` (whole phrase), `->splitIndividualSearchTerms()` (column searches too; per column `TextColumn::make('title')->searchable(isIndividual: true)->splitIndividualSearchTerms(false)`), table-level `->searchable(['id', 'author.email'])` for attributes that are not columns, `->searchUsing(fn (Builder $query, string $search) => $query->whereKey(Post::search($search)->keys()))` for Scout.
- `->stackedOnMobile()` renders rows as cards with column labels below `md`.
- Selection: `->maxSelectableRecords(50)`, `->selectGroupsOnly()` (while grouped), both enforced on the server (422). `BulkAction::make('x')->fetchSelectedRecords(false)` gives `Illuminate\Support\Collection $records` of keys (`DeleteBulkAction`, `ForceDeleteBulkAction`, `RestoreBulkAction`, `DetachBulkAction` then act by query in chunks of 500, without model events; add `->authorizeIndividualRecords(null)` to skip loading records for the per-record policy check); inside the action `$action->reportBulkProcessingFailure('key', message: fn (int $failureCount, int $totalCount): string => ...)` and `->failureNotificationTitle(fn (int $successCount, int $failureCount, int $totalCount): string => ...)` report partial failures. Record actions get the selection with `Action::make('copy')->accessSelectedRecords()->action(fn (Model $record, Collection $selectedRecords) => ...)` (use `->selectable()` when the table has no bulk actions).
- `SelectColumn::make('author_id')->native(false)->optionsRelationship('author', 'name', ignoreRecord: false, modifyQueryUsing: fn (Builder $query) => ...)->searchableOptions(['name', 'email'])->optionsLimit(20)->allowOptionsHtml()` (labels sanitised; `preloadOptions()`, `getOptionsSearchResultsUsing()`, `getOptionLabelUsing()`, `getOptionLabelFromRecordUsing()`; combobox messages `searchPrompt()`, `loadingMessage()`, `searchingMessage()`, `noSearchResultsMessage()`, `noOptionsMessage()`). `ImageColumn::make('cover')->alt(fn (Post $record) => $record->title)`.
- Long pages: tables with more than 50 rows render only the visible rows (`->virtualize(false)`, `->virtualizeThreshold(200)`, `->virtualizeEstimateSize(64)`); the markup keeps `aria-rowcount` / `aria-rowindex`. Content layouts and `contentGrid()` cards are windowed by whole rows too.
- Non-Eloquent data: `->records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int $page, int|string $recordsPerPage, array $filters) => [...])`. Arrays and collections are searched, sorted and paged by the table; return a paginator to do it yourself. Rows carry record actions, bulk selection and inline edits: the row's key is posted back and resolved from the closure (or `->resolveRecordUsing(fn (string $key): ?array => ...)`); closures take `array $record` (a `Model $record` closure gets null), bulk actions get a `Collection` of arrays, and inline-editable columns need `updateStateUsing()` (400 without it). A closure asking for `$filters` filters the rows itself; otherwise the table applies the filters to the rows: `SelectFilter`/`TernaryFilter` compare their attribute, and `query(fn (Collection $records, array $data) => $records->where(...))` gets the rows (keep the keys; a `Builder`-typed closure is skipped).
- `ViewColumn::make('status')->view('StatusSwitcher')->viewData(fn ($record) => [...])` and `ViewEntry` render a registered frontend component.

## Actions

- A modal `CreateAction` (with `->form()`) shows "Create & create another", which saves, keeps the modal open and resets the form; `->createAnother(false)` hides it, `->preserveFormDataWhenCreatingAnother(['category_id'])` (or no argument for every field, or `fn (array $data): array => Arr::only($data, ['category_id'])`) keeps values for the next record; `->createAnotherAction(fn (Action $action): Action => $action->label('Save and add another')->icon('plus'))` customises the button (return `false` to remove it).
- Custom modal content: `->modalContent('order-summary', fn (Order $record): array => ['total' => $record->total])` renders a component registered with `createAlfakit({ components })` (data in `component.props`; closures in the data get `$record`, `$data`, `$arguments`), `->modalContent(fn (Order $record) => view('orders.advance', ['record' => $record]))` renders a Blade view as trusted HTML, and arrays of schema components still work. `modalContentFooter()` takes the same values.
- Prebuilt: `CreateAction`, `EditAction`, `ViewAction`, `DeleteAction`, `RestoreAction`, `ForceDeleteAction`, `ReplicateAction`, plus `AttachAction`, `DetachAction`, `AssociateAction` and `DissociateAction` in relation managers, and the `*BulkAction` versions.
- Custom actions: `Action::make('name')->icon('send')->color('success')->requiresConfirmation()->schema([...])->action(fn (Model $record, array $data) => …)`.
- Undo: `DeleteAction::make()->undoable()` / `DeleteBulkAction::make()->undoable()` restore soft-deleted records from an "Undo" button (with countdown, Ctrl/Cmd+Z) in the success toast and drop the confirmation modal (`->skipConfirmationWhenUndoable(false)` keeps it); `EditAction::make()->undoable(seconds: 10)` and `protected static bool $isSaveUndoable = true;` on an `EditRecord` page put back the changed attributes (refused if the record changed since; `undoTrackedAttributes([...])`). Custom: `->action(function (Post $record, Action $action) { $action->undoData(['previous' => $record->status]); … })->undoable(fn (Post $record, array $undoData) => $record->update(['status' => $undoData['previous']]))`. The undo token is single-use, short-lived and bound to user/panel/tenant; the policy is re-checked (`POST /_alfakit/actions/undo` with `{undo}`).
- An action with `->url(...)` is a link and is never executed. `->url(...)->download()` makes it a plain link with the `download` attribute (for file routes; a normal URL action is an Inertia visit). A handler may return a file (`response()->streamDownload()`, `response()->download()`, `Storage::download()`): the browser downloads it (served once from a signed link).
- Modal forms with a record fill from its attributes; `->mutateRecordDataUsing(fn (array $data, Model $record): array => [...$data, 'title' => ucfirst($data['title'])])` changes them first (skipped when `fillForm()` is set). Form hooks: `->beforeFormFilled(fn () => …)` / `->afterFormFilled(fn (array $data, Schema $schema) => …)` run when the modal opens; `->beforeFormValidated(fn (array $data) => …)` / `->afterFormValidated(fn (array $data) => …)` run on submit around validation, before `before()` and the handler.
- Page header actions go in `getHeaderActions()` on the page class.
- Resource page hooks (protected methods on `CreateRecord` / `EditRecord` / `ViewRecord` subclasses): `beforeFill()` / `afterFill()` (once, when the page loads), `beforeValidate()` / `afterValidate()` (on submit), `beforeCreate(array $data)` / `afterCreate()`, `beforeSave(array $data)` / `afterSave()`, plus `mutateFormDataBeforeFill/Create/Save(array $data): array`. Traits add to any hook with `{hook}{TraitName}()`, e.g. `protected function afterSaveHasAuditLog(): void` in `trait HasAuditLog`. `$this->halt()` in a hook stops without saving (send a `Notification` first); in wizard step `beforeValidation()` / `afterValidation()` closures `throw new \AlfaSix\Alfakit\Actions\Exceptions\Halt` keeps the wizard on the step.
- Guest access (public pages in a panel): `$panel->guestAccess()` (or `->guestAccess(fn (?Team $tenant): bool => $tenant === null || $tenant->is_public)` on tenancy panels), then `protected static bool $isGuestAccessible = true;` on each page, or on a resource for all its pages. Other pages still redirect guests to login. Guests are authorised with a null user: write policy methods for them as `viewAny(?User $user)`; `User $user` methods deny guests. Resources without a policy allow everything, so give guest-accessible resources a policy. The renderers hide the user menu, notifications and global search for guests and show a sign-in link.
- Nested resources: `make:alfakit-resource Comment --generate --nested=PostResource` writes the nested resource (parent foreign key left out of the form) plus `PostResource\RelationManagers\CommentsRelationManager` with `$relatedResource = CommentResource::class`, registered in `PostResource::getRelations()` (nested resources have no navigation item; this is how users reach them). If the parent already has that relation manager (made by hand earlier), it is kept and linked (`$relatedResource` added). Chain deeper with `Reply --nested=CommentResource`. By hand: `make:alfakit-relation-manager PostResource comments body --related=CommentResource --register`.
- Import/export: generate `php artisan make:alfakit-importer Product --generate` / `make:alfakit-exporter Product --generate` (aliases `alfakit:make-importer`, `alfakit:make-exporter`), then `$table->headerActions([ImportAction::make()->importer(ProductImporter::class), ExportAction::make()->exporter(ProductExporter::class)])` and `->bulkActions([ExportBulkAction::make()->exporter(ProductExporter::class)])`. Importers extend `AlfaSix\Alfakit\Actions\Imports\Importer` (`protected static ?string $model`, `getColumns()` of `ImportColumn::make('sku')->requiredMapping()->rules([...])->numeric()|integer()|boolean()|array()|relationship(resolveUsing: 'email')` (BelongsTo, or BelongsToMany with `->multiple(',')`, synced after the save), `resolveRecord()` returning `$this->newQuery()->firstOrNew([...])` (return null to skip the row), hooks like `protected function beforeSave(): void` (traits add `{hook}{TraitName}()`), `->multiple('|')->nestedRecursiveRules(['integer'])` for item rules, `ImportColumn::make('status')->enum(Status::class)` validates backed-enum values); exporters extend `AlfaSix\Alfakit\Actions\Exports\Exporter` with `ExportColumn::make('author.name')`, `->counts('comments')`, `->exists('comments')`, `->avg('comments', 'rating')` (also `sum`, `min`, `max`), `->hidden(fn () => ...)`/`->visible()` (never offered or written), `->formatStateUsing()`, `->enabledByDefault(false)`. Options: `maxRows()`, `chunkSize()`, `formats([ExportFormat::Csv])`, `columnMapping(false)`, `columnMappingColumns(3)`, `enableVisibleTableColumnsByDefault()`, `fileName()`, `fileDisk()`; imports `headerOffset(1)`, `fileRules([...])`, `ImportColumn::make('password')->sensitive()` (kept out of failed rows), `->validationAttribute('e-mail address')` (name in validation messages). Importers/exporters may override `getCompletedNotificationTitle()`, `modifyCompletedNotification(Notification $notification, Import|Export $run)`, `getJobMiddleware()`, `getJobRetryUntil()`, `getJobBackoff()`, `getJobTags()`, `getJobBatchName()`; importers `getFailedRowsDownloader(): ?callable`, exporters `getDownloader(ExportFormat $format): ?callable` and XLSX styling with `AlfaSix\Alfakit\Actions\Exports\Writers\XlsxStyle` (`getXlsxCellStyle()`, `getXlsxHeaderCellStyle()`, `makeXlsxRow(array $values, ?XlsxStyle $style): XlsxRow` with `XlsxCell::fromValue()`, `configureXlsxWriterAfterOpen()` / `configureXlsxWriterBeforeClose(XlsxWriter $writer)` for `setSheetName()`, `freezeRows()`, `setColumnWidth()`). Users can rename exported column headers in the modal (`columnLabels`). CSV formula-injection escaping is always on; Filament's `preventFormulaInjection()` methods are accepted no-ops. Needs `php artisan alfakit:install --import-export && php artisan migrate`, a queue worker (or the `sync` driver) and database notifications; downloads are signed links in the completion notification.
- Authorize with policies (resources use them automatically) or `->authorize('ability')` / `->visible(fn () => …)`. `authorize()` closures may return `Illuminate\Auth\Access\Response`. Unauthorised actions are hidden; `->authorizationTooltip()` shows them disabled with the policy's message as tooltip, `->authorizationNotification()` keeps them clickable and sends the message as a danger notification, `->authorizationMessage('…')` is the fallback when the policy has no message (without any message they stay hidden). They never run. A record ability checked without a record (`Resource::can('update')`, `Authorization::allows('view', Post::class)`, or an `EditAction` / `DeleteAction` that has no record yet) denies when the policy method needs the model (`update(User $user, Post $post)`; `Authorization::requiresRecord($policy, 'update')`) instead of calling it without one; a record-less `ViewAction` (calendar events, opened for their record) asks `viewAny`, and `view` is checked once the record is resolved; `->offeredBeforeRecord()` does the same for `EditAction`, `DeleteAction` and the other resource actions (the calendar sets it on its event click action). No need for `?Post $post = null` parameters.
- Modal lifecycle: `->mountUsing(fn (Schema $form) => $form->fill([...]))` runs when the modal opens (replaces the default fill; `$action->halt()` keeps it closed), `->modalHidden(fn () => …)` runs without the modal (form uses its default state), `->disabledForm()` makes the modal form read-only, `->modalSubmitAction(fn (Action $action) => $action->label('Send')->color('danger')->icon('send'))` / `->modalCancelAction(fn (Action $action) => $action->label('Close'))` (or `false` to remove), `->unsavedChangesAlert(false)` (default: the panel's `unsavedChangesAlerts()`), `->url(...)->postToUrl()` for POST links.
- Sudo mode: `->schema([\AlfaSix\Alfakit\Auth\MultiFactor\MultiFactorChallenge::make()])` asks for an authenticator, emailed or recovery code; it is verified during validation (fields under `multiFactorChallenge`, never in `$data`) and fails for users without a second factor (`MultiFactorChallenge::make()->hasEnabledProviders($user)`).
- Wizard modal: `Action::make('onboard')->steps([Step::make('Account')->schema([...]), Step::make('Profile')->schema([...])])->skippableSteps()` (`AlfaSix\Alfakit\Schemas\Components\Wizard\Step`); "Next" validates the current step on the server (`POST /_alfakit/schema/validate` with `step` = the Step's key; `Step::beforeValidation()` / `afterValidation()` run there), the submit button shows on the last step and all steps validate again on submit. Page wizards (`Wizard::make([...])`) behave the same.
- Bulk-action forms refresh with the selection (`bulk`, `records`, `allRecords`, `tableQuery` travel with live refreshes and option searches), so `->form(fn (Enumerable $records) => [...])` and the nested modal actions see the same `$records` as the handler. A single `FileUpload` in a modal form is sent as one path and validated as such.
- Nested modal actions: `->extraModalFooterActions([Action::make('preview')->schema([...])->action(...)])` open their own modal (the parent is hidden until it closes, as in Filament; `->overlayParentActions()` stacks it on top instead; an `ActionGroup` in the list is a dropdown of nested actions), share the parent's `$record` / bulk `$records`, and `->cancelParentActions()` closes the parents after success (`->cancelParentActions('edit')` up to that parent). `->registerModalActions([...])` makes actions resolvable without a footer button. Paths look like `edit>preview`. In a nested action, inject `array $mountedActions` (outermost first) or use `$action->getParentAction()` with `->getRawData()`, `->getValidatedData()` (errors show in the parent modal) and `->fillData(['slug' => ...])` (writes into the open parent form). `->cancelParentActionsOnClose()` closes the parents when this modal is dismissed. Extra submit buttons: `->extraModalFooterActions(fn (Action $action): array => [$action->makeModalSubmitAction('saveAndPublish', arguments: ['publish' => true])])` (read `$arguments` in `action()`). Chain with `$action->replaceMountedAction('confirm', ['step' => 2])` inside `action()`.
- Modal presentation: `->stickyModalHeader()`, `->stickyModalFooter()`, `->slideOver()->slideOverPosition(SlideOverPosition::Start)` (`AlfaSix\Alfakit\Support\Enums\SlideOverPosition`, logical/RTL-aware), `->modalFooterActionsAlignment(Alignment::Between)`, `->modalClickThrough()` (no backdrop), `->extraModalWindowAttributes([...])`, `->extraModalOverlayAttributes([...])` (`merge: true` to add). `->modalAutofocus(false)`, `->modifyFormUsing(fn (Schema $schema) => $schema->columns(1))`. Buttons: `->labeledFrom('md')` (icon only below the breakpoint), `->hiddenLabel()` (icon only, label stays the accessible name), `->iconPosition(IconPosition::After)`, `->extraAttributes(['data-tour' => 'x'])`, `->badge()` with no argument (trigger rendered as a badge; `->badge(5)` is a count badge), `->openDatabaseNotifications()` (opens the bell panel). Groups: `ActionGroup::make([...])->buttonGroup()` (joined buttons), `->dropdown(false)` (a nested group becomes a divided section), `->dropdownWidth(Width::Small)`, `->dropdownOffset(8)`, `->maxHeight('300px')`.
- Toasts after an action: `->successNotificationTitle('Archived')` or `->successNotification(fn (Notification $notification) => $notification->warning())` on any action (custom ones too) sends a success notification after the handler, unless the handler already sent one (`Notification::make()->...->send()`); `->failureNotificationTitle()` for bulk failures. Prebuilt actions have their own titles.
- `->rateLimit(5)` (successful-validation runs per user per minute; `rateLimit(10, decaySeconds: 3600)`, `rateLimitedNotificationTitle(fn (int $minutes) => ...)`); `->requiresPasswordConfirmation()` (remembered for `auth.password_timeout`, or `requiresPasswordConfirmation(rememberForMinutes: 5)`; `0` asks every time).
- Bulk actions: `BulkAction::make('archive')->authorizeIndividualRecords('archive')->chunkSelectedRecords(500)->action(fn (Enumerable $records) => ...)`; type-hint `Illuminate\Support\Enumerable` because chunked `$records` are a `LazyCollection`. Prebuilt delete/restore/force-delete bulk actions authorise each record. Relation managers also have `DetachBulkAction` and `DissociateBulkAction`.
- Shared resource forms/tables in relation managers: `->hiddenOn(RelationManager::class)` / `->visibleOn(RelationManager::class)` on fields, layouts, columns and filters (mixable with operations: `->hiddenOn([RelationManager::class, 'create'])`).

## Relation managers, infolists, widgets and pages

- `make:alfakit-relation-manager PostResource comments body --register` (adds it to `PostResource::getRelations()`; by hand write `PostResource\RelationManagers\CommentsRelationManager::class`, since the resource doesn't import `RelationManagers`). Set `protected static string $relationship = 'comments';` and define `form()` and `table()` (instance methods).
- BelongsToMany managers: fields named after `withPivot()` columns are pivot attributes. `CreateAction` creates and attaches with them, `EditAction` fills them from the pivot and saves them with `updateExistingPivot()`; columns read them as `TextColumn::make('role')` or `TextColumn::make('pivot.role')` (not sortable/searchable). `AttachAction::make()->preloadRecordSelect()->recordSelectSearchColumns(['name', 'email'])->recordSelectOptionsQuery(fn (Builder $query) => $query->where('active', true))->recordSelect(fn (Select $select) => $select->placeholder('…'))->allowDuplicates()->form([TextInput::make('role')])`; `AssociateAction` takes the same select options (not `allowDuplicates()`). Custom layout: `->schema(fn (AttachAction $action): array => [$action->getRecordSelect(), TextInput::make('role')])`. `AttachAction::make()->tableSelect(ProductsTable::class)` (table class with `static configure(Table $table): Table`, resource class or closure) picks the records from a modal table (a `ModalTableSelect` over the attachable records) instead of the select.
- `make:alfakit-related-records-page PostResource comments body` (`--attach`, `--associate`) generates `Resources/PostResource/Pages/ManagePostComments` extending `ManageRelatedRecords`; register it as `'comments' => Pages\ManagePostComments::route('/{record}/comments')`.
- `infolist(Schema $schema)` uses entries: `TextEntry::make('author.name')`, `IconEntry::make('is_featured')->boolean()`, `TextEntry::make('price')->money('EUR')`. Entries: `->alignEnd()` (`alignment(Alignment::Center)`, `alignStart()`, `alignJustify()`), `->extraAttributes([...])` on the value and `->extraEntryWrapperAttributes([...])` on the label + value wrapper, `->hiddenOn('edit')` / `->visibleOn([...])`; `IconEntry::make('active')->boolean()->trueColor('success')->falseColor('gray')`, `KeyValueEntry::make('meta')->keyLabel('Setting')->valueLabel('Value')`.
- Entry extras: `TextEntry::make('comments_count')->counts('comments')` (also `exists()`, `sum()`, `avg()`, `min()`, `max()`, like table columns; loaded onto the record). For `*-to-many` lists `color()`/`icon()`/`url()` closures run per item: `TextEntry::make('tags.name')->badge()->color(fn (Tag $relatedRecord) => $relatedRecord->color)`; `formatStateUsing()`, `limit()`, `words()`, `prefix()`/`suffix()`, `timezone()` and `IconColumn::icon()`/`color()` get `$relatedRecord` per item too. `ImageEntry::make('photo')->imageHeight(80)->alt(fn (?string $state, Product $record) => $record->name)` (`imageWidth()`/`imageHeight()`/`imageSize()` alias `width()`/`height()`/`size()`). `CodeEntry::make('payload')->grammar(Language::Json)->copyable()` (`AlfaSix\Alfakit\Infolists\Components\CodeEntry`; arrays print as JSON; `lightTheme()`/`darkTheme()` names become `data-light-theme`/`data-dark-theme`).
- `Actions::make([Action::make('star')->action(fn (Set $set) => $set('rating', 5))])->alignment(Alignment::Center)->verticalAlignment(VerticalAlignment::End)->fullWidth()` (`AlfaSix\Alfakit\Schemas\Components\Actions`) puts a row of buttons anywhere in a form or infolist (also standalone forms and action modals); the actions run like field actions (`$get`/`$set` on the surrounding form).
- Widgets: `make:alfakit-widget Revenue`. Extend `StatsOverviewWidget` and return `Stat::make('Revenue', '$48,210')->description('+12%')->color('success')->chart([...])` from `getStats()` (`->placeholder('—')` for null/empty values, globally with `Stat::configureUsing(fn (Stat $stat) => $stat->placeholder('—'))`). Charts can override `protected function getChartAssistiveContent(): string|Htmlable|null` for screen readers. Register them with `->widgets([...])` on the panel. Chart widgets: `protected ?string $color = 'info'`, `protected bool $isCollapsible = true`, empty state when `getData()` returns `[]` (`$emptyStateHeading`, `$emptyStateDescription`, `$emptyStateIcon`, override `isEmpty()`; buttons from `protected function getEmptyStateActions(): array`, run through the widget; or `protected ?string $emptyStateView = 'orders-empty'` + `getEmptyStateViewData()` for a registered component), JS callbacks in `getOptions()` as named strings (`'callback' => 'js:money'`, registered with `createAlfakit({ chartFormatters: { money: (v) => ... } })`; never eval), Chart.js plugins with `createAlfakit({ chartPlugins: [...] })`, theme hooks `--chart-tooltip-bg`, `--chart-tooltip-foreground`, `--chart-tooltip-border`, `--chart-legend-foreground`, `--chart-text`, `--chart-grid`, `--stat-chart-stroke-width`, `--stat-chart-fill-opacity`, and a filter form with `use AlfaSix\Alfakit\Widgets\ChartWidget\Concerns\HasFiltersSchema;` + `public function filtersSchema(Schema $schema): Schema` read as `$this->filters['startDate'] ?? null` (`protected bool $hasDeferredFilters = true` for an Apply button; restyle the buttons by overriding `public function filtersApplyAction(Action $action): Action` / `filtersResetAction()`). The chart type comes from `protected function getChartType(): string` (Filament's `getType()`; AlfaKit's `getType()` is the schema type). Stats: `->descriptionIcon('trending-up', IconPosition::After)`, `->descriptionColor('success')`, `->extraAttributes([...])`; `protected int|array|null $columns = 3` on the widget.
- Widgets and relation managers are lazy by default (`protected static bool $isLazy = true;`): the page shows a skeleton and loads them with one Inertia deferred request (`alfakitLazy`) right after the first response. Set `$isLazy = false` to send one with the page. In HTTP tests a lazy widget is a placeholder (`lazy: true`) in `props.page.content`; request its data with the partial headers `X-Inertia-Partial-Component` and `X-Inertia-Partial-Data: alfakitLazy`.
- List tabs on a `ListRecords` page: `public function getTabs(): array { return ['all' => Tab::make(), 'published' => Tab::make()->modifyQueryUsing(fn (Builder $query) => $query->where('is_published', true))->badge(fn () => Post::where('is_published', true)->count())->icon('circle-check')]; }` (`AlfaSix\Alfakit\Schemas\Components\Tabs\Tab`); the active tab is `?activeTab=`, `getDefaultActiveTab()` picks the default. Tab options: `->excludeQueryWhenResolvingRecord()` (record actions find records that left the tab), `->deferBadge()` (badge counted in a deferred request), `->extraAttributes([...])`.
- Open an action from a URL: `PostResource::getUrl('index', ['tableAction' => 'edit', 'tableActionRecord' => $post->getKey()])` opens that table action's modal on load (simple/modal resources, notification links); any page: `protected ?string $defaultAction = 'onboarding';` (+ `$defaultActionArguments`, `$defaultActionContext = ['table' => true, 'recordKey' => 5]`) or `?defaultAction=onboarding` (Filament's `?action=onboarding&actionArguments[step]=2` works too, for page actions). Only actions with a modal open this way.
- Create/Edit/View `content()`: `return $schema->components([Callout::make()..., $this->getFormContentComponent(), $this->getRelationManagersContentComponent()]);` (`getInfolistContentComponent()` on View; `EmbeddedTable::make()` on List). Content without markers goes above the page blocks.
- Edit/create helpers: `$this->refreshFormData(['status'])` (Edit/View, after an action changed `$this->record`); `$page->saveFormComponentOnly('shipping')` from a section action with `->formId('form')` (saves only that component's fields; `Section::make()->key('shipping')`); `protected function preserveFormDataWhenCreatingAnother(array $data): array { return Arr::only($data, ['category_id']); }` on CreateRecord; panel `->resourceCreatePageRedirect('index')` / `->resourceEditPageRedirect('view')` (`index|view|edit`); header submit button `$this->getSaveFormAction()->formId('form')` in `getHeaderActions()`.
- Wizard create page: `use AlfaSix\Alfakit\Resources\Pages\CreateRecord\Concerns\HasWizard;` with `protected function getSteps(): array { return [Step::make('Customer')->schema([...])]; }`, optional `hasSkippableSteps(): bool` / `getStartStep(): int`; the form buttons show on the last step.
- Relation manager tab badges: `protected static ?string $badge`, `$badgeColor`, `$badgeTooltip`, or `public static function getBadge(Model $ownerRecord, string $pageClass): ?string` (also `getBadgeColor()`, `getBadgeTooltip()`).
- Nested resources: `protected static ?string $parentResource = PostResource::class;` on the child (or `getParentResourceRegistration()` returning `PostResource::asParent()->relationship('comments')->inverseRelationship('post')`); routes become `/posts/{post}/comments/...`, queries are scoped to the parent, creates go through the relationship, breadcrumbs start with the parent. A relation manager on the parent with `protected static ?string $relatedResource = CommentResource::class;` links its `CreateAction` / `EditAction` / `ViewAction` to those nested pages instead of opening modals (also when the manager defines `form()`). Generate with `make:alfakit-resource Comment --nested=PostResource`; link from a relation manager with `protected static ?string $relatedResource = CommentResource::class;`.
- `RelationGroup::make('Contacts', [PhonesRelationManager::class, EmailsRelationManager::class])->icon('users')` in `getRelations()` shows several managers under one tab (`AlfaSix\Alfakit\Resources\RelationManagers\RelationGroup`).
- `getRelations()` keys name the tab in `?relation=comments` and prefix the manager's table query string (unkeyed tabs use their position); `'approved' => CommentsRelationManager::make(['status' => 'approved'])` fills the manager's public properties (`public ?string $status = null;`). `protected static bool $isBadgeDeferred = true;` on a manager loads its tab badge in the deferred `alfakitLazy` request (built from `getTitle()`/`getIcon()`/`getBadge()`). Nested resources without an index: `PostResource::asParent()->relationship('comments')->page('manageComments')` (the parent page listing them; default the kebab relationship name) is their index URL in breadcrumbs and redirects.
- Relation manager tabs: `public static function getTabComponent(Model $ownerRecord, string $pageClass): Tab` returns `Tab::make('Comments')->icon('message-circle')->badge($ownerRecord->comments()->count())->badgeColor('warning')` (`AlfaSix\Alfakit\Schemas\Components\Tabs\Tab`; tooltip from `getBadgeTooltip()`). On Edit/View pages: `public function getAllRelationManagers(): array` (managers for that page only), `public function hasCombinedRelationManagerTabsWithContent(): bool { return true; }` (form/infolist becomes a tab), `getContentTabLabel()`, `getContentTabIcon()`, `getContentTabComponent(): Tab`, `getContentTabPosition(): ?ContentTabPosition` (`AlfaSix\Alfakit\Resources\Pages\Enums\ContentTabPosition::After`). Panel: `->readOnlyRelationManagersOnResourceViewPagesByDefault(false)` lets managers write on View pages.
- Configurable resources: `protected static ?string $configurationClass = OrderResourceConfiguration::class;` (extends `AlfaSix\Alfakit\Resources\ResourceConfiguration`, fluent options of your own plus `slug()`), register `->resources([OrderResource::class, OrderResource::make('archived')->archived()])` (URL `/orders/archived`, or `->slug('archive')`), read `static::getConfiguration()` / `hasConfiguration()` in `getEloquentQuery()`, `getNavigationLabel()`…, link with `OrderResource::getUrl(configuration: 'archived')` or `OrderResource::withConfiguration('archived', fn () => …)`.
- Configurable pages: `protected static ?string $configurationClass = SettingsPageConfiguration::class;` (extends `AlfaSix\Alfakit\Pages\PageConfiguration`), register `->pages([SettingsPage::make('general')->slug('general-settings')->category('general')])` (default URL `{page-slug}/{key}`), read `static::getConfiguration()` in `mount()`, `getNavigationLabel()`…; `SettingsPage::withConfiguration('general', fn () => SettingsPage::getUrl())`.
- Pages: `protected ?bool $hasDatabaseTransactions = true;` runs the page's actions (saves included) in a transaction (`false` opts out of the panel's `databaseTransactions()`; an action's `databaseTransaction()` wins). `public static function isTenantSubscriptionRequired(Panel $panel): bool` on a page or resource exempts it from (or requires) the billing subscription. `protected static bool $isDiscovered = false;` keeps a resource, page or widget out of discovery. Resources: `protected static bool $shouldCheckPolicyExistence = false;` sends every check to the Gate (`Gate::before()`/`Gate::define()` decide; a missing policy method denies), `protected static ?string $tenantRelationshipName = 'posts';` creates records through `$tenant->posts()`, `getGlobalSearchEloquentQuery()` eager-loads for search results, `Resource::scopeToTenant(false)` in a provider turns tenant scoping off for all resources.
- Labels and breadcrumbs: `protected static bool $hasTitleCaseModelLabel = false;` keeps the model label's case in navigation and titles; `protected static ?string $breadcrumb = 'Reports';` gives a custom page a breadcrumb (resource pages: their last breadcrumb).
- `ManageRelatedRecords` page (`AlfaSix\Alfakit\Resources\Pages\ManageRelatedRecords`): `protected static string $relationship = 'comments';` plus `form()` / `table()`; register as `'comments' => Pages\ManagePostComments::route('/{record}/comments')`. Actions behave as in a relation manager. Generate with `make:alfakit-related-records-page`.
- API resources (records from a REST API or any non-Eloquent source): `make:alfakit-api-resource Supplier [--url=https://erp.example.com/api] [--panel=admin]` writes `SupplierResource extends \AlfaSix\Alfakit\Resources\ApiResource` (`protected static ?string $dataSource = SupplierDataSource::class;`), pages extending `ListApiRecords` / `CreateApiRecord` / `EditApiRecord` / `ViewApiRecord` (`AlfaSix\Alfakit\Resources\Pages`, never `ListRecords` etc.: `getEloquentQuery()` throws) and the data source. A `\AlfaSix\Alfakit\Resources\Api\DataSource` implements `records(RecordsQuery $query): iterable` (rows to search/sort/page in memory, or a `LengthAwarePaginator` it paged itself; `$query->search`, `sortColumn`, `sortDirection`, `filter('status')`, `page`, `perPage`, `tab`, `tenant`, `owner`), `find(string $key): ?array`, `create(array $data): array`, `update(string $key, array $data): array`, `delete(string $key): void`, optional `search(string $search, int $limit): iterable` (enables global search) and `getRecordKey()` / `getRecordTitle()`; `HttpDataSource` does it for JSON REST APIs (`$endpoint`, `getBaseUrl()`, `request()` for auth headers, `$recordsKey = 'data'`, `$totalKey = 'meta.total'`, `$recordKey`, `mapRecord()`, `mapPayload()`, `$errorFields`). Records reach closures as `ApiRecord` (an unsaved Eloquent model: `$record['name']`, `$record->save()` / `delete()` go to the source). API 422 `{errors: {field: [...]}}` and thrown `ValidationException`s become field errors; other failures (`DataSourceException`) an error notification. Authorise by overriding `canViewAny()`, `canCreate()`, `canEdit(Model $record)`, `canView()`, `canDelete()`, `canDeleteAny()` or setting `protected static ?string $policy`. Scope every call to `$this->getTenant()` yourself (no query scope applies). `$cacheSeconds` caches reads. Relation-like sections from a source: `class OrdersRelationManager extends \AlfaSix\Alfakit\Resources\RelationManagers\ApiRelationManager { protected static string $dataSource = OrderDataSource::class; }` (the source reads `getOwnerRecord()`). In HTTP tests use `Http::fake()` for `HttpDataSource`s.
- Singular resource (settings): the only page extends `ManageSingularRecord` (`'index' => Pages\ManageSettings::route('/')`); it edits the first record and creates it on the first save. Generate with `make:alfakit-resource Setting --singular`.
- Global search result buttons: `public static function getGlobalSearchResultActions(Model $record): array { return [Action::make('comments')->url(...)]; }` (URL actions only).
- Dashboard filters: `use AlfaSix\Alfakit\Pages\Dashboard\Concerns\HasFiltersForm;` on a `Dashboard` subclass with `public function filtersForm(Schema $schema): Schema { return $schema->components([DatePicker::make('startDate'), DatePicker::make('endDate')]); }`. Applied values are validated and kept in the session (per panel, tenant, dashboard); widgets read them as `$this->pageFilters['startDate'] ?? null` with `use AlfaSix\Alfakit\Widgets\Concerns\InteractsWithPageFilters;`. In HTTP tests apply them with the `applyFilters` action (`data` = the filter values) and reset with `resetFilters`. Fields marked `live()` apply during the live refresh (`POST /_alfakit/schema/refresh` on `component: form`) and the renderer reloads the widgets; the Apply button stays for non-live fields and disappears when every field is live (`hasLiveFilters()`, `hasOnlyLiveFilters()`). `public function persistsFiltersInSession(): bool { return false; }` on the dashboard keeps them in the URL (`?filters[startDate]=…`; apply/reset redirect, values are unvalidated user input, `live()` waits for Apply). Filters in a slide-over instead: `use AlfaSix\Alfakit\Pages\Dashboard\Concerns\HasFiltersAction;` and return `[FilterAction::make()->schema([...])]` (`AlfaSix\Alfakit\Pages\Dashboard\Actions\FilterAction`) from `getHeaderActions()`.
- Widget properties: `OrdersByStatus::make(['status' => 'pending'])` sets the widget's public properties (`public ?string $status = null;`); the same class can be listed several times (ids get a suffix from the properties). Works in `getHeaderWidgets()`/`getFooterWidgets()` of any page and `getWidgets()` of a `Dashboard` subclass. Unknown or non-public properties throw.
- Widgets reading the list table: `use AlfaSix\Alfakit\Widgets\Concerns\InteractsWithPageTable;` on a header/footer widget of a `ListRecords` page, optional `protected function getTablePage(): string { return ListOrders::class; }`; `$this->getPageTableQuery()->sum('total')` (tab, filters, search applied, no sort, tenant-scoped), `$this->getPageTableRecords()` (current page), `$this->getPageTableRecordsCount()`. State comes from the page URL, so polling and lazy widgets keep following it. Test with `inertiaGet('/admin/orders?activeTab=paid&filters[status][value]=x')` and read the widget's stats in `props.page.content`.
- Several dashboards: `->pages([Dashboard::class, SalesDashboard::class])` where `class SalesDashboard extends \AlfaSix\Alfakit\Pages\Dashboard` overrides `getWidgets()`. Only `Dashboard` (or a subclass named `Dashboard`) lives at the panel root; other subclasses get `/{slug}` (`/admin/sales-dashboard`) unless they declare `$routePath`.
- Personal dashboards: `$panel->customizableDashboards()->availableDashboardWidgets([TopCustomers::class])` or `Dashboard::customizable()` (in a service provider; `false` opts a dashboard out, `null` follows the panel); publish the table with `vendor:publish --tag=alfakit-dashboard-migrations`. Users reorder (drag or arrow keys), resize, hide and add widgets; layouts are stored per panel, dashboard, user and tenant and the default comes from `getWidgets()`/`getColumns()`. Limit sizes with `protected static ?array $columnSpanOptions = [1, 'full'];` or `->allowedColumnSpans([1, 2])`; name library entries with `$libraryLabel` / `$libraryDescription`; offer a different library by overriding `getAvailableWidgets()`. Widgets are filtered by `canView()`. Save in tests with `POST /{panel}/_alfakit/dashboard/layout` `{token, layout: [{id, span, hidden}]}` or `{token, reset: true}` (token from the Widgets block's `customize.token`).
- Custom page: `make:alfakit-page Settings`. Build it with `content(Schema $schema)`, or set `protected static ?string $component` to render your own frontend page. Route parameters: `protected static ?string $routePath = '/reports/{period}';` passes them to `mount(mixed ...$parameters)` in order (`$parameters[0]`); such a page has no navigation item, link to it with `Reports::getUrl(['period' => 'month'])`.
- App pages (any interactive screen: POS, board, calendar, inbox): `make:alfakit-page Pos --component [--adapter=react|vue|svelte] [--fullscreen] [--resource=Order]` writes the page (`$component = 'Pages/Pos'`, `getViewData()`, an example `#[Expose]` method) and the component, registered under `pages` in `resources/js/alfakit/app.*` and `ssr.*`. Mark callable methods with `#[\AlfaSix\Alfakit\Pages\Attributes\Expose(rateLimit: 20, authorize: ['create', Order::class], rules: ['items' => 'required|array'], messages: [...], transaction: true)]` (only public non-static methods with the attribute can be called, through `POST /_alfakit/pages/call`); scalar, backed enum and `TemporaryUpload` parameters are cast, other parameters injected. Inside: `$this->reloadProps('cart')` (Inertia partial reload of view data), `$this->formState('sale')` (validates a named form sent with `forms: ['sale']`, errors under `forms.sale.*`), return `redirect()` to navigate, throw `ValidationException` for 422 errors, send toasts with `Notification::make()->send()`. Scope every query to `alfakit()->getTenant()` yourself and authorise per record (`Gate::authorize('update', $order)`): exposed methods are not resource queries. Named pieces: public `{name}Form(Schema $schema): Schema`, `{name}Table(Table $table): Table`, `{name}Infolist(Schema $schema): Schema` (blocks `form:{name}`, `table:{name}`, `infolist:{name}`, rendered with `<PageForm name>` etc.; initial form state via `$this->formsData['sale'] = [...]` in `mount()`). `protected static string $layout = 'fullscreen';` (no sidebar/topbar; `$hasFullscreenHeader = false` for kiosks; exit link from `getExitUrl()`). Test with `postJson('/admin/_alfakit/pages/call', ['token' => $token, 'method' => 'checkout', 'arguments' => [...], 'forms' => [...]])`, `$token` from `props.page.token` of an Inertia GET of the page.

## Navigation, clusters and panel options

- Clusters: `make:alfakit-cluster Settings` creates `class Settings extends Cluster` (`AlfaSix\Alfakit\Clusters\Cluster`). The command adds `->discoverClusters(in: app_path('Alfakit/Clusters'), for: 'App\\Alfakit\\Clusters')` to the panel provider when the panel doesn't discover clusters yet; otherwise register it like a page (`->pages([Settings::class])`) and add pages/resources with `protected static ?string $cluster = Settings::class;`. Clustered URLs are prefixed with the cluster slug (`/admin/settings/tags`); route names and `getUrl()` don't change. The main navigation shows one cluster item; the pages appear as sub-navigation.
- Record sub-navigation: `public static function getRecordSubNavigation(\AlfaSix\Alfakit\Resources\Pages\Page $page): array { return $page->generateNavigationItems([Pages\ViewUser::class, Pages\EditUser::class]); }` on the resource. Pages the user can't open for the record are skipped. Position with `protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;` (`Start`, `End`, `Top`; `AlfaSix\Alfakit\Navigation\SubNavigationPosition`) on a page, resource or cluster. A `Start`/`End` column sits beside the content only when the content area is at least 42rem (container query); narrower it becomes a scrolling row of links.
- Panel options: `->collapsibleNavigationGroups(false)`, `->sidebarWidth('18rem')`, `->collapsedSidebarWidth('4rem')`, `->simplePageMaxContentWidth(Width::Large)`, `->spaUrlExceptions([url('/reports'), '*/exports/*'])`, `->databaseTransactions()` (actions and page saves run in a transaction; per action `->databaseTransaction(false)`), `->strictAuthorization()` (missing policies/methods deny), `->errorNotifications(false)` and `->registerErrorNotification('Title', 'Body', 403)` (error notifications replace Inertia's error modal when `app.debug` is off), `->hiddenErrorNotification(403)` (caught, nothing shown) and `->disabledErrorNotification(503)` (default handling). Per page: `protected ?bool $hasErrorNotifications = false;` or `hasErrorNotifications(): bool`, and `protected function setUpErrorNotifications(): void { $this->registerErrorNotification('Title', 'Body', 503); $this->hiddenErrorNotification(404); }`. In the icon-only sidebar a `NavigationGroup` with `->icon()` opens its items in a flyout.
- Accounts: `->multiFactorAuthentication([AppAuthentication::make()->recoverable(), EmailAuthentication::make()], isRequired: false)` (`AlfaSix\Alfakit\Auth\MultiFactor\App\AppAuthentication`, `...\Email\EmailAuthentication`) needs `->login()->profile()` and the user model to implement `HasAppAuthentication` / `HasAppAuthenticationRecovery` / `HasEmailAuthentication` (traits `InteractsWithAppAuthentication`, `InteractsWithAppAuthenticationRecovery`, `InteractsWithEmailAuthentication` in `AlfaSix\Alfakit\Auth\MultiFactor\Concerns`, columns `app_authentication_secret`, `app_authentication_recovery_codes` (text) and `has_email_authentication` (boolean)). Users set methods up on the profile page; login then goes through `/{panel}/multi-factor-challenge`. `->multiFactorAuthenticationRememberDevice(30)` adds "trust this device". `->emailChangeVerification()` keeps a new profile email pending until the emailed link is opened. `AppAuthentication::make()->recoverable()->regenerableRecoveryCodes(false)` removes "New recovery codes" from the profile.
- Auth routes and profile: `->loginRouteSlug('sign-in')`, `->registrationRouteSlug()`, `->passwordResetRoutePrefix()` / `passwordResetRequestRouteSlug()` / `passwordResetRouteSlug()`, `->emailVerificationRoutePrefix()` / `emailVerificationPromptRouteSlug()` / `emailVerificationRouteSlug()`, `->emailChangeVerificationRoutePrefix()` / `emailChangeVerificationRouteSlug()` (route names unchanged), `->revealablePasswords(false)`. Custom profile pages extend `AlfaSix\Alfakit\Auth\Pages\EditProfile` and reuse `$this->getNameFormComponent()`, `getEmailFormComponent()`, `getPasswordFormComponent()`, `getPasswordConfirmationFormComponent()`, `getCurrentPasswordFormComponent()` (protected, return `Component`).
- Customer portals: `$panel->portal()` (top navigation, no global search, command palette, shortcuts overlay, recent records, breadcrumbs or credit; login, password reset and a full profile; `6xl` content; override after it). `->invitationOnlyRegistration(expiresAfterDays: 7)` (registration only from signed links: `$panel->inviteToRegister($email)` mails a `RegistrationInvitation`, `getRegistrationInvitationUrl($email)` returns one; the address is read-only and re-checked). `->scopeToUser('customer_id', fn (User $user) => $user->customer_id)` scopes every resource's `getEloquentQuery()` (lists, record pages, global search) to the user, fills the owner on create and keeps it on edit and when a record version is restored (column or relationship name; guests/null keys see nothing; resource generators leave the owner column out); per resource `protected static string|false|null $userOwnership = 'owner_id'` (`false` opts out), `OrderResource::scopeToUser('owner_id')`, or `OrderResource::make('mine')->scopeToUser('customer_id')`. Scoping isn't authorisation: keep policies (read-only: override `can()` to allow only `viewAny`/`view`).
- User and tenant menus take actions: `->userMenuItems(['profile' => fn (Action $action) => $action->label('Edit profile'), 'logout' => fn (Action $action) => $action->label('Log out'), [Action::make('billing')->url('/billing')->icon('credit-card')], [Action::make('feedback')->schema([...])->action(...)]])` (nested arrays are groups, `->sort()` orders within one, `->postToUrl()` for POST links, actions with `action()`/modals run as component `userMenu`). `->tenantMenuItems([...])` is the same with the keys `profile`, `register` and `billing` (component `tenantMenu`). `MenuItem::make()` still works but is deprecated.
- Navigation items: `protected static ?string $navigationParentItem = UserResource::class;` (parent by class or label; page/resource items are keyed by class, custom ones with `NavigationItem::make('Docs')->key('docs')`, children merged and sorted). Static setters from a plugin or provider: `UserResource::navigationGroup('Team')`, `::navigationLabel()`, `::navigationSort()`, `::navigationIcon()`, `::navigationParentItem()` (that class only). `NavigationItem::make('Orders')->badge(fn () => 12)->badgeColor(fn (int $badge) => $badge > 10 ? 'danger' : 'gray')->badgeTooltip(fn (int $badge) => "{$badge} new")->extraAttributes(['class' => '…', 'data-tour' => 'orders'])`; `$badge` is injected into badge colour/tooltip closures of actions, tabs and relation groups too. `getNavigationLabel(): string|Htmlable` / `NavigationItem::make(new HtmlString(...))` and `HasName::getAlfakitName(): string|Htmlable` on tenants render sanitised HTML. `->navigation(fn (): bool => …)`: `true` keeps auto-discovered items, `false` hides them; a closure returning a `NavigationBuilder` builds the navigation.
- Relation tabs: `RelationGroup::make(...)->badge(fn () => …)->deferBadge()`; on Edit/View pages with `hasCombinedRelationManagerTabsWithContent()`, override `getCombinedTabComponents()` (`['comments' => $this->getRelationManagerTabComponent('comments'), '' => $this->getContentTabComponent(), 'tags' => …]`) to place the content tab between relation tabs.
- Panel layout: `->globalSearch(position: GlobalSearchPosition::Sidebar)` and `->userMenu(position: UserMenuPosition::Topbar)` (`AlfaSix\Alfakit\Support\Enums`; also `globalSearchPosition()` / `userMenuPosition()`), `->breadcrumbs(hasNavigationHierarchy: true)` (group and parent item first), `->subNavigationPosition(SubNavigationPosition::Top)` (default for pages, clusters, resources), `->domains(['admin.example.com', 'admin.example.org'])`, `->assets([Css::make('id', $path), Js::make(...)], package: 'app/admin')` (this panel only), `->tenantRoutePrefix('team')` (`/admin/team/{tenant}`). `HasCurrentTenantLabel::getCurrentTenantLabel()` on the tenant is a caption above its name in the tenant menu.
- Panel chrome: `->topbar(false)`, `->themeSwitcher(false)`, `->defaultThemeMode(ThemeMode::Dark)`, `->searchableTenantMenu()` (automatic above 10 tenants; `false` to never), `->tenantSwitcher(false)` (menu keeps its items, lists no other tenants), `->defaultAvatarProvider(InitialsAvatarProvider::class)` or `UiAvatarsProvider` (`AlfaSix\Alfakit\AvatarProviders\*`; custom: implement `AvatarProviders\Contracts\AvatarProvider::get(Model|Authenticatable $record): string`; used when `HasAvatar` returns null and there is no `avatar_url`). Global search: `->globalSearchDebounce('500ms')`, `->globalSearchFieldSuffix(fn () => 'Ctrl K')`, `->globalSearchFieldKeyBindingSuffix(false)`, `->globalSearchKeyBindings(['mod+k'])`, `->globalSearchResourceOptIn()` (only resources declaring `protected static bool $isGloballySearchable = true;`); on resources `protected static ?int $globalSearchSort = 1;` and `protected static ?bool $shouldSplitGlobalSearchTerms = false;`. Pages: `protected static ?string $navigationBadgeTooltip`, `public function getWidgetData(): array` (fills widgets' public properties; `Widget::make([...])` wins), `protected ?Alignment $headerActionsAlignment`, `protected array $extraBodyAttributes`; clusters: `protected static bool $shouldRegisterSubNavigation = false;`. Inertia props: `->deferNavigationBadges()` loads navigation badges with the deferred `alfakitNavigationBadges` prop (items with a badge show a placeholder; `getNavigationBadge()` is not called for the page); the translations, navigation structure, endpoints, localization, command palette and theme are once props keyed by a content hash (`->onceProps(false)` turns this off), and the active items and badges travel in `alfakit.navigationState` — read the navigation through `usePanel()`, which merges them back.
- Command palette (⌘K): lists global search results plus the page's header actions, panel commands, navigation, "New …" links for resources the user can create, recent records and theme switching. Panel commands are actions: `$panel->commands([\AlfaSix\Alfakit\CommandPalette\Command::make('clear-cache')->label('Clear cache')->keywords(['flush'])->group('Maintenance')->description('…')->shortcut('mod+shift+x')->requiresConfirmation()->visible(fn (): bool => …)->action(fn () => cache()->flush()), Command::make('orders')->url(fn (): string => OrderResource::getUrl())->shortcut('g o')])` (run through the action endpoint with component `commands` and the normal authorisation; links are never executed). `->commandPalette(false)` turns the extras off; `->recentRecords(8)` / `->recentRecords(false)` (visits of view/edit pages, per user, panel and tenant, in the cache; re-authorised with the resource query and policy on every read); `protected static bool $hasRecentRecords = false;` on a resource keeps it out.
- Keyboard shortcuts: built-ins `/` (table search), `g d` (dashboard), `?` (overlay listing every shortcut on the page; users change, turn off or reset keys, stored per user and panel: publish `--tag=alfakit-user-preferences-migrations` for a table, else the cache). Bindings: up to three space-separated steps of `mod|ctrl|alt|shift|meta+key` (`mod` = ⌘/Ctrl). Commands use `->shortcut()`, actions `->keyBindings([...])`. `->keyboardShortcuts(false)` or `->keyboardShortcuts(customizable: false)` on the panel.
- Record history: `use \AlfaSix\Alfakit\Versions\HasRecordVersions;` on the model (publish `--tag=alfakit-record-versions-migrations`; optional `protected array $versionInclude = [...]`, `protected array $versionExclude = [...]`, `protected int $maxVersions = 50;`) snapshots tracked attributes on create/update/delete/restore with author and tenant (a record from before the trait gets a `baseline` version of its earlier values on its first tracked update); `$model->recordVersions()` (alias `versions()`), `Model::withoutVersions(fn () => …)`, `$model->restoreRecordVersion($version)`. Edit/view pages of the resource get `\AlfaSix\Alfakit\Actions\HistoryAction` (slide-over with field-by-field and word diffs; viewing needs `view`/`update`, "Restore this version" needs `update` and writes a new version); `protected static ?bool $hasVersions = false;` on the resource hides it; place `HistoryAction::make()->attributeLabels([...])->limit(30)->restorable(fn () => …)` yourself to configure it. Prune with `php artisan alfakit:prune-versions --days=365` / `--keep=20 [--model=App\Models\Post]`.
- Custom routes: `->routes(fn (Router $router, Panel $panel) => $router->get('/invitations/{user}/{hash}', AcceptInvitation::class)->middleware('signed')->name('auth.invitation.accept'))` adds guest-reachable routes inside the panel group (path, `alfakit.{id}.` names, panel middleware); `->authenticatedRoutes(fn (Router $router) => ...)` adds them behind the auth middleware (and tenant prefix).
- Impersonation: `ImpersonateAction::make()` (`AlfaSix\Alfakit\Actions\ImpersonateAction`) in a users table's `recordActions()`. The impersonator must implement `AlfaSix\Alfakit\Contracts\CanImpersonate` (`canImpersonate(): bool`); targets can refuse with `CanBeImpersonated` (`canBeImpersonated(): bool`); an `impersonate(User $user, User $target)` policy method, when present, must allow it (use it to protect higher roles). A banner with **Leave** (`POST /{panel}/impersonation/leave`) shows meanwhile (in starter-kit mode also on the app's own pages, through the header components and `alfakitNavigation[].widgets.impersonation`). After switching, the user lands on the panel home: the first navigation page they may open (`canAccess()`), or the app's `dashboard` route on a starter-kit panel where they may open none.
- Tenancy extras: `->tenantDomain('{tenant:slug}.example.com')` or `->tenantDomain('{tenant:domain}')` (tenant from the host; set `SESSION_DOMAIN`), `->tenantBillingProvider(new MyBillingProvider)` (`AlfaSix\Alfakit\Billing\Providers\Contracts\BillingProvider`: `getRouteAction()`, `getSubscribedMiddleware()`) with `->requiresTenantSubscription()` (adds `/{tenant}/billing` and a Billing tenant-menu item), `->tenantUploadDirectory()` (files under `tenants/{key}/`). Database notifications sent inside a tenant stay in that tenant (`->tenant($team)` / `->forAllTenants()` on the notification). Relation managers only list the current tenant's records of tenant-owned related models, also under a shared owner record (`$isScopedToTenant = false`).
- Tenancy audit: `php artisan alfakit:tenancy:check [--panel=app] [--strict] [--shared=Currency]` lists, per tenancy panel, each resource's model and whether it is scoped / has the ownership relationship, each relationship `Select`/`CheckboxList` (checked against a relationship repeater's or group's own model when nested in one)/`SelectFilter`/`AttachAction`/`AssociateAction` with whether its related model is tenant-scoped or shared, and `DB::`/`withoutGlobalScopes()` usages in resource, page and relation-manager files, as OK/WARN/FAIL rows. Exit code 1 on FAIL (and on WARN with `--strict`). Mark deliberately shared models with `#[AlfaSix\Alfakit\Tenancy\SharedAcrossTenants]`.
- Icons: Lucide names, `heroicon-o-*` names / `Heroicon::Outlined*` cases (mapped to Lucide; use the enum inside closures or `Icons::register()`), image URLs and `HtmlString` SVG (sanitised). `Icons::registerSet('app', resource_path('svg'))` adds an SVG icon set (`app-logo`). Icon aliases: `Icons::register(['alfakit::actions.delete' => 'trash'])` (`AlfaSix\Alfakit\Support\Icons`) in a service provider changes Alfakit's own icons; register your own (`'app::orders' => 'shopping-cart'`) too. Aliases are resolved on the server for every icon option: component, entry, column, action, modal, notification, navigation, menu item, relation manager and stat icons, field `prefixIcon`/`suffixIcon`/`hintIcon`, `Toggle`/`ToggleColumn` `onIcon`/`offIcon`, option `icons([...])`, query-builder constraint and block icons.

## Inside a Laravel starter kit

- `php artisan alfakit:install --starter-kit` (or `alfakit:setup`, which picks it when `resources/js/layouts/app-layout.tsx`, `AppLayout.vue` or `AppLayout.svelte` exists) wires a panel into an app made from Laravel's React, Vue or Svelte starter kit. It writes `resources/js/pages/Alfakit/Page.(tsx|vue|svelte)`, `resources/js/components/alfakit-nav.tsx` (`AlfakitNav.vue`, `AlfakitNav.svelte`, built from the app's `@/components/ui/sidebar`), adds `<AlfakitNav />` after `<NavMain />` in the app sidebar (block marked `alfakit:nav`), adds `<AlfakitSearch />`, `<AlfakitNotifications />`, `<AlfakitCopilot />` and `<AlfakitLanguageSwitcher />` to `app-sidebar-header` and `app-header` (`AppSidebarHeader`, `AppHeader`; block marked `alfakit:header`, skip with `--no-header-widgets`; running the installer again with the same `--panel`/`--path` adds components a block written by an earlier version lacks, and `alfakit:upgrade` notes a header without `<AlfakitLanguageSwitcher />`), wraps the row of placeholder boxes of the kit's dashboard page (`resources/js/pages/dashboard.tsx`, `Dashboard.vue`, `Dashboard.svelte`) in `<AlfakitWidgets>` (block marked `alfakit:widgets`, skip with `--no-dashboard-widgets`), imports `@alfakit/<kit>/standalone.css` in `resources/css/app.css`, and creates a panel provider with `->starterKit()` and `->databaseNotifications()` (the header bell; the notifications table migration is created too). The entry `resources/js/app.*` is not changed: Inertia resolves `Alfakit/Page` from `resources/js/pages`. Don't create `resources/js/alfakit/app.*` in this mode.
- `$panel->starterKit(rootView: 'app', loginRoute: 'login')` (alias `->embedded()`): the host's root view and Fortify login (AlfaKit's login, registration, password reset and profile pages are off), pages inside the host `AppLayout`, no AlfaKit theme or appearance on the document (the host's shadcn tokens and dark mode apply). Keep `->middleware(['web'])`; add `->authMiddleware(['verified'])` to match the kit's dashboard. The navigation is shared with every Inertia page as the `alfakitNavigation` prop (the panel's own `alfakit.navigation` is empty; tenancy panels share empty `groups` outside a tenant), each entry with `widgets` (`endpoint`, `search`, `notifications`, `tenancy`) for the header components. `->multiFactorAuthentication()` is ignored while the host signs users in (use the kit's Fortify two-factor settings); it applies again only with `->login()`.
- Widgets in starter kit mode: the panel has no dashboard of its own; its widgets (`->widgets([...])`, `->discoverWidgets(in: app_path('Alfakit/Widgets'), for: 'App\\Alfakit\\Widgets')`, which the generated provider has; or a registered `Dashboard` subclass's `getWidgets()` and `getColumns()`) appear on the host's `/dashboard` through `<AlfakitWidgets />` (`@alfakit/react`, `@alfakit/vue`, `@alfakit/svelte`; props `panel`, `components` for custom widget components, `class`/`className`; children/default slot render when there are no widgets). So `php artisan make:alfakit-widget BlogStats --type=stats` shows up on `/dashboard` with nothing else to register (it prints where). The widgets are shared as the `alfakitWidgets` prop (`[{ id, component }]`, a `Standalone\StarterKitDashboard` payload bound to the panel) only on the routes of `$panel->starterKitDashboard('dashboard')` (route names or patterns; `false` turns it off), and are null for guests, users whose `canAccessPanel()` is false and tenancy panels. Lazy widgets and polling go through `GET /{path}/_alfakit/standalone/components`, which re-checks the user, `canAccessPanel()` and the panel's auth middleware; each widget's `canView()`, `$sort` and `$columnSpan` apply. With a registered `Dashboard` page that is customizable (`->pages([Dashboard::class])->customizableDashboards()`), `<AlfakitWidgets />` shows the user's layout and **Edit dashboard**, saving that page's layout (`customize.token` is the Dashboard page's; the page also gets a sidebar link, hide it with a subclass that sets `protected static bool $shouldRegisterNavigation = false;`). A lazy widget whose request fails shows an error with a Retry button (`LazyWidgetSchema.failed`, set in the browser).
- Frontend: the page file exports `createStarterKitPage({ layout: AppLayout, discover: import.meta.glob('../../alfakit/{fields,columns,entries,widgets,pages,components,blocks}/**/*.tsx'), components: { 'map-picker': MapPicker } })` (`@alfakit/react`, `@alfakit/vue` with `*.vue`); Svelte uses `export const layout = createStarterKitLayout({ layout: AppLayout, discover: import.meta.glob('…/**/*.svelte') })` in `<script module>` and renders `<AlfakitPage />`. `discover` auto-registers what the generators write in `resources/js/alfakit/*`; other custom components go in `components`. The sidebar reads `useAlfakitNavigation(panelId?)` (`groups[].items[]` with `title`, `href`, `icon`, `isActive`, `badge`, `badgeTooltip`, `openInNewTab`, `children`; render icons with `<Icon name={item.icon} />`) and `userMenuItems` (the panel's `userMenuItems()` link entries and plugin entries such as Billing's: `title`, `href`, `icon`, `openInNewTab`, `method`, `startsGroup`; entries with `action()` or a modal are left out); the installer generates `alfakit-user-menu-items` (`AlfakitUserMenuItems.vue`/`.svelte`, the app's own `DropdownMenuItem`s) and puts `<AlfakitUserMenuItems />` before Log out in the kit's user menu (block `alfakit:user-menu`). AlfaKit's sidebar, top bar (and its `Slot::TOPBAR_*` content) and user menu are not rendered in this mode; its command palette/global search, database notifications bell, tenant switcher, language menu and the AI plugin's copilot go in the host header as `<AlfakitSearch />`, `<AlfakitNotifications />`, `<AlfakitTenantSwitcher />`, `<AlfakitLanguageSwitcher />` and `<AlfakitCopilot />` (same names in `@alfakit/react`, `@alfakit/vue`, `@alfakit/svelte`; optional `panel` prop; without it they, and `useAlfakitNavigation()`, follow the open AlfaKit page's panel, else the first panel that has that part, so a Teams workspace on a second `starterKit()` panel works; `alfakit:add teams` adds `<AlfakitTenantSwitcher />` to the `alfakit:header` block). They work on every host page, render nothing for guests or without `globalSearch()`/commands, `databaseNotifications()`, `tenant()`, two or more `locales()` (and `localeSwitcher()` not false; `languageSwitcher()` positions don't apply) or `AiPlugin::make()->copilot()`, and lazy-load their code and the panel data (`GET /{path}/_alfakit/starter-kit`). The header palette lists search results, navigation, create links and recent records (no panel commands, page actions or theme items); the key bindings (`globalSearchKeyBindings()`, ⌘K) are registered once. `<AlfakitLanguageSwitcher />` shows the current language at once (`alfakitNavigation[].widgets.language`), posts the pick to the panel's `_alfakit/locale` endpoint and reloads; AlfaKit's pages and the header components then use the new language (the panel data is loaded per language), and in a right-to-left language the whole page mirrors: `<html lang dir>` follows the shared `alfakitDocument` prop on every page, the root view's `<html>` gets `dir="{{ \AlfaSix\Alfakit\Support\StarterKitDocument::direction() }}"` and the kit's `<Sidebar>` gets `side` from `useAlfakitDirection()` (both added by the installer, blocks marked `alfakit:direction`). The same exports are available from `@alfakit/<kit>/starter-kit` (areas also have `/kanban`, `/cms`, `/translation-manager`, `/ai`, `/tree` and `/realtime` subpaths); the renderers ship one file per module with only CSS marked as side effects, so a host that imports `useAlfakitNavigation`, `Icon` and the header components from the package root bundles only those (the panel runtime loads with `Alfakit/Page`).

## Plugins

`php artisan make:alfakit-plugin Blog --vendor=acme [--frontend=react|vue|svelte]` creates `packages/blog`: `BlogPlugin implements AlfaSix\Alfakit\Contracts\Plugin` (`make()`, `getId()`, `register(Panel $panel)`, `boot(Panel $panel)`; add with `$panel->plugin(BlogPlugin::make())`), a service provider and `composer.json`. Plugin CSS/JS go in the panel `<head>` with `AlfaSix\Alfakit\Support\Assets\Assets::register([Css::make('blog', __DIR__.'/../resources/dist/blog.css'), Js::make('blog', $path)->module()], package: 'acme/blog')` (optionally `panel: 'admin'`); local files are published by `php artisan alfakit:assets` (run by `alfakit:upgrade`, which `alfakit:install` adds to composer's `post-autoload-dump`). `->loadedOnRequest()` keeps one out of `<head>`: send `Assets::getStyleHref('id', 'acme/blog')` / `getScriptSrc()` in the component's `getSchemaProps()` and call `loadStyle(href)` / `loadScript(src)` from `@alfakit/core` in the component. `Js::make('analytics')->html('<script>…</script>')` renders an inline script (no path; `<script>` tags get the CSP nonce). `Assets::registerCssVariables(['blog-accent' => '#f50'], package: 'acme/blog')` sets `:root` variables; `Assets::registerScriptData(['blog' => [...]])` fills `window.alfakitData` (`getScriptData('blog')` in `@alfakit/core`). Renderer components are exported by the plugin's frontend package and spread into `createAlfakit({ components })`.

Declare compatibility in the plugin's `composer.json` under `extra.alfakit`: `{"version": "^1.0", "frontend": "@acme/blog"}` (`version` is a Composer constraint on `alfasix/alfakit`; `frontend` is the optional npm package and must be in the app's `package.json` at the plugin's major). `php artisan alfakit:upgrade` reports every installed plugin and `@alfakit/*` npm package as compatible / incompatible / unknown against the installed version and only fails with `--strict`. Report deprecated APIs with `AlfaSix\Alfakit\Support\Deprecations::warn('Table::actions()', 'use recordActions() instead', since: '1.2')` (logged once per process; `Deprecations::all()` / `toDebug()` list them, `flush()` resets). Every page shares `alfakit.schemaContract` (`AlfaSix\Alfakit\Support\SchemaContract::VERSION`); renderers show a translated message instead of the page when its major differs from `SCHEMA_CONTRACT_VERSION` in `@alfakit/core`.

## Notifications

```php
Notification::make()->title(__('Saved'))->body(__('Your changes were saved.'))->success()->send();
Notification::make()->title('Export ready')->sendToDatabase($user); // needs ->databaseNotifications() on the panel
Notification::make()->title('Deployed')->success()->broadcast($user); // live toast; needs ->realtime() (or ->realtimeNotifications()) and Laravel Echo
Notification::make()->title('New comment')->sendToDatabase($user, isEventDispatched: true); // also refreshes open bells live
```

Database notification actions: `Action::make('unread')->markAsUnread()` (`AlfaSix\Alfakit\Notifications\Actions\Action`; also `->color()`, `->icon()`, `->size(Size::Small)`, `->button()`/`->link()`). `Notification::make()->color('success')` tints the toast/bell background (`status()` still picks the icon), `->date($iso)` sets the bell date. Show a toast from JS with `showToast({ id, title, status, format: 'alfakit' })` from `@alfakit/react|vue|svelte`. Tests outside page tests: `Notification::fake()` then `Notification::assertNotified('Saved')` / `assertNotNotified()` (without the fake they read the session flash). Custom Laravel notifications return `Notification::make()->title('Paid')->getDatabaseMessage()` from `toDatabase()` and `->getBroadcastMessage()` from `toBroadcast()`. Open the bell panel with a page action `->openDatabaseNotifications()` or `openDatabaseNotifications()` from `@alfakit/core`. Close a toast by id with `closeNotification('greeting')` from `@alfakit/core` (ids: `Notification::make('greeting')` or `$notification->getId()`; from PHP a notification action can `->dispatch('alfakit:close-notification', ['id' => 'greeting'])`). Put the bell in the sidebar with `->databaseNotifications(position: DatabaseNotificationsPosition::Sidebar)` (`AlfaSix\Alfakit\Support\Enums`; also `->databaseNotificationsPosition('sidebar')`); `topbar(false)` moves it there by default. Renderers show `<NotificationsMenu variant="sidebar">` in the sidebar when `panel.databaseNotifications.position === 'sidebar'`.

Toast placement: `->toastPosition(ToastPosition::BottomEnd)` (`AlfaSix\Alfakit\Support\Enums\ToastPosition`; `start`/`end` follow the reading direction).

## Emails

AlfaKit's and its plugins' emails share a branded theme (default panel's brand name, logo, primary colour, font; `dir`/`lang` from the locale the mail is rendered in; dark variant; CSS inlined). Options in `config('alfakit.mail')`: `theme`, `verify_email`, `panel`, `name`, `logo` (PNG; SVG panel logos are skipped), `dark_logo`, `logo_height`, `primary_color`, `font`, `url`. Put your own mails in the theme:

```php
use AlfaSix\Alfakit\Mail\MailMessage; // notifications: instead of Laravel's MailMessage
return (new MailMessage)->subject('Export ready')->line('Download it within 24 hours.')->action('Download', $url);

use AlfaSix\Alfakit\Mail\Concerns\UsesAlfakitMailTheme; // Mailables with new Content(markdown: ...)
```

Preview page: `$panel->pages([\AlfaSix\Alfakit\Pages\MailPreview::class])` (local/debug, the `viewAlfakitMailPreview` gate, or `MailPreview::authorize(fn (User $user): bool => ...)`) renders every registered mail light/dark and LTR/RTL. Register mails (preview with unsaved models) and make them editable by admins (`alfasix/alfakit-mail-templates`):

```php
use AlfaSix\Alfakit\Mail\{EditableTemplates, MailDefinition, Mails};
use AlfaSix\Alfakit\Mail\Concerns\UsesAlfakitMailTheme;
use AlfaSix\Alfakit\Mail\Concerns\UsesEditableTemplate;

Mails::register(MailDefinition::make('orders.shipped')->label(fn () => __('Order shipped'))->group(fn () => __('Orders'))
    ->preview(fn () => new OrderShipped(Order::factory()->make()))
    ->editable(mergeTags: ['name' => 'Customer', 'button' => 'Track button'], subject: 'Order {{ name }}', body: fn () => MailDefinition::paragraphs('Hi {{ name }},', '{{ button }}')));

class OrderShipped extends Mailable { use UsesAlfakitMailTheme, UsesEditableTemplate;
    public function envelope(): Envelope { return new Envelope(subject: $this->templateSubject('Order shipped')); }
    public function content(): Content { return $this->templateContent(new Content(markdown: 'mail.shipped')); }
    public function templateKey(): string { return 'orders.shipped'; }
    public function templateData(): array { return ['name' => $this->order->customer_name, 'button' => EditableTemplates::button($url, 'Track')]; }
}
// Notifications: return $this->templateMailMessage((new MailMessage)->...);
```

Merge tag values are escaped unless `Htmlable` (`EditableTemplates::button()` / `link()`); templates apply per locale only. `MailTheme::render($view, $data)` / `renderText()`, `MailTheme::brandUsing(fn (?Panel $panel) => new MailBrand(...))`, `MailPreview::renderMail(Mails::get($key), 'ar')` in tests. Publish views with `--tag=alfakit-mail`.

## Real-time collaboration

`$panel->realtime()` (off by default; `bool|Closure`, optional `connection:`; also `->broadcastConnection('reverb')`) turns on, over Laravel broadcasting + Echo: presence avatars on View/Edit pages ("Dana is editing"), a non-blocking conflict banner on Edit pages when someone else saves (review the changed fields vs your unsaved values, reload, keep mine), live tables/widgets and instant bell refreshes for stored database notifications. While off nothing is registered. Set up with `php artisan alfakit:realtime`, then `php artisan reverb:start` and a queue worker.

```php
$panel->realtime()->broadcastModelChanges(); // resource models are observed automatically (keys only are broadcast)
$table->live(); // alias liveUpdates(debounce: 500, newRecordsIndicator: true, fallbackPollingInterval: '30s', models: [Post::class])
StatsOverview::make()->live([Order::class]); // widgets; or protected bool|Closure $hasLiveUpdates = true; protected array|Closure|null $liveModels = [...]
class Comment extends Model { use \AlfaSix\Alfakit\Realtime\BroadcastsToAlfakit; } // models without a resource
```

- Channels (authorised by AlfaKit with the panel guard, panel access, tenant membership and policies): `private-alfakit.{panel}.{tenant}.{Model-Class}` (`viewAny`) and `presence-alfakit.{panel}.{tenant}.{Model-Class}.{key}` (`view` or `update` on the record via the resource's tenant-scoped query); `{tenant}` is the tenant key or `_`. API resources' presence channels name the resource class instead of the model (`…{tenant}.App-Alfakit-Resources-SupplierResource.{key}`, always with the tenant on tenancy panels) and find the record through the data source. Echo authenticates at `/{panel}/_alfakit/broadcasting/auth`.
- Changes dispatch `AlfaSix\Alfakit\Realtime\RecordChanged` after commit with `{event, model, key, by, at}` only, never attributes; skip some with `shouldBroadcastToAlfakit(string $event): bool` on the model. Test with `Event::fake([RecordChanged::class])`.
- Optimistic concurrency works without realtime: Edit pages send the loaded record version (`recordVersion`, a keyed hash of the row) and a stale save is refused (error notification + banner, validation key `alfakitConflict`). On by default; `->detectConflicts(false)` on the panel or `protected static ?bool $detectsConflicts = false;` on an `EditRecord` page. Code calling `$page->save($data)` isn't checked.
- Frontend: Echo comes from `window.Echo`, `createAlfakit({ echo: (options) => new Echo({ ...options, broadcaster: 'reverb', Pusher }) })`, or `config('alfakit.broadcasting.echo')` (public options; `laravel-echo`/`pusher-js` are optional peers loaded dynamically). Without Echo, live tables/widgets poll at `fallbackPollingInterval`.

## Installable app (PWA) and Web Push

`$panel->pwa()` (off by default) adds a web app manifest (`/{path}/manifest.webmanifest`, name/short_name from the brand, `theme_color` from `primary`, `lang`/`dir` from the locale, start_url/scope = panel path), a dependency-free service worker (`/{path}/sw.js` with `Service-Worker-Allowed`: precaches the panel's Vite entries, cache-first build assets, network-first pages that are never stored, offline page `/{path}/offline`, never caches `/_alfakit/*`), head tags (manifest, light/dark `theme-color`, `apple-touch-icon`), an "Install app" item in the user menu and command palette (iOS Safari: "Add to Home Screen" hint) and a "A new version is available — Reload" toast after deploys.

```php
$panel->pwa(
    icons: ['192x192' => '/icons/icon-192.png', '512x512' => '/icons/icon-512.png', ['src' => '/icons/maskable.png', 'sizes' => '512x512', 'purpose' => 'maskable']],
    push: true,                    // Web Push for database notifications
    name: 'Acme', shortName: 'Acme', description: null,
    themeColor: '#0f766e', darkThemeColor: '#042f2e', backgroundColor: '#ffffff',
    manifest: ['categories' => ['business']], // extra members, merged last
);
```

- Without `icons:` an SVG favicon/logo is listed as-is and 192/512/maskable PNGs are generated (GD) from a raster favicon/logo in `public/` or the brand initial.
- Starter kit panels: add `@alfakitPwa` to the `<head>` of `resources/views/app.blade.php` for the PWA head tags.
- Push setup: `php artisan alfakit:pwa-keys` (writes `ALFAKIT_PWA_VAPID_PUBLIC_KEY`/`_PRIVATE_KEY` to `.env`; `--show`, `--force`), set `ALFAKIT_PWA_VAPID_SUBJECT`, `php artisan vendor:publish --tag=alfakit-pwa-migrations && php artisan migrate` (table `alfakit_push_subscriptions`). No composer package needed (OpenSSL encryption + VAPID). Every `Notification::make()->sendToDatabase($user)` is also pushed to the user's subscribed browsers (title, plain-text body, first action URL); push anything else with `AlfaSix\Alfakit\Pwa\PushNotifications::send($user, ['title' => '…', 'body' => '…', 'url' => '/admin/…'])`.
- Custom root views: add `{{ $alfakitPanel->renderPwaHeadTags() }}` to `<head>`. Frontend: `pwaStore`, `promptPwaInstall()`, `applyPwaUpdate()`, `subscribeToPush()`, `unsubscribeFromPush()`, `togglePushNotifications()` from `@alfakit/core`; menu items carry `data-slot="pwa-install"` / `"pwa-push"` (in a starter kit the push toggle is in `<AlfakitNotifications />`'s menu); the offline page is the `alfakit::pwa.offline` view.

## Testing

Use the page test helpers (`AlfaSix\Alfakit\Testing\PageTester`, via `alfakit()->testPage()`). They load the page and run forms, actions and tables through the real internal endpoints, so validation, policies and tenancy apply. Authenticate first with `actingAs()`.

```php
alfakit()->testPage(EditPost::class, ['record' => $post])
    ->fillForm(['title' => 'Hello'])        // runs afterStateUpdated via the refresh endpoint
    ->assertFormSet(['slug' => 'hello'])
    ->call('save')                          // `create` on create pages
    ->assertHasNoFormErrors()               // also fails on 403/404
    ->assertNotified('Saved');

alfakit()->testPage(CreatePost::class)->fillForm(['title' => ''])->call('create')
    ->assertHasFormErrors(['title' => 'required']);

alfakit()->testPage(ListPosts::class)
    ->searchTable('Hello')->sortTable('title', 'desc')->filterTable('is_published', true)
    ->assertCanSeeTableRecords([$post])->assertCanNotSeeTableRecords([$draft])
    ->callTableAction(DeleteAction::class, $post)
    ->callTableBulkAction('delete', [$other])          // null records = "select all"
    ->assertTableActionHidden('delete', $locked);

alfakit()->testPage(SettingsPage::class)
    ->assertActionVisible('publish')->assertActionHidden('purge')
    ->callAction('rename', ['name' => 'New'])           // opens the modal, fills, submits
    ->mountAction('rename')->fillForm(['name' => ''])->callMountedAction()->assertHasActionErrors(['name'])
    ->callAction('stop')->assertActionHalted('stop')
    ->assertActionHasIcon('publish', 'send')->assertActionHasColor('publish', 'success')
    ->assertActionListInOrder(['publish', 'archive'])
    ->mountAction('edit')->mountAction(['edit', 'preview'])   // `edit` stays mounted underneath
    ->assertMountedActionModalSee('Preview')->assertMountedActionModalSeeHtml('<strong>');

alfakit()->testPage(ListPosts::class)
    ->assertTableColumnStateSet('price', 1250, $post)               // raw state
    ->assertTableColumnFormattedStateSet('price', '$12.50', $post)  // what the cell shows
    ->assertTableColumnHasDescription('title', 'By Ada', $post)
    ->assertTableFilterHidden('secret')->removeTableFilters()->toggleAllTableColumns()
    ->assertTableColumnSummarySet('price', 'sum', 4200);

alfakit()->testPage(EditPost::class, ['record' => $post])
    ->relationManager(CommentsRelationManager::class)   // table helpers now drive the manager
    ->assertCanSeeTableRecords($post->comments)->callTableAction('approve', $comment)
    ->relationManager(null)
    ->assertSchemaComponentExists('shipping')            // sections, tabs, fields by key
    ->assertSchemaStateSet(['status' => 'Draft'], 'infolist');

alfakit()->testPage(CreateOrder::class)                 // HasWizard page or a Wizard in the form
    ->fillForm(['customer' => 'Ada'])->goToNextWizardStep()->assertWizardCurrentStep(2)
    ->goToWizardStep(3)->goToPreviousWizardStep();

ProductImporter::test()->import(['name' => 'Mug', 'sku' => 'M-1'])->assertImported();
ProductImporter::test()->import(['name' => ''])->assertHasErrors(['name' => ['required']]);
expect(ProductExporter::test(['name' => 'Name'])->export($product))->toBe(['Mug']);
$imports = ImportAction::fake(); /* run the action */ $imports->assertDispatched(ProductImporter::class);
```

Other panels: `alfakit()->testPage(ListProjects::class, ['tenant' => $team], panel: 'app')`. Hidden fields and actions aren't sent to the browser, so "hidden" assertions also pass for names that don't exist. Raw Inertia requests still work:

```php
$this->actingAs($admin)
    ->get('/admin/posts', \AlfaSix\Alfakit\Testing\PageTester::inertiaHeaders()) // X-Inertia + the app's asset version (else 409)
    ->assertOk()
    ->assertJsonPath('props.page.content.0.type', 'Table');
```

## Debugging

- `php artisan alfakit:doctor` checks the installation (versions, renderer package and version match, Vite manifest, SSR, panels/resources/policies, translation overrides, storage:link, queue, broadcasting for `->realtime()`, APP_KEY, debug in production, migrations of opted-in features, caches) and prints fixes; `--json` for CI, `--strict` fails on warnings. Run it first when something does not render or a feature silently does nothing. Packages add checks with `Doctor::extend(fn () => [DoctorCheck::ok('Group', 'Label', 'message')])`.
- `$panel->devTools()` (default: on in `local`, never unless `app.debug` is on) adds a floating schema inspector: the page props as a searchable JSON tree with copy, what the context token identifies (panel, page, route parameters, expiry; never secrets), the `Server-Timing` server time, payload size and editor links for the page/resource/model classes (`APP_EDITOR`). Replace it with the `panel:dev-tools` registry key.
- `$panel->debugPanel()` (default: on in `local`, and never unless `app.debug` is on) adds a bug icon to the top bar. The drawer shows the page's resolved schema tree with visibility and authorisation per component and the reason a node is hidden, the live form state, every internal request with server time (`Server-Timing`) and sizes, query count, memory and the closures that ran (owner, file, line, count). Nothing is sent in production.
- In tests, `alfakit()->testPage(EditPost::class, ['record' => $post])->dump()` prints the same tree (`->dd()` stops; `->getDebugTree()` returns the array) without enabling the panel.

## Pitfalls

- Never import `Filament\...` classes; the API is similar, but the namespaces are `AlfaSix\Alfakit\...`.
- Classes and pages live in the panel's discovery directories; run `php artisan alfakit:optimize-clear` if a new one doesn't show up.
- Don't build JavaScript for things PHP can express. Custom frontend code is only for new visual components. See the `alfakit-frontend-components` skill.
- Notification bodies, modal descriptions, helper text and hints escape plain strings. Wrap trusted markup in `new HtmlString(...)`.
- `->html()` / `->markdown()` on `TextColumn`/`TextEntry`, string content of `Html::make()` and `RichContentRenderer` are sanitised on the server (Symfony HtmlSanitizer); an `Htmlable` from code is trusted. Opt out only for trusted content: `->html(sanitize: false)`, `Html::make($s)->sanitize(false)`, `RichContentRenderer::make($c)->sanitize(false)`. Widen the allowlist per panel with `->htmlSanitizer(fn (HtmlSanitizerConfig $config) => $config->allowElement('iframe', ['src']))`; sanitise your own output with `AlfaSix\Alfakit\Support\Html::sanitize($html)`.
- `url()` on columns, entries, actions, navigation/menu items, notification actions and stats keeps only http(s), mailto, tel and relative URLs (`javascript:` etc. become no link). Use `AlfaSix\Alfakit\Support\Url::sanitize($url)` for URLs you output yourself.
- `ColorColumn` / `ColorEntry` only send plain CSS colours (hex, `rgb()`/`hsl()`/`oklch()`… with numbers, keywords); sanitise colours you inline yourself with `Str::sanitizeCssColor($c)` (null when unsafe). Fluent versions: `str($html)->sanitizeHtml()`, `str($url)->sanitizeUrl()`, `str($c)->sanitizeCssColor()`.
- Relationship options, `unique()` and `exists()` are scoped to the current tenant automatically; use `AlfaSix\Alfakit\Support\TenantScope::apply($query)` for your own queries in tenant panels.
- In tenant panels, implement `AlfaSix\Alfakit\Contracts\HasDefaultTenant` on the user to choose which tenant the panel root and login redirect to (it must be a tenant the user can access; otherwise the first tenant is used).
- Relation managers are read-only on View pages; override `isReadOnly()` to change that.
- `->tenantRegistration(RegisterTeam::class)` and `->tenantProfile(EditTeamProfile::class)` need your own concrete subclass of `RegisterTenant` / `EditTenantProfile`; the base classes are abstract and are rejected with an `InvalidArgumentException`. Their `getLabel()` labels the tenant menu's profile / registration links.

AlfaKit development

When to use this skill

Use it when adding or changing panels, resources, forms, tables, actions, relation managers, infolists, widgets or custom pages in an app that uses alfasix/alfakit. Everything is defined in PHP; the React/Vue/Svelte renderer only displays the schema.

Workflow

  1. New app: php artisan alfakit:setup is the guided install (--mode=panel|starter-kit|standalone, --users, --roles (Shield), --ai=openai,anthropic, --realtime, --plugins=activity-log,comments (or all), --bundle=saas|content|backoffice|support|ops (comma-separated or all: a curated set of plugins registered with default navigation groups), --brand-color="#2563eb", --logo=images/logo.svg, --font=Inter, --demo, --migrate, --admin-email= / --admin-name= / --admin-password= to create the user you sign in with (panel mode; interactively it offers make:alfakit-user), --no-install to never run composer/npm, --no-interaction); re-running skips finished steps; it defaults to a minimal install (every optional step no, no plugins). Add first-party plugins later with php artisan alfakit:add comments activity-log [--panel=admin] [--migrate] [--no-install] (composer require after a confirmation, ->plugin(...) on the panel provider, migrations), list them with alfakit:add --list (alfakit:add --list kanban prints a plugin's highlights, its live-demo page from config('alfakit.demo_url') / ALFAKIT_DEMO_URL with a short tour, and the plugins it works with), and remove them with alfakit:remove comments (unregisters, composer remove; tables are kept). Its parts run on their own: php artisan alfakit:users [--panel=] [--model=] [--roles] [--no-migrations] [--no-provider] [--force] generates App\Alfakit\Resources\UserResource (invite with a signed link → Users\Pages\AcceptInvitation, ImpersonateAction, suspend/ban, require a new password → Users\Pages\ChangePassword, reset 2FA, last sign-in via App\Listeners\RecordUserSignIn, sessions with revoke on the database session driver) plus App\Alfakit\Users\UserAccounts helpers (status(), suspend(), ban(), requirePasswordChange(), resetTwoFactor(), sessions(), revokeSessions(), invite(User::class, $data, $panel)), the EnsureAccountIsActive/RequirePasswordChange auth middleware (for a ->starterKit() panel EnsureAccountIsActive is also appended to the web group in bootstrap/app.php, so blocked users are signed out of the app's own pages and its login page shows the status message) and a columns migration; php artisan alfakit:ai-setup [--providers=openai,anthropic] [--default=] [--model=] [--check] [--no-test] writes laravel/ai keys to .env (hidden prompt; without interaction from the process environment), publishes config/ai.php, sets the default provider/model and live-tests each key (--check only reports and tests, never installs, and exits 1 on a failing key or a missing laravel/ai); php artisan alfakit:realtime [--no-install] installs Reverb (install:broadcasting --reverb, reverb:install), writes REVERB_*/VITE_REVERB_*, creates resources/js/alfakit/echo.ts (a factory that sets up window.Echo when called, browser only, importing laravel-echo and pusher-js inside it; the panel entry passes it as createAlfakit({ echo: () => import('./echo').then((module) => module.default()) }), a starter kit or standalone entry as configureRealtime(null, { echo: () => import('./alfakit/echo').then((module) => module.default()) }) from the renderer package, so Echo only connects on pages with ->realtime()) and enables ->realtime() (real-time collaboration, see below).
  2. Generate, don't hand-write: php artisan make:alfakit-resource Post --generate (--view adds a view page, --simple gives modal CRUD on one page, --soft-deletes adds trash support, --model --migration --factory create them, --model-namespace=App\Domain\Blog\Models). --generate reads the database columns (literal column defaults become ->default(...) on the generated fields: default(true) toggles, 'draft' strings, numbers). To choose columns, filters, infolist entries and relation managers: php artisan alfakit:generate Post (or make:alfakit-resource --wizard); in scripts pass every choice: alfakit:generate Post --form=title,body,author_id --table=title,author_id --filters=author_id,is_published --infolist=title,body --relation-managers=comments --no-soft-deletes --no-interaction (none selects nothing; omitted lists use the type-based defaults). Also make:alfakit-relation-manager PostResource comments body --soft-deletes --view, make:alfakit-widget PostStats --resource=Post, make:alfakit-page ViewPostStats --resource=Post --type=ViewRecord|EditRecord|ManageRelatedRecords, make:alfakit-form|table Name --model=Post --generate, make:alfakit-rich-content-custom-block Hero, make:alfakit-theme acme --pm=pnpm, make:alfakit-issue. Frontend files the generators write (and alfakit:eject copies, alfakit:realtime's echo.ts) are formatted with the app's own vp fmt / Oxfmt / Prettier when configured (alfakit.file_generation.format_frontend). Customise generated files by binding a subclass of AlfaSix\Alfakit\Commands\Generators\ClassGenerator (override modify(string $stub, string $contents, array $replacements)).
  3. Edit the generated form(), table() and infolist().
  4. Register relation managers in getRelations(), widgets on the panel or a page.
  5. Check the result in the browser and with a Pest HTTP test.
  6. Coming from FilamentPHP: run php artisan alfakit:migrate-from-filament --dry-run first (writes alfakit-migration-report.md without changing files), then without --dry-run (--adapter=react|vue|svelte, which on a Blade/Livewire app without Inertia also sets up the Inertia toolchain; --keep-filament to leave the composer packages). It rewrites PHP (Filament\Forms\Form → Schema, toolbarActions()/BulkActionGroup → headerActions()/bulkActions(), Heroicon → Lucide names, Color::Amber → hex, $this->form->getState() on pages → $this->getForm()->validate(), $view pages → reported), moves app/Filament → app/Alfakit and registers App\Providers\Alfakit\*PanelProvider. Every manual step is marked // TODO(alfakit-migration): … in the code (search for it); the report's summary gives the share converted automatically and the php artisan alfakit:add … command for the first-party plugins that replace Filament plugins (shield, media-library, tags, settings, comments, kanban, calendar, activity-log, tree, …). Livewire components get scaffolds to port: app/Alfakit/Components/<Name>.php (<Name>::make() in any schema) and resources/js/alfakit/components/<Name>.(tsx|vue|svelte) (auto-registered). Then: Blade views become frontend components, themes make:alfakit-theme, render hooks slots, Livewire tests alfakit()->testPage().

Forms

use AlfaSix\Alfakit\Forms\Components\{Builder, CodeEditor, DatePicker, FileUpload, ModalTableSelect, MoneyInput, Repeater, RichEditor, Select, Slider, TextInput, Textarea, Toggle, ToggleButtons};
use AlfaSix\Alfakit\Schemas\Components\{Grid, Section, Tabs, Wizard};
use AlfaSix\Alfakit\Schemas\Components\Utilities\{Get, Set};

public static function form(Schema $schema): Schema
{
    return $schema->components([
        Section::make('Details')->schema([
            TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)
                ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug((string) $state))),
            TextInput::make('slug')->required()->alphaDash()->unique(),
            Select::make('category_id')->relationship('category', 'name')->searchable()->preload(),
            Select::make('tags')->relationship('tags', 'name')->multiple(),
            RichEditor::make('body')->columnSpanFull(),
        ])->columns(2),
        Section::make('Pricing')->schema([
            Select::make('currency')->currencies()->default('USD')->live(),
            MoneyInput::make('price')->currency(fn (Get $get) => $get('currency'))->minorUnits(),
        ]),
        Repeater::make('items')->relationship()->schema([
            TextInput::make('name')->required(),
        ]),
    ]);
}
  • Validation lives on the field (required(), email(), numeric(), unique(), rule()); it runs on the server.
  • $get('x') / $set('x', $v) resolve from the component's container, as in Filament: inside a repeater item or builder block that is the item (items.0, content.0.data), for fields and layout components (Text, Section…) alike; each leading ../ goes up one level ($get('../../currency') reaches the root from an item); isAbsolute: true reads from the root. A dotted field (make('settings.url')) reads a sibling by its full name ($get('settings.mode')). Closure child schemas (Section::make()->schema(fn (Get $get) => [...])) are rebuilt whenever the state changes, so live updates see the browser's state.
  • unique() ignores the bound record on edit forms (only when it checks the record's own table); unique(ignoreRecord: false) checks every row. unique()/exists() are tenant-scoped on tenant-owned tables. Laravel shortcuts: alpha(), alphaDash(), alphaNum(), ascii(), string(), filled(), activeUrl(), hexColor(), ipv4(), ipv6(), macAddress(), enum(Status::class), multipleOf(5), notRegex(), gt/gte/lt/lte('other_field' or 10), prohibitedIf(), prohibitedUnless(), prohibits(), requiredIfAccepted(), requiredWithAll(), requiredWithoutAll(); field references resolve like $get() (siblings inside repeater items). scopedUnique() / scopedExists(Post::class, 'id') query through the Eloquent model (global scopes such as soft deletes apply, tenant-scoped) and take modifyQueryUsing: fn (Builder $query) => $query->withoutGlobalScope(SoftDeletingScope::class); scopedUnique() has ignorable: / ignoreRecord: like unique().
  • distinct() on a field inside a Repeater or Builder block fails duplicates across items, with the error on each duplicate item path (items.2.sku).
  • Checkbox/Toggle: accepted(), declined(). CheckboxList::columns(3) (alias gridColumns()) lays out the options, ->gridDirection('column') fills column by column; getOptionDescriptionFromRecordUsing(fn (Tag $record) => $record->summary) for relationship options; selectAllAction(fn (Action $action) => $action->label('…')->icon('…')) / deselectAllAction(...) customise the bulk toggle links (label, icon, hidden()). ToggleButtons::columns(3)->gridDirection('column') lays buttons out in a grid.
  • Select: noOptionsMessage(), searchingMessage(), wrapOptionLabels(false); allowHtml() on Select/CheckboxList renders option labels as HTML, sanitised on the server. prefixIconColor('danger') / suffixIconColor() on TextInput, Select and the date pickers. Date pickers: hoursStep(), minutesStep(15), secondsStep(), weekStartsOnMonday() / weekStartsOnSunday() (default: the panel locale's first day), defaultFocusedDate(now()->addMonth()). Textarea::disableGrammarly().
  • Content slots on fields and entries: aboveLabel(), beforeLabel(), afterLabel() (end-aligned next to the hint), belowLabel(), aboveContent(), beforeContent(), afterContent(), belowContent(), and on fields aboveErrorMessage() / belowErrorMessage() (shown with an error). Each takes a string, HtmlString, schema component (Text, Icon…), Action/ActionGroup, an array of these or a closure, plus an optional Alignment::Start|End|Between: TextInput::make('slug')->belowContent(['Used in the URL.', Action::make('generate')->action(fn (Get $get, Set $set) => $set('slug', Str::slug($get('title'))))]). Slot actions run like field actions. extraFieldWrapperAttributes([...]) / extraEntryWrapperAttributes([...]) set wrapper attributes (merged with extraAttributes()).
  • FusedGroup::make([Select::make('code')->placeholder('Code'), TextInput::make('phone')->placeholder('Number')->columnSpan(2)])->label('Phone')->columns(3) (AlfaSix\Alfakit\Schemas\Components\FusedGroup) fuses fields into one bordered control; child labels become screen-reader text, child helper texts are hidden, errors are listed below.
  • saved(false): validated and kept in the form state, but left out of getState() and its relationship isn't saved (password confirmations); disabled()->saved() saves a disabled field's server value. validatedWhenNotDehydrated(false) skips the rules of non-dehydrated fields. allowHtmlValidationMessages() renders the field's messages as HTML (don't use :input in them).
  • Private files: FileUpload::make('doc')->disk('s3')->visibility('private') serves previews/links via temporaryUrl() (config('alfakit.temporary_file_url_expiry_minutes'), 30, rounded up to the end of the hour; ->temporaryUrlExpiry(60) per FileUpload/ImageColumn/ImageEntry/RichEditor; the disk must support it, e.g. S3 or a local disk with serve); RichEditor::make('body')->fileAttachmentsVisibility('private') saves plain URLs and signs them when the form is filled; sign for display with RichContentRenderer::make($content)->fileAttachments('s3', 'posts', 'private') (or $post->renderRichContent('content') with the model config). Visibility defaults to the disk's visibility config. FileUpload::make('attachments')->multiple()->storeFileNamesIn('attachment_file_names') saves original names to that sibling attribute ([path => name], cast it to array; a single field stores the name) and shows them.
  • relationship() on Select, Repeater and CheckboxList saves the relation for you.
  • disableOptionWhen(['archived']), a closure returning values, or a per-option closure fn (string $value): bool => $value === 'archived' (also $label) on Select, Radio, CheckboxList and ToggleButtons: disabled options render disabled and are refused on submit (a value the record already holds stays valid).
  • Polymorphic relations: MorphToSelect::make('commentable')->types([MorphToSelect\Type::make(Post::class)->titleAttribute('title'), MorphToSelect\Type::make(Product::class)->getOptionLabelFromRecordUsing(fn (Product $record) => $record->name)->modifyOptionsQueryUsing(fn (Builder $query) => $query->where('active', true))])->searchable()->preload()->required() (AlfaSix\Alfakit\Forms\Components\MorphToSelect). It is a fieldset with a type Select (typeSelectToggleButtons() for buttons) and a key Select, filling and saving the record's commentable_type / commentable_id (make them fillable); the type is the morph class (morph map alias). The type must be one of types(), the key must exist in that type's tenant-scoped query. modifyTypeSelectUsing(), modifyKeySelectUsing(), Type::label(), Type::searchColumns(), contained(false), native(), optionsLimit().
  • Repeater relationships: Repeater::make('products')->relationship() also takes a BelongsToMany (items are pivot rows: the item field named after the related pivot key, e.g. Select::make('product_id'), picks the record and is required + distinct; other item fields are pivot columns, attached/updated/detached), or use a HasMany to a pivot model (orderProducts). relationship(modifyQueryUsing: fn (Builder $query) => ..., modifyRecordsUsing: fn (Collection $records) => ...) limits the managed records (others are never touched); afterCreate(fn (array $data, Model $record)), afterUpdate(...), afterDelete(fn (Model $record)); mutateRelationshipDataBeforeCreateUsing() / ...BeforeSaveUsing() returning null skip the item.
  • Inside a relationship() Repeater, item fields are scoped to the related model: unique()/exists() check its table, $record is the item's record, and rule references like requiredIf('type', ...) or same('other') mean sibling fields of the same item. addable(false), deletable(false) and reorderable(false) are enforced on the server.
  • Block content: Builder::make('content')->blocks([Block::make('heading')->icon('heading')->schema([TextInput::make('text')->required()]), Block::make('image')->maxItems(1)->schema([...])])->blockPickerColumns(2)->collapsible()->cloneable() (AlfaSix\Alfakit\Forms\Components\Builder\Block). The state is a list of ['type' => 'heading', 'data' => [...]]; store it in a JSON column with an array cast. Block types, block maxItems() and every block field are validated on the server; Block::label(fn (?array $state) => ...) labels items after their data. Block::summary(fn (array $state): ?string => $state['text'] ?? null) adds a plain-text line after the label (HTML stripped, 160 characters; also gets $index), and Builder::blockActionsMenu() moves the item actions (move, duplicate, extra actions, delete) into one More menu per item header. The block picker flips above the add button when there is no room below and stays clear of the sticky top bar. Picker headings: ->blocks(['Content' => [Block::make('heading'), ...], 'Media' => [...]]) or Block::make('x')->group('Media') (ungrouped blocks first). No relationship()/simple() on Builder.
  • ToggleButtons::make('status')->options([...])->colors([...])->icons([...])->inline() (or ->grouped(), ->multiple(), ->boolean(), ->columns(3)); Slider::make('price')->range(minValue: 0, maxValue: 1000)->step(10)->default([200, 800])->tooltips()->pips() (an array default makes a range; minDifference()/maxDifference(); rangePadding([10, 20]), rtl(), nonLinearPoints(['20%' => 50, '50%' => [75, 5]]), pips(PipsMode::Count)->pipsValues(5) with AlfaSix\Alfakit\Forms\Components\Slider\Enums\PipsMode (Steps, Positions, Count, Values), steppedPips(), pipsFilter(fn (int|float $value): int => …) run in PHP (1 large label, 2 small, 0 tick, -1 hidden), behavior([Behavior::Tap, Behavior::Drag, Behavior::Fixed]) from …\Slider\Enums\Behavior, behavior(null) for none); CodeEditor::make('snippet')->language(Language::Php) (AlfaSix\Alfakit\Forms\Components\CodeEditor\Enums\Language; wrap(), lineNumbers(false), maxHeight()).
  • ModalTableSelect::make('author_id')->relationship('author', 'name')->tableConfiguration(UserResource::class) picks records from a table in a modal (search, filters, sort, pagination). tableConfiguration() takes a resource class, a class with static configure(Table $table): Table, or a closure receiving $table; add ->multiple()->maxItems(5) for BelongsToMany. Without a relationship, the configuration sets query() and recordTitleAttribute() names the label. The list is tenant scoped and submitted keys are validated against it. ->selectAction(fn (Action $action) => $action->label('Pick')->icon('search')->modalHeading('Search')->modalDescription('…')->modalSubmitActionLabel('Use')->modalWidth(Width::FiveExtraLarge)) customises the button and modal; ->tableArguments(fn (Get $get): array => ['category_id' => $get('category_id')]) passes form values to a closure configuration as $arguments (fn (Table $table, array $arguments) => …; classes read $table->getContext()->getTableArguments()), display only.
  • ->live(debounce: 500) takes milliseconds or a duration string ('750ms', '1s'). Without it, typed fields debounce 150 ms and choice fields (select, toggle, checkbox, radio, date pickers, uploads) refresh at once; custom fields override getDefaultLiveDebounce(): int. $set('region', null, shouldCallUpdatedHooks: true) also runs the target's afterStateUpdated().
  • afterStateUpdated(fn ($state, $old) => ...) receives the previous value as $old. Live refreshes answer with only the changed components and state keys; nothing to configure. To limit further: ->partiallyRenderComponentsAfterStateUpdated(['total']) (sibling names, keys or state paths), ->partiallyRenderAfterStateUpdated() (the field only), ->skipRenderAfterStateUpdated() (no components; $set() state still arrives; without afterStateUpdated() no request at all).
  • Field actions: TextInput::make('slug')->suffixAction(Action::make('generate')->icon('sparkles')->iconButton()->action(fn (Get $get, Set $set) => $set('slug', Str::slug($get('title'))))), also prefixAction(s)() and hintAction(s)() on fields and infolist entries. In their closures $component is the field, $state its state, $get/$set act on the field's form (the change reaches the browser), $data is the action's own modal data. Actions of hidden or disabled fields can't run.
  • Select option modals: Select::make('author_id')->relationship('author', 'name')->createOptionForm([TextInput::make('name')->required()])->editOptionForm([TextInput::make('name')->required()]) creates (policy create) or updates (policy update) the related record and selects it; createOptionUsing(fn (array $data) => $key) / updateOptionUsing(fn (array $data, Model $record) => ...) for custom saving (required without a relationship), createOptionAction(fn (Action $action) => $action->modalWidth('lg')), createOptionModalHeading().
  • One related record in a layout component: Section::make('SEO')->relationship('seo')->schema([TextInput::make('meta_title')]) (also Group, Fieldset; HasOne, MorphOne, BelongsTo, MorphTo with relationship('attachable', relatedModel: Image::class) to create one). State nests under the name (seo.meta_title), the data stays out of the owner's attributes and is saved after the owner; relationship('address', condition: fn (array $state) => filled($state['street'] ?? null)) skips (and deletes or dissociates) when false; mutateRelationshipDataBeforeFillUsing()/...BeforeCreateUsing()/...BeforeSaveUsing(). Not inside repeater items.
  • Long Repeaters and Builders render only the visible items above 50 (virtualize(false) turns it off, virtualizeThreshold(200) moves the threshold, virtualizeEstimateSize(96) tunes the pre-measurement row height in px); all three renderers keep keyboard navigation, drag reorder, collapse state and scroll the first errored item into view on validation. A multi-column grid() repeater is windowed by whole rows (the renderer counts the rendered grid's columns).
  • Table repeaters: Repeater::make('lines')->table([TableColumn::make('Product')->width('50%'), TableColumn::make('Qty')->alignment(Alignment::End)])->schema([...]) (AlfaSix\Alfakit\Forms\Components\Repeater\TableColumn; columns follow the schema order; hiddenHeaderLabel(), markAsRequired(), wrapHeader()). RepeatableEntry::make('lines')->table([...]) uses AlfaSix\Alfakit\Infolists\Components\RepeatableEntry\TableColumn.
  • Repeater / Builder item actions are Actions: ->deleteAction(fn (Action $action) => $action->requiresConfirmation()), also addAction(), cloneAction(), reorderAction(), moveUpAction(), moveDownAction(), collapseAction(), expandAction(), collapseAllAction(), expandAllAction(); ->extraItemActions([Action::make('email')->action(fn (array $arguments, Repeater $component) => $component->getItemState($arguments['item']))]). $arguments['item'] is the item's position ('0'); reorder gets $arguments['items'], the Builder's add $arguments['block']. Restyled-only actions run in the browser; ones with a modal/handler/hooks run on the server. Layout: reorderableWithButtons(false), reorderableWithDragAndDrop(false), addActionAlignment(Alignment::End), itemNumbers(). KeyValue has addAction(), deleteAction(), reorderAction().
  • Select::make('product_id')->disableOptionsWhenSelectedInSiblingRepeaterItems() inside a repeater item disables options other items picked (adds distinct() + live()).
  • Builder previews: ->blockPreviews(areInteractive: false) with Block::make('hero')->preview('blocks.hero') (Blade view, item data as variables; or a closure returning HTML) or ->previewComponent('hero') (renderer registry key builder-preview:hero, props data, block, index). blockPickerWidth(Width::Large). Tests: Repeater::fake() / Builder::fake() return an undo closure (keys are positional anyway).
  • RichEditor: ->json() stores a Tiptap document (cast to array); ->customBlocks([HeroBlock::class]) with class HeroBlock extends RichContentCustomBlock (configureEditorAction(Action $action): Action adds ->schema([...]), toPreviewHtml(array $config), toHtml(array $config, array $data); escape config values); ->mentions([MentionProvider::make('@')->getSearchResultsUsing(fn (string $search): array => User::where('name', 'like', "%{$search}%")->limit(10)->pluck('name', 'id')->all())]) or ->items([...]); ->mergeTags(['name', 'today' => 'Today']). Display with RichContentRenderer::make($content)->mergeTags(['name' => $user->name])->customBlocks([HeroBlock::class])->toHtml() (namespace AlfaSix\Alfakit\Forms\Components\RichEditor\); stored HTML is sanitised first. In an infolist return the renderer itself: TextEntry::make('content')->html()->state(fn (Post $record) => RichContentRenderer::make($record->content)).
  • RichEditor toolbar extras (opt in via ->toolbarButtons([...]); the default toolbar is unchanged): subscript, superscript, code, highlight, textColor (palette from ->textColors(['#dc2626' => 'Red', ...RichEditor::getDefaultTextColors()]), ->customTextColors() adds a free picker), alignStart/alignCenter/alignEnd/alignJustify (logical, stored as style="text-align: …"), table (plus tableAddColumnBefore|After, tableDeleteColumn, tableAddRowBefore|After, tableDeleteRow, tableMergeCells, tableSplitCell, tableToggleHeaderRow|Cell, tableDelete, shown in a row while the cursor is in a table), details, h1–h6, paragraph, clearFormatting; ->linkProtocols(['https', 'mailto']) limits link schemes (editor, save and RichContentRenderer::linkProtocols()). Images pointing at another file on the attachments disk are dropped on save unless the stored content referenced it (->preventFileAttachmentTampering(bool, allowFilePathUsing: fn (string $file): bool => ...), on by default). Custom storage: ->fileAttachmentProvider(new MyProvider) implementing RichEditor\FileAttachmentProviders\Contracts\FileAttachmentProvider (saveUploadedFileAttachment(TemporaryUpload $file): ?string returns the URL to store, getFileAttachmentUrl(), getDefaultFileAttachmentVisibility(), isExistingRecordRequiredToSaveNewFileAttachments(), cleanUpFileAttachments(array $exceptIds)), also on registerRichContent() or from a plugin implementing Plugins\Contracts\HasFileAttachmentProvider; default DiskFileAttachmentProvider. ->fileAttachmentsAcceptedFileTypes(['image/png', 'image/jpeg']) and ->fileAttachmentsMaxSize(2048) (KB) are checked in the browser and again on save (validation error); SVG is never accepted. MarkdownEditor's table button stores GitHub tables. The sanitiser keeps only color, background-color and text-align in style.
  • RichEditor layout: ToolbarButtonGroup::make('Heading', ['h1', 'h2', 'h3'])->icon('heading')->textualButtons() inside toolbarButtons([...]) is a dropdown; ->floatingToolbars(['paragraph' => ['bold', 'link'], 'heading' => [...], 'table' => [...]]) shows buttons above the selection (text nodes while text is selected; table/image/customBlock… while inside or selected; a table one replaces the table row); ->stickyToolbar(), ->stickyPanels(), ->stickyOffset('4rem'); ->resizableImages() stores <img width="320">. Grids get a default floating toolbar (gridAddColumnBefore, gridAddColumnAfter, gridDeleteColumn, gridDelete). Custom block previews skip prose styles unless the block's shouldApplyProseStylingToPreview(array $config): bool returns true. Mentions render the provider's getLabelsUsing() label (else the stored one). The customBlocks/mergeTags buttons open side panels: ->customBlocks(['Marketing' => [HeroBlock::class], DividerBlock::class]) groups blocks, plus ->searchableCustomBlocks(), ->customBlocksGrid(), ->minimalCustomBlockControls(), ->activePanel('customBlocks'|'mergeTags'); ->mergeTags(['name'])->mergeTagLabels(['name' => 'Full name']).
  • Rich content models: class Post extends Model implements HasRichContent { use InteractsWithRichContent; public function setUpRichContent(): void { $this->registerRichContent('content')->fileAttachmentsDisk('s3')->fileAttachmentsVisibility('private')->customBlocks([...])->mergeTags(['name' => fn () => auth()->user()?->name])->mergeTagLabels([...])->plugins([...]); } } (RichEditor\Models\Contracts\HasRichContent, RichEditor\Models\Concerns\InteractsWithRichContent). RichEditor::make('content') uses it for unset settings; $post->renderRichContent('content') renders (sanitised, blocks, merge tag values, private images signed).
  • RichEditor plugins: ->plugins([MyPlugin::make()]) with class MyPlugin extends RichEditor\Plugins\RichContentPlugin overriding getTipTapJsExtensions() (registry keys; register the JS with createAlfakit({ richEditorExtensions: { key: () => import('./ext') } })), getEditorTools() (RichEditorTool::make('name')->label()->icon()->command('toggleMark', 'x') or ->action() + ->activeWhen('x')), getEditorActions() (actions named like the tool; return $component->runCommands([EditorCommand::make('setMark', 'x', $attrs)], editorSelection: $arguments['editorSelection'] ?? null)), getEnabledToolbarButtons()/getDisabledToolbarButtons(), configureHtmlSanitizer(HtmlSanitizerConfig $config) (allow the plugin's tags only) and renderJson(array $node, string $content): ?string. No jsHandler(). RichContentRenderer::make($c)->plugins([...]).
  • Fake content: RichContentFaker::make()->heading()->paragraphs(3, links: true)->bulletList()->image()->toHtml() (toArray()/toJson() for json(), toMarkdown(), toRenderedHtml(), toText(); mergeTag(), mention(), customBlock(HeroBlock::class, $config), table(), details()), MarkdownEditor\MarkdownFaker::make()->heading()->paragraphs(2)->codeBlock('php')->toMarkdown(); on Faker as fake()->alfakitRichContent($attribute = null) / fake()->alfakitMarkdown() (aliases filamentRichContent() / filamentMarkdown()). customBlock() without arguments picks an attribute block implementing RichEditor\Contracts\CanGenerateFakeConfiguration (generateFakeConfiguration(Generator $faker): array); toValue() follows the attribute's json(). Needs fakerphp/faker.
  • FileUpload images: ->imageEditor() (crop/zoom/rotate/flip dialog with X/Y/width/height/rotation inputs before upload, plus an "Edit image" button on uploaded images that uploads the edited copy in the old file's place; ->imageEditorAspectRatios(['16:9', null]), alias imageEditorAspectRatioOptions(), null = free), ->circleCropper() (1:1, round viewport), ->imageAspectRatio('16:9') / ->imageDimensions(minWidth: 800, maxHeight: 4000) validated on the server against the real pixels, ->orientImagesFromExif() (off by default; GD + exif on the server, else the browser re-encodes JPEGs), ->pasteable(false) to stop clipboard pastes into the focused drop zone (on by default). ->imageEditorMode(0) lets the crop leave the image (1–3 keep it inside; 2 can't zoom out below fitting, 3 always fills the editor, like Cropper.js view modes), ->imageEditorEmptyFillColor('#000000') paints the empty space, ->imageEditorViewportWidth(1920)->imageEditorViewportHeight(1080) opens with that crop ratio. Automatic crop/resize in the browser: imageCropAspectRatio('16:9'), imageResizeMode('cover'), imageResizeTargetWidth(1920), imageResizeTargetHeight(1080) (Filament aliases automaticallyCropImagesToAspectRatio(), automaticallyResizeImagesMode(), automaticallyResizeImagesToWidth()/ToHeight()). Also minFiles(), previewable(false), storeFiles(false) (state keeps the tmp: key), uploadingMessage('Uploading…'). Files over config('alfakit.uploads.chunk_size') (5 MB) upload in resumable chunks automatically (FileUpload, rich editor attachments, file manager); ->maxSize(512000) may exceed alfakit.uploads.max_size_kb because the field sends an encrypted upload policy; schedule alfakit:prune-uploads to remove unfinished chunks. Custom frontends: uploadTemporaryFile(panel.endpoints.upload, file, { ...panelUploadOptions(panel, component.uploadPolicy), onProgress }) from @alfakit/core, wrapped in form.trackUpload(promise) (the field's FormStore) so a save made while it runs waits for it instead of dropping the file (form.hasPendingUploads); when it fails, call form.failUpload(id, component.statePath, message) so saves stop with message under the field, and form.forgetFailedUpload(id) when the user removes the file.
  • More field options: TextInput::autocapitalize('words') ('none', 'sentences', 'characters'; true/false = on/off); Textarea::length(4); Select::make('tags')->multiple()->reorderable() (chosen options reorder by drag or Alt + arrows); Select::relationship('parent', 'name', ignoreRecord: true) leaves the edited record out (and rejects it); CheckboxList::getOptionLabelFromRecordUsing(fn (Tag $record) => …), searchPrompt(), noSearchResultsMessage(), searchDebounce(300); date pickers locale('fr') and datalist([...]) (native pickers only); ColorPicker::rgba() adds an opacity slider (an empty ColorPicker shows a struck-through data-empty swatch and a #rrggbb placeholder; pass placeholder() when empty has a meaning, e.g. "Default"); IconPicker::make('icon')->sets(['lucide', 'mdi'])->allowedIcons([...])->exclude([...])->gridColumns(8)->previewSize('lg')->allowCustomSvg() stores an icon name usable in any icon() (server-side paginated search, validated against the offered sets); ToggleButtons::tooltips(['draft' => '…']), size(Size::Small) (AlfaSix\Alfakit\Support\Enums\Size), fullWidth(); Slider::vertical(isTopToBottom: true), tooltips([true, false]), fillTrack([false, true, false]); Repeater::table([...])->compact(); Repeater/Builder ->deferLoading() (sent as a skeleton, loaded when scrolled into view); Builder::searchable()->searchDebounce(500)->searchPrompt('Find a block')->noSearchResultsMessage('No match') (block picker search); OneTimeCodeInput::make('code')->length(6)->submitOnCompletion() (autocomplete="one-time-code", digits only, used by the MFA forms). RichEditor toolbar lead, small, grid, gridDelete; MentionProvider::url(fn (string $id, string $label) => route('users.show', $id)) renders mentions as links via RichContentRenderer::make($content)->mentions([...]). Hidden values are editable by the user: never trust them.
  • FileUpload options: ->maxParallelUploads(3) (default 1), ->appendFiles(false) (new files first), ->panelLayout('integrated') (compact, grid, integrated, circle), ->panelAspectRatio('2:1'), ->imagePreviewHeight(250), ->removeUploadedFileButtonPosition('left'), ->uploadProgressIndicatorPosition('left') / ->loadingIndicatorPosition(), ->uploadButtonPosition('center'), ->getOpenableFileUrlUsing(fn (string $file): ?string => …), ->getDownloadableFileUrlUsing(fn (string $file): ?string => …), ->fetchFileInformation(false) (skip disk size/existence checks), ->mimeTypeMap(['skp' => 'application/vnd.sketchup.skp']). Stored paths not filled into the field or on the record are always rejected; ->preventFilePathTampering(allowFilePathUsing: fn (string $file): bool => …) allows more (message key tampered), ->preventFilePathTampering(false) accepts any.
  • visible(), hidden(), disabled() and required() accept closures receiving $get, $record and $operation (create, edit, view).
  • Hidden fields (also inside a hidden Section/Tab/Step) are never validated, saved or sent to the browser. Disabled fields keep their server value unless you add ->dehydrated(). readOnly() fields (and everything inside a read-only Repeater) ignore posted values and save the server value; values the server writes with $set() in a live update are kept.
  • Don't add ->label() just to repeat the column name. Labels are generated: category_id becomes "Category".
  • Layout: Flex::make([...])->from('md') puts children side by side (->grow(false) on a child keeps it narrow; Split is an alias; ->alignment(Alignment::End) / ->alignCenter() aligns them horizontally). Section::make()->headerActions([...]) / footerActions([...]), Callout::actions() / controlActions() and EmptyState::actions() accept ActionGroup::make([...]) too. Wizard::goToStep('payment') (step key() or label slug) moves the browser to a step from afterStateUpdated() or a schema action; non-skippable wizards only move back. hiddenOn() / visibleOn() / disabledOn() accept Operation::Edit (AlfaSix\Alfakit\Support\Enums\Operation) or arrays of it. numeric(locale: AppLocale::German) / money(locale: ...) accept a backed enum. Prime components: Callout::make('Heading')->description('…')->warning()->actions([...]), EmptyState::make('No orders')->description('…')->icon('inbox')->actions([...]), Icon::make('star')->color('warning'), Image::make($url, 'Alt')->imageHeight('8rem')->alignCenter(), UnorderedList::make(['One', Text::make('Two')]) (all in AlfaSix\Alfakit\Schemas\Components). Callout/EmptyState descriptions escape plain strings. Callouts: ->iconSize('sm'|'md'|'lg'), ->footerActionsAlignment(Alignment::End), ->controls([Text::make('Beta')->badge()]) and ->controlActions([Action::make('dismiss')->iconButton()->icon('x')->action(...)]) (top end corner), ->danger()->color(null) (no background, the status icon colour and role="alert" stay), ->iconColor('warning'). ->footer([Text::make('…'), Action::make('fix')]) (components and actions) on Section, Callout and EmptyState (on EmptyState it is no longer an alias of actions()). Fieldset::make('Address')->contained(false) drops the border. Text::make('ORD-1')->color('neutral')->fontFamily('mono')->tooltip('Order number')->copyable(copyMessage: 'Copied!', copyMessageDuration: 1500).
  • Layout extras: Section::make('Cart')->id('order-cart')->collapsible()->persistCollapsed() (local storage, key from id() or heading), ->afterHeader([Text::make('Beta')->badge(), Action::make('help')]) (actions run like headerActions()), ->footerActionsAlignment(Alignment::End), ->headingLevel(4); sections are h2 at the top and one level deeper per nesting, Group::make([...])->rootHeadingLevel(3) (and $schema->rootHeadingLevel(3)) changes the start. Tabs::make()->id('order-tabs')->persistTab() (storage: 'session' for session storage), ->persistTabInQueryString('tab') (value: the tab's id(), explicit key() or label slug, ?tab=billing-address) and ->scrollable(false) (wrap the tab list). Any component: ->columnOrder(2) / ->columnOrder(['default' => 1, 'lg' => 3]); layout components: ->dense() (half gap), ->gap(false). ->deferLoading() on Section, Fieldset, Group, Grid, Tabs/Tab, Wizard/Step (forms and infolists): the page's first response sends a skeleton and the children load from POST /_alfakit/schema/deferred once in view (or at once when a field inside has an error); state, validation and saving work before that; modals and live updates render in full. Tab::make('Orders')->badge(fn () => …)->deferBadge() loads a schema tab's badge after the page renders; Tab::make()->iconPosition(IconPosition::After)->badgeColor('danger').
  • Container-query columns: Grid::make(['default' => 1, '@md' => 2, '@xl' => 3]) and ->columnSpan(['@md' => 2]) follow the width of the surrounding container instead of the viewport.
  • columnSpan(2) / columnStart(2) with a number apply from lg up (like columns(3)), so phones stack; columnSpanFull() / 'full' apply everywhere; pass ['default' => 2] to span on phones too.
  • Inline labels: ->inlineLabel() on a field or entry, a layout (Section::make()->inlineLabel()) or the whole $schema->inlineLabel(); children inherit it and inlineLabel(false) opts out. The label sits beside the control from sm up.
  • Text inputs: ->trim() (also Textarea, TagsInput), ->stripCharacters(',') (string or array) apply to validation and the saved value, not the input; ->telRegex('/.../') replaces the tel() pattern; ->copyable(copyMessage: 'Copied!', copyMessageDuration: 1500) adds a copy button; ->autocomplete(false) renders off. Any field: ->mutateStateForValidationUsing(fn ($state) => ...).
  • Typed Get: $get->string('name'), integer(), float(), boolean(), array(), date() (Carbon), enum('status', Status::class), filled(), blank(); isNullable: true returns null for blank values, isAbsolute: true reads from the root. Closures can take $rawState (entries: the attribute before casts).
  • Page wizards: Wizard::make([...])->nextAction(fn (Action $action) => $action->label('Continue'))->previousAction(fn (Action $action) => $action->hidden())->submitAction(Action::make('place')->action(...)) (or an HtmlString with <button type="submit">), ->completedIcon('circle-check') (also per Step), ->persistStepInQueryString('checkout-step'), ->startOnStep(2), ->skippable(). Next/previous actions only style the buttons; the submit Action runs through the action endpoint and is authorised like section actions.
  • Reusable schemas: php artisan make:alfakit-schema AddressFields [--model=Post] writes app/Alfakit/Schemas/AddressFields.php (with --model it starts from the resource generator's form fields for that model), a class AddressFields extends Fragment (AlfaSix\Alfakit\Schemas\Fragment) whose define(): array returns components. Use AddressFields::make() inside any components([...]); it is an invisible Group, so ->statePath('billing') prefixes its state, ->relationship('address') edits a related record and ->hidden()/->visible() work. Constructor arguments go through make(). Not components(): that name is the Group setter.
  • Fields from a model: Schema::fromModel(Post::class, except: ['slug']) (or only: [...]) returns a schema bound to the model with fields inferred from casts and columns (*_id + BelongsTo → relationship Select, enum cast → Select, boolean → Toggle, json → KeyValue, dates → pickers, text → Textarea; non-nullable columns without default are required; $fillable limits the list). Tweak it: $schema->getComponent('title')?->maxLength(120), $schema->components([...$schema->getComponents(), Toggle::make('featured')]). ModelSchema::fields(Post::class) gives just the components.

Tables

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('title')->searchable()->sortable()->limit(50),
            TextColumn::make('category.name'),                         // label "Category"
            TextColumn::make('status')->badge()->color(fn (string $state) => match ($state) {
                'published' => 'success', 'draft' => 'gray', default => 'warning',
            }),
            TextColumn::make('price')->money(fn (Post $r) => $r->currency, inMinorUnits: true)->alignEnd(),
            TextColumn::make('published_at')->since()->sortable()->toggleable(),
            ToggleColumn::make('is_featured'),
        ])
        ->defaultSort('published_at', 'desc')
        ->filters([
            SelectFilter::make('category')->relationship('category', 'name'),
            TernaryFilter::make('is_featured'),
            TrashedFilter::make(),
        ])
        ->recordActions([ViewAction::make(), EditAction::make(), DeleteAction::make()])
        ->bulkActions([DeleteBulkAction::make()]);
}
  • Enums: implement AlfaSix\Alfakit\Support\Contracts\{HasLabel, HasColor, HasIcon, HasDescription} (getLabel(), getColor(), getIcon(), getDescription()). options(Status::class) on Select/SelectColumn/SelectFilter uses the labels, Radio/CheckboxList add the descriptions, ToggleButtons the colours and icons. TextColumn::make('status')->badge() / TextEntry / IconColumn / IconEntry show the label, colour and icon of an enum-cast attribute; for a stored raw value add ->enum(Status::class). Explicit color()/icon()/formatStateUsing() win and receive the case as $state. Navigation groups may be enum cases (NavigationGroup::make(Section::Shop), ->group(Section::Shop)). Helpers: AlfaSix\Alfakit\Support\EnumState::resolve($value, Status::class), ::label(), ::options().
  • Relationship aggregates: name the column after Eloquent's attribute: TextColumn::make('comments_count')->counts('comments')->sortable()->searchable(), ->exists('comments'), ->avg('comments', 'rating') (also sum, min, max; constrain with counts(['comments as approved_count' => fn (Builder $q) => $q->where('approved', true)]); the column may be an expression: ->sum('items as order_total', DB::raw('price * quantity')), also on TextEntry and ExportColumn). Sorting uses the alias, searching the same sub-select (exists() is not searchable).
  • Column breakpoints: TextColumn::make('email')->visibleFrom('md'), ->hiddenFrom('xl') (sm–2xl, closures allowed) hide the header and cells (or the layout block) with responsive classes; the column stays searchable, sortable and in the column manager.
  • Content layouts (AlfaSix\Alfakit\Tables\Columns\Layout\{Split, Stack, Panel, Grid}): Split::make([ImageColumn::make('avatar')->circular()->grow(false), Stack::make([...])->space(1)])->from('md'), Panel::make([...])->collapsible(); ->contentGrid(['md' => 2, 'xl' => 3]) shows cards; its breakpoints follow the table's width (container query: md ≈ a 24rem table, xl ≈ 56rem) and a count applies only once each card can be 15rem wide (2 columns from 36rem), so a narrow table shows fewer columns; container keys ('@lg' => 2) are used as given. Inside a record, Split::from(), Grid columns and columnSpan()/columnStart() follow the record's width (container query: sm 16rem, md 20rem, lg 28rem, xl 36rem, 2xl 48rem), not the viewport; visibleFrom()/hiddenFrom() stay on the viewport. Header groups: ColumnGroup::make('Visibility', [...]).
  • ->reorderable('sort', direction: 'desc') enables drag-and-drop record ordering (asc by default). Reorder mode lists every record (pagination off, ?reordering=1) unless ->paginatedWhileReordering(); saved records swap their existing positions, unlisted ones keep theirs. ->beforeReordering(fn (array $order) => ...) / ->afterReordering(fn (array $order) => ...) get the moved keys in their new order. The bulk selection clears when filters, search or the list tab change; ->deselectAllRecordsWhenFiltered(false) keeps it.
  • ->reorderableColumns() lets users reorder columns from the column manager by drag and drop or the move up / down buttons (stored per user in the session); ->recordActionsPosition(RecordActionsPosition::BeforeCells) (AlfaSix\Alfakit\Tables\Enums).
  • Pagination: ->paginationMode(PaginationMode::Simple | Cursor | LoadMore | InfiniteScroll) (AlfaSix\Alfakit\Tables\Enums\PaginationMode); simple, cursor and appending modes skip the count query. Cursor tables sort by plain, relationship (author.name) and aggregate columns; a raw sortable(query:) expression throws a LogicException (order by a select alias instead). A failed load-more / infinite-scroll request shows a translated retry control. ->deferTotals() keeps numbered pagination but loads the total, page numbers and summaries after the rows (POST /_alfakit/table/totals; placeholders meanwhile; paging and sorting never count again; inline for polled and live() tables). Hovering or focusing "next" prefetches the next page (prefetchTablePage() from @alfakit/core, cached PREFETCH_CACHE_FOR = 10s, dropped after any mutation).
  • Summaries (AlfaSix\Alfakit\Tables\Columns\Summarizers\{Sum, Average, Count, Range, Summarizer}): TextColumn::make('price')->summarize([Sum::make()->money('EUR'), Average::make()->label('Mean')]); ->query(fn (Builder $q) => $q->where(...)) narrows, Summarizer::make()->using(fn (\Illuminate\Database\Query\Builder $query, string $attribute) => $query->max($attribute)) is custom. Rows: all matching records, the current page (when there are more pages) and each group; hide the first two with $table->summaries(pageCondition: false, allTableCondition: false). ->hiddenLabel() keeps a summarizer label for screen readers only; IconColumn::make('is_published')->boolean()->summarize(Count::make()->icons()) counts each icon. Relationship columns need using().
  • Grouping (AlfaSix\Alfakit\Tables\Grouping\Group): ->groups([Group::make('status')->collapsible(), Group::make('author.name')->label('Author'), Group::make('published_at')->date()])->defaultGroup('status'); also getTitleFromRecordUsing(), getDescriptionFromRecordUsing(), getKeyFromRecordUsing() + scopeQueryByKeyUsing(), orderQueryUsing(), titlePrefixedWithLabel(false); an enum-cast attribute is titled with the enum label (headlined case name without HasLabel) plus its HasIcon icon in its HasColor colour, ->enum(Status::class) for raw stored values; table: groupingSettingsHidden(), groupingDirectionSettingHidden(), collapsedGroupsByDefault(), groupsOnly(). Works on records() arrays too (closures take array $record; no group summaries). State: ?group= / ?groupDirection=.
  • Query builder filter (AlfaSix\Alfakit\Tables\Filters\QueryBuilder + ...\QueryBuilder\Constraints\{TextConstraint, NumberConstraint, DateConstraint, BooleanConstraint, SelectConstraint, RelationshipConstraint}): QueryBuilder::make()->constraints([TextConstraint::make('title'), TextConstraint::make('author.name'), NumberConstraint::make('rating')->integer(), SelectConstraint::make('status')->options([...])->multiple(), RelationshipConstraint::make('tags')->titleAttribute('name')->multiple()])->maxDepth(2); string keys group constraints in the picker: ->constraints([TextConstraint::make('title'), 'Dates' => [DateConstraint::make('created_at')]]) (getGroupedConstraints()). Rules are validated server-side; invalid ones are ignored.
  • ->deferFilters() adds an Apply button; ->filtersLayout(FiltersLayout::Modal) (AlfaSix\Alfakit\Tables\Enums\FiltersLayout) opens filters in a dialog (always applied with Apply); FiltersLayout::BeforeContent / AfterContent (+ BeforeContentCollapsible / AfterContentCollapsible) put them in a sidebar. ->filtersFormWidth(Width::Large), ->filtersFormMaxHeight('400px'), ->filtersFormSchema(fn (array $filters): array => [Section::make('Who')->schema([$filters['author']])]) (each filter is a group keyed by name), ->hiddenFilterIndicators().
  • Toolbar and columns: ->pushColumns([...]) / ->pushFilters([...]) (add, e.g. in Table::configureUsing()), ->extremePaginationLinks(), ->loadingSkeleton(), ->defaultSortOptionLabel('Newest'), ->defaultKeySort(false) (sorts get a primary-key tiebreaker by default). Buttons take a closure receiving AlfaSix\Alfakit\Actions\Action (only its look is used): ->filtersTriggerAction(fn (Action $action) => $action->button()->label('Filter')), filtersApplyAction(), filtersResetAction(), filtersRemoveAllAction(), columnManagerTriggerAction(), columnManagerApplyAction(), columnManagerResetAction(), groupRecordsTriggerAction(), reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => ...). Column manager: ->deferColumnManager() (AlfaKit applies toggles at once by default), ->columnManagerLayout(ColumnManagerLayout::Modal) (then columnManagerTriggerAction(fn (Action $action) => $action->slideOver()->modalWidth(Width::Large)->modalHeading('Columns')) applies to the modal), ->columnManagerColumns(2), ->columnManagerResetActionPosition(ColumnManagerResetActionPosition::Header); ->filtersResetActionPosition(FiltersResetActionPosition::Header) (enums in AlfaSix\Alfakit\Tables\Enums); ->groupingSettingsInDropdownOnDesktop() (desktop shows grouping selects inline by default); Group::make('status')->groupQueryUsing(fn (Builder $q) => $q->groupBy('status')) (groups-only tables). Columns: headerTooltip(), disabledClick(), verticallyAlignStart|Center|End(), closures inject stdClass $rowLoop / ?int $index. TextColumn/TextEntry: isoDate('LL'), isoDateTime(), isoTime(), dateTooltip(), dateTimeTooltip(), timeTooltip(), isoDateTooltip(), isoDateTimeTooltip(), isoTimeTooltip(), sinceTooltip(), limitList(3)->expandableLimitedList(), getCharacterLimit() (->tooltip(fn (TextColumn $column) => mb_strlen($column->getState()) > $column->getCharacterLimit() ? $column->getState() : null)). ImageColumn/ImageEntry: ring(4), overlap(3), extraImgAttributes(['loading' => 'lazy']), limitedRemainingText(size: 'lg'), checkFileExistence(); ImageColumn::wrap(). TextInputColumn: prefix('$'), suffix(), prefixIcon(), prefixIconColor(), suffixIcon(), suffixIconColor(). Formatting (TextColumn/TextEntry): ->dateTime()->timezone('Asia/Dubai'), numeric(decimalPlaces: 2, decimalSeparator: ',', thousandsSeparator: '.', maxDecimalPlaces: null, locale: null), money('EUR', decimalPlaces: 0); copy: copyable()->copyMessage('Copied')->copyMessageDuration(3000) (also ColorColumn/ColorEntry). IconColumn with a list state shows one icon per item (->wrap()); ColorColumn::wrap(). SelectColumn: optionsSearchDebounce(500), wrapOptionLabels(false), rememberOptions() (no-op). Inside a layout Grid: TextColumn::make('title')->columnSpan(2), columnStart(). ->defaultGroup('status', direction: 'desc'); ->defaultSort(fn (Builder $query, string $direction) => $query->orderBy('position', $direction)) orders the query itself. Range::make()->minimalDateTimeDifference() / minimalTextualDifference().
  • Filter extras: ->modifyFormFieldUsing(fn (Select $field) => $field->native()) (built-in field only), ->baseQuery(fn (Builder $query, array $data) => ...) (base query, also when resolving records; never a security boundary), ->excludeWhenResolvingRecord() (accepted; filter queries never apply to record resolution), SelectFilter::make('author')->relationship('author', 'name', hasEmptyOption: true)->emptyRelationshipOptionLabel('No author'), indicateUsing() returning AlfaSix\Alfakit\Tables\Filters\Indicator::make('From …')->removeField('from') / ->removable(false). Query builder: TextConstraint::make('email')->operators(['contains', 'equals'])->pushOperators([Operator::make('is_company')->label('is a company')->query(fn (Builder $query, string $column) => ...)]) / unshiftOperators() (AlfaSix\Alfakit\Tables\Filters\QueryBuilder\Operators\Operator; no value unless ->input('number'|'text'|'date'|'datetime'|'month'|'year'|'select'), with ->options([...]) for selects and $value injected into query(); ->validateValueUsing(fn ($value) => ...)), RelationshipConstraint::make('tags')->emptyable(false), ->selectable(IsRelatedToOperator::make()->titleAttribute('name')->searchable()->multiple()) (...\QueryBuilder\Operators\IsRelatedToOperator), SelectConstraint::make('status')->options([...])->searchable(), DateConstraint::make('published_at')->time() (operators also include is month / is year), ->nullable(false) removes is filled / is blank, QueryBuilder::make()->constraintPickerColumns(2)->constraintPickerWidth('lg') (constraint icon()s show in the picker).
  • Actions: BulkActionGroup::make([...]) (AlfaSix\Alfakit\Actions\BulkActionGroup, a labelled dropdown), ->groupedBulkActions([...]), ->toolbarActions([...]) (bulk actions and bulk-only groups join bulkActions(), the rest headerActions()), ->modifyUngroupedRecordActionsUsing(fn (Action $action) => $action->iconButton()). Custom data across pages: ->resolveSelectedRecordsUsing(fn (array $keys, bool $isTrackingDeselectedKeys): array => ...) (rows keyed by key; select-all passes $isTrackingDeselectedKeys = true).
  • Session persistence (per panel, tenant, user and table): ->persistInSession() for everything, or ->persistFiltersInSession(), ->persistSearchInSession(), ->persistColumnSearchesInSession(), ->persistSortInSession(), ->persistRecordsPerPageInSession(), ->persistColumnsInSession(), ->persistGroupInSession() (each takes false to override persistInSession()). Off by default.
  • Saved views (opt-in): ->savedViews() (or $panel->tableSavedViews(); publish alfakit-table-views-migrations) lets users save the current filters, search, sort, visible/ordered columns, grouping and per-page as "My views" and, with a manageSharedTableViews policy ability (or ->canManageSharedViews(fn () => ...)), share them per tenant. Code views: ->views([TableView::make('overdue')->label('Overdue')->icon('heroicon-o-clock')->filters(['status' => ['value' => 'overdue']])->sort('due_at', 'desc')->columns(['number', 'due_at'])->group('status')->perPage(50)->default()]) (AlfaSix\Alfakit\Tables\Views\TableView); ->viewsLayout('dropdown'). The URL names the view (?view=slug); stored state is sanitised against the table on read.
  • Spreadsheet mode (opt-in, lazy-loaded): ->spreadsheet() (or ->spreadsheet(atomic: false) to save valid cells when others fail) adds "Edit as spreadsheet": keyboard grid, TSV copy/paste with a validated preview, Ctrl/Cmd+D fill down, undo, one batch save checked per cell with the column's rules() and per record with the update policy. Inline-editable columns are editable (->editable(false) opts one out); TextColumn::make('stock')->editable()->rules(['integer']) opts a text column in.
  • Search: ->splitSearchTerms(false) (whole phrase), ->splitIndividualSearchTerms() (column searches too; per column TextColumn::make('title')->searchable(isIndividual: true)->splitIndividualSearchTerms(false)), table-level ->searchable(['id', 'author.email']) for attributes that are not columns, ->searchUsing(fn (Builder $query, string $search) => $query->whereKey(Post::search($search)->keys())) for Scout.
  • ->stackedOnMobile() renders rows as cards with column labels below md.
  • Selection: ->maxSelectableRecords(50), ->selectGroupsOnly() (while grouped), both enforced on the server (422). BulkAction::make('x')->fetchSelectedRecords(false) gives Illuminate\Support\Collection $records of keys (DeleteBulkAction, ForceDeleteBulkAction, RestoreBulkAction, DetachBulkAction then act by query in chunks of 500, without model events; add ->authorizeIndividualRecords(null) to skip loading records for the per-record policy check); inside the action $action->reportBulkProcessingFailure('key', message: fn (int $failureCount, int $totalCount): string => ...) and ->failureNotificationTitle(fn (int $successCount, int $failureCount, int $totalCount): string => ...) report partial failures. Record actions get the selection with Action::make('copy')->accessSelectedRecords()->action(fn (Model $record, Collection $selectedRecords) => ...) (use ->selectable() when the table has no bulk actions).
  • SelectColumn::make('author_id')->native(false)->optionsRelationship('author', 'name', ignoreRecord: false, modifyQueryUsing: fn (Builder $query) => ...)->searchableOptions(['name', 'email'])->optionsLimit(20)->allowOptionsHtml() (labels sanitised; preloadOptions(), getOptionsSearchResultsUsing(), getOptionLabelUsing(), getOptionLabelFromRecordUsing(); combobox messages searchPrompt(), loadingMessage(), searchingMessage(), noSearchResultsMessage(), noOptionsMessage()). ImageColumn::make('cover')->alt(fn (Post $record) => $record->title).
  • Long pages: tables with more than 50 rows render only the visible rows (->virtualize(false), ->virtualizeThreshold(200), ->virtualizeEstimateSize(64)); the markup keeps aria-rowcount / aria-rowindex. Content layouts and contentGrid() cards are windowed by whole rows too.
  • Non-Eloquent data: ->records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int $page, int|string $recordsPerPage, array $filters) => [...]). Arrays and collections are searched, sorted and paged by the table; return a paginator to do it yourself. Rows carry record actions, bulk selection and inline edits: the row's key is posted back and resolved from the closure (or ->resolveRecordUsing(fn (string $key): ?array => ...)); closures take array $record (a Model $record closure gets null), bulk actions get a Collection of arrays, and inline-editable columns need updateStateUsing() (400 without it). A closure asking for $filters filters the rows itself; otherwise the table applies the filters to the rows: SelectFilter/TernaryFilter compare their attribute, and query(fn (Collection $records, array $data) => $records->where(...)) gets the rows (keep the keys; a Builder-typed closure is skipped).
  • ViewColumn::make('status')->view('StatusSwitcher')->viewData(fn ($record) => [...]) and ViewEntry render a registered frontend component.

Actions

  • A modal CreateAction (with ->form()) shows "Create & create another", which saves, keeps the modal open and resets the form; ->createAnother(false) hides it, ->preserveFormDataWhenCreatingAnother(['category_id']) (or no argument for every field, or fn (array $data): array => Arr::only($data, ['category_id'])) keeps values for the next record; ->createAnotherAction(fn (Action $action): Action => $action->label('Save and add another')->icon('plus')) customises the button (return false to remove it).
  • Custom modal content: ->modalContent('order-summary', fn (Order $record): array => ['total' => $record->total]) renders a component registered with createAlfakit({ components }) (data in component.props; closures in the data get $record, $data, $arguments), ->modalContent(fn (Order $record) => view('orders.advance', ['record' => $record])) renders a Blade view as trusted HTML, and arrays of schema components still work. modalContentFooter() takes the same values.
  • Prebuilt: CreateAction, EditAction, ViewAction, DeleteAction, RestoreAction, ForceDeleteAction, ReplicateAction, plus AttachAction, DetachAction, AssociateAction and DissociateAction in relation managers, and the *BulkAction versions.
  • Custom actions: Action::make('name')->icon('send')->color('success')->requiresConfirmation()->schema([...])->action(fn (Model $record, array $data) => …).
  • Undo: DeleteAction::make()->undoable() / DeleteBulkAction::make()->undoable() restore soft-deleted records from an "Undo" button (with countdown, Ctrl/Cmd+Z) in the success toast and drop the confirmation modal (->skipConfirmationWhenUndoable(false) keeps it); EditAction::make()->undoable(seconds: 10) and protected static bool $isSaveUndoable = true; on an EditRecord page put back the changed attributes (refused if the record changed since; undoTrackedAttributes([...])). Custom: ->action(function (Post $record, Action $action) { $action->undoData(['previous' => $record->status]); … })->undoable(fn (Post $record, array $undoData) => $record->update(['status' => $undoData['previous']])). The undo token is single-use, short-lived and bound to user/panel/tenant; the policy is re-checked (POST /_alfakit/actions/undo with {undo}).
  • An action with ->url(...) is a link and is never executed. ->url(...)->download() makes it a plain link with the download attribute (for file routes; a normal URL action is an Inertia visit). A handler may return a file (response()->streamDownload(), response()->download(), Storage::download()): the browser downloads it (served once from a signed link).
  • Modal forms with a record fill from its attributes; ->mutateRecordDataUsing(fn (array $data, Model $record): array => [...$data, 'title' => ucfirst($data['title'])]) changes them first (skipped when fillForm() is set). Form hooks: ->beforeFormFilled(fn () => …) / ->afterFormFilled(fn (array $data, Schema $schema) => …) run when the modal opens; ->beforeFormValidated(fn (array $data) => …) / ->afterFormValidated(fn (array $data) => …) run on submit around validation, before before() and the handler.
  • Page header actions go in getHeaderActions() on the page class.
  • Resource page hooks (protected methods on CreateRecord / EditRecord / ViewRecord subclasses): beforeFill() / afterFill() (once, when the page loads), beforeValidate() / afterValidate() (on submit), beforeCreate(array $data) / afterCreate(), beforeSave(array $data) / afterSave(), plus mutateFormDataBeforeFill/Create/Save(array $data): array. Traits add to any hook with {hook}{TraitName}(), e.g. protected function afterSaveHasAuditLog(): void in trait HasAuditLog. $this->halt() in a hook stops without saving (send a Notification first); in wizard step beforeValidation() / afterValidation() closures throw new \AlfaSix\Alfakit\Actions\Exceptions\Halt keeps the wizard on the step.
  • Guest access (public pages in a panel): $panel->guestAccess() (or ->guestAccess(fn (?Team $tenant): bool => $tenant === null || $tenant->is_public) on tenancy panels), then protected static bool $isGuestAccessible = true; on each page, or on a resource for all its pages. Other pages still redirect guests to login. Guests are authorised with a null user: write policy methods for them as viewAny(?User $user); User $user methods deny guests. Resources without a policy allow everything, so give guest-accessible resources a policy. The renderers hide the user menu, notifications and global search for guests and show a sign-in link.
  • Nested resources: make:alfakit-resource Comment --generate --nested=PostResource writes the nested resource (parent foreign key left out of the form) plus PostResource\RelationManagers\CommentsRelationManager with $relatedResource = CommentResource::class, registered in PostResource::getRelations() (nested resources have no navigation item; this is how users reach them). If the parent already has that relation manager (made by hand earlier), it is kept and linked ($relatedResource added). Chain deeper with Reply --nested=CommentResource. By hand: make:alfakit-relation-manager PostResource comments body --related=CommentResource --register.
  • Import/export: generate php artisan make:alfakit-importer Product --generate / make:alfakit-exporter Product --generate (aliases alfakit:make-importer, alfakit:make-exporter), then $table->headerActions([ImportAction::make()->importer(ProductImporter::class), ExportAction::make()->exporter(ProductExporter::class)]) and ->bulkActions([ExportBulkAction::make()->exporter(ProductExporter::class)]). Importers extend AlfaSix\Alfakit\Actions\Imports\Importer (protected static ?string $model, getColumns() of ImportColumn::make('sku')->requiredMapping()->rules([...])->numeric()|integer()|boolean()|array()|relationship(resolveUsing: 'email') (BelongsTo, or BelongsToMany with ->multiple(','), synced after the save), resolveRecord() returning $this->newQuery()->firstOrNew([...]) (return null to skip the row), hooks like protected function beforeSave(): void (traits add {hook}{TraitName}()), ->multiple('|')->nestedRecursiveRules(['integer']) for item rules, ImportColumn::make('status')->enum(Status::class) validates backed-enum values); exporters extend AlfaSix\Alfakit\Actions\Exports\Exporter with ExportColumn::make('author.name'), ->counts('comments'), ->exists('comments'), ->avg('comments', 'rating') (also sum, min, max), ->hidden(fn () => ...)/->visible() (never offered or written), ->formatStateUsing(), ->enabledByDefault(false). Options: maxRows(), chunkSize(), formats([ExportFormat::Csv]), columnMapping(false), columnMappingColumns(3), enableVisibleTableColumnsByDefault(), fileName(), fileDisk(); imports headerOffset(1), fileRules([...]), ImportColumn::make('password')->sensitive() (kept out of failed rows), ->validationAttribute('e-mail address') (name in validation messages). Importers/exporters may override getCompletedNotificationTitle(), modifyCompletedNotification(Notification $notification, Import|Export $run), getJobMiddleware(), getJobRetryUntil(), getJobBackoff(), getJobTags(), getJobBatchName(); importers getFailedRowsDownloader(): ?callable, exporters getDownloader(ExportFormat $format): ?callable and XLSX styling with AlfaSix\Alfakit\Actions\Exports\Writers\XlsxStyle (getXlsxCellStyle(), getXlsxHeaderCellStyle(), makeXlsxRow(array $values, ?XlsxStyle $style): XlsxRow with XlsxCell::fromValue(), configureXlsxWriterAfterOpen() / configureXlsxWriterBeforeClose(XlsxWriter $writer) for setSheetName(), freezeRows(), setColumnWidth()). Users can rename exported column headers in the modal (columnLabels). CSV formula-injection escaping is always on; Filament's preventFormulaInjection() methods are accepted no-ops. Needs php artisan alfakit:install --import-export && php artisan migrate, a queue worker (or the sync driver) and database notifications; downloads are signed links in the completion notification.
  • Authorize with policies (resources use them automatically) or ->authorize('ability') / ->visible(fn () => …). authorize() closures may return Illuminate\Auth\Access\Response. Unauthorised actions are hidden; ->authorizationTooltip() shows them disabled with the policy's message as tooltip, ->authorizationNotification() keeps them clickable and sends the message as a danger notification, ->authorizationMessage('…') is the fallback when the policy has no message (without any message they stay hidden). They never run. A record ability checked without a record (Resource::can('update'), Authorization::allows('view', Post::class), or an EditAction / DeleteAction that has no record yet) denies when the policy method needs the model (update(User $user, Post $post); Authorization::requiresRecord($policy, 'update')) instead of calling it without one; a record-less ViewAction (calendar events, opened for their record) asks viewAny, and view is checked once the record is resolved; ->offeredBeforeRecord() does the same for EditAction, DeleteAction and the other resource actions (the calendar sets it on its event click action). No need for ?Post $post = null parameters.
  • Modal lifecycle: ->mountUsing(fn (Schema $form) => $form->fill([...])) runs when the modal opens (replaces the default fill; $action->halt() keeps it closed), ->modalHidden(fn () => …) runs without the modal (form uses its default state), ->disabledForm() makes the modal form read-only, ->modalSubmitAction(fn (Action $action) => $action->label('Send')->color('danger')->icon('send')) / ->modalCancelAction(fn (Action $action) => $action->label('Close')) (or false to remove), ->unsavedChangesAlert(false) (default: the panel's unsavedChangesAlerts()), ->url(...)->postToUrl() for POST links.
  • Sudo mode: ->schema([\AlfaSix\Alfakit\Auth\MultiFactor\MultiFactorChallenge::make()]) asks for an authenticator, emailed or recovery code; it is verified during validation (fields under multiFactorChallenge, never in $data) and fails for users without a second factor (MultiFactorChallenge::make()->hasEnabledProviders($user)).
  • Wizard modal: Action::make('onboard')->steps([Step::make('Account')->schema([...]), Step::make('Profile')->schema([...])])->skippableSteps() (AlfaSix\Alfakit\Schemas\Components\Wizard\Step); "Next" validates the current step on the server (POST /_alfakit/schema/validate with step = the Step's key; Step::beforeValidation() / afterValidation() run there), the submit button shows on the last step and all steps validate again on submit. Page wizards (Wizard::make([...])) behave the same.
  • Bulk-action forms refresh with the selection (bulk, records, allRecords, tableQuery travel with live refreshes and option searches), so ->form(fn (Enumerable $records) => [...]) and the nested modal actions see the same $records as the handler. A single FileUpload in a modal form is sent as one path and validated as such.
  • Nested modal actions: ->extraModalFooterActions([Action::make('preview')->schema([...])->action(...)]) open their own modal (the parent is hidden until it closes, as in Filament; ->overlayParentActions() stacks it on top instead; an ActionGroup in the list is a dropdown of nested actions), share the parent's $record / bulk $records, and ->cancelParentActions() closes the parents after success (->cancelParentActions('edit') up to that parent). ->registerModalActions([...]) makes actions resolvable without a footer button. Paths look like edit>preview. In a nested action, inject array $mountedActions (outermost first) or use $action->getParentAction() with ->getRawData(), ->getValidatedData() (errors show in the parent modal) and ->fillData(['slug' => ...]) (writes into the open parent form). ->cancelParentActionsOnClose() closes the parents when this modal is dismissed. Extra submit buttons: ->extraModalFooterActions(fn (Action $action): array => [$action->makeModalSubmitAction('saveAndPublish', arguments: ['publish' => true])]) (read $arguments in action()). Chain with $action->replaceMountedAction('confirm', ['step' => 2]) inside action().
  • Modal presentation: ->stickyModalHeader(), ->stickyModalFooter(), ->slideOver()->slideOverPosition(SlideOverPosition::Start) (AlfaSix\Alfakit\Support\Enums\SlideOverPosition, logical/RTL-aware), ->modalFooterActionsAlignment(Alignment::Between), ->modalClickThrough() (no backdrop), ->extraModalWindowAttributes([...]), ->extraModalOverlayAttributes([...]) (merge: true to add). ->modalAutofocus(false), ->modifyFormUsing(fn (Schema $schema) => $schema->columns(1)). Buttons: ->labeledFrom('md') (icon only below the breakpoint), ->hiddenLabel() (icon only, label stays the accessible name), ->iconPosition(IconPosition::After), ->extraAttributes(['data-tour' => 'x']), ->badge() with no argument (trigger rendered as a badge; ->badge(5) is a count badge), ->openDatabaseNotifications() (opens the bell panel). Groups: ActionGroup::make([...])->buttonGroup() (joined buttons), ->dropdown(false) (a nested group becomes a divided section), ->dropdownWidth(Width::Small), ->dropdownOffset(8), ->maxHeight('300px').
  • Toasts after an action: ->successNotificationTitle('Archived') or ->successNotification(fn (Notification $notification) => $notification->warning()) on any action (custom ones too) sends a success notification after the handler, unless the handler already sent one (Notification::make()->...->send()); ->failureNotificationTitle() for bulk failures. Prebuilt actions have their own titles.
  • ->rateLimit(5) (successful-validation runs per user per minute; rateLimit(10, decaySeconds: 3600), rateLimitedNotificationTitle(fn (int $minutes) => ...)); ->requiresPasswordConfirmation() (remembered for auth.password_timeout, or requiresPasswordConfirmation(rememberForMinutes: 5); 0 asks every time).
  • Bulk actions: BulkAction::make('archive')->authorizeIndividualRecords('archive')->chunkSelectedRecords(500)->action(fn (Enumerable $records) => ...); type-hint Illuminate\Support\Enumerable because chunked $records are a LazyCollection. Prebuilt delete/restore/force-delete bulk actions authorise each record. Relation managers also have DetachBulkAction and DissociateBulkAction.
  • Shared resource forms/tables in relation managers: ->hiddenOn(RelationManager::class) / ->visibleOn(RelationManager::class) on fields, layouts, columns and filters (mixable with operations: ->hiddenOn([RelationManager::class, 'create'])).

Relation managers, infolists, widgets and pages

  • make:alfakit-relation-manager PostResource comments body --register (adds it to PostResource::getRelations(); by hand write PostResource\RelationManagers\CommentsRelationManager::class, since the resource doesn't import RelationManagers). Set protected static string $relationship = 'comments'; and define form() and table() (instance methods).
  • BelongsToMany managers: fields named after withPivot() columns are pivot attributes. CreateAction creates and attaches with them, EditAction fills them from the pivot and saves them with updateExistingPivot(); columns read them as TextColumn::make('role') or TextColumn::make('pivot.role') (not sortable/searchable). AttachAction::make()->preloadRecordSelect()->recordSelectSearchColumns(['name', 'email'])->recordSelectOptionsQuery(fn (Builder $query) => $query->where('active', true))->recordSelect(fn (Select $select) => $select->placeholder('…'))->allowDuplicates()->form([TextInput::make('role')]); AssociateAction takes the same select options (not allowDuplicates()). Custom layout: ->schema(fn (AttachAction $action): array => [$action->getRecordSelect(), TextInput::make('role')]). AttachAction::make()->tableSelect(ProductsTable::class) (table class with static configure(Table $table): Table, resource class or closure) picks the records from a modal table (a ModalTableSelect over the attachable records) instead of the select.
  • make:alfakit-related-records-page PostResource comments body (--attach, --associate) generates Resources/PostResource/Pages/ManagePostComments extending ManageRelatedRecords; register it as 'comments' => Pages\ManagePostComments::route('/{record}/comments').
  • infolist(Schema $schema) uses entries: TextEntry::make('author.name'), IconEntry::make('is_featured')->boolean(), TextEntry::make('price')->money('EUR'). Entries: ->alignEnd() (alignment(Alignment::Center), alignStart(), alignJustify()), ->extraAttributes([...]) on the value and ->extraEntryWrapperAttributes([...]) on the label + value wrapper, ->hiddenOn('edit') / ->visibleOn([...]); IconEntry::make('active')->boolean()->trueColor('success')->falseColor('gray'), KeyValueEntry::make('meta')->keyLabel('Setting')->valueLabel('Value').
  • Entry extras: TextEntry::make('comments_count')->counts('comments') (also exists(), sum(), avg(), min(), max(), like table columns; loaded onto the record). For *-to-many lists color()/icon()/url() closures run per item: TextEntry::make('tags.name')->badge()->color(fn (Tag $relatedRecord) => $relatedRecord->color); formatStateUsing(), limit(), words(), prefix()/suffix(), timezone() and IconColumn::icon()/color() get $relatedRecord per item too. ImageEntry::make('photo')->imageHeight(80)->alt(fn (?string $state, Product $record) => $record->name) (imageWidth()/imageHeight()/imageSize() alias width()/height()/size()). CodeEntry::make('payload')->grammar(Language::Json)->copyable() (AlfaSix\Alfakit\Infolists\Components\CodeEntry; arrays print as JSON; lightTheme()/darkTheme() names become data-light-theme/data-dark-theme).
  • Actions::make([Action::make('star')->action(fn (Set $set) => $set('rating', 5))])->alignment(Alignment::Center)->verticalAlignment(VerticalAlignment::End)->fullWidth() (AlfaSix\Alfakit\Schemas\Components\Actions) puts a row of buttons anywhere in a form or infolist (also standalone forms and action modals); the actions run like field actions ($get/$set on the surrounding form).
  • Widgets: make:alfakit-widget Revenue. Extend StatsOverviewWidget and return Stat::make('Revenue', '$48,210')->description('+12%')->color('success')->chart([...]) from getStats() (->placeholder('—') for null/empty values, globally with Stat::configureUsing(fn (Stat $stat) => $stat->placeholder('—'))). Charts can override protected function getChartAssistiveContent(): string|Htmlable|null for screen readers. Register them with ->widgets([...]) on the panel. Chart widgets: protected ?string $color = 'info', protected bool $isCollapsible = true, empty state when getData() returns [] ($emptyStateHeading, $emptyStateDescription, $emptyStateIcon, override isEmpty(); buttons from protected function getEmptyStateActions(): array, run through the widget; or protected ?string $emptyStateView = 'orders-empty' + getEmptyStateViewData() for a registered component), JS callbacks in getOptions() as named strings ('callback' => 'js:money', registered with createAlfakit({ chartFormatters: { money: (v) => ... } }); never eval), Chart.js plugins with createAlfakit({ chartPlugins: [...] }), theme hooks --chart-tooltip-bg, --chart-tooltip-foreground, --chart-tooltip-border, --chart-legend-foreground, --chart-text, --chart-grid, --stat-chart-stroke-width, --stat-chart-fill-opacity, and a filter form with use AlfaSix\Alfakit\Widgets\ChartWidget\Concerns\HasFiltersSchema; + public function filtersSchema(Schema $schema): Schema read as $this->filters['startDate'] ?? null (protected bool $hasDeferredFilters = true for an Apply button; restyle the buttons by overriding public function filtersApplyAction(Action $action): Action / filtersResetAction()). The chart type comes from protected function getChartType(): string (Filament's getType(); AlfaKit's getType() is the schema type). Stats: ->descriptionIcon('trending-up', IconPosition::After), ->descriptionColor('success'), ->extraAttributes([...]); protected int|array|null $columns = 3 on the widget.
  • Widgets and relation managers are lazy by default (protected static bool $isLazy = true;): the page shows a skeleton and loads them with one Inertia deferred request (alfakitLazy) right after the first response. Set $isLazy = false to send one with the page. In HTTP tests a lazy widget is a placeholder (lazy: true) in props.page.content; request its data with the partial headers X-Inertia-Partial-Component and X-Inertia-Partial-Data: alfakitLazy.
  • List tabs on a ListRecords page: public function getTabs(): array { return ['all' => Tab::make(), 'published' => Tab::make()->modifyQueryUsing(fn (Builder $query) => $query->where('is_published', true))->badge(fn () => Post::where('is_published', true)->count())->icon('circle-check')]; } (AlfaSix\Alfakit\Schemas\Components\Tabs\Tab); the active tab is ?activeTab=, getDefaultActiveTab() picks the default. Tab options: ->excludeQueryWhenResolvingRecord() (record actions find records that left the tab), ->deferBadge() (badge counted in a deferred request), ->extraAttributes([...]).
  • Open an action from a URL: PostResource::getUrl('index', ['tableAction' => 'edit', 'tableActionRecord' => $post->getKey()]) opens that table action's modal on load (simple/modal resources, notification links); any page: protected ?string $defaultAction = 'onboarding'; (+ $defaultActionArguments, $defaultActionContext = ['table' => true, 'recordKey' => 5]) or ?defaultAction=onboarding (Filament's ?action=onboarding&actionArguments[step]=2 works too, for page actions). Only actions with a modal open this way.
  • Create/Edit/View content(): return $schema->components([Callout::make()..., $this->getFormContentComponent(), $this->getRelationManagersContentComponent()]); (getInfolistContentComponent() on View; EmbeddedTable::make() on List). Content without markers goes above the page blocks.
  • Edit/create helpers: $this->refreshFormData(['status']) (Edit/View, after an action changed $this->record); $page->saveFormComponentOnly('shipping') from a section action with ->formId('form') (saves only that component's fields; Section::make()->key('shipping')); protected function preserveFormDataWhenCreatingAnother(array $data): array { return Arr::only($data, ['category_id']); } on CreateRecord; panel ->resourceCreatePageRedirect('index') / ->resourceEditPageRedirect('view') (index|view|edit); header submit button $this->getSaveFormAction()->formId('form') in getHeaderActions().
  • Wizard create page: use AlfaSix\Alfakit\Resources\Pages\CreateRecord\Concerns\HasWizard; with protected function getSteps(): array { return [Step::make('Customer')->schema([...])]; }, optional hasSkippableSteps(): bool / getStartStep(): int; the form buttons show on the last step.
  • Relation manager tab badges: protected static ?string $badge, $badgeColor, $badgeTooltip, or public static function getBadge(Model $ownerRecord, string $pageClass): ?string (also getBadgeColor(), getBadgeTooltip()).
  • Nested resources: protected static ?string $parentResource = PostResource::class; on the child (or getParentResourceRegistration() returning PostResource::asParent()->relationship('comments')->inverseRelationship('post')); routes become /posts/{post}/comments/..., queries are scoped to the parent, creates go through the relationship, breadcrumbs start with the parent. A relation manager on the parent with protected static ?string $relatedResource = CommentResource::class; links its CreateAction / EditAction / ViewAction to those nested pages instead of opening modals (also when the manager defines form()). Generate with make:alfakit-resource Comment --nested=PostResource; link from a relation manager with protected static ?string $relatedResource = CommentResource::class;.
  • RelationGroup::make('Contacts', [PhonesRelationManager::class, EmailsRelationManager::class])->icon('users') in getRelations() shows several managers under one tab (AlfaSix\Alfakit\Resources\RelationManagers\RelationGroup).
  • getRelations() keys name the tab in ?relation=comments and prefix the manager's table query string (unkeyed tabs use their position); 'approved' => CommentsRelationManager::make(['status' => 'approved']) fills the manager's public properties (public ?string $status = null;). protected static bool $isBadgeDeferred = true; on a manager loads its tab badge in the deferred alfakitLazy request (built from getTitle()/getIcon()/getBadge()). Nested resources without an index: PostResource::asParent()->relationship('comments')->page('manageComments') (the parent page listing them; default the kebab relationship name) is their index URL in breadcrumbs and redirects.
  • Relation manager tabs: public static function getTabComponent(Model $ownerRecord, string $pageClass): Tab returns Tab::make('Comments')->icon('message-circle')->badge($ownerRecord->comments()->count())->badgeColor('warning') (AlfaSix\Alfakit\Schemas\Components\Tabs\Tab; tooltip from getBadgeTooltip()). On Edit/View pages: public function getAllRelationManagers(): array (managers for that page only), public function hasCombinedRelationManagerTabsWithContent(): bool { return true; } (form/infolist becomes a tab), getContentTabLabel(), getContentTabIcon(), getContentTabComponent(): Tab, getContentTabPosition(): ?ContentTabPosition (AlfaSix\Alfakit\Resources\Pages\Enums\ContentTabPosition::After). Panel: ->readOnlyRelationManagersOnResourceViewPagesByDefault(false) lets managers write on View pages.
  • Configurable resources: protected static ?string $configurationClass = OrderResourceConfiguration::class; (extends AlfaSix\Alfakit\Resources\ResourceConfiguration, fluent options of your own plus slug()), register ->resources([OrderResource::class, OrderResource::make('archived')->archived()]) (URL /orders/archived, or ->slug('archive')), read static::getConfiguration() / hasConfiguration() in getEloquentQuery(), getNavigationLabel()…, link with OrderResource::getUrl(configuration: 'archived') or OrderResource::withConfiguration('archived', fn () => …).
  • Configurable pages: protected static ?string $configurationClass = SettingsPageConfiguration::class; (extends AlfaSix\Alfakit\Pages\PageConfiguration), register ->pages([SettingsPage::make('general')->slug('general-settings')->category('general')]) (default URL {page-slug}/{key}), read static::getConfiguration() in mount(), getNavigationLabel()…; SettingsPage::withConfiguration('general', fn () => SettingsPage::getUrl()).
  • Pages: protected ?bool $hasDatabaseTransactions = true; runs the page's actions (saves included) in a transaction (false opts out of the panel's databaseTransactions(); an action's databaseTransaction() wins). public static function isTenantSubscriptionRequired(Panel $panel): bool on a page or resource exempts it from (or requires) the billing subscription. protected static bool $isDiscovered = false; keeps a resource, page or widget out of discovery. Resources: protected static bool $shouldCheckPolicyExistence = false; sends every check to the Gate (Gate::before()/Gate::define() decide; a missing policy method denies), protected static ?string $tenantRelationshipName = 'posts'; creates records through $tenant->posts(), getGlobalSearchEloquentQuery() eager-loads for search results, Resource::scopeToTenant(false) in a provider turns tenant scoping off for all resources.
  • Labels and breadcrumbs: protected static bool $hasTitleCaseModelLabel = false; keeps the model label's case in navigation and titles; protected static ?string $breadcrumb = 'Reports'; gives a custom page a breadcrumb (resource pages: their last breadcrumb).
  • ManageRelatedRecords page (AlfaSix\Alfakit\Resources\Pages\ManageRelatedRecords): protected static string $relationship = 'comments'; plus form() / table(); register as 'comments' => Pages\ManagePostComments::route('/{record}/comments'). Actions behave as in a relation manager. Generate with make:alfakit-related-records-page.
  • API resources (records from a REST API or any non-Eloquent source): make:alfakit-api-resource Supplier [--url=https://erp.example.com/api] [--panel=admin] writes SupplierResource extends \AlfaSix\Alfakit\Resources\ApiResource (protected static ?string $dataSource = SupplierDataSource::class;), pages extending ListApiRecords / CreateApiRecord / EditApiRecord / ViewApiRecord (AlfaSix\Alfakit\Resources\Pages, never ListRecords etc.: getEloquentQuery() throws) and the data source. A \AlfaSix\Alfakit\Resources\Api\DataSource implements records(RecordsQuery $query): iterable (rows to search/sort/page in memory, or a LengthAwarePaginator it paged itself; $query->search, sortColumn, sortDirection, filter('status'), page, perPage, tab, tenant, owner), find(string $key): ?array, create(array $data): array, update(string $key, array $data): array, delete(string $key): void, optional search(string $search, int $limit): iterable (enables global search) and getRecordKey() / getRecordTitle(); HttpDataSource does it for JSON REST APIs ($endpoint, getBaseUrl(), request() for auth headers, $recordsKey = 'data', $totalKey = 'meta.total', $recordKey, mapRecord(), mapPayload(), $errorFields). Records reach closures as ApiRecord (an unsaved Eloquent model: $record['name'], $record->save() / delete() go to the source). API 422 {errors: {field: [...]}} and thrown ValidationExceptions become field errors; other failures (DataSourceException) an error notification. Authorise by overriding canViewAny(), canCreate(), canEdit(Model $record), canView(), canDelete(), canDeleteAny() or setting protected static ?string $policy. Scope every call to $this->getTenant() yourself (no query scope applies). $cacheSeconds caches reads. Relation-like sections from a source: class OrdersRelationManager extends \AlfaSix\Alfakit\Resources\RelationManagers\ApiRelationManager { protected static string $dataSource = OrderDataSource::class; } (the source reads getOwnerRecord()). In HTTP tests use Http::fake() for HttpDataSources.
  • Singular resource (settings): the only page extends ManageSingularRecord ('index' => Pages\ManageSettings::route('/')); it edits the first record and creates it on the first save. Generate with make:alfakit-resource Setting --singular.
  • Global search result buttons: public static function getGlobalSearchResultActions(Model $record): array { return [Action::make('comments')->url(...)]; } (URL actions only).
  • Dashboard filters: use AlfaSix\Alfakit\Pages\Dashboard\Concerns\HasFiltersForm; on a Dashboard subclass with public function filtersForm(Schema $schema): Schema { return $schema->components([DatePicker::make('startDate'), DatePicker::make('endDate')]); }. Applied values are validated and kept in the session (per panel, tenant, dashboard); widgets read them as $this->pageFilters['startDate'] ?? null with use AlfaSix\Alfakit\Widgets\Concerns\InteractsWithPageFilters;. In HTTP tests apply them with the applyFilters action (data = the filter values) and reset with resetFilters. Fields marked live() apply during the live refresh (POST /_alfakit/schema/refresh on component: form) and the renderer reloads the widgets; the Apply button stays for non-live fields and disappears when every field is live (hasLiveFilters(), hasOnlyLiveFilters()). public function persistsFiltersInSession(): bool { return false; } on the dashboard keeps them in the URL (?filters[startDate]=…; apply/reset redirect, values are unvalidated user input, live() waits for Apply). Filters in a slide-over instead: use AlfaSix\Alfakit\Pages\Dashboard\Concerns\HasFiltersAction; and return [FilterAction::make()->schema([...])] (AlfaSix\Alfakit\Pages\Dashboard\Actions\FilterAction) from getHeaderActions().
  • Widget properties: OrdersByStatus::make(['status' => 'pending']) sets the widget's public properties (public ?string $status = null;); the same class can be listed several times (ids get a suffix from the properties). Works in getHeaderWidgets()/getFooterWidgets() of any page and getWidgets() of a Dashboard subclass. Unknown or non-public properties throw.
  • Widgets reading the list table: use AlfaSix\Alfakit\Widgets\Concerns\InteractsWithPageTable; on a header/footer widget of a ListRecords page, optional protected function getTablePage(): string { return ListOrders::class; }; $this->getPageTableQuery()->sum('total') (tab, filters, search applied, no sort, tenant-scoped), $this->getPageTableRecords() (current page), $this->getPageTableRecordsCount(). State comes from the page URL, so polling and lazy widgets keep following it. Test with inertiaGet('/admin/orders?activeTab=paid&filters[status][value]=x') and read the widget's stats in props.page.content.
  • Several dashboards: ->pages([Dashboard::class, SalesDashboard::class]) where class SalesDashboard extends \AlfaSix\Alfakit\Pages\Dashboard overrides getWidgets(). Only Dashboard (or a subclass named Dashboard) lives at the panel root; other subclasses get /{slug} (/admin/sales-dashboard) unless they declare $routePath.
  • Personal dashboards: $panel->customizableDashboards()->availableDashboardWidgets([TopCustomers::class]) or Dashboard::customizable() (in a service provider; false opts a dashboard out, null follows the panel); publish the table with vendor:publish --tag=alfakit-dashboard-migrations. Users reorder (drag or arrow keys), resize, hide and add widgets; layouts are stored per panel, dashboard, user and tenant and the default comes from getWidgets()/getColumns(). Limit sizes with protected static ?array $columnSpanOptions = [1, 'full']; or ->allowedColumnSpans([1, 2]); name library entries with $libraryLabel / $libraryDescription; offer a different library by overriding getAvailableWidgets(). Widgets are filtered by canView(). Save in tests with POST /{panel}/_alfakit/dashboard/layout {token, layout: [{id, span, hidden}]} or {token, reset: true} (token from the Widgets block's customize.token).
  • Custom page: make:alfakit-page Settings. Build it with content(Schema $schema), or set protected static ?string $component to render your own frontend page. Route parameters: protected static ?string $routePath = '/reports/{period}'; passes them to mount(mixed ...$parameters) in order ($parameters[0]); such a page has no navigation item, link to it with Reports::getUrl(['period' => 'month']).
  • App pages (any interactive screen: POS, board, calendar, inbox): make:alfakit-page Pos --component [--adapter=react|vue|svelte] [--fullscreen] [--resource=Order] writes the page ($component = 'Pages/Pos', getViewData(), an example #[Expose] method) and the component, registered under pages in resources/js/alfakit/app.* and ssr.*. Mark callable methods with #[\AlfaSix\Alfakit\Pages\Attributes\Expose(rateLimit: 20, authorize: ['create', Order::class], rules: ['items' => 'required|array'], messages: [...], transaction: true)] (only public non-static methods with the attribute can be called, through POST /_alfakit/pages/call); scalar, backed enum and TemporaryUpload parameters are cast, other parameters injected. Inside: $this->reloadProps('cart') (Inertia partial reload of view data), $this->formState('sale') (validates a named form sent with forms: ['sale'], errors under forms.sale.*), return redirect() to navigate, throw ValidationException for 422 errors, send toasts with Notification::make()->send(). Scope every query to alfakit()->getTenant() yourself and authorise per record (Gate::authorize('update', $order)): exposed methods are not resource queries. Named pieces: public {name}Form(Schema $schema): Schema, {name}Table(Table $table): Table, {name}Infolist(Schema $schema): Schema (blocks form:{name}, table:{name}, infolist:{name}, rendered with <PageForm name> etc.; initial form state via $this->formsData['sale'] = [...] in mount()). protected static string $layout = 'fullscreen'; (no sidebar/topbar; $hasFullscreenHeader = false for kiosks; exit link from getExitUrl()). Test with postJson('/admin/_alfakit/pages/call', ['token' => $token, 'method' => 'checkout', 'arguments' => [...], 'forms' => [...]]), $token from props.page.token of an Inertia GET of the page.

Navigation, clusters and panel options

  • Clusters: make:alfakit-cluster Settings creates class Settings extends Cluster (AlfaSix\Alfakit\Clusters\Cluster). The command adds ->discoverClusters(in: app_path('Alfakit/Clusters'), for: 'App\\Alfakit\\Clusters') to the panel provider when the panel doesn't discover clusters yet; otherwise register it like a page (->pages([Settings::class])) and add pages/resources with protected static ?string $cluster = Settings::class;. Clustered URLs are prefixed with the cluster slug (/admin/settings/tags); route names and getUrl() don't change. The main navigation shows one cluster item; the pages appear as sub-navigation.
  • Record sub-navigation: public static function getRecordSubNavigation(\AlfaSix\Alfakit\Resources\Pages\Page $page): array { return $page->generateNavigationItems([Pages\ViewUser::class, Pages\EditUser::class]); } on the resource. Pages the user can't open for the record are skipped. Position with protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top; (Start, End, Top; AlfaSix\Alfakit\Navigation\SubNavigationPosition) on a page, resource or cluster. A Start/End column sits beside the content only when the content area is at least 42rem (container query); narrower it becomes a scrolling row of links.
  • Panel options: ->collapsibleNavigationGroups(false), ->sidebarWidth('18rem'), ->collapsedSidebarWidth('4rem'), ->simplePageMaxContentWidth(Width::Large), ->spaUrlExceptions([url('/reports'), '*/exports/*']), ->databaseTransactions() (actions and page saves run in a transaction; per action ->databaseTransaction(false)), ->strictAuthorization() (missing policies/methods deny), ->errorNotifications(false) and ->registerErrorNotification('Title', 'Body', 403) (error notifications replace Inertia's error modal when app.debug is off), ->hiddenErrorNotification(403) (caught, nothing shown) and ->disabledErrorNotification(503) (default handling). Per page: protected ?bool $hasErrorNotifications = false; or hasErrorNotifications(): bool, and protected function setUpErrorNotifications(): void { $this->registerErrorNotification('Title', 'Body', 503); $this->hiddenErrorNotification(404); }. In the icon-only sidebar a NavigationGroup with ->icon() opens its items in a flyout.
  • Accounts: ->multiFactorAuthentication([AppAuthentication::make()->recoverable(), EmailAuthentication::make()], isRequired: false) (AlfaSix\Alfakit\Auth\MultiFactor\App\AppAuthentication, ...\Email\EmailAuthentication) needs ->login()->profile() and the user model to implement HasAppAuthentication / HasAppAuthenticationRecovery / HasEmailAuthentication (traits InteractsWithAppAuthentication, InteractsWithAppAuthenticationRecovery, InteractsWithEmailAuthentication in AlfaSix\Alfakit\Auth\MultiFactor\Concerns, columns app_authentication_secret, app_authentication_recovery_codes (text) and has_email_authentication (boolean)). Users set methods up on the profile page; login then goes through /{panel}/multi-factor-challenge. ->multiFactorAuthenticationRememberDevice(30) adds "trust this device". ->emailChangeVerification() keeps a new profile email pending until the emailed link is opened. AppAuthentication::make()->recoverable()->regenerableRecoveryCodes(false) removes "New recovery codes" from the profile.
  • Auth routes and profile: ->loginRouteSlug('sign-in'), ->registrationRouteSlug(), ->passwordResetRoutePrefix() / passwordResetRequestRouteSlug() / passwordResetRouteSlug(), ->emailVerificationRoutePrefix() / emailVerificationPromptRouteSlug() / emailVerificationRouteSlug(), ->emailChangeVerificationRoutePrefix() / emailChangeVerificationRouteSlug() (route names unchanged), ->revealablePasswords(false). Custom profile pages extend AlfaSix\Alfakit\Auth\Pages\EditProfile and reuse $this->getNameFormComponent(), getEmailFormComponent(), getPasswordFormComponent(), getPasswordConfirmationFormComponent(), getCurrentPasswordFormComponent() (protected, return Component).
  • Customer portals: $panel->portal() (top navigation, no global search, command palette, shortcuts overlay, recent records, breadcrumbs or credit; login, password reset and a full profile; 6xl content; override after it). ->invitationOnlyRegistration(expiresAfterDays: 7) (registration only from signed links: $panel->inviteToRegister($email) mails a RegistrationInvitation, getRegistrationInvitationUrl($email) returns one; the address is read-only and re-checked). ->scopeToUser('customer_id', fn (User $user) => $user->customer_id) scopes every resource's getEloquentQuery() (lists, record pages, global search) to the user, fills the owner on create and keeps it on edit and when a record version is restored (column or relationship name; guests/null keys see nothing; resource generators leave the owner column out); per resource protected static string|false|null $userOwnership = 'owner_id' (false opts out), OrderResource::scopeToUser('owner_id'), or OrderResource::make('mine')->scopeToUser('customer_id'). Scoping isn't authorisation: keep policies (read-only: override can() to allow only viewAny/view).
  • User and tenant menus take actions: ->userMenuItems(['profile' => fn (Action $action) => $action->label('Edit profile'), 'logout' => fn (Action $action) => $action->label('Log out'), [Action::make('billing')->url('/billing')->icon('credit-card')], [Action::make('feedback')->schema([...])->action(...)]]) (nested arrays are groups, ->sort() orders within one, ->postToUrl() for POST links, actions with action()/modals run as component userMenu). ->tenantMenuItems([...]) is the same with the keys profile, register and billing (component tenantMenu). MenuItem::make() still works but is deprecated.
  • Navigation items: protected static ?string $navigationParentItem = UserResource::class; (parent by class or label; page/resource items are keyed by class, custom ones with NavigationItem::make('Docs')->key('docs'), children merged and sorted). Static setters from a plugin or provider: UserResource::navigationGroup('Team'), ::navigationLabel(), ::navigationSort(), ::navigationIcon(), ::navigationParentItem() (that class only). NavigationItem::make('Orders')->badge(fn () => 12)->badgeColor(fn (int $badge) => $badge > 10 ? 'danger' : 'gray')->badgeTooltip(fn (int $badge) => "{$badge} new")->extraAttributes(['class' => '…', 'data-tour' => 'orders']); $badge is injected into badge colour/tooltip closures of actions, tabs and relation groups too. getNavigationLabel(): string|Htmlable / NavigationItem::make(new HtmlString(...)) and HasName::getAlfakitName(): string|Htmlable on tenants render sanitised HTML. ->navigation(fn (): bool => …): true keeps auto-discovered items, false hides them; a closure returning a NavigationBuilder builds the navigation.
  • Relation tabs: RelationGroup::make(...)->badge(fn () => …)->deferBadge(); on Edit/View pages with hasCombinedRelationManagerTabsWithContent(), override getCombinedTabComponents() (['comments' => $this->getRelationManagerTabComponent('comments'), '' => $this->getContentTabComponent(), 'tags' => …]) to place the content tab between relation tabs.
  • Panel layout: ->globalSearch(position: GlobalSearchPosition::Sidebar) and ->userMenu(position: UserMenuPosition::Topbar) (AlfaSix\Alfakit\Support\Enums; also globalSearchPosition() / userMenuPosition()), ->breadcrumbs(hasNavigationHierarchy: true) (group and parent item first), ->subNavigationPosition(SubNavigationPosition::Top) (default for pages, clusters, resources), ->domains(['admin.example.com', 'admin.example.org']), ->assets([Css::make('id', $path), Js::make(...)], package: 'app/admin') (this panel only), ->tenantRoutePrefix('team') (/admin/team/{tenant}). HasCurrentTenantLabel::getCurrentTenantLabel() on the tenant is a caption above its name in the tenant menu.
  • Panel chrome: ->topbar(false), ->themeSwitcher(false), ->defaultThemeMode(ThemeMode::Dark), ->searchableTenantMenu() (automatic above 10 tenants; false to never), ->tenantSwitcher(false) (menu keeps its items, lists no other tenants), ->defaultAvatarProvider(InitialsAvatarProvider::class) or UiAvatarsProvider (AlfaSix\Alfakit\AvatarProviders\*; custom: implement AvatarProviders\Contracts\AvatarProvider::get(Model|Authenticatable $record): string; used when HasAvatar returns null and there is no avatar_url). Global search: ->globalSearchDebounce('500ms'), ->globalSearchFieldSuffix(fn () => 'Ctrl K'), ->globalSearchFieldKeyBindingSuffix(false), ->globalSearchKeyBindings(['mod+k']), ->globalSearchResourceOptIn() (only resources declaring protected static bool $isGloballySearchable = true;); on resources protected static ?int $globalSearchSort = 1; and protected static ?bool $shouldSplitGlobalSearchTerms = false;. Pages: protected static ?string $navigationBadgeTooltip, public function getWidgetData(): array (fills widgets' public properties; Widget::make([...]) wins), protected ?Alignment $headerActionsAlignment, protected array $extraBodyAttributes; clusters: protected static bool $shouldRegisterSubNavigation = false;. Inertia props: ->deferNavigationBadges() loads navigation badges with the deferred alfakitNavigationBadges prop (items with a badge show a placeholder; getNavigationBadge() is not called for the page); the translations, navigation structure, endpoints, localization, command palette and theme are once props keyed by a content hash (->onceProps(false) turns this off), and the active items and badges travel in alfakit.navigationState — read the navigation through usePanel(), which merges them back.
  • Command palette (⌘K): lists global search results plus the page's header actions, panel commands, navigation, "New …" links for resources the user can create, recent records and theme switching. Panel commands are actions: $panel->commands([\AlfaSix\Alfakit\CommandPalette\Command::make('clear-cache')->label('Clear cache')->keywords(['flush'])->group('Maintenance')->description('…')->shortcut('mod+shift+x')->requiresConfirmation()->visible(fn (): bool => …)->action(fn () => cache()->flush()), Command::make('orders')->url(fn (): string => OrderResource::getUrl())->shortcut('g o')]) (run through the action endpoint with component commands and the normal authorisation; links are never executed). ->commandPalette(false) turns the extras off; ->recentRecords(8) / ->recentRecords(false) (visits of view/edit pages, per user, panel and tenant, in the cache; re-authorised with the resource query and policy on every read); protected static bool $hasRecentRecords = false; on a resource keeps it out.
  • Keyboard shortcuts: built-ins / (table search), g d (dashboard), ? (overlay listing every shortcut on the page; users change, turn off or reset keys, stored per user and panel: publish --tag=alfakit-user-preferences-migrations for a table, else the cache). Bindings: up to three space-separated steps of mod|ctrl|alt|shift|meta+key (mod = ⌘/Ctrl). Commands use ->shortcut(), actions ->keyBindings([...]). ->keyboardShortcuts(false) or ->keyboardShortcuts(customizable: false) on the panel.
  • Record history: use \AlfaSix\Alfakit\Versions\HasRecordVersions; on the model (publish --tag=alfakit-record-versions-migrations; optional protected array $versionInclude = [...], protected array $versionExclude = [...], protected int $maxVersions = 50;) snapshots tracked attributes on create/update/delete/restore with author and tenant (a record from before the trait gets a baseline version of its earlier values on its first tracked update); $model->recordVersions() (alias versions()), Model::withoutVersions(fn () => …), $model->restoreRecordVersion($version). Edit/view pages of the resource get \AlfaSix\Alfakit\Actions\HistoryAction (slide-over with field-by-field and word diffs; viewing needs view/update, "Restore this version" needs update and writes a new version); protected static ?bool $hasVersions = false; on the resource hides it; place HistoryAction::make()->attributeLabels([...])->limit(30)->restorable(fn () => …) yourself to configure it. Prune with php artisan alfakit:prune-versions --days=365 / --keep=20 [--model=App\Models\Post].
  • Custom routes: ->routes(fn (Router $router, Panel $panel) => $router->get('/invitations/{user}/{hash}', AcceptInvitation::class)->middleware('signed')->name('auth.invitation.accept')) adds guest-reachable routes inside the panel group (path, alfakit.{id}. names, panel middleware); ->authenticatedRoutes(fn (Router $router) => ...) adds them behind the auth middleware (and tenant prefix).
  • Impersonation: ImpersonateAction::make() (AlfaSix\Alfakit\Actions\ImpersonateAction) in a users table's recordActions(). The impersonator must implement AlfaSix\Alfakit\Contracts\CanImpersonate (canImpersonate(): bool); targets can refuse with CanBeImpersonated (canBeImpersonated(): bool); an impersonate(User $user, User $target) policy method, when present, must allow it (use it to protect higher roles). A banner with Leave (POST /{panel}/impersonation/leave) shows meanwhile (in starter-kit mode also on the app's own pages, through the header components and alfakitNavigation[].widgets.impersonation). After switching, the user lands on the panel home: the first navigation page they may open (canAccess()), or the app's dashboard route on a starter-kit panel where they may open none.
  • Tenancy extras: ->tenantDomain('{tenant:slug}.example.com') or ->tenantDomain('{tenant:domain}') (tenant from the host; set SESSION_DOMAIN), ->tenantBillingProvider(new MyBillingProvider) (AlfaSix\Alfakit\Billing\Providers\Contracts\BillingProvider: getRouteAction(), getSubscribedMiddleware()) with ->requiresTenantSubscription() (adds /{tenant}/billing and a Billing tenant-menu item), ->tenantUploadDirectory() (files under tenants/{key}/). Database notifications sent inside a tenant stay in that tenant (->tenant($team) / ->forAllTenants() on the notification). Relation managers only list the current tenant's records of tenant-owned related models, also under a shared owner record ($isScopedToTenant = false).
  • Tenancy audit: php artisan alfakit:tenancy:check [--panel=app] [--strict] [--shared=Currency] lists, per tenancy panel, each resource's model and whether it is scoped / has the ownership relationship, each relationship Select/CheckboxList (checked against a relationship repeater's or group's own model when nested in one)/SelectFilter/AttachAction/AssociateAction with whether its related model is tenant-scoped or shared, and DB::/withoutGlobalScopes() usages in resource, page and relation-manager files, as OK/WARN/FAIL rows. Exit code 1 on FAIL (and on WARN with --strict). Mark deliberately shared models with #[AlfaSix\Alfakit\Tenancy\SharedAcrossTenants].
  • Icons: Lucide names, heroicon-o-* names / Heroicon::Outlined* cases (mapped to Lucide; use the enum inside closures or Icons::register()), image URLs and HtmlString SVG (sanitised). Icons::registerSet('app', resource_path('svg')) adds an SVG icon set (app-logo). Icon aliases: Icons::register(['alfakit::actions.delete' => 'trash']) (AlfaSix\Alfakit\Support\Icons) in a service provider changes Alfakit's own icons; register your own ('app::orders' => 'shopping-cart') too. Aliases are resolved on the server for every icon option: component, entry, column, action, modal, notification, navigation, menu item, relation manager and stat icons, field prefixIcon/suffixIcon/hintIcon, Toggle/ToggleColumn onIcon/offIcon, option icons([...]), query-builder constraint and block icons.

Inside a Laravel starter kit

  • php artisan alfakit:install --starter-kit (or alfakit:setup, which picks it when resources/js/layouts/app-layout.tsx, AppLayout.vue or AppLayout.svelte exists) wires a panel into an app made from Laravel's React, Vue or Svelte starter kit. It writes resources/js/pages/Alfakit/Page.(tsx|vue|svelte), resources/js/components/alfakit-nav.tsx (AlfakitNav.vue, AlfakitNav.svelte, built from the app's @/components/ui/sidebar), adds <AlfakitNav /> after <NavMain /> in the app sidebar (block marked alfakit:nav), adds <AlfakitSearch />, <AlfakitNotifications />, <AlfakitCopilot /> and <AlfakitLanguageSwitcher /> to app-sidebar-header and app-header (AppSidebarHeader, AppHeader; block marked alfakit:header, skip with --no-header-widgets; running the installer again with the same --panel/--path adds components a block written by an earlier version lacks, and alfakit:upgrade notes a header without <AlfakitLanguageSwitcher />), wraps the row of placeholder boxes of the kit's dashboard page (resources/js/pages/dashboard.tsx, Dashboard.vue, Dashboard.svelte) in <AlfakitWidgets> (block marked alfakit:widgets, skip with --no-dashboard-widgets), imports @alfakit/<kit>/standalone.css in resources/css/app.css, and creates a panel provider with ->starterKit() and ->databaseNotifications() (the header bell; the notifications table migration is created too). The entry resources/js/app.* is not changed: Inertia resolves Alfakit/Page from resources/js/pages. Don't create resources/js/alfakit/app.* in this mode.
  • $panel->starterKit(rootView: 'app', loginRoute: 'login') (alias ->embedded()): the host's root view and Fortify login (AlfaKit's login, registration, password reset and profile pages are off), pages inside the host AppLayout, no AlfaKit theme or appearance on the document (the host's shadcn tokens and dark mode apply). Keep ->middleware(['web']); add ->authMiddleware(['verified']) to match the kit's dashboard. The navigation is shared with every Inertia page as the alfakitNavigation prop (the panel's own alfakit.navigation is empty; tenancy panels share empty groups outside a tenant), each entry with widgets (endpoint, search, notifications, tenancy) for the header components. ->multiFactorAuthentication() is ignored while the host signs users in (use the kit's Fortify two-factor settings); it applies again only with ->login().
  • Widgets in starter kit mode: the panel has no dashboard of its own; its widgets (->widgets([...]), ->discoverWidgets(in: app_path('Alfakit/Widgets'), for: 'App\\Alfakit\\Widgets'), which the generated provider has; or a registered Dashboard subclass's getWidgets() and getColumns()) appear on the host's /dashboard through <AlfakitWidgets /> (@alfakit/react, @alfakit/vue, @alfakit/svelte; props panel, components for custom widget components, class/className; children/default slot render when there are no widgets). So php artisan make:alfakit-widget BlogStats --type=stats shows up on /dashboard with nothing else to register (it prints where). The widgets are shared as the alfakitWidgets prop ([{ id, component }], a Standalone\StarterKitDashboard payload bound to the panel) only on the routes of $panel->starterKitDashboard('dashboard') (route names or patterns; false turns it off), and are null for guests, users whose canAccessPanel() is false and tenancy panels. Lazy widgets and polling go through GET /{path}/_alfakit/standalone/components, which re-checks the user, canAccessPanel() and the panel's auth middleware; each widget's canView(), $sort and $columnSpan apply. With a registered Dashboard page that is customizable (->pages([Dashboard::class])->customizableDashboards()), <AlfakitWidgets /> shows the user's layout and Edit dashboard, saving that page's layout (customize.token is the Dashboard page's; the page also gets a sidebar link, hide it with a subclass that sets protected static bool $shouldRegisterNavigation = false;). A lazy widget whose request fails shows an error with a Retry button (LazyWidgetSchema.failed, set in the browser).
  • Frontend: the page file exports createStarterKitPage({ layout: AppLayout, discover: import.meta.glob('../../alfakit/{fields,columns,entries,widgets,pages,components,blocks}/**/*.tsx'), components: { 'map-picker': MapPicker } }) (@alfakit/react, @alfakit/vue with *.vue); Svelte uses export const layout = createStarterKitLayout({ layout: AppLayout, discover: import.meta.glob('…/**/*.svelte') }) in <script module> and renders <AlfakitPage />. discover auto-registers what the generators write in resources/js/alfakit/*; other custom components go in components. The sidebar reads useAlfakitNavigation(panelId?) (groups[].items[] with title, href, icon, isActive, badge, badgeTooltip, openInNewTab, children; render icons with <Icon name={item.icon} />) and userMenuItems (the panel's userMenuItems() link entries and plugin entries such as Billing's: title, href, icon, openInNewTab, method, startsGroup; entries with action() or a modal are left out); the installer generates alfakit-user-menu-items (AlfakitUserMenuItems.vue/.svelte, the app's own DropdownMenuItems) and puts <AlfakitUserMenuItems /> before Log out in the kit's user menu (block alfakit:user-menu). AlfaKit's sidebar, top bar (and its Slot::TOPBAR_* content) and user menu are not rendered in this mode; its command palette/global search, database notifications bell, tenant switcher, language menu and the AI plugin's copilot go in the host header as <AlfakitSearch />, <AlfakitNotifications />, <AlfakitTenantSwitcher />, <AlfakitLanguageSwitcher /> and <AlfakitCopilot /> (same names in @alfakit/react, @alfakit/vue, @alfakit/svelte; optional panel prop; without it they, and useAlfakitNavigation(), follow the open AlfaKit page's panel, else the first panel that has that part, so a Teams workspace on a second starterKit() panel works; alfakit:add teams adds <AlfakitTenantSwitcher /> to the alfakit:header block). They work on every host page, render nothing for guests or without globalSearch()/commands, databaseNotifications(), tenant(), two or more locales() (and localeSwitcher() not false; languageSwitcher() positions don't apply) or AiPlugin::make()->copilot(), and lazy-load their code and the panel data (GET /{path}/_alfakit/starter-kit). The header palette lists search results, navigation, create links and recent records (no panel commands, page actions or theme items); the key bindings (globalSearchKeyBindings(), ⌘K) are registered once. <AlfakitLanguageSwitcher /> shows the current language at once (alfakitNavigation[].widgets.language), posts the pick to the panel's _alfakit/locale endpoint and reloads; AlfaKit's pages and the header components then use the new language (the panel data is loaded per language), and in a right-to-left language the whole page mirrors: <html lang dir> follows the shared alfakitDocument prop on every page, the root view's <html> gets dir="{{ \AlfaSix\Alfakit\Support\StarterKitDocument::direction() }}" and the kit's <Sidebar> gets side from useAlfakitDirection() (both added by the installer, blocks marked alfakit:direction). The same exports are available from @alfakit/<kit>/starter-kit (areas also have /kanban, /cms, /translation-manager, /ai, /tree and /realtime subpaths); the renderers ship one file per module with only CSS marked as side effects, so a host that imports useAlfakitNavigation, Icon and the header components from the package root bundles only those (the panel runtime loads with Alfakit/Page).

Plugins

php artisan make:alfakit-plugin Blog --vendor=acme [--frontend=react|vue|svelte] creates packages/blog: BlogPlugin implements AlfaSix\Alfakit\Contracts\Plugin (make(), getId(), register(Panel $panel), boot(Panel $panel); add with $panel->plugin(BlogPlugin::make())), a service provider and composer.json. Plugin CSS/JS go in the panel <head> with AlfaSix\Alfakit\Support\Assets\Assets::register([Css::make('blog', __DIR__.'/../resources/dist/blog.css'), Js::make('blog', $path)->module()], package: 'acme/blog') (optionally panel: 'admin'); local files are published by php artisan alfakit:assets (run by alfakit:upgrade, which alfakit:install adds to composer's post-autoload-dump). ->loadedOnRequest() keeps one out of <head>: send Assets::getStyleHref('id', 'acme/blog') / getScriptSrc() in the component's getSchemaProps() and call loadStyle(href) / loadScript(src) from @alfakit/core in the component. Js::make('analytics')->html('<script>…</script>') renders an inline script (no path; <script> tags get the CSP nonce). Assets::registerCssVariables(['blog-accent' => '#f50'], package: 'acme/blog') sets :root variables; Assets::registerScriptData(['blog' => [...]]) fills window.alfakitData (getScriptData('blog') in @alfakit/core). Renderer components are exported by the plugin's frontend package and spread into createAlfakit({ components }).

Declare compatibility in the plugin's composer.json under extra.alfakit: {"version": "^1.0", "frontend": "@acme/blog"} (version is a Composer constraint on alfasix/alfakit; frontend is the optional npm package and must be in the app's package.json at the plugin's major). php artisan alfakit:upgrade reports every installed plugin and @alfakit/* npm package as compatible / incompatible / unknown against the installed version and only fails with --strict. Report deprecated APIs with AlfaSix\Alfakit\Support\Deprecations::warn('Table::actions()', 'use recordActions() instead', since: '1.2') (logged once per process; Deprecations::all() / toDebug() list them, flush() resets). Every page shares alfakit.schemaContract (AlfaSix\Alfakit\Support\SchemaContract::VERSION); renderers show a translated message instead of the page when its major differs from SCHEMA_CONTRACT_VERSION in @alfakit/core.

Notifications

Notification::make()->title(__('Saved'))->body(__('Your changes were saved.'))->success()->send();
Notification::make()->title('Export ready')->sendToDatabase($user); // needs ->databaseNotifications() on the panel
Notification::make()->title('Deployed')->success()->broadcast($user); // live toast; needs ->realtime() (or ->realtimeNotifications()) and Laravel Echo
Notification::make()->title('New comment')->sendToDatabase($user, isEventDispatched: true); // also refreshes open bells live

Database notification actions: Action::make('unread')->markAsUnread() (AlfaSix\Alfakit\Notifications\Actions\Action; also ->color(), ->icon(), ->size(Size::Small), ->button()/->link()). Notification::make()->color('success') tints the toast/bell background (status() still picks the icon), ->date($iso) sets the bell date. Show a toast from JS with showToast({ id, title, status, format: 'alfakit' }) from @alfakit/react|vue|svelte. Tests outside page tests: Notification::fake() then Notification::assertNotified('Saved') / assertNotNotified() (without the fake they read the session flash). Custom Laravel notifications return Notification::make()->title('Paid')->getDatabaseMessage() from toDatabase() and ->getBroadcastMessage() from toBroadcast(). Open the bell panel with a page action ->openDatabaseNotifications() or openDatabaseNotifications() from @alfakit/core. Close a toast by id with closeNotification('greeting') from @alfakit/core (ids: Notification::make('greeting') or $notification->getId(); from PHP a notification action can ->dispatch('alfakit:close-notification', ['id' => 'greeting'])). Put the bell in the sidebar with ->databaseNotifications(position: DatabaseNotificationsPosition::Sidebar) (AlfaSix\Alfakit\Support\Enums; also ->databaseNotificationsPosition('sidebar')); topbar(false) moves it there by default. Renderers show <NotificationsMenu variant="sidebar"> in the sidebar when panel.databaseNotifications.position === 'sidebar'.

Toast placement: ->toastPosition(ToastPosition::BottomEnd) (AlfaSix\Alfakit\Support\Enums\ToastPosition; start/end follow the reading direction).

Emails

AlfaKit's and its plugins' emails share a branded theme (default panel's brand name, logo, primary colour, font; dir/lang from the locale the mail is rendered in; dark variant; CSS inlined). Options in config('alfakit.mail'): theme, verify_email, panel, name, logo (PNG; SVG panel logos are skipped), dark_logo, logo_height, primary_color, font, url. Put your own mails in the theme:

use AlfaSix\Alfakit\Mail\MailMessage; // notifications: instead of Laravel's MailMessage
return (new MailMessage)->subject('Export ready')->line('Download it within 24 hours.')->action('Download', $url);

use AlfaSix\Alfakit\Mail\Concerns\UsesAlfakitMailTheme; // Mailables with new Content(markdown: ...)

Preview page: $panel->pages([\AlfaSix\Alfakit\Pages\MailPreview::class]) (local/debug, the viewAlfakitMailPreview gate, or MailPreview::authorize(fn (User $user): bool => ...)) renders every registered mail light/dark and LTR/RTL. Register mails (preview with unsaved models) and make them editable by admins (alfasix/alfakit-mail-templates):

use AlfaSix\Alfakit\Mail\{EditableTemplates, MailDefinition, Mails};
use AlfaSix\Alfakit\Mail\Concerns\UsesAlfakitMailTheme;
use AlfaSix\Alfakit\Mail\Concerns\UsesEditableTemplate;

Mails::register(MailDefinition::make('orders.shipped')->label(fn () => __('Order shipped'))->group(fn () => __('Orders'))
    ->preview(fn () => new OrderShipped(Order::factory()->make()))
    ->editable(mergeTags: ['name' => 'Customer', 'button' => 'Track button'], subject: 'Order {{ name }}', body: fn () => MailDefinition::paragraphs('Hi {{ name }},', '{{ button }}')));

class OrderShipped extends Mailable { use UsesAlfakitMailTheme, UsesEditableTemplate;
    public function envelope(): Envelope { return new Envelope(subject: $this->templateSubject('Order shipped')); }
    public function content(): Content { return $this->templateContent(new Content(markdown: 'mail.shipped')); }
    public function templateKey(): string { return 'orders.shipped'; }
    public function templateData(): array { return ['name' => $this->order->customer_name, 'button' => EditableTemplates::button($url, 'Track')]; }
}
// Notifications: return $this->templateMailMessage((new MailMessage)->...);

Merge tag values are escaped unless Htmlable (EditableTemplates::button() / link()); templates apply per locale only. MailTheme::render($view, $data) / renderText(), MailTheme::brandUsing(fn (?Panel $panel) => new MailBrand(...)), MailPreview::renderMail(Mails::get($key), 'ar') in tests. Publish views with --tag=alfakit-mail.

Real-time collaboration

$panel->realtime() (off by default; bool|Closure, optional connection:; also ->broadcastConnection('reverb')) turns on, over Laravel broadcasting + Echo: presence avatars on View/Edit pages ("Dana is editing"), a non-blocking conflict banner on Edit pages when someone else saves (review the changed fields vs your unsaved values, reload, keep mine), live tables/widgets and instant bell refreshes for stored database notifications. While off nothing is registered. Set up with php artisan alfakit:realtime, then php artisan reverb:start and a queue worker.

$panel->realtime()->broadcastModelChanges(); // resource models are observed automatically (keys only are broadcast)
$table->live(); // alias liveUpdates(debounce: 500, newRecordsIndicator: true, fallbackPollingInterval: '30s', models: [Post::class])
StatsOverview::make()->live([Order::class]); // widgets; or protected bool|Closure $hasLiveUpdates = true; protected array|Closure|null $liveModels = [...]
class Comment extends Model { use \AlfaSix\Alfakit\Realtime\BroadcastsToAlfakit; } // models without a resource
  • Channels (authorised by AlfaKit with the panel guard, panel access, tenant membership and policies): private-alfakit.{panel}.{tenant}.{Model-Class} (viewAny) and presence-alfakit.{panel}.{tenant}.{Model-Class}.{key} (view or update on the record via the resource's tenant-scoped query); {tenant} is the tenant key or _. API resources' presence channels name the resource class instead of the model (…{tenant}.App-Alfakit-Resources-SupplierResource.{key}, always with the tenant on tenancy panels) and find the record through the data source. Echo authenticates at /{panel}/_alfakit/broadcasting/auth.
  • Changes dispatch AlfaSix\Alfakit\Realtime\RecordChanged after commit with {event, model, key, by, at} only, never attributes; skip some with shouldBroadcastToAlfakit(string $event): bool on the model. Test with Event::fake([RecordChanged::class]).
  • Optimistic concurrency works without realtime: Edit pages send the loaded record version (recordVersion, a keyed hash of the row) and a stale save is refused (error notification + banner, validation key alfakitConflict). On by default; ->detectConflicts(false) on the panel or protected static ?bool $detectsConflicts = false; on an EditRecord page. Code calling $page->save($data) isn't checked.
  • Frontend: Echo comes from window.Echo, createAlfakit({ echo: (options) => new Echo({ ...options, broadcaster: 'reverb', Pusher }) }), or config('alfakit.broadcasting.echo') (public options; laravel-echo/pusher-js are optional peers loaded dynamically). Without Echo, live tables/widgets poll at fallbackPollingInterval.

Installable app (PWA) and Web Push

$panel->pwa() (off by default) adds a web app manifest (/{path}/manifest.webmanifest, name/short_name from the brand, theme_color from primary, lang/dir from the locale, start_url/scope = panel path), a dependency-free service worker (/{path}/sw.js with Service-Worker-Allowed: precaches the panel's Vite entries, cache-first build assets, network-first pages that are never stored, offline page /{path}/offline, never caches /_alfakit/*), head tags (manifest, light/dark theme-color, apple-touch-icon), an "Install app" item in the user menu and command palette (iOS Safari: "Add to Home Screen" hint) and a "A new version is available — Reload" toast after deploys.

$panel->pwa(
    icons: ['192x192' => '/icons/icon-192.png', '512x512' => '/icons/icon-512.png', ['src' => '/icons/maskable.png', 'sizes' => '512x512', 'purpose' => 'maskable']],
    push: true,                    // Web Push for database notifications
    name: 'Acme', shortName: 'Acme', description: null,
    themeColor: '#0f766e', darkThemeColor: '#042f2e', backgroundColor: '#ffffff',
    manifest: ['categories' => ['business']], // extra members, merged last
);
  • Without icons: an SVG favicon/logo is listed as-is and 192/512/maskable PNGs are generated (GD) from a raster favicon/logo in public/ or the brand initial.
  • Starter kit panels: add @alfakitPwa to the <head> of resources/views/app.blade.php for the PWA head tags.
  • Push setup: php artisan alfakit:pwa-keys (writes ALFAKIT_PWA_VAPID_PUBLIC_KEY/_PRIVATE_KEY to .env; --show, --force), set ALFAKIT_PWA_VAPID_SUBJECT, php artisan vendor:publish --tag=alfakit-pwa-migrations && php artisan migrate (table alfakit_push_subscriptions). No composer package needed (OpenSSL encryption + VAPID). Every Notification::make()->sendToDatabase($user) is also pushed to the user's subscribed browsers (title, plain-text body, first action URL); push anything else with AlfaSix\Alfakit\Pwa\PushNotifications::send($user, ['title' => '…', 'body' => '…', 'url' => '/admin/…']).
  • Custom root views: add {{ $alfakitPanel->renderPwaHeadTags() }} to <head>. Frontend: pwaStore, promptPwaInstall(), applyPwaUpdate(), subscribeToPush(), unsubscribeFromPush(), togglePushNotifications() from @alfakit/core; menu items carry data-slot="pwa-install" / "pwa-push" (in a starter kit the push toggle is in <AlfakitNotifications />'s menu); the offline page is the alfakit::pwa.offline view.

Testing

Use the page test helpers (AlfaSix\Alfakit\Testing\PageTester, via alfakit()->testPage()). They load the page and run forms, actions and tables through the real internal endpoints, so validation, policies and tenancy apply. Authenticate first with actingAs().

alfakit()->testPage(EditPost::class, ['record' => $post])
    ->fillForm(['title' => 'Hello'])        // runs afterStateUpdated via the refresh endpoint
    ->assertFormSet(['slug' => 'hello'])
    ->call('save')                          // `create` on create pages
    ->assertHasNoFormErrors()               // also fails on 403/404
    ->assertNotified('Saved');

alfakit()->testPage(CreatePost::class)->fillForm(['title' => ''])->call('create')
    ->assertHasFormErrors(['title' => 'required']);

alfakit()->testPage(ListPosts::class)
    ->searchTable('Hello')->sortTable('title', 'desc')->filterTable('is_published', true)
    ->assertCanSeeTableRecords([$post])->assertCanNotSeeTableRecords([$draft])
    ->callTableAction(DeleteAction::class, $post)
    ->callTableBulkAction('delete', [$other])          // null records = "select all"
    ->assertTableActionHidden('delete', $locked);

alfakit()->testPage(SettingsPage::class)
    ->assertActionVisible('publish')->assertActionHidden('purge')
    ->callAction('rename', ['name' => 'New'])           // opens the modal, fills, submits
    ->mountAction('rename')->fillForm(['name' => ''])->callMountedAction()->assertHasActionErrors(['name'])
    ->callAction('stop')->assertActionHalted('stop')
    ->assertActionHasIcon('publish', 'send')->assertActionHasColor('publish', 'success')
    ->assertActionListInOrder(['publish', 'archive'])
    ->mountAction('edit')->mountAction(['edit', 'preview'])   // `edit` stays mounted underneath
    ->assertMountedActionModalSee('Preview')->assertMountedActionModalSeeHtml('<strong>');

alfakit()->testPage(ListPosts::class)
    ->assertTableColumnStateSet('price', 1250, $post)               // raw state
    ->assertTableColumnFormattedStateSet('price', '$12.50', $post)  // what the cell shows
    ->assertTableColumnHasDescription('title', 'By Ada', $post)
    ->assertTableFilterHidden('secret')->removeTableFilters()->toggleAllTableColumns()
    ->assertTableColumnSummarySet('price', 'sum', 4200);

alfakit()->testPage(EditPost::class, ['record' => $post])
    ->relationManager(CommentsRelationManager::class)   // table helpers now drive the manager
    ->assertCanSeeTableRecords($post->comments)->callTableAction('approve', $comment)
    ->relationManager(null)
    ->assertSchemaComponentExists('shipping')            // sections, tabs, fields by key
    ->assertSchemaStateSet(['status' => 'Draft'], 'infolist');

alfakit()->testPage(CreateOrder::class)                 // HasWizard page or a Wizard in the form
    ->fillForm(['customer' => 'Ada'])->goToNextWizardStep()->assertWizardCurrentStep(2)
    ->goToWizardStep(3)->goToPreviousWizardStep();

ProductImporter::test()->import(['name' => 'Mug', 'sku' => 'M-1'])->assertImported();
ProductImporter::test()->import(['name' => ''])->assertHasErrors(['name' => ['required']]);
expect(ProductExporter::test(['name' => 'Name'])->export($product))->toBe(['Mug']);
$imports = ImportAction::fake(); /* run the action */ $imports->assertDispatched(ProductImporter::class);

Other panels: alfakit()->testPage(ListProjects::class, ['tenant' => $team], panel: 'app'). Hidden fields and actions aren't sent to the browser, so "hidden" assertions also pass for names that don't exist. Raw Inertia requests still work:

$this->actingAs($admin)
    ->get('/admin/posts', \AlfaSix\Alfakit\Testing\PageTester::inertiaHeaders()) // X-Inertia + the app's asset version (else 409)
    ->assertOk()
    ->assertJsonPath('props.page.content.0.type', 'Table');

Debugging

  • php artisan alfakit:doctor checks the installation (versions, renderer package and version match, Vite manifest, SSR, panels/resources/policies, translation overrides, storage:link, queue, broadcasting for ->realtime(), APP_KEY, debug in production, migrations of opted-in features, caches) and prints fixes; --json for CI, --strict fails on warnings. Run it first when something does not render or a feature silently does nothing. Packages add checks with Doctor::extend(fn () => [DoctorCheck::ok('Group', 'Label', 'message')]).
  • $panel->devTools() (default: on in local, never unless app.debug is on) adds a floating schema inspector: the page props as a searchable JSON tree with copy, what the context token identifies (panel, page, route parameters, expiry; never secrets), the Server-Timing server time, payload size and editor links for the page/resource/model classes (APP_EDITOR). Replace it with the panel:dev-tools registry key.
  • $panel->debugPanel() (default: on in local, and never unless app.debug is on) adds a bug icon to the top bar. The drawer shows the page's resolved schema tree with visibility and authorisation per component and the reason a node is hidden, the live form state, every internal request with server time (Server-Timing) and sizes, query count, memory and the closures that ran (owner, file, line, count). Nothing is sent in production.
  • In tests, alfakit()->testPage(EditPost::class, ['record' => $post])->dump() prints the same tree (->dd() stops; ->getDebugTree() returns the array) without enabling the panel.

Pitfalls

  • Never import Filament\... classes; the API is similar, but the namespaces are AlfaSix\Alfakit\....
  • Classes and pages live in the panel's discovery directories; run php artisan alfakit:optimize-clear if a new one doesn't show up.
  • Don't build JavaScript for things PHP can express. Custom frontend code is only for new visual components. See the alfakit-frontend-components skill.
  • Notification bodies, modal descriptions, helper text and hints escape plain strings. Wrap trusted markup in new HtmlString(...).
  • ->html() / ->markdown() on TextColumn/TextEntry, string content of Html::make() and RichContentRenderer are sanitised on the server (Symfony HtmlSanitizer); an Htmlable from code is trusted. Opt out only for trusted content: ->html(sanitize: false), Html::make($s)->sanitize(false), RichContentRenderer::make($c)->sanitize(false). Widen the allowlist per panel with ->htmlSanitizer(fn (HtmlSanitizerConfig $config) => $config->allowElement('iframe', ['src'])); sanitise your own output with AlfaSix\Alfakit\Support\Html::sanitize($html).
  • url() on columns, entries, actions, navigation/menu items, notification actions and stats keeps only http(s), mailto, tel and relative URLs (javascript: etc. become no link). Use AlfaSix\Alfakit\Support\Url::sanitize($url) for URLs you output yourself.
  • ColorColumn / ColorEntry only send plain CSS colours (hex, rgb()/hsl()/oklch()… with numbers, keywords); sanitise colours you inline yourself with Str::sanitizeCssColor($c) (null when unsafe). Fluent versions: str($html)->sanitizeHtml(), str($url)->sanitizeUrl(), str($c)->sanitizeCssColor().
  • Relationship options, unique() and exists() are scoped to the current tenant automatically; use AlfaSix\Alfakit\Support\TenantScope::apply($query) for your own queries in tenant panels.
  • In tenant panels, implement AlfaSix\Alfakit\Contracts\HasDefaultTenant on the user to choose which tenant the panel root and login redirect to (it must be a tenant the user can access; otherwise the first tenant is used).
  • Relation managers are read-only on View pages; override isReadOnly() to change that.
  • ->tenantRegistration(RegisterTeam::class) and ->tenantProfile(EditTeamProfile::class) need your own concrete subclass of RegisterTenant / EditTenantProfile; the base classes are abstract and are rejected with an InvalidArgumentException. Their getLabel() labels the tenant menu's profile / registration links.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource