occ-therapist/form-request-validation-for-filament
Reuse Laravel Form Requests inside Filament: attach one to a schema or table filter and its rules, messages, and attributes apply automatically to matching fields, with wildcard mapping, context-aware per-page rules, and smart rule merging.
Use Laravel Form Request validation inside Filament — define your rules, messages, and attributes once, reuse them across API routes, controllers, and Filament UI.
Filament validates fields through dedicated methods like ->required() and ->email() on each input. That works well for simple forms, but validation logic often belongs in a central place — especially when the same rules already exist in a Form Request.
This plugin bridges that gap: attach a Form Request to your schema or table filters and its validation rules are automatically applied to the matching fields.
rules(), messages(), and attributes()items.*.name)'email' => ['required', 'email'] and 'email' => 'required|email'required_if, Rule::unique(), etc.)getFormRequestValidated() — optional helper trait for validated data| Context | API |
|---|---|
| Resource Create / Edit | Schema::formRequest() |
| Standalone forms / Settings pages | Schema::formRequest() |
| Action modals | Schema::formRequest() |
| Wizards (per-step validation) | Schema::formRequest() |
| Relation managers | Schema::formRequest() |
| Table filters | Table::filtersFormRequest() |
| Dependency | Version |
|---|---|
| PHP | 8.2+ |
| Laravel | 11, 12, or 13 |
| Filament | 4.x or 5.x |
composer require occ-therapist/form-request-validation-for-filament
Auto-registers via Laravel package discovery. No manual setup required.
// app/Http/Requests/StoreUserRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
];
}
public function messages(): array
{
return [
'email.unique' => 'This email address is already registered.',
];
}
public function attributes(): array
{
return [
'name' => 'full name',
];
}
}
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Schema;
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name'),
TextInput::make('email'),
])
->formRequest(
class: fn () => $this instanceof EditRecord
? UpdateUserRequest::class
: StoreUserRequest::class,
);
}
Important: Call
->formRequest()after->components()so the validation hook is appended correctly.
Validation errors appear directly on the matching input fields.
Schema::formRequest()$schema->formRequest(
class: fn (Component $livewire): string => StoreUserRequest::class,
mergeInput: fn (array $state, Component $livewire): array => $state,
);
| Parameter | Type | Required | Description |
|---|---|---|---|
class |
Closure |
Yes | Returns the Form Request class. Receives $livewire via dependency injection. |
mergeInput |
Closure |
No | Merges additional data into the simulated request input. Receives $state and $livewire. |
->formRequest(
class: fn () => match (true) {
$this instanceof CreateRecord => StorePostRequest::class,
$this instanceof EditRecord => UpdatePostRequest::class,
default => StorePostRequest::class,
},
)
->formRequest(
class: fn () => UpdateUserRequest::class,
mergeInput: fn (array $state) => [
...$state,
'role' => $this->record?->role,
'tenant_id' => filament()->getTenant()?->id,
],
)
Useful when Form Requests depend on record data, route parameters, or values not visible in the form.
Table::filtersFormRequest()$table->filtersFormRequest(
class: fn () => FilterCustomersRequest::class,
mergeInput: fn (array $state, Component $livewire): array => $state,
);
Same parameters as Schema::formRequest(). Attaches validation to the table's filter form.
| Filter mode | When validation runs |
|---|---|
| Deferred (default) | When the user clicks Apply |
Live (->deferFilters(false)) |
On every filter change |
public function form(Schema $schema): Schema
{
return $schema
->components([/* fields */])
->formRequest(class: fn () => StoreUserRequest::class);
}
Action::make('invite')
->schema(fn (Schema $schema) => $schema
->components([
TextInput::make('email'),
TextInput::make('role'),
])
->formRequest(class: fn () => InviteUserRequest::class))
->action(function (array $data): void {
// ...
});
One Form Request for the entire wizard — attach it to the root schema:
return $schema
->components([
Wizard::make([
Step::make('Account')->schema([
TextInput::make('email'),
]),
Step::make('Profile')->schema([
TextInput::make('name'),
]),
]),
])
->formRequest(class: fn () => StoreUserRequest::class);
Only fields in the active step are validated per step. Use mergeInput if rules depend on data from other steps:
mergeInput: fn (array $state, Component $livewire): array => [
...($livewire->data ?? []),
...$state,
],
class PostsRelationManager extends RelationManager
{
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('title'),
])
->formRequest(class: fn () => StorePostRequest::class);
}
}
For modal actions on the table, attach formRequest() inside the action's schema callback.
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
public function table(Table $table): Table
{
return $table
->filters([
Filter::make('created')
->schema([
DatePicker::make('from'),
DatePicker::make('until'),
]),
])
->filtersFormRequest(class: fn () => FilterCustomersRequest::class);
}
// app/Http/Requests/FilterCustomersRequest.php
public function rules(): array
{
return [
'created.from' => ['nullable', 'date'],
'created.until' => ['nullable', 'date', 'after_or_equal:created.from'],
];
}
Rules are matched to fields automatically by name — no per-field configuration needed.
| Form Request key | Filament field | Match type |
|---|---|---|
email |
TextInput::make('email') |
Exact |
address.street |
TextInput::make('address.street') |
Dot notation |
items.*.name |
Repeater child TextInput::make('name') |
Wildcard |
created.from |
Filter field DatePicker::make('from') in Filter::make('created') |
Dot notation |
Filament uses prefixed state paths internally. The plugin strips these automatically:
| Internal state path | Resolved key |
|---|---|
data.email |
email |
mountedActions.0.data.email |
email |
tableDeferredFilters.created.from |
created.from |
When a field already has Filament validation rules, both sets are merged:
TextInput::make('email')->email()->nullable()
// Form Request
'email' => ['required', 'email', 'unique:users']
Result: string, required, email, unique:users
nullable vs. required)Rules without a matching schema field still run on submit. Failures show a Filament notification instead of an inline field error.
To show the error on a field, add a matching input (visible or hidden):
Checkbox::make('terms_accepted')->label('I accept the terms'),
// or
Hidden::make('terms_accepted'),
use OccTherapist\FormRequestValidationForFilament\Concerns\InteractsWithFormRequestValidation;
class CreateUser extends CreateRecord
{
use InteractsWithFormRequestValidation;
protected function handleRecordCreation(array $data): Model
{
$data = $this->getFormRequestValidated();
return User::create($data);
}
}
formRequest() / filtersFormRequest()
│
▼
Form Request resolved with simulated HTTP request
(form state + optional mergeInput + route parameters)
│
▼
rules(), messages(), attributes() extracted
│
▼
Rules mapped to fields (exact + wildcard + state path normalization)
│
▼
Merged with existing field rules → applied on validation
│
▼
Orphan rule failures → Filament notification
| Form Request feature | Status |
|---|---|
rules() |
Supported |
messages() |
Supported |
attributes() |
Supported |
authorize() |
Not supported — use Filament policies |
prepareForValidation() |
Not supported |
withValidator() |
Not supported |
passedValidation() / failedValidation() |
Not supported |
composer test
Contributions are welcome! Please open an issue or pull request on GitHub.
MIT © occTherapist
How can I help you explore Laravel packages today?