Adds a form or any request that sends a payload the Strictly Laravel way: an input class the action type-hints, a TypedForm or useRouteForm typed by the route, and feature tests that submit it. Use when adding a form, a field, an input, validation, or a create/update/delete request.
php artisan boost:add-skill mrsafalpiya/strictly-laravel
Save this content to: .claude/skills/strictly-add-form/SKILL.md
# Add a form or input
The worked example adds a form that creates a `Project` (`name`, nullable `description`). The route's action decides the payload type: the form, its field names and its error keys all come from the input class the action type-hints.
## 1. Write the input class
An input class extends `StrictlyLaravel\Data\InputData`. Its types are the rules: non-null is required, `int` is `integer`, an array is `present`, each item of a typed list is validated, a literal union is `in:`, a date must be in the format its cast reads. Add attributes for anything else:
```php title="app/Data/StoreProjectData.php"
final class StoreProjectData extends InputData
{
public function __construct(
#[Max(80), Unique('projects')]
public string $name,
#[Max(500)]
public ?string $description,
) {}
}
```
- A nested object is a property typed by a plain `Data` class; a list is `public array $tasks` with `@param list<TaskInputData> $tasks` on the constructor. A nested class that has `rules()` extends `InputData` too, or its `rules()` replace the ones inferred from its types.
- A timestamp is a `CarbonImmutable` (or `CarbonInterface`) property, sent as `new Date().toISOString()` (`2026-10-14T09:30:00.000Z`) or as `2026-10-14T09:30:00+00:00` (DATE_ATOM, what the server sends back); a calendar date is `#[DateOnly]`, sent as `2026-10-14`. Their format rule is inferred, so don't add `#[DateFormat]`: a property with a format of its own reads only that format, not `toISOString()`'s.
- For an update where a field may be left out, type it `Optional|string` (`Spatie\LaravelData\Optional`); the key is then optional in TypeScript.
- Extra rules go in `public static function rules(): array`; they are merged with the inferred ones.
- A field that holds a model's key declares the model: `#[KeyOf(User::class)] public ?int $assigneeId`, or `#[KeyOf(Label::class)] public array $labelIds` for a list (`StrictlyLaravel\Data\KeyOf`; `#[KeyOf(Issue::class, 'number')]` for a key by another field). TypeScript then takes only that model's key for it, so fill it from the Data the server sent (`member.id`). A select's value is a string: look it up among the options' keys with `selectedKey()` (`@mrsafalpiya/strictly-laravel/routes`), which keeps the brand and returns `null` for any other value (a "none" item), never `Number(value)`. Keep the `exists` rule: it checks the row exists for this user.
```tsx
<Select onValueChange={(value) => form.setData('assigneeId', selectedKey(members.map((member) => member.id), value))}>
```
- A field typed by a backed enum takes one of its values; a select of them reads its value back with the same lookup among `enumValues` (`@/generated`): `selectedKey(enumValues.IssueStatus, value)`, typed as the enum and `null` for any other value. Never `value as IssueStatus`, which lint rejects, or `Number(value)` for an int-backed enum, which `tsc` does.
- Authorize in the input class, which runs before validation: `public static function authorize(Request $request): bool`.
- `authorize()` and `rules()` receive the route's bound models: name a model parameter after the route's (`authorize(Project $project)` for `{project}`, `rules(Label $label)` to `->ignore($label->id)`). Don't read `$request->route('project')`. A model parameter with a contextual attribute comes from the container instead (`#[CurrentUser] User $user` is the signed-in user, whatever the route's `{user}`). Only an input class gets the route's models; a query class's or a plain Data class's model parameter would be a new, empty model.
## 2. Type-hint it in the action
```php title="app/Http/Controllers/ProjectController.php"
public function store(StoreProjectData $data): RedirectResponse
{
Project::query()->create([
'name' => $data->name,
'description' => $data->description,
]);
FlashData::toast(ToastType::Success, 'Project created.');
return back();
}
```
```php title="routes/web.php"
Route::post('projects', [ProjectController::class, 'store'])->name('projects.store');
```
Read the payload only through `$data`. Never take a FormRequest (`strictly.formRequest`): its `rules()` and `authorize()` belong in the input class. An action without an input class reads no input from the request (`strictly.inputRead`), since nothing types it, and hands the request to no app code that could read it (`strictly.requestHandOff`): pass an action class, service or job the input class, or what it needs (`$request->user()`), never the request. Route the form to a controller action, never a closure: Wayfinder types a route function only from a controller action, so a closure route may neither take an input class (`strictly.closureRoute`) nor read input (`strictly.inputRead`). Every validation error must be a field of the input; a refusal that isn't about a field is a toast: `FlashData::toast(ToastType::Error, '…'); return back();`.
## 3. Build the form from the route
`<TypedForm>` takes the route's `.form()`; `field()` names each control and `errors` is keyed by the input's fields (nested ones as `'settings.notify'`, list items as `'tasks.0.title'`):
```tsx title="resources/js/components/projects/create-project-form.tsx"
import { TypedForm } from '@mrsafalpiya/strictly-laravel/react';
import { store } from '@/actions/App/Http/Controllers/ProjectController';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function CreateProjectForm() {
return (
<TypedForm {...store.form()} resetOnSuccess>
{({ errors, field, processing }) => (
<>
<Input name={field('name')} placeholder="Name" />
<InputError message={errors.name} />
<textarea name={field('description')} />
<InputError message={errors.description} />
<Button disabled={processing}>Create project</Button>
</>
)}
</TypedForm>
);
}
```
The other request helpers of `@mrsafalpiya/strictly-laravel/react`, all typed by the route:
- `const form = useRouteForm(update(project), defaults)` for a form whose state the component drives (`form.data`, `form.setData`, `form.errors`, `form.submit()`). Send `defaults` from the page as a prop typed by the input class.
- `submit(update(project), { name })` for a one-off request with a payload.
- `visit(destroy(project))` for a request without a payload.
A component that forwards `name` to a control declares it `name?: FieldName` (type `FieldName` from `@mrsafalpiya/strictly-laravel/react`).
## 4. Submit it in feature tests
Submit it valid and invalid. Between them, the accepted (valid) requests must send each nullable field both `null` and set, and each `Optional` field of an update both sent and left out, since the client may send any of them, or `ci:check:coverage` fails:
```php title="tests/Feature/Projects/CreateProjectTest.php"
test('a project is created from the form', function () {
$this->actingAs(User::factory()->create())
->from(route('dashboard'))
->post(route('projects.store'), ['name' => 'Launch', 'description' => 'The first release'])
->assertRedirect(route('dashboard'))
->assertSessionHasNoErrors();
expect(Project::query()->where('name', 'Launch')->exists())->toBeTrue();
});
test('a project needs no description', function () {
$this->actingAs(User::factory()->create())
->post(route('projects.store'), ['name' => 'Backlog', 'description' => null])
->assertSessionHasNoErrors();
});
test('a taken name is an error on the name field', function () {
Project::query()->create(['name' => 'Launch', 'description' => null]);
$this->actingAs(User::factory()->create())
->from(route('dashboard'))
->post(route('projects.store'), ['name' => 'Launch', 'description' => null])
->assertRedirect(route('dashboard'))
->assertSessionHasErrors(['name']);
});
```
Done when `composer ci:check` passes.
## When a check fails
- `ci:check:lint` fails with ``Inertia's `useForm()` is untyped: use `useRouteForm(route, defaults)` `` (likewise `<Form>`, `useHttp()`, `router.post()`): use the helper it names.
- `ci:check:lint` fails with ``Untyped field name "email": use `name={field('…')}` from `<TypedForm>`'s render prop``: name every control with `field()`.
- `ci:check:tsc` fails with `error TS2322: Type 'string' is not assignable to type 'FieldName'.`: a plain string names a control. Use `field('…')`, and type a forwarded `name` as `FieldName`.
- `ci:check:tsc` fails with `error TS2339: Property 'nmae' does not exist on type 'Partial<Record<"name"`: the error key isn't a field of the input.
- `ci:check:test` fails with `App\Http\Controllers\ProjectController::store() takes`: the action takes a FormRequest. Move its `rules()` and `authorize()` into an input class, take that instead, and read the payload from `$data`.
- `ci:check:test` fails with `App\Http\Controllers\ProjectController::store() reads input`: the action takes no input class, so nothing types what it reads (`$request->input()`, `string()`, `all()`, `validate()`, `$request->name`). Take an input class and read the field from `$data`; a GET's query string goes in a query class.
- `ci:check:test` fails with `App\Http\Controllers\ProjectController::store() hands the request to`: the action passes the request to app code (an action class, a service, a job) that could read its input unseen. Take an input class and pass it, or the values the code needs (`$request->user()`), instead.
- `ci:check:test` fails with `The closure route POST projects takes App\Data\StoreProjectData`: move the route to a controller action that takes the input class.
- `ci:check:test` fails with `A closure route reads input from the request with string()`: move the route to a controller action that takes an input class, and read the field from it.
- `ci:check:test` fails with `doesn't extend StrictlyLaravel\Data\InputData.`: the action's Data parameter must extend `InputData`.
- `ci:check:test` fails with `App\Data\BulkUpdateIssuesData::$ids is validated by an exists rule`: the field holds a model's key; declare the model with `#[KeyOf(Issue::class)]` (`strictly.keyOf`). A value that is no model's key takes `@phpstan-ignore strictly.keyOf (why)`.
- `ci:check:tsc` fails with `error TS2322: Type 'number | null' is not assignable to type '(number & { readonly [keyOf]: "User.id"; }) | null'.`: a plain number, such as `Number(value)` from a select, was sent where the input takes a model's key. Look the select's value up with `selectedKey(members.map((member) => member.id), value)`; never cast it.
- `ci:check:tsc` fails with `error TS2345: Argument of type 'number & { readonly [keyOf]: "Issue.id"; }' is not assignable to parameter of type '(number & { readonly [keyOf]: "User.id"; }) | null'.`: an issue's key was sent where the input takes a user's. Send the key of the model the field declares.
- `ci:check:test` fails with `App\Data\StoreTaskData::$task nests App\Data\TaskData, whose rules()`: a Data class with `rules()` nested in an input must extend `InputData` too (or carry `#[MergeValidationRules]` itself).
- `ci:check:test` fails with `Gate::authorize() after App\Data\StoreProjectData was validated, so`: move the check into the input's `authorize()`.
- `ci:check:test` fails with `App\Data\UpdateEmailData::authorize() takes App\Models\User $account, but the route users/{user}/email has no {account} parameter.`: name the model parameter after the route's parameter. If it says the route binds `{user}` to a string, the action must take the model (`User $user`) so the route binds it.
- `ci:check:test` fails with `App\Data\TeamFiltersData::rules() takes App\Models\User $user`: only an input class's `authorize()` and `rules()` receive the route's models (`strictly.routeModelParameter`).
- `ci:check:tsc` fails with `error TS2353: Object literal may only specify known properties, and '"project"' does not exist in type 'Partial<Record<"name" | "description" | "estimate"`: the server returned an error key that isn't an input field. Make it a field, or flash a toast instead.
- `ci:check:tsc` fails with `error TS2322: Type '"notify"' is not assignable to type '"name" | "public"'.`: the action read a request key the input doesn't declare. Add it to the input class and read it from `$data`.
- `ci:check:coverage` fails with `✗ StoreProjectData.description is only ever null (at POST /projects › description): add a feature test that submits to POST /projects with description not null.`: no accepted request sent it set. Submit one that does; only a request the server accepts counts.
- `ci:check:coverage` fails with `✗ UpdateProjectData.name is never left out (at PATCH /projects/{project} › name): add a feature test that submits to PATCH /projects/{project} without name.`: submit an update that leaves the field out.
- `ci:check:coverage` fails with `✗ POST /projects: no feature test submits to this form endpoint: add a feature test that sends App\Data\StoreProjectData to POST /projects.`: add the step 4 tests.
The worked example adds a form that creates a Project (name, nullable description). The route's action decides the payload type: the form, its field names and its error keys all come from the input class the action type-hints.
An input class extends StrictlyLaravel\Data\InputData. Its types are the rules: non-null is required, int is integer, an array is present, each item of a typed list is validated, a literal union is in:, a date must be in the format its cast reads. Add attributes for anything else:
final class StoreProjectData extends InputData
{
public function __construct(
#[Max(80), Unique('projects')]
public string $name,
#[Max(500)]
public ?string $description,
) {}
}
Data class; a list is public array $tasks with @param list<TaskInputData> $tasks on the constructor. A nested class that has rules() extends InputData too, or its rules() replace the ones inferred from its types.CarbonImmutable (or CarbonInterface) property, sent as new Date().toISOString() (2026-10-14T09:30:00.000Z) or as 2026-10-14T09:30:00+00:00 (DATE_ATOM, what the server sends back); a calendar date is #[DateOnly], sent as 2026-10-14. Their format rule is inferred, so don't add #[DateFormat]: a property with a format of its own reads only that format, not toISOString()'s.Optional|string (Spatie\LaravelData\Optional); the key is then optional in TypeScript.public static function rules(): array; they are merged with the inferred ones.#[KeyOf(User::class)] public ?int $assigneeId, or #[KeyOf(Label::class)] public array $labelIds for a list (StrictlyLaravel\Data\KeyOf; #[KeyOf(Issue::class, 'number')] for a key by another field). TypeScript then takes only that model's key for it, so fill it from the Data the server sent (member.id). A select's value is a string: look it up among the options' keys with selectedKey() (@mrsafalpiya/strictly-laravel/routes), which keeps the brand and returns null for any other value (a "none" item), never Number(value). Keep the exists rule: it checks the row exists for this user. <Select onValueChange={(value) => form.setData('assigneeId', selectedKey(members.map((member) => member.id), value))}>
enumValues (@/generated): selectedKey(enumValues.IssueStatus, value), typed as the enum and null for any other value. Never value as IssueStatus, which lint rejects, or Number(value) for an int-backed enum, which tsc does.public static function authorize(Request $request): bool.authorize() and rules() receive the route's bound models: name a model parameter after the route's (authorize(Project $project) for {project}, rules(Label $label) to ->ignore($label->id)). Don't read $request->route('project'). A model parameter with a contextual attribute comes from the container instead (#[CurrentUser] User $user is the signed-in user, whatever the route's {user}). Only an input class gets the route's models; a query class's or a plain Data class's model parameter would be a new, empty model. public function store(StoreProjectData $data): RedirectResponse
{
Project::query()->create([
'name' => $data->name,
'description' => $data->description,
]);
FlashData::toast(ToastType::Success, 'Project created.');
return back();
}
Route::post('projects', [ProjectController::class, 'store'])->name('projects.store');
Read the payload only through $data. Never take a FormRequest (strictly.formRequest): its rules() and authorize() belong in the input class. An action without an input class reads no input from the request (strictly.inputRead), since nothing types it, and hands the request to no app code that could read it (strictly.requestHandOff): pass an action class, service or job the input class, or what it needs ($request->user()), never the request. Route the form to a controller action, never a closure: Wayfinder types a route function only from a controller action, so a closure route may neither take an input class (strictly.closureRoute) nor read input (strictly.inputRead). Every validation error must be a field of the input; a refusal that isn't about a field is a toast: FlashData::toast(ToastType::Error, '…'); return back();.
<TypedForm> takes the route's .form(); field() names each control and errors is keyed by the input's fields (nested ones as 'settings.notify', list items as 'tasks.0.title'):
import { TypedForm } from '@mrsafalpiya/strictly-laravel/react';
import { store } from '@/actions/App/Http/Controllers/ProjectController';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function CreateProjectForm() {
return (
<TypedForm {...store.form()} resetOnSuccess>
{({ errors, field, processing }) => (
<>
<Input name={field('name')} placeholder="Name" />
<InputError message={errors.name} />
<textarea name={field('description')} />
<InputError message={errors.description} />
<Button disabled={processing}>Create project</Button>
</>
)}
</TypedForm>
);
}
The other request helpers of @mrsafalpiya/strictly-laravel/react, all typed by the route:
const form = useRouteForm(update(project), defaults) for a form whose state the component drives (form.data, form.setData, form.errors, form.submit()). Send defaults from the page as a prop typed by the input class.submit(update(project), { name }) for a one-off request with a payload.visit(destroy(project)) for a request without a payload.A component that forwards name to a control declares it name?: FieldName (type FieldName from @mrsafalpiya/strictly-laravel/react).
Submit it valid and invalid. Between them, the accepted (valid) requests must send each nullable field both null and set, and each Optional field of an update both sent and left out, since the client may send any of them, or ci:check:coverage fails:
test('a project is created from the form', function () {
$this->actingAs(User::factory()->create())
->from(route('dashboard'))
->post(route('projects.store'), ['name' => 'Launch', 'description' => 'The first release'])
->assertRedirect(route('dashboard'))
->assertSessionHasNoErrors();
expect(Project::query()->where('name', 'Launch')->exists())->toBeTrue();
});
test('a project needs no description', function () {
$this->actingAs(User::factory()->create())
->post(route('projects.store'), ['name' => 'Backlog', 'description' => null])
->assertSessionHasNoErrors();
});
test('a taken name is an error on the name field', function () {
Project::query()->create(['name' => 'Launch', 'description' => null]);
$this->actingAs(User::factory()->create())
->from(route('dashboard'))
->post(route('projects.store'), ['name' => 'Launch', 'description' => null])
->assertRedirect(route('dashboard'))
->assertSessionHasErrors(['name']);
});
Done when composer ci:check passes.
ci:check:lint fails with Inertia's `useForm()` is untyped: use `useRouteForm(route, defaults)` (likewise <Form>, useHttp(), router.post()): use the helper it names.ci:check:lint fails with Untyped field name "email": use `name={field('…')}` from `<TypedForm>`'s render prop: name every control with field().ci:check:tsc fails with error TS2322: Type 'string' is not assignable to type 'FieldName'.: a plain string names a control. Use field('…'), and type a forwarded name as FieldName.ci:check:tsc fails with error TS2339: Property 'nmae' does not exist on type 'Partial<Record<"name": the error key isn't a field of the input.ci:check:test fails with App\Http\Controllers\ProjectController::store() takes: the action takes a FormRequest. Move its rules() and authorize() into an input class, take that instead, and read the payload from $data.ci:check:test fails with App\Http\Controllers\ProjectController::store() reads input: the action takes no input class, so nothing types what it reads ($request->input(), string(), all(), validate(), $request->name). Take an input class and read the field from $data; a GET's query string goes in a query class.ci:check:test fails with App\Http\Controllers\ProjectController::store() hands the request to: the action passes the request to app code (an action class, a service, a job) that could read its input unseen. Take an input class and pass it, or the values the code needs ($request->user()), instead.ci:check:test fails with The closure route POST projects takes App\Data\StoreProjectData: move the route to a controller action that takes the input class.ci:check:test fails with A closure route reads input from the request with string(): move the route to a controller action that takes an input class, and read the field from it.ci:check:test fails with doesn't extend StrictlyLaravel\Data\InputData.: the action's Data parameter must extend InputData.ci:check:test fails with App\Data\BulkUpdateIssuesData::$ids is validated by an exists rule: the field holds a model's key; declare the model with #[KeyOf(Issue::class)] (strictly.keyOf). A value that is no model's key takes @phpstan-ignore strictly.keyOf (why).ci:check:tsc fails with error TS2322: Type 'number | null' is not assignable to type '(number & { readonly [keyOf]: "User.id"; }) | null'.: a plain number, such as Number(value) from a select, was sent where the input takes a model's key. Look the select's value up with selectedKey(members.map((member) => member.id), value); never cast it.ci:check:tsc fails with error TS2345: Argument of type 'number & { readonly [keyOf]: "Issue.id"; }' is not assignable to parameter of type '(number & { readonly [keyOf]: "User.id"; }) | null'.: an issue's key was sent where the input takes a user's. Send the key of the model the field declares.ci:check:test fails with App\Data\StoreTaskData::$task nests App\Data\TaskData, whose rules(): a Data class with rules() nested in an input must extend InputData too (or carry #[MergeValidationRules] itself).ci:check:test fails with Gate::authorize() after App\Data\StoreProjectData was validated, so: move the check into the input's authorize().ci:check:test fails with App\Data\UpdateEmailData::authorize() takes App\Models\User $account, but the route users/{user}/email has no {account} parameter.: name the model parameter after the route's parameter. If it says the route binds {user} to a string, the action must take the model (User $user) so the route binds it.ci:check:test fails with App\Data\TeamFiltersData::rules() takes App\Models\User $user: only an input class's authorize() and rules() receive the route's models (strictly.routeModelParameter).ci:check:tsc fails with error TS2353: Object literal may only specify known properties, and '"project"' does not exist in type 'Partial<Record<"name" | "description" | "estimate": the server returned an error key that isn't an input field. Make it a field, or flash a toast instead.ci:check:tsc fails with error TS2322: Type '"notify"' is not assignable to type '"name" | "public"'.: the action read a request key the input doesn't declare. Add it to the input class and read it from $data.ci:check:coverage fails with ✗ StoreProjectData.description is only ever null (at POST /projects › description): add a feature test that submits to POST /projects with description not null.: no accepted request sent it set. Submit one that does; only a request the server accepts counts.ci:check:coverage fails with ✗ UpdateProjectData.name is never left out (at PATCH /projects/{project} › name): add a feature test that submits to PATCH /projects/{project} without name.: submit an update that leaves the field out.ci:check:coverage fails with ✗ POST /projects: no feature test submits to this form endpoint: add a feature test that sends App\Data\StoreProjectData to POST /projects.: add the step 4 tests.How can I help you explore Laravel packages today?