A multi-step invoice creation wizard built with Filament, where each step is defined once in a central factory and reused across both a standalone create page and a Quick Invoice modal launched from the customer edit page. Form components — customer/vehicle selects and labour/parts repeaters — are exposed as static factories so the same fragments power the wizard, the action, and the standard edit form, with tax rate and default hourly rate auto-pulled from the Setting model.
This project demonstrates how to build a multi-step invoice wizard in Filament whose steps are defined once in a central step factory and then reused across two different contexts: the standalone invoice create page and a Quick Invoice action launched directly from the customer edit page. It also showcases shared form components — customer/vehicle selects and labour/parts repeaters — exposed as static factories so the same fragments power the wizard, the action, and the standard edit form.
Setting modelThe repository contains the complete Laravel + Filament project to demonstrate the functionality, including migrations/seeds for the demo data.
The Filament project is in the app/Filament folder.
Feel free to pick the parts that you actually need in your projects.
git clone.env.example file to .env and edit database credentials therecomposer installphp artisan key:generatephp artisan storage:linkphp artisan migrate --seed (it has some seeded data for your testing)/admin and log in with credentials [email protected] and password.


This project consists of four pieces working together: a central wizard step factory, a standalone create page that consumes it, a Quick Invoice action that reuses the same steps inside a modal, and a shared form helper class that supplies fields and repeaters to both flows.
The InvoiceWizardSteps class exposes every wizard step as a static factory method returning a Step instance. Defining the steps in one place is what makes it possible to reuse them in completely different contexts without duplicating field definitions.
app/Filament/Resources/Invoices/Schemas/InvoiceWizardSteps.php
class InvoiceWizardSteps{ public static function getCustomerVehicleStep(): Step { return Step::make('Customer & Vehicle') ->description('Select the customer and their vehicle') ->icon(Heroicon::OutlinedUser) ->schema([ Section::make() ->schema([ InvoiceForm::getCustomerField()->columnSpanFull(), InvoiceForm::getVehicleField()->columnSpanFull(), TextInput::make('mileage')->numeric()->minValue(0), Hidden::make('tax_rate') ->default(fn () => Setting::get('tax_rate', '5.00')), Hidden::make('default_hourly_rate') ->default(fn () => Setting::get('default_hourly_rate', '95.00')), ]), ]) ->columnSpanFull(); } public static function getVehicleStep(Customer|Model $customer): Step { return Step::make('Vehicle') ->description('Select a vehicle') ->icon(Heroicon::OutlinedTruck) ->schema([ Section::make()->schema([ Select::make('vehicle_id') ->label('Vehicle') ->required() ->options(fn () => $customer->vehicles->mapWithKeys( fn (Vehicle $v) => [$v->id => trim("{$v->year} {$v->make} {$v->model}")] )) ->default(fn () => $customer->vehicles->count() === 1 ? $customer->vehicles->first()->id : null), TextInput::make('mileage')->numeric()->minValue(0), ]), ]); } public static function getLabourStep(bool $withRelationship = true): Step { /* ... */ } public static function getPartsStep(bool $withRelationship = true): Step { /* ... */ } public static function getReviewStep(): Step { /* ... */ }}
The key points:
Step instance — pure construction, no state, safe to call from anywheregetCustomerVehicleStep() is the full picker used by the standalone create page; getVehicleStep(Customer $customer) is a slimmer variant for contexts where the customer is already known — it skips the customer select entirely and pre-selects the vehicle when the customer owns exactly onegetLabourStep() and getPartsStep() accept a bool $withRelationship = true parameter so the same step definition works in two very different worlds — relationship-bound wizards that auto-persist child rows, and action modals where no parent record exists yetgetReviewStep() uses TextEntry infolist entries with state(fn (Get $get) => …) to render a live summary of the wizard state collected in the previous steps, so the user gets a final confirmation screen without re-querying anythingtax_rate and default_hourly_rate fields seed the form from the Setting model so the labour repeater's per-row default rate can pull from the parent form state belowThe standalone create page uses Filament's HasWizard trait and lists the four steps in getSteps(). There is no inline step construction — every step comes straight from the factory.