Turn a Filament v5 Create Page Into a Multi-Step Wizard

Filament wizard create record in v5: add the HasWizard trait, split a long resource form into validated steps, share field factories and test it with Pest.

Steven Richardson
Steven Richardson
· 10 min read

A create form with 20-plus fields is a wall. Users scroll, miss a required select halfway down, submit, and get a validation summary pointing at sections they never reached. Filament has a wizard built for exactly this, but the setup for a panel resource is not the one you get from copying the standalone Wizard component — and almost every result you'll find is Filament v3 or v4, importing Step from a namespace that no longer exists.

Add the HasWizard trait to the create page#

Open the resource's Create page class and pull in CreateRecord\Concerns\HasWizard, then declare an empty getSteps(). The trait overrides the page's form() method to wrap whatever getSteps() returns in a Wizard, which is why your resource's own schema class never gets edited — the edit page keeps using it exactly as before.

<?php

namespace App\Filament\Resources\Categories\Pages;

use App\Filament\Resources\Categories\CategoryResource;
use Filament\Resources\Pages\CreateRecord;

class CreateCategory extends CreateRecord
{
    use CreateRecord\Concerns\HasWizard;

    protected static string $resource = CategoryResource::class;

    protected function getSteps(): array
    {
        return [];
    }
}

The use CreateRecord\Concerns\HasWizard; line resolves against the imported CreateRecord class, so it lands on Filament\Resources\Pages\CreateRecord\Concerns\HasWizard. There is a sibling trait at EditRecord\Concerns\HasWizard for edit pages, and Filament\Actions\Concerns\HasWizard if you want the wizard inside a modal action instead of on a full page.

Split the resource form into wizard steps#

Return Step objects from getSteps(), each with its own schema(). This is the point where v3 and v4 tutorials break: in v5 the wizard moved with the rest of the layout components into the schemas package, so Step comes from Filament\Schemas\Components\Wizard\Step, not Filament\Forms\Components\Wizard\Step. Field classes like TextInput stayed in Filament\Forms.

use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Str;

protected function getSteps(): array
{
    return [
        Step::make('Name')
            ->schema([
                TextInput::make('name')
                    ->required()
                    ->live(onBlur: true) // sync the slug once, not on every keystroke
                    ->afterStateUpdated(fn (?string $state, Set $set) => $set('slug', Str::slug((string) $state))),
                TextInput::make('slug')
                    ->required()
                    ->unique(ignoreRecord: true),
            ]),
        Step::make('Description')
            ->schema([
                MarkdownEditor::make('description')
                    ->columnSpanFull(),
            ]),
        Step::make('Visibility')
            ->schema([
                Toggle::make('is_visible')
                    ->label('Visible to customers')
                    ->default(true),
            ]),
    ];
}

If your project is mid-migration and half your imports still point at Filament\Forms\Components\Wizard, the Filament v5 upgrade guide covers the full Forms-to-Schemas move and the automated rewrite that handles most of it.

Label each step with a description and icon#

Give every step a description() and an icon() so the header reads as a progress bar instead of three anonymous numbers. Icons take the Heroicon enum in v5, and completedIcon() swaps the glyph once the user has moved past that step.

use Filament\Schemas\Components\Wizard\Step;
use Filament\Support\Icons\Heroicon;

Step::make('Name')
    ->description('Give the category a clear, unique name')
    ->icon(Heroicon::Tag)
    ->completedIcon(Heroicon::Check)
    ->schema([
        // ...
    ]),

description(), icon() and completedIcon() all accept a closure as well as a static value, so you can inject $get and label a step from earlier answers.

Validate each step before the user advances#

You do not have to wire this up — it is the whole reason to use a wizard. Clicking "Next" validates only the fields in the current step's schema, so a required field in step 3 never blocks somebody leaving step 1, and errors appear against the inputs the user is actually looking at.

Step::make('Name')
    ->schema([
        TextInput::make('name')
            ->required()
            ->maxLength(120),
        TextInput::make('slug')
            ->required()
            ->unique(ignoreRecord: true)
            ->regex('/^[a-z0-9-]+$/'),
    ]),

The trade-off is that cross-step rules have nowhere natural to live. A rule like "the discount date must fall inside the campaign window" spans two steps, and neither step's validation can see the other's state. Those belong in mutateFormDataBeforeCreate() or a form request-style check on submit, not in the step schema.

Run side effects between steps with afterValidation#

afterValidation() fires once the current step passes, before the next step renders — the right place for anything that needs to happen mid-flow, like reserving a slug or hitting an external API. Throw Filament\Support\Exceptions\Halt from inside it and Filament keeps the user on the current step.

