DEV3 Technologies home

Filament create page as a multi-step wizard

Turn a Filament 4 create record page into a step-by-step wizard with per-step validation, dependent fields, a skippable step and a confirm step.

FilamentFormsfilamentwizardcreate-recordform

Code

<?php

namespace App\Filament\Resources\Bookings\Pages;

use App\Filament\Resources\Bookings\BookingResource;
use App\Models\Service;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;

class CreateBooking extends CreateRecord
{
    use HasWizard;

    protected static string $resource = BookingResource::class;

    /** @return array<Step> */
    protected function getSteps(): array
    {
        return [
            Step::make('Client')
                ->description('Who is this for?')
                ->icon('heroicon-o-user')
                ->schema([
                    Select::make('client_id')->relationship('client', 'name')->searchable()->preload()->required()
                        ->createOptionForm([TextInput::make('name')->required(), TextInput::make('phone')->tel()]),
                ]),

            Step::make('Service')
                ->description('What and for how long')
                ->icon('heroicon-o-scissors')
                ->schema([
                    Select::make('service_id')
                        ->options(fn () => Service::query()->orderBy('name')->pluck('name', 'id'))
                        ->required()
                        ->live()
                        // The chosen service sets the duration and price in the next fields.
                        ->afterStateUpdated(function (?int $state, Set $set) {
                            $service = Service::find($state);
                            $set('duration_minutes', $service?->duration_minutes);
                            $set('price', $service?->price);
                        }),
                    TextInput::make('duration_minutes')->numeric()->suffix('min')->required(),
                    TextInput::make('price')->numeric()->prefix('$')->required(),
                ])->columns(3),

            Step::make('Time')
                ->icon('heroicon-o-calendar')
                ->schema([
                    DateTimePicker::make('starts_at')->seconds(false)->minDate(now())->required()
                        ->minutesStep(15)->helperText(fn (Get $get) => $get('duration_minutes') ? 'Ends '.$get('duration_minutes').' minutes later.' : null),
                ])
                // Runs when the user presses Next, after validation passed.
                ->afterValidation(fn (Get $get) => logger()->info('Booking time chosen', ['at' => $get('starts_at')])),

            Step::make('Confirm')
                ->icon('heroicon-o-check-badge')
                ->schema([
                    Textarea::make('notes')->rows(3)->maxLength(500),
                    Toggle::make('send_reminder')->default(true),
                ]),
        ];
    }

    // Let staff jump around freely when editing; keep order enforced on create.
    protected function hasSkippableSteps(): bool
    {
        return false;
    }
}

Needs: filament/filament@^4.0

How it works

What it solves

Long creation forms overwhelm people and hide errors. Splitting the form into steps shows progress, validates as the user goes and lets later steps depend on earlier choices. For a booking, picking the client, then the service, then the time maps to how staff think.

How it works

Adding the HasWizard trait to a CreateRecord page replaces the single form with a wizard; you only implement getSteps(), which returns Step objects. Each step has a title, description, icon and its own schema, and Filament validates the fields of the current step when the user presses Next, so errors appear where they are made instead of at the end.

The service step shows dependency between fields. The service Select is live(), and its afterStateUpdated copies the service's duration and price into the next two inputs through Set, which staff can still override. The time step reads that duration through Get for its helper text. afterValidation() is a hook that runs once the step is valid, handy for logging, reserving a slot or fetching data for the next step.

The last step collects notes and a reminder toggle, and the standard Create button appears there. hasSkippableSteps() returns false to force the order.

Adapting it

Change field names to your schema, add createOptionForm on selects to add related records without leaving the wizard, and use ->visible() on a step to show it conditionally. The resource form is not used on this page, so keep field rules in the steps or share them from a helper.

Pitfalls

  • All fields must exist on the model's fillable list, as with any create form.
  • State from skipped or hidden steps is still submitted, so null-check optional values on save.
  • A reservation made in afterValidation() should be released if the wizard is abandoned.

How to use it

  1. Create the resource: php artisan make:filament-resource Booking.
  2. Replace the generated CreateBooking page with this file, adjusting the model fields.
  3. Make sure BookingResource::getPages() points its create route at this page.
  4. Seed a few services with duration_minutes and price columns.
  5. Open Bookings, click New, and step through the wizard.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets