| name | structuring-filament-code |
| description | Use when writing or reviewing Filament v4+ code in a Canyon GBS app and want it maintainable and small — organizing a resource's form, infolist, and table into separate `configure()` classes; extracting fields, columns, filters, or actions into their own classes; deciding between a static `make()` factory and extending a component base class; or splitting a schema per page instead of branching by operation/context. Trigger whenever a Filament schema/table/action definition grows beyond a few lines or gains a complex closure, when adding a resource page schema, when you add record-dependent logic to a `delete`/`restore`/`forceDelete` policy method and must authorize individual records on the model's Filament bulk actions, or when you see per-page branching (`hiddenOn`/`visibleOn`/`disabledOn`, `$operation`, `$livewire instanceof`). Do not use for: non-Filament PHP (use `laravel-best-practices`), Filament file uploads (use `handling-file-uploads`), settings-page wiring (use `managing-settings`), or writing tests (use `writing-tests`). |
| user-invocable | false |
| license | Elastic-2.0 |
| metadata | {"author":"canyongbs"} |
Structuring Filament Code
The goal is to keep every Filament file as small as possible — without creating pointless ones. Two forces balance each other: don't let one class hold several definitions, and don't split out a file that is only used in one place. Practical heuristic: a class should define at most one schema/table. A resource declares a form, an infolist, and a table — three definitions — so each moves to its own configure() class; a page that declares only one schema keeps it inline. Whatever the container, pull non-trivial fields, columns, filters, and actions into their own classes so the definition reads as a short list.
Resources: Separate configure() Classes (v4)
A resource's form, infolist, and table each live in their own class with a static configure() method. The resource only delegates.
Resources/<Name>/
<Name>Resource.php
Schemas/<Name>Form.php # configure(Schema $schema): Schema
Schemas/<Name>Infolist.php # configure(Schema $schema): Schema
Tables/<Name>Table.php # configure(Table $table): Table
public static function form(Schema $schema): Schema
{
return DepartmentForm::configure($schema);
}
public static function table(Table $table): Table
{
return DepartmentsTable::configure($table);
}
class DepartmentForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
NameInput::make(),
// ...
]);
}
}
Exception: relation managers and ManageRelatedRecords pages that do not have a backing resource define their form/table inline (there is no resource to host separate schema classes). If a relation manager surfaces records that do have their own resource, reuse that resource's schema/table classes instead of duplicating them.
Extract Non-Trivial Components into Their Own Classes
Rule of thumb: if a field, column, filter, or action definition is more than a few lines, or takes a complex closure argument, give it its own class. This keeps the configure() classes to a readable list.
Co-locate extracted classes under the resource, or share them more widely as their scope grows:
Resources/<Name>/Schemas/Components/NameInput.php # resource-specific
Resources/<Name>/Tables/Columns/... # resource-specific
app/Filament/Tables/Columns/StatusColumn.php # app-wide
Truly cross-app components live in canyongbs/common.
Prefer a Static make() Factory
Default pattern: a plain class with public static function make(): <ComponentType> that builds and returns a configured base component. Pass every mandatory input as a make() parameter, so a caller cannot construct it without them. Chain extra, page-specific config at the call site.
namespace App\Filament\Resources\Posts\Schemas\Components;
use Filament\Forms\Components\TextInput;
class NameInput
{
public static function make(): TextInput
{
return TextInput::make('name')
->required()
->maxLength(255);
}
}
Pass required context in as parameters:
class AuthorSelect
{
public static function make(Organization $organization): Select
{
return Select::make('author_id')
->options($organization->users()->pluck('name', 'id'))
->required();
}
}
AuthorSelect::make($organization)->columnSpanFull();
Actions follow the same shape — a factory returning a configured Action:
class AboutAction
{
public static function make(): Action
{
return Action::make('about')
->label('About')
->modalContent(fn () => view('filament.actions.about'));
}
}
Extend the Base Class Only in Extreme Cases
Extending a component base class with a setUp() method should be rare. Use it only for a generic, reusable component that:
- works out of the box with no required parameters,
- offers optional fluent config methods, and
- needs to store config state in properties.
An extended class can be instantiated with no arguments, so it cannot enforce mandatory inputs — that is exactly why a make() factory is the default. Reach for extends only when a make() factory would be awkward: a generic, OOTB action that works with no configuration but exposes optional fluent config methods backed by config properties.
class DeactivateAction extends Action
{
protected bool | Closure $shouldRedirectToList = true;
protected function setUp(): void
{
parent::setUp();
$this->label('Deactivate');
$this->icon(Heroicon::Pause);
$this->requiresConfirmation();
$this->action(function (Model $record): void {
$record->deactivate();
$this->success();
});
$this->successRedirectUrl(fn (): ?string => $this->evaluate($this->shouldRedirectToList)
? $this->getResource()::getUrl()
: null);
}
public {
->shouldRedirectToList = ;
;
}
}
Used out of the box as DeactivateAction::make(), or configured with DeactivateAction::make()->shouldRedirectToList(false). The action callback calls $this->success() so Filament sends the success notification and runs the redirect — a custom action() that omits it never reports success.
Common already ships an action built exactly this way — CanyonGBS\Common\Filament\Actions\ArchiveAction — so reuse it rather than re-implementing archiving (see the archiving-records skill).
Bulk Actions: Authorize Individual Records
A bulk action authorizes once against the *Any policy method (deleteAny / restoreAny / forceDeleteAny) to decide whether its button shows. It does not run the per-record delete / restore / forceDelete method for each selected record. So when a per-record method holds record-dependent logic its *Any counterpart does not, a plain bulk action will process records the user may not act on individually.
Whenever you add record-dependent logic to a delete / restore / forceDelete policy method (making it diverge from deleteAny / restoreAny / forceDeleteAny), find every Filament bulk action for that model — all apps use Filament — and add ->authorizeIndividualRecords('<method>'). It runs the policy method per record and drops denied records from $records:
DeleteBulkAction::make()
->authorizeIndividualRecords('delete');
Denied records are dropped silently, so report the outcome. Return Filament's DenyResponse from the policy method (not a bare false) so the message reflects how many records were affected, and set the notification titles so the user sees the count actually processed:
use Filament\Support\Authorization\DenyResponse;
use Illuminate\Auth\Access\Response;
public function delete(User $user, User $model): bool | Response
{
if (! $model->is_admin) {
return true;
}
return DenyResponse::make('cannot_delete_admin', message: fn (int $failureCount, int $totalCount): string => $failureCount === $totalCount
? 'All selected users were admins.'
: "{$failureCount} of the selected users were admins.");
}
DeleteBulkAction::make()
->authorizeIndividualRecords('delete')
->successNotificationTitle('Deleted users')
->failureNotificationTitle(fn (int $successCount, int $totalCount): string => $successCount
? "{$successCount} of {$totalCount} users deleted"
: 'Failed to delete any users');
For failures that occur after authorization passes (e.g. $record->delete() returns false), inject BulkAction $action and call $action->reportBulkProcessingFailure('key', message: ...) once per record, then stop processing that record.
Put a Single-Use Schema on the Page
Extract a schema into its own class only when it is shared across more than one place, or when it would otherwise be one of several definitions in the same class (the reason a resource's form, infolist, and table each get a class). A page that declares only one schema does not need a separate file for it — define it inline on the page. Don't create a CreateRoleForm / EditRoleForm class that is referenced in a single place.
public function form(Schema $schema): Schema
{
return $schema->components([
NameInput::make(),
GuardNameSelect::make(),
]);
}
Extract it to a shared class only when another page, action, or place needs the same schema. Either way, keep the definition short by pulling non-trivial fields into component classes.
No Per-Context Branching
Never branch a shared schema by page or operation. Do not use hiddenOn() / visibleOn() / disabledOn(), $operation-based closures, $livewire instanceof ..., or an injected $context to change behaviour per page. Give each page its own schema — inline on the page — referencing shared component classes and chaining the differences.
Incorrect — one schema branching across pages:
Select::make('guard_name')
->disabled(fn (string $operation): bool => $operation === 'edit');
Correct — each page's inline schema reuses shared components and differs only by chained config:
public function form(Schema $schema): Schema
{
return $schema->components([GuardNameSelect::make()]);
}
public function form(Schema $schema): Schema
{
return $schema->components([GuardNameSelect::make()->disabled()]);
}
Checklist
- A class defines at most one schema/table: a resource's form / infolist / table each become separate
configure() classes; the resource just delegates.
- Single-use schema → inline on the page (
public function form(Schema $schema)); extract to a class only when shared across 2+ places. (Relation managers / ManageRelatedRecords without a resource also define inline.)
- Any multi-line or complex-closure field / column / filter / action → its own class.
public static function make(<mandatory params>) factory by default; extends + setUp() only for OOTB, no-required-param, optionally-configurable components.
- No
hiddenOn / visibleOn / disabledOn / $operation / $livewire instanceof / $context branching — each page owns its schema, reusing shared component classes.
- Added record-dependent logic to a
delete / restore / forceDelete policy method? Add ->authorizeIndividualRecords('<method>') to every Filament bulk action for that model, and return DenyResponse from the policy method for accurate failure notifications.
Related: handling-file-uploads, managing-settings, archiving-records, and writing-tests; for non-Filament PHP, the laravel-best-practices skill.