use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Support\Exceptions\Halt;

Step::make('Name')
    ->afterValidation(function (Get $get): void {
        // Reserve the slug so a second editor can't claim it mid-wizard.
        if (! app(SlugReservations::class)->reserve($get('slug'))) {
            Notification::make()
                ->danger()
                ->title('That slug was claimed a moment ago')
                ->body('Pick another before continuing.')
                ->send();

            throw new Halt();
        }
    })
    ->schema([
        // ...
    ]),

beforeValidation() is the mirror image and runs before the step's rules, which makes it useful for normalising input — trimming a pasted SKU, upper-casing a country code — so validation sees the cleaned value. Both hooks accept the usual injected utilities, so $get, $set and $livewire are all available. If your steps lean on reactive fields, the same live() and afterStateUpdated() mechanics from dependent reactive select fields apply unchanged inside a step.

Share field definitions between the resource form and the wizard#

Do not copy field definitions into the steps. The moment you do, every validation-rule change has two homes and one of them will drift. Extract static factory methods onto the resource's schema class and call them from both places.

<?php

namespace App\Filament\Resources\Categories\Schemas;

use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;

class CategoryForm
{
    public static function configure(Schema $schema): Schema
    {
        return $schema->components([
            static::getNameFormField(),
            static::getSlugFormField(),
            // ...
        ]);
    }

    public static function getNameFormField(): TextInput
    {
        return TextInput::make('name')
            ->required()
            ->maxLength(120)
            ->live(onBlur: true)
            ->afterStateUpdated(fn (?string $state, Set $set) => $set('slug', Str::slug((string) $state)));
    }

    public static function getSlugFormField(): TextInput
    {
        return TextInput::make('slug')
            ->required()
            ->unique(ignoreRecord: true);
    }
}

The wizard then composes the same field objects:

use App\Filament\Resources\Categories\Schemas\CategoryForm;
use Filament\Schemas\Components\Wizard\Step;

protected function getSteps(): array
{
    return [
        Step::make('Name')
            ->description('Give the category a clear, unique name')
            ->schema([
                CategoryForm::getNameFormField(),
                CategoryForm::getSlugFormField(),
            ]),
        // ...
    ];
}

Each call returns a fresh instance, so there's no shared-state surprise between the edit page's schema and the wizard step.

Persist the current step in the query string#

On a standalone wizard you'd chain persistStepInQueryString() onto the component. On a resource page the trait builds the Wizard for you, so override getWizardComponent(), call the parent, and configure what it hands back. Now a refresh or a shared link reopens on the same step.

use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Wizard;

public function getWizardComponent(): Component
{
    /** @var Wizard $wizard */
    $wizard = parent::getWizardComponent();

    return $wizard->persistStepInQueryString('wizard-step');
}

Call it with no argument and the key defaults to step. This is also the hook for anything else on the Wizard object — hiddenHeader(), nextAction() and previousAction() are all reachable the same way. Note that the parent call already wires cancelAction(), submitAction() and the Alpine submit handler, so returning a brand-new Wizard::make() here instead of decorating the parent's will lose your submit button.

Make steps skippable or start mid-wizard#

Two more overrides cover resumable flows. hasSkippableSteps() lets the user jump around freely rather than walking the steps in order, and getStartStep() opens the wizard part-way in — handy when you've stashed a draft and want to drop somebody back where they left off.

public function getStartStep(): int
{
    // Skip the intro step for users who already have a default profile.
    return auth()->user()->hasDefaultProfile() ? 2 : 1;
}

protected function hasSkippableSteps(): bool
{
    return true;
}

getStartStep() is declared public on the trait, so it has to stay public. hasSkippableSteps() is declared protected; the official docs example widens it to public, which PHP allows, so either signature compiles. Skippable steps mean a user can reach the final step without ever triggering step 1's validation, so keep server-side rules on the model or a listener rather than trusting the step schema alone.

Test the wizard with Pest#

Filament ships wizard-aware Livewire assertions: assertWizardCurrentStep(), goToNextWizardStep(), goToWizardStep() and goToPreviousWizardStep(). Point livewire() at the Create page class and you can drive the whole flow, including asserting that bad input keeps the user put.

<?php

use App\Filament\Resources\Categories\Pages\CreateCategory;
use App\Models\Category;
use App\Models\User;

use function Pest\Livewire\livewire;

beforeEach(function (): void {
    $this->actingAs(User::factory()->create());
});

it('will not advance past step one without a name', function (): void {
    livewire(CreateCategory::class)
        ->assertWizardCurrentStep(1)
        ->fillForm(['name' => ''])
        ->goToNextWizardStep()
        ->assertHasFormErrors(['name' => 'required'])
        ->assertWizardCurrentStep(1);
});

it('creates the category from all three steps', function (): void {
    livewire(CreateCategory::class)
        ->fillForm(['name' => 'Road Bikes'])
        ->goToNextWizardStep()
        ->assertWizardCurrentStep(2)
        ->fillForm(['description' => 'Everything drop-bar.'])
        ->goToNextWizardStep()
        ->fillForm(['is_visible' => true])
        ->call('create')
        ->assertHasNoFormErrors();

    expect(Category::firstWhere('slug', 'road-bikes'))
        ->not->toBeNull()
        ->is_visible->toBeTrue();
});

The first test is the one worth having. It proves the per-step boundary actually holds, which is the behaviour a future refactor is most likely to break. The broader setup — authenticating a panel user, the 403 path, table assertions — is covered in testing Filament v5 resources with Pest.

Sidestep the wizard gotchas that bite in production#

Five things catch people out, and four of them only show up once real data hits the form.

Overriding getFormActions() to return an empty array kills the submit button entirely. The trait passes $this->getSubmitFormAction() into the wizard's submitAction(), so the button you see on the last step is the page's form action. Same applies if you replace getWizardComponent() wholesale instead of decorating the parent's return value.

mutateFormDataBeforeCreate() receives the merged data from every step, not a per-step slice. That's what you want for cross-step rules, but it means a dd($data) there tells you nothing about which step contributed what.

Reactive fields work inside a step, but state from later steps is not available in earlier ones — $get('some_later_field') returns null, not a stale value. Design your live() chains to flow forward only.

Temporary file uploads survive step navigation, but a browser refresh mid-wizard loses them even with persistStepInQueryString() on. The query string restores the step, not the upload. If a step carries a large upload, warn the user or move the upload to the final step. Nested data in a step behaves the same way as anywhere else — the repeater and builder patterns apply unchanged.

Finally, the trait calls ->columns(null) on the page's form, so column configuration set at the resource level does not reach the wizard. Set the grid per step with Step::make('Name')->columns(2) instead.

Ship the wizard, then decide where the form logic belongs#

Convert one long create form, keep the resource schema as the single source of field definitions, and add the step-boundary test before you move on. If the flow needs a layout the panel can't give you — a custom progress bar, a preview pane, a save-and-resume draft — a custom Filament page with Livewire widgets gives you the room without giving up the panel. And if you're building the same flow outside a panel entirely, a multi-step wizard in raw Livewire with per-step validation is the version with no Filament in the stack.

FAQ#

How do I create a multi-step form in Filament?

On a panel resource, add the CreateRecord\Concerns\HasWizard trait to the resource's Create page class and implement getSteps(), returning an array of Step objects each with their own schema(). The trait wraps those steps in a Wizard and moves the submit button to the final step. Outside a resource, use the Wizard schema component directly and add submitAction() yourself.

Where is the Wizard class in Filament v5?

Filament\Schemas\Components\Wizard and Filament\Schemas\Components\Wizard\Step. Filament v5 unified forms, infolists and layouts into the schemas package, so the v3 and v4 imports from Filament\Forms\Components\Wizard no longer resolve. Field classes such as TextInput and Toggle did not move and still live under Filament\Forms\Components.

Does Filament validate each wizard step separately?

Yes. Advancing to the next step validates only the fields in the current step's schema, so a required field further along never blocks progress out of an earlier step. Rules that span steps have to run somewhere else — mutateFormDataBeforeCreate() or a model-level check on submit — because a step's validation cannot see another step's state.

How do I run code between wizard steps in Filament?

Use afterValidation() on the Step for code that should run once the step passes, and beforeValidation() for code that should run before its rules are applied. Throwing Filament\Support\Exceptions\Halt from either one keeps the user on the current step, which is how you refuse to advance after a failed external check.

Can I use a wizard on the edit page as well as create?

Yes. There is a matching EditRecord\Concerns\HasWizard trait that works the same way — add it to the Edit page class and implement getSteps(). There is also Filament\Actions\Concerns\HasWizard for rendering a wizard inside an action modal. Adding a wizard to the create page alone leaves the edit page using the resource's own schema.

How do I avoid duplicating fields between a Filament resource form and its wizard?

Extract each field into a public static factory method on the resource's schema class — public static function getNameFormField(): TextInput — and call those methods from both configure() and the wizard steps. Each call returns a fresh field instance, so the two schemas share one definition without sharing state, and a validation-rule change only has to be made once.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.