Originally published at hafiz.dev
You know the resource is in trouble when scrolling to table() takes three full page-downs past the form definition. When every pull request touches the same file because everything lives in one place. When adding a column to the table means scrolling past 400 lines of form fields to find where the table starts.
Filament resources grow faster than almost any other file in a Laravel project. The framework makes it easy to define forms, tables, actions, filters, and relations in one class, which is great for small models. But for anything complex, that single class becomes the most dangerous file in your codebase: too long to navigate, too coupled to test, too fragile to change.
I hit this with a resource that managed a multi-step onboarding flow. The form had tabs, conditional fields, repeater components, and custom validation. The table had 15 columns, 6 filters, and 4 custom actions. The resource file was 1,247 lines. Every time I opened it, I spent more time scrolling than coding.
This post walks through four levels of extraction I used to bring it back under control. Each level is independent. You can apply one, two, or all four depending on how large your resource is and how much refactoring time you have.
Level 1: Split form and table into methods
This takes five minutes and immediately makes the file navigable.
Instead of defining everything inline in form() and table(), extract logical sections into static methods within the same class:
class OrderResource extends Resource
{
public static function form(Form $form): Form
{
return $form->schema([
Tabs::make('Order')
->tabs([
Tab::make('Details')->schema(static::detailsFormSchema()),
Tab::make('Shipping')->schema(static::shippingFormSchema()),
Tab::make('Payment')->schema(static::paymentFormSchema()),
]),
]);
}
protected static function detailsFormSchema(): array
{
return [
TextInput::make('order_number')->disabled(),
Select::make('status')->options(OrderStatus::labels()),
Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->preload(),
// ... 15 more fields
];
}
protected static function shippingFormSchema(): array
{
return [
TextInput::make('shipping_address'),
TextInput::make('shipping_city'),
Select::make('shipping_country')
->options(Country::pluck('name', 'code')),
// ...
];
}
protected static function paymentFormSchema(): array
{
// ...
}
}
Do the same for the table:
public static function table(Table $table): Table
{
return $table
->columns(static::tableColumns())
->filters(static::tableFilters())
->actions(static::tableActions())
->bulkActions(static::tableBulkActions());
}
protected static function tableColumns(): array
{
return [
TextColumn::make('order_number')->sortable()->searchable(),
TextColumn::make('customer.name')->sortable(),
BadgeColumn::make('status')->colors(OrderStatus::colors()),
TextColumn::make('total')->money('eur')->sortable(),
// ...
];
}
This doesn't reduce line count, but it makes the file navigable. You can collapse methods in your IDE. The form() method becomes 10 lines instead of 200. You can jump directly to shippingFormSchema() when that's the section you need to edit.
When to use this: Always. Even for small resources, this is good practice. It costs nothing and pays off immediately.
Level 2: Extract form sections into separate classes
When a form section has 20+ fields or complex conditional logic, extract it into its own class. This is where the real line count reduction happens.
Create a class that extends Filament\Forms\Components\Section:
namespace App\Filament\Forms\Sections\Order;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
class ShippingSection extends Section
{
public static function make(string $heading = 'Shipping'): static
{
return parent::make($heading)
->schema([
TextInput::make('shipping_address')
->required()
->maxLength(255),
TextInput::make('shipping_city')
->required(),
Select::make('shipping_country')
->options(Country::pluck('name', 'code'))
->searchable()
->required(),
TextInput::make('shipping_postal_code'),
TextInput::make('shipping_phone'),
])
->columns(2)
->collapsible();
}
}
Now the resource references it with one line:
use App\Filament\Forms\Sections\Order\ShippingSection;
public static function form(Form $form): Form
{
return $form->schema([
Tabs::make('Order')->tabs([
Tab::make('Details')->schema(static::detailsFormSchema()),
Tab::make('Shipping')->schema([
ShippingSection::make(),
]),
Tab::make('Payment')->schema([
PaymentSection::make(),
]),
]),
]);
}
Each section class owns its fields, validation, and layout. The resource file drops from 1,200 lines to maybe 200. And if the same shipping form is needed in another resource (like a CustomerResource or a ReturnResource), you reuse the section class without duplicating a thing.
Directory structure I use:
app/Filament/Forms/Sections/
Order/
ShippingSection.php
PaymentSection.php
DetailsSection.php
Customer/
PersonalInfoSection.php
AddressSection.php
When to use this: When a form section has more than 15 fields, when the same fields appear in multiple resources, or when a section has complex conditional logic that makes the resource file hard to read.
Level 3: Extract table actions into Action classes
Filament table actions defined inline add up fast. A single action with a confirmation modal, form fields, and business logic can be 30-50 lines. Four actions and you're at 200 lines just for the actions array.
The solution: create dedicated Action classes. Here's a pattern adapted from Johannes Pichler's approach:
namespace App\Filament\Actions\Order;
use Filament\Tables\Actions\Action;
use Filament\Notifications\Notification;
class MarkAsShippedAction
{
public static function make(): Action
{
return Action::make('mark-as-shipped')
->label('Mark Shipped')
->icon('heroicon-o-truck')
->requiresConfirmation()
->action(function ($record) {
$record->update(['status' => 'shipped']);
$record->customer->notify(new OrderShippedNotification($record));
Notification::make()
->title('Order marked as shipped')
->success()
->send();
})
->visible(fn ($record) => $record->status === 'processing');
}
}
In the resource, the action registration becomes one line:
protected static function tableActions(): array
{
return [
MarkAsShippedAction::make(),
SendInvoiceAction::make(),
RefundAction::make(),
Tables\Actions\EditAction::make(),
];
}
Each action is testable independently. Each action can be reused across resources if needed. And the resource file doesn't grow every time you add a new action.
When to use this: When actions contain business logic beyond simple CRUD. If the action is just EditAction::make() or DeleteAction::make(), leave it inline. Extract when the action has custom logic, confirmations, form fields, or notifications.
For a broader framework on when to extract logic into Actions versus Services, the Service vs Action vs Job decision tree covers the general principles.
Level 4: Use page-level overrides
Filament resources delegate to page classes for Create, Edit, View, and List. Each page can override the form or table configuration from the resource. This is useful when your create and edit forms are significantly different.
namespace App\Filament\Resources\OrderResource\Pages;
use App\Filament\Resources\OrderResource;
use Filament\Resources\Pages\CreateRecord;
use Filament\Forms\Form;
class CreateOrder extends CreateRecord
{
protected static string $resource = OrderResource::class;
public function form(Form $form): Form
{
return $form->schema([
// Simplified create form: only essential fields
TextInput::make('customer_name')->required(),
Select::make('product_id')->relationship('product', 'name'),
TextInput::make('quantity')->numeric()->default(1),
]);
}
}
The create form has 3 fields while the edit form (defined on the resource) has 40. Without this separation, you'd have conditional ->hidden() calls everywhere, making the form definition harder to follow.
When to use this: When create and edit forms differ significantly, when the list page needs a different table configuration than what the resource defines, or when a specific page has unique header actions.
How far to go
Not every resource needs all four levels. Here's the simple version:
Under 200 lines: Don't refactor. The resource is fine.
200-500 lines: Level 1 (method extraction). Takes five minutes. Do it.
500-1,000 lines: Levels 1 + 2 (method extraction + section classes). This handles most cases.
Over 1,000 lines: All four levels. Your resource is complex enough to justify the structure. The time spent refactoring pays back within a week of not scrolling past 400 lines of form fields to find the table definition.
The goal isn't to have the fewest lines in the resource. It's to make the resource navigable, testable, and safe to change. If you can open the file, find what you need in under 5 seconds, and change it without worrying about side effects, the refactoring worked.
For the broader architecture of a Filament-based SaaS, the full SaaS guide covers the patterns beyond individual resources. And for the design patterns that underpin these extraction techniques, Strategy and Composition are the two that matter most here.
FAQ
Does extracting form sections affect Filament's reactivity (wire:model, conditional visibility)?
No. Extracted sections are just PHP classes that return the same component arrays. Livewire's reactivity is based on the rendered component tree, not on which PHP class defined the components. ->visible(fn (Get $get) => $get('status') === 'active') works identically whether it's defined in the resource or in a section class.
Should I use Traits instead of separate classes?
Traits work for small extractions (a shared set of filters, a common set of bulk actions). For form sections and table configurations, separate classes are better because they're independently testable and don't pollute the resource's namespace. A resource with 6 traits is just as hard to navigate as a resource with 1,200 lines.
What about the rmitesh/filament-action package?
It provides an artisan command to generate Filament action classes: php artisan make:filament-action CommentAction --resource=UserResource. If you're extracting many actions across multiple resources, it saves boilerplate. For a few extractions, the manual approach in Level 3 is lighter.
How do I test extracted section classes?
Instantiate the section, call the static make() method, and assert the schema contains the expected components. You can also use Filament's testing utilities to render a full form with the section and assert field values. The key benefit: you test the section without loading the entire resource.
Can I use this approach with Filament v4/v5?
Yes. The section and action extraction patterns work across Filament v3, v4, and v5. The component API is stable. The only thing that changes between versions is import paths and some method names, which are easy to update.
The result
After applying all four levels to that 1,247-line resource, the resource file itself dropped to 187 lines. The form is a list of section references. The table is a list of column and action references. Each section and action lives in its own file, testable and reusable.
The total line count across all files is actually higher than the original single file. That's the right trade-off. Code organization isn't about fewer lines. It's about each file doing one thing, being easy to find, and being safe to change without side effects.
Working with Filament on a project that's outgrowing its current structure? Get in touch.