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
kinetix-resources
happones/kinetix
claude-code
kinetix
crud
inertia
relation-managers
multi-tenancy
authorization
ui-reuse

Guides the creation and integration of CRUD Resources in Kinetix (Controllers, Routes, Pages, Views, Forms, Tables). Activates when building or modifying controller resource paths.

Install
php artisan boost:add-skill happones/kinetix

Save this content to: .claude/skills/kinetix-resources/SKILL.md

# Kinetix Resources Integration

## When to Apply

Activate this skill when:
- Designing CRUD pages (Index, Create, Edit, Show) for database models.
- Building controllers that map models to Kinetix tables and forms.
- Designing standard Inertia.js views to display listings and forms.
- Scoping operations under tenant/team parameters (e.g. `{current_team}/posts`).
- Managing a parent record's related records (hasMany/belongsToMany) on its edit/show page via **Relation Managers**.

## Security & teams rules (REQUIRED)

- **Policy-if-exists on every endpoint.** Resource controllers must enforce the
  model policy per action (`viewAny`/`create`/`view`/`update`/`delete`/`restore`/`forceDelete`);
  `kinetix:make-resource` scaffolds an `authorizeAction()` helper for this.
  Without a policy every check is skipped — create it
  (`make:policy {Model}Policy --model={Model}`) and keep routes in the `auth` group.
- **One scoping point.** All reads and record resolution go through
  `{Resource}::getEloquentQuery()` (`->findOrFail($id)` — never implicit
  route-model binding, never an inline `where('team_id', …)` in the controller).
- **Writes go through the resource's save hook.**
  `{Model}::create({Resource}::mutateFormDataBeforeSave($form->getState(...), 'create'))`
  and `$record->update(...->mutateFormDataBeforeSave(..., 'edit', $record))` — the
  hook stamps `team_id` on create and strips it on edit. `team_id` never
  belongs in a form schema.
- **Relation managers declare `$relatedResource`.** A manager with
  Attach/Associate actions or create/edit modals sets
  `protected static ?string $relatedResource = {Related}Resource::class;` — the
  pickers and the ids they accept then resolve through that resource's
  `getEloquentQuery()`, and modal saves run its `mutateFormDataBeforeSave()`.
  No resource for the related model? Override `getRelatedQuery()` /
  `mutateFormDataBeforeSave()` on the manager (the parent is `$this->parent`).
  With neither, the pickers list every team's records and created children get
  no `team_id`.
- **Team-safe redirects.** Use `{Resource}::getUrl('index')`, never
  `route('x.index')` — the bare route call throws under a `{current_team}` prefix.
- **Gate Create explicitly.** `CreateAction::make()->authorize('create', {Model}::class)`
  — it has no record, so it renders for everyone otherwise.
- **Register permissions.** Override `permissionFeature()` (return the slug) so the
  resource's CRUD abilities appear in the role matrix; sync with `kinetix:permissions:sync`.

## UI reuse (DRY — REQUIRED)

Never re-write a component's classes to imitate an existing Kinetix component:

- **Buttons**: use `<KinetixButton variant="…" size="…" :loading :disabled>` —
  it owns the shadcn recipe plus the pending contract (loading → disabled +
  spinner + aria-busy). Map an action's status color with
  `actionButtonVariant(color)`. Only when a component genuinely can't be used
  (a `<DropdownMenuTrigger>`, a link) compose classes with
  `buttonVariants({ variant, size })` from `useKinetixShadcnVariants` — never
  a hand-copied class string.
- **Modals**: build on `primitives/KinetixModal.vue` (the shadcn new-york-v4
  dialog shell: overlay/panel animation, header/footer stacks, close button,
  focus trap, Kinetix z-scale) — never a hand-rolled Teleport + overlay div.
- **Checkboxes/selects/inputs**: `<KinetixCheckbox>`, `<KinetixSelect>`, the
  form field components.

A duplicated class string is a bug: when the base component evolves, the copy
silently drifts.

## Documentation

For component details, reference:
- [Kinetix Tables Reference](https://happones.github.io/kinetix/tables)
- [Kinetix Forms Reference](https://happones.github.io/kinetix/forms)

## Localizing labels

Every human-facing string across the resource's `table()`/`form()`/`infolist()`
is **your app's copy** — wrap it in Laravel's `__()` so it's translatable
(`->label(__('posts.fields.title'))`, `->heading(__('posts.table.heading'))`). For
the sidebar entry, override `getNavigationLabel()` to return `__('posts.nav')`
(you can't call `__()` in the `$navigationLabel` property default). Attribute-derived
column/field labels need no wrapping. See the **kinetix-locale** skill.

---

## CRUD Architecture Pattern

A Kinetix Resource represents a complete administrative module. It integrates a **Kinetix Table** on the index listing page and a **Kinetix Form** on the create/edit views.

### 1. Standard Resource Controller

Encapsulate schema definitions in dedicated helper methods (`getTable()` and `getForm()`) to keep actions lightweight and clean.

```php
namespace App\Http\Controllers;

use App\Models\Article;
use App\Enums\ArticleStatus;
use Happones\Kinetix\Tables\Table;
use Happones\Kinetix\Tables\Columns\TextColumn;
use Happones\Kinetix\Tables\Columns\ToggleColumn;
use Happones\Kinetix\Forms\Form;
use Happones\Kinetix\Forms\Components\Grid;
use Happones\Kinetix\Forms\Components\TextInput;
use Happones\Kinetix\Forms\Components\Select;
use Happones\Kinetix\Actions\Action;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    /**
     * Define the Index listing table
     */
    protected function getTable(): Table
    {
        return Table::make(Article::query())
            ->heading('Articles')
            ->description('Manage your database publications.')
            ->striped()
            ->columns([
                TextColumn::make('title')->searchable()->sortable(),
                TextColumn::make('status')
                    ->badge()
                    ->badgeColor(fn ($state) => $state === 'published' ? 'success' : 'gray'),
                ToggleColumn::make('is_featured')->label('Featured'),
            ])
            ->recordActions([
                Action::make('edit')
                    ->icon('edit')
                    ->url(fn ($record) => route('articles.edit', $record)),
            ]);
    }

/**
     * Define the Create/Edit form
     */
    protected function getForm(Article $article): Form
    {
        return Form::make($article)
            ->schema([
                Grid::make(12)->schema([
                    TextInput::make('title')
                        ->required()
                        ->maxLength(200)
                        ->columnSpan(8),

                    Select::make('status')
                        ->options(ArticleStatus::class)
                        ->required()
                        ->columnSpan(4),
                ]),
            ]);
    }

    public function index()
    {
        return inertia('Articles/Index', [
            'articlesTable' => $this->getTable()->toArray(),
        ]);
    }

public function create()
    {
        $form = $this->getForm(new Article())->fill();

        return inertia('Articles/Create', [
            'articleForm' => $form->toArray(),
        ]);
    }

    public function store(Request $request)
    {
        $form = $this->getForm(new Article());
        $form->validate($request->all());
        
        Article::create($form->getState($request->all()));

        return redirect()->route('articles.index');
    }

    public function edit(Article $article)
    {
        $form = $this->getForm($article)->fill($article);

        return inertia('Articles/Edit', [
            'articleForm' => $form->toArray(),
        ]);
    }

public function update(Request $request, Article $article)
    {
        $form = $this->getForm($article);
        $form->validate($request->all());

        $article->update($form->getState($request->all()));

        return redirect()->route('articles.index');
    }
}
```

### 2. Frontend Index View (`resources/js/pages/Articles/Index.vue`)

Use `<KinetixTable>` to render the listing with built-in sorting, filtering, and inline column editing.

```vue
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import KinetixTable from '@/components/kinetix/KinetixTable.vue';
import type { KinetixTableData } from '@/types/kinetix';

defineProps<{
    articlesTable: KinetixTableData;
}>();
</script>

<template>
    <div class="max-w-7xl mx-auto py-10 px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-xl font-bold">Publications</h1>
            <button
                @click="router.get(route('articles.create'))"
                class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-semibold shadow"
            >
                New Article
            </button>
        </div>
        
        <KinetixTable :table="articlesTable" />
    </div>
</template>
```

### 3. Frontend Edit View (`resources/js/pages/Articles/Edit.vue`)

Bind the serialized form DTO directly onto `<KinetixForm>`.

```vue
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import KinetixForm from '@/components/kinetix/KinetixForm.vue';

const props = defineProps<{
    articleForm: any;
}>();

const handleSubmit = (values: Record<string, any>) => {
    router.put(route('articles.update', { article: values.id }), values, {
        preserveScroll: true,
    });
};
</script>

<template>
    <div class="max-w-3xl mx-auto py-10 px-4 sm:px-6">
        <h2 class="text-lg font-bold mb-6">Modify Publication Details</h2>
        
        <KinetixForm :form="articleForm" @submit="handleSubmit" />
    </div>
</template>
```

---

## Relation Managers

To manage a parent's related records on its edit/show page, extend `RelationManager`. It scopes a Kinetix Table to `$parent->{relationship}()` and namespaces its query string (`posts_search`, `posts_page`, …) so multiple managers coexist.

```php
use Happones\Kinetix\Resources\RelationManager;
use Happones\Kinetix\Tables\Table;
use Happones\Kinetix\Tables\Columns\TextColumn;

class PostsRelationManager extends RelationManager
{
    protected static string $relationship = 'posts';

    public function table(Table $table): Table
    {
        return $table->columns([
            TextColumn::make('title')->searchable()->sortable(),
        ]);
    }
}

// Edit/show controller — pass the record so canViewForRecord() gating applies.
return inertia('Users/Edit', [
    'relations' => collect(UserResource::relationManagersFor('edit', $user))
        ->map(fn (string $m) => $m::make($user)->toData())
        ->values(),
]);
```

```vue
<!-- Auto-tabs when >1 manager (with getBadge() counts); plain section when 1. -->
<KinetixRelationManagers :managers="relations" />
```

Key rules: resolve the PARENT through `Resource::getEloquentQuery()->findOrFail($id)` (never implicit route-model binding — a team-prefixed URL would render another team's record; `kinetix:make-resource` scaffolds this). **Modal CRUD**: declare `form()` (+ optional `infolist()`) on the MANAGER and flag actions `->modal('create'|'edit'|'view'|'delete')` — the manager wires parent-bound endpoints automatically (create goes THROUGH the relationship so the FK/pivot is stamped server-side; edit/delete resolve through it, foreign ids 404; parent `update` policy + child policy gate everything). Never add a parent select/FK field to the manager's form. `recordModals()` inside a manager THROWS — the modal convention above replaces it. `getBadge()` puts counts on the tab; `canViewForRecord(Model $parent, string $page)` is the record/user-aware gate; `$title` passes through `__()`. BelongsToMany: `AttachAction`/`DetachAction` (picker of unattached records inside `$relatedResource`'s query, labelled by `$recordTitleAttribute` — an accessor works when `$recordSelectSearchColumns` names the real columns to search/sort; detach = pivot-only); **pivot columns**: declare `->withPivot('role')` on the relationship, then `TextColumn::make('pivot.role')` displays/sorts/searches (real Pivot model hydrated; custom `->as()` accessor supported). **Writing pivot data** (all paths only touch `withPivot()` columns): `AttachAction::make()->form([TextInput::make('role')])` collects pivot fields in the attach modal (validated server-side against the manager's own form; written to every attached record's pivot row; a non-pivot field throws at serialize); plain-named fields matching pivot columns in the manager's `form()` fill from and save to the pivot row (`updateExistingPivot` on edit, attach pivot data on create; pivot wins over a same-named related attribute); editable `pivot.*` columns inline-edit the pivot row (undeclared ones throw); `TextEntry::make('pivot.role')` resolves in the view infolist. HasMany/MorphMany: `AssociateAction`/`DissociateAction` (picker of in-scope FK-NULL orphans — the endpoint accepts nothing else; dissociate = null the FK). Wrong relation type for any of the four throws at serialize time. **Relation-scoped export**: `ExportAction::make()->exporter(TaskExporter::class)` in a manager's toolbar/footer/bulk exports ONLY the parent's related records (signed descriptor in the start URL; parent `view` policy; the exporter's `$model` must match the related model and the action must use `->exporter()` — else serialize-time throw); `ImportAction` in a manager throws. `protected static bool $readOnly = true` strips all actions. `protected static bool $isLazy = true` defers the manager: only the tab stub (title + badge — keep `getBadge()` cheap) serializes until its tab is the active `?relation=`; the frontend host loads it automatically with a skeleton (deliberately NOT eager for the first tab; with several lazy managers use the tabs host — the stacked layout can only load one `?relation=` at a time). Lazy is opt-in BY DESIGN — a deferred load costs a full Inertia page revisit, so reserve it for genuinely heavy tabs; eager stays the default. **Groups**: managers sharing `protected static ?string $group = 'Attachments'` render as ONE tab (sections stacked, each with its heading; tab badge = sum of numeric member badges; `?relation=` key = raw label slugged, locale-stable; opening the group tab loads all its lazy members in one request). **Collapsible**: `$isCollapsible` adds a toggle to the section heading, `$isCollapsed` starts closed (implies collapsible); a collapsed lazy section defers its load until expanded; no-op in a plain tab (heading hidden there). For multiple standalone tables on one page (without a relation), call `Table::make($q)->queryPrefix('foo_')` directly. Full reference: [Relation Managers](https://happones.github.io/kinetix/relation-managers).

---

## Best Practices

- **Route Definitions**: Use standard resource routes:
  ```php
  Route::resource('articles', ArticleController::class);
  ```
- **Row click is on by default**: a resource table row opens its record through its `view` (else `edit`) record action — a `->route()`/`->url()` action navigates, a `->modal('view')` action opens the modal — so you do not wire `recordUrl()` for it. Turn it off with `->clickableRows(false)` (or `kinetix.tables.clickable_rows`); the "⋯" actions menu is unaffected either way. See the **kinetix-tables** skill → Clickable rows.
- **Thin Actions**: Keep actions simple. Offload all column rendering and layout blueprints to `getTable()` and `getForm()` helpers.
- **Scope Relationships**: Eager load related records inside the base query (`Article::with('author')`) to prevent N+1 queries during TextColumn rendering.
- **Validate Confidentially**: Never save validation confirmations like password checks in your model updates. Use `$field->saved(false)` to exclude them.

Kinetix Resources Integration

When to Apply

Activate this skill when:

  • Designing CRUD pages (Index, Create, Edit, Show) for database models.
  • Building controllers that map models to Kinetix tables and forms.
  • Designing standard Inertia.js views to display listings and forms.
  • Scoping operations under tenant/team parameters (e.g. {current_team}/posts).
  • Managing a parent record's related records (hasMany/belongsToMany) on its edit/show page via Relation Managers.

Security & teams rules (REQUIRED)

  • Policy-if-exists on every endpoint. Resource controllers must enforce the model policy per action (viewAny/create/view/update/delete/restore/forceDelete); kinetix:make-resource scaffolds an authorizeAction() helper for this. Without a policy every check is skipped — create it (make:policy {Model}Policy --model={Model}) and keep routes in the auth group.
  • One scoping point. All reads and record resolution go through {Resource}::getEloquentQuery() (->findOrFail($id) — never implicit route-model binding, never an inline where('team_id', …) in the controller).
  • Writes go through the resource's save hook. {Model}::create({Resource}::mutateFormDataBeforeSave($form->getState(...), 'create')) and $record->update(...->mutateFormDataBeforeSave(..., 'edit', $record)) — the hook stamps team_id on create and strips it on edit. team_id never belongs in a form schema.
  • Relation managers declare $relatedResource. A manager with Attach/Associate actions or create/edit modals sets protected static ?string $relatedResource = {Related}Resource::class; — the pickers and the ids they accept then resolve through that resource's getEloquentQuery(), and modal saves run its mutateFormDataBeforeSave(). No resource for the related model? Override getRelatedQuery() / mutateFormDataBeforeSave() on the manager (the parent is $this->parent). With neither, the pickers list every team's records and created children get no team_id.
  • Team-safe redirects. Use {Resource}::getUrl('index'), never route('x.index') — the bare route call throws under a {current_team} prefix.
  • Gate Create explicitly. CreateAction::make()->authorize('create', {Model}::class) — it has no record, so it renders for everyone otherwise.
  • Register permissions. Override permissionFeature() (return the slug) so the resource's CRUD abilities appear in the role matrix; sync with kinetix:permissions:sync.

UI reuse (DRY — REQUIRED)

Never re-write a component's classes to imitate an existing Kinetix component:

  • Buttons: use <KinetixButton variant="…" size="…" :loading :disabled> — it owns the shadcn recipe plus the pending contract (loading → disabled + spinner + aria-busy). Map an action's status color with actionButtonVariant(color). Only when a component genuinely can't be used (a <DropdownMenuTrigger>, a link) compose classes with buttonVariants({ variant, size }) from useKinetixShadcnVariants — never a hand-copied class string.
  • Modals: build on primitives/KinetixModal.vue (the shadcn new-york-v4 dialog shell: overlay/panel animation, header/footer stacks, close button, focus trap, Kinetix z-scale) — never a hand-rolled Teleport + overlay div.
  • Checkboxes/selects/inputs: <KinetixCheckbox>, <KinetixSelect>, the form field components.

A duplicated class string is a bug: when the base component evolves, the copy silently drifts.

Documentation

For component details, reference:

Localizing labels

Every human-facing string across the resource's table()/form()/infolist() is your app's copy — wrap it in Laravel's __() so it's translatable (->label(__('posts.fields.title')), ->heading(__('posts.table.heading'))). For the sidebar entry, override getNavigationLabel() to return __('posts.nav') (you can't call __() in the $navigationLabel property default). Attribute-derived column/field labels need no wrapping. See the kinetix-locale skill.


CRUD Architecture Pattern

A Kinetix Resource represents a complete administrative module. It integrates a Kinetix Table on the index listing page and a Kinetix Form on the create/edit views.

1. Standard Resource Controller

Encapsulate schema definitions in dedicated helper methods (getTable() and getForm()) to keep actions lightweight and clean.

namespace App\Http\Controllers;

use App\Models\Article;
use App\Enums\ArticleStatus;
use Happones\Kinetix\Tables\Table;
use Happones\Kinetix\Tables\Columns\TextColumn;
use Happones\Kinetix\Tables\Columns\ToggleColumn;
use Happones\Kinetix\Forms\Form;
use Happones\Kinetix\Forms\Components\Grid;
use Happones\Kinetix\Forms\Components\TextInput;
use Happones\Kinetix\Forms\Components\Select;
use Happones\Kinetix\Actions\Action;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    /**
     * Define the Index listing table
     */
    protected function getTable(): Table
    {
        return Table::make(Article::query())
            ->heading('Articles')
            ->description('Manage your database publications.')
            ->striped()
            ->columns([
                TextColumn::make('title')->searchable()->sortable(),
                TextColumn::make('status')
                    ->badge()
                    ->badgeColor(fn ($state) => $state === 'published' ? 'success' : 'gray'),
                ToggleColumn::make('is_featured')->label('Featured'),
            ])
            ->recordActions([
                Action::make('edit')
                    ->icon('edit')
                    ->url(fn ($record) => route('articles.edit', $record)),
            ]);
    }

/**
     * Define the Create/Edit form
     */
    protected function getForm(Article $article): Form
    {
        return Form::make($article)
            ->schema([
                Grid::make(12)->schema([
                    TextInput::make('title')
                        ->required()
                        ->maxLength(200)
                        ->columnSpan(8),

                    Select::make('status')
                        ->options(ArticleStatus::class)
                        ->required()
                        ->columnSpan(4),
                ]),
            ]);
    }

    public function index()
    {
        return inertia('Articles/Index', [
            'articlesTable' => $this->getTable()->toArray(),
        ]);
    }

public function create()
    {
        $form = $this->getForm(new Article())->fill();

        return inertia('Articles/Create', [
            'articleForm' => $form->toArray(),
        ]);
    }

    public function store(Request $request)
    {
        $form = $this->getForm(new Article());
        $form->validate($request->all());
        
        Article::create($form->getState($request->all()));

        return redirect()->route('articles.index');
    }

    public function edit(Article $article)
    {
        $form = $this->getForm($article)->fill($article);

        return inertia('Articles/Edit', [
            'articleForm' => $form->toArray(),
        ]);
    }

public function update(Request $request, Article $article)
    {
        $form = $this->getForm($article);
        $form->validate($request->all());

        $article->update($form->getState($request->all()));

        return redirect()->route('articles.index');
    }
}

2. Frontend Index View (resources/js/pages/Articles/Index.vue)

Use <KinetixTable> to render the listing with built-in sorting, filtering, and inline column editing.

<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import KinetixTable from '@/components/kinetix/KinetixTable.vue';
import type { KinetixTableData } from '@/types/kinetix';

defineProps<{
    articlesTable: KinetixTableData;
}>();
</script>

<template>
    <div class="max-w-7xl mx-auto py-10 px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-xl font-bold">Publications</h1>
            <button
                @click="router.get(route('articles.create'))"
                class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-semibold shadow"
            >
                New Article
            </button>
        </div>
        
        <KinetixTable :table="articlesTable" />
    </div>
</template>

3. Frontend Edit View (resources/js/pages/Articles/Edit.vue)

Bind the serialized form DTO directly onto <KinetixForm>.

<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import KinetixForm from '@/components/kinetix/KinetixForm.vue';

const props = defineProps<{
    articleForm: any;
}>();

const handleSubmit = (values: Record<string, any>) => {
    router.put(route('articles.update', { article: values.id }), values, {
        preserveScroll: true,
    });
};
</script>

<template>
    <div class="max-w-3xl mx-auto py-10 px-4 sm:px-6">
        <h2 class="text-lg font-bold mb-6">Modify Publication Details</h2>
        
        <KinetixForm :form="articleForm" @submit="handleSubmit" />
    </div>
</template>

Relation Managers

To manage a parent's related records on its edit/show page, extend RelationManager. It scopes a Kinetix Table to $parent->{relationship}() and namespaces its query string (posts_search, posts_page, …) so multiple managers coexist.

use Happones\Kinetix\Resources\RelationManager;
use Happones\Kinetix\Tables\Table;
use Happones\Kinetix\Tables\Columns\TextColumn;

class PostsRelationManager extends RelationManager
{
    protected static string $relationship = 'posts';

    public function table(Table $table): Table
    {
        return $table->columns([
            TextColumn::make('title')->searchable()->sortable(),
        ]);
    }
}

// Edit/show controller — pass the record so canViewForRecord() gating applies.
return inertia('Users/Edit', [
    'relations' => collect(UserResource::relationManagersFor('edit', $user))
        ->map(fn (string $m) => $m::make($user)->toData())
        ->values(),
]);
<!-- Auto-tabs when >1 manager (with getBadge() counts); plain section when 1. -->
<KinetixRelationManagers :managers="relations" />

Key rules: resolve the PARENT through Resource::getEloquentQuery()->findOrFail($id) (never implicit route-model binding — a team-prefixed URL would render another team's record; kinetix:make-resource scaffolds this). Modal CRUD: declare form() (+ optional infolist()) on the MANAGER and flag actions ->modal('create'|'edit'|'view'|'delete') — the manager wires parent-bound endpoints automatically (create goes THROUGH the relationship so the FK/pivot is stamped server-side; edit/delete resolve through it, foreign ids 404; parent update policy + child policy gate everything). Never add a parent select/FK field to the manager's form. recordModals() inside a manager THROWS — the modal convention above replaces it. getBadge() puts counts on the tab; canViewForRecord(Model $parent, string $page) is the record/user-aware gate; $title passes through __(). BelongsToMany: AttachAction/DetachAction (picker of unattached records inside $relatedResource's query, labelled by $recordTitleAttribute — an accessor works when $recordSelectSearchColumns names the real columns to search/sort; detach = pivot-only); pivot columns: declare ->withPivot('role') on the relationship, then TextColumn::make('pivot.role') displays/sorts/searches (real Pivot model hydrated; custom ->as() accessor supported). Writing pivot data (all paths only touch withPivot() columns): AttachAction::make()->form([TextInput::make('role')]) collects pivot fields in the attach modal (validated server-side against the manager's own form; written to every attached record's pivot row; a non-pivot field throws at serialize); plain-named fields matching pivot columns in the manager's form() fill from and save to the pivot row (updateExistingPivot on edit, attach pivot data on create; pivot wins over a same-named related attribute); editable pivot.* columns inline-edit the pivot row (undeclared ones throw); TextEntry::make('pivot.role') resolves in the view infolist. HasMany/MorphMany: AssociateAction/DissociateAction (picker of in-scope FK-NULL orphans — the endpoint accepts nothing else; dissociate = null the FK). Wrong relation type for any of the four throws at serialize time. Relation-scoped export: ExportAction::make()->exporter(TaskExporter::class) in a manager's toolbar/footer/bulk exports ONLY the parent's related records (signed descriptor in the start URL; parent view policy; the exporter's $model must match the related model and the action must use ->exporter() — else serialize-time throw); ImportAction in a manager throws. protected static bool $readOnly = true strips all actions. protected static bool $isLazy = true defers the manager: only the tab stub (title + badge — keep getBadge() cheap) serializes until its tab is the active ?relation=; the frontend host loads it automatically with a skeleton (deliberately NOT eager for the first tab; with several lazy managers use the tabs host — the stacked layout can only load one ?relation= at a time). Lazy is opt-in BY DESIGN — a deferred load costs a full Inertia page revisit, so reserve it for genuinely heavy tabs; eager stays the default. Groups: managers sharing protected static ?string $group = 'Attachments' render as ONE tab (sections stacked, each with its heading; tab badge = sum of numeric member badges; ?relation= key = raw label slugged, locale-stable; opening the group tab loads all its lazy members in one request). Collapsible: $isCollapsible adds a toggle to the section heading, $isCollapsed starts closed (implies collapsible); a collapsed lazy section defers its load until expanded; no-op in a plain tab (heading hidden there). For multiple standalone tables on one page (without a relation), call Table::make($q)->queryPrefix('foo_') directly. Full reference: Relation Managers.


Best Practices

  • Route Definitions: Use standard resource routes:
    Route::resource('articles', ArticleController::class);
    
  • Row click is on by default: a resource table row opens its record through its view (else edit) record action — a ->route()/->url() action navigates, a ->modal('view') action opens the modal — so you do not wire recordUrl() for it. Turn it off with ->clickableRows(false) (or kinetix.tables.clickable_rows); the "⋯" actions menu is unaffected either way. See the kinetix-tables skill → Clickable rows.
  • Thin Actions: Keep actions simple. Offload all column rendering and layout blueprints to getTable() and getForm() helpers.
  • Scope Relationships: Eager load related records inside the base query (Article::with('author')) to prevent N+1 queries during TextColumn rendering.
  • Validate Confidentially: Never save validation confirmations like password checks in your model updates. Use $field->saved(false) to exclude them.
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.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle