Wizards and steps
Defining a wizard
A wizard is a class that extends Invelity\WizardPackage\Wizard and lists its steps in order:
namespace App\Wizards;
use App\Wizards\Steps\BillingStep;
use App\Wizards\Steps\CalculatorStep;
use App\Wizards\Steps\ConfirmationStep;
use App\Wizards\Steps\NewsletterStep;
use App\Wizards\Steps\PersonalDataStep;
use App\Wizards\Steps\SummaryStep;
use Invelity\WizardPackage\Wizard;
class OrderWizard extends Wizard
{
protected array $steps = [
CalculatorStep::class,
PersonalDataStep::class,
NewsletterStep::class,
BillingStep::class,
SummaryStep::class,
ConfirmationStep::class,
];
}
| Property | Default | Meaning |
|---|---|---|
$steps |
[] |
The step classes, in order. They are resolved through the container, so they may inject services. |
$name |
kebab-cased class name without Wizard (order) |
Identifies the wizard in stores, route names and events. |
$store |
null (the default store) |
The store that keeps the state. |
$allowJumping |
false |
Lets the visitor open any step regardless of order. Dependencies still apply. |
Generate one with php artisan wizard:make OrderWizard.
Using a wizard
Type-hint the wizard anywhere the container resolves arguments: controllers, jobs, Livewire components. Or call
Wizard::for(). Either way you get a fresh instance bound to the current visitor.
public function show(OrderWizard $wizard, string $step) { /* ... */ }
$wizard = Wizard::for(OrderWizard::class);
$wizard = Wizard::for(OrderWizard::class, $user); // another visitor, with the cache or database store
Reading never writes. The state is stored the first time something changes, and WizardStarted is dispatched then.
Defining a step
A step extends Invelity\WizardPackage\Step:
namespace App\Wizards\Steps;
use App\Http\Requests\Wizards\CalculatorRequest;
use App\Services\PriceCalculator;
use Invelity\WizardPackage\Step;
class CalculatorStep extends Step
{
protected ?string $formRequest = CalculatorRequest::class;
public function handle(array $data, PriceCalculator $prices): array
{
return [...$data, 'price' => $prices->quote($data['weight'])];
}
}
| Property or method | Default | Meaning |
|---|---|---|
$id / id() |
kebab-cased class name without Step (calculator) |
Identifies the step within the wizard and in URLs. |
$title / title() |
headline of the class name (Calculator) |
Shown in navigation. Override title() to translate it. |
$formRequest |
null |
The form request that validates the input. Without one the step accepts no input. |
$optional |
false |
The visitor may skip the step. |
$displayOnly |
false |
The step only shows information (a confirmation page). |
$dependencies |
[] |
Step classes that must be finished first; see reopening. |
shouldSkip(State $state) |
false |
Leaves the step out of the flow for this state; see conditional steps. |
handle() |
stores the validated data | Called through the container with $data (and $wizard). Returns the array to store, or nothing to store the validated data. |
Generate one with php artisan wizard:make-step CalculatorStep --wizard=OrderWizard. Add --view to also create its
Blade view with Laravel’s make:view. The view is named after the wizard and the step (order.calculator, so
resources/views/order/calculator.blade.php), and --view=checkout.payment picks another name.
Validation
process() validates the step’s input exactly like Laravel validates a type-hinted form request.
- The whole lifecycle runs:
prepareForValidation(),authorize(), the rules, theafter()hooks andpassedValidation(). - The request knows the context:
$this->user()and$this->route()work, and$this->input()contains the submitted data. - Only
validated()data is stored. - Failures throw Laravel’s exceptions: a
ValidationException(422 for JSON, otherwise a redirect back with errors) or anAuthorizationException(403).
class PersonalDataRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
];
}
}
process() accepts the request itself, which keeps uploaded files, or a plain array:
$wizard->process('personal-data', $request);
$wizard->process('personal-data', ['name' => 'Jane', 'email' => 'jane@example.com']);
Uploaded files reach handle() as UploadedFile objects. Store them there and return their paths, because the state
must stay serialisable.
Moving through the wizard
$wizard->process('calculator', $request); // validate, store, move on
$wizard->skip('newsletter'); // only optional steps
$wizard->goTo('calculator'); // move back to an accessible step
$wizard->current(); // the step the visitor should be on
$wizard->next('calculator'); // relative to any step, or to the current one
$wizard->previous();
$wizard->firstUnfinished(); // where to resume
$wizard->canAccess('summary');
A step is accessible when it takes part in the flow, its dependencies are finished, and every required step before it is
finished. Optional and display-only steps never block the steps after them. Processing or skipping a step the visitor
may not open throws StepNotAccessibleException (403).
Optional steps
class NewsletterStep extends Step
{
protected bool $optional = true;
}
The visitor may skip() it. Skipped steps count as finished for progress and completion.
Conditional steps
Leave a step out of the flow based on the state:
class BillingStep extends Step
{
public function shouldSkip(State $state): bool
{
return ($state->data('plan')['plan'] ?? null) === 'free';
}
}
A step left out does not appear in the navigation, does not count towards progress and never blocks completion.
Dependencies and reopening
A step can depend on earlier steps:
class SummaryStep extends Step
{
protected array $dependencies = [CalculatorStep::class, PersonalDataStep::class];
}
The summary opens only once both are finished. When the visitor processes the calculator again with different data,
the summary is reopened: it must be submitted again. Its previous data is kept for prefilling, and StepReopened is
dispatched. Processing the calculator with the same data changes nothing.
To reopen a step and its dependents yourself, call reopen(). It also moves the visitor there:
$wizard->reopen('calculator');
Display-only steps
A confirmation page, a “what happens next” page or an info screen:
class ConfirmationStep extends Step
{
protected bool $displayOnly = true;
}
It takes no input, so processing it is refused, and it never counts towards progress or completion. It becomes
accessible once the required steps before it are finished. After complete() the visitor lands on the last display-only
step.
Completing
$data = $wizard->complete();
- Requirement: every required step must be finished; otherwise a
ValidationExceptionlists the missing steps. - On success:
WizardCompletedis dispatched with the data of every step. - Afterwards: a completed wizard refuses further input with
WizardAlreadyCompletedException(409), so completing twice cannot create a duplicate order.reopen()makes it editable again;reset()starts over.
Metadata
Keep data that belongs to the wizard but not to a step, such as the chosen carrier or a basket, as metadata:
$wizard->putMetadata('carrier', 'gls');
$wizard->putMetadata(['parcels.0.weight' => 2.5, 'cash_on_delivery' => true]);
$wizard->metadata('parcels.0.weight'); // 2.5
$wizard->forgetMetadata('cash_on_delivery');
$wizard->start(['source' => 'landing-page']); // initial metadata, only when the wizard starts
Events
| Event | When | Payload |
|---|---|---|
WizardStarted |
the state is stored for the first time | wizard, scope, metadata |
StepCompleted |
a step was processed | wizard, scope, step, data, percentage |
StepSkipped |
an optional step was skipped | wizard, scope, step |
StepReopened |
a finished step was reopened | wizard, scope, step |
WizardCompleted |
the wizard was completed | wizard, scope, data |
WizardReset |
the state was removed | wizard, scope |
wizard is the wizard class, so a queued listener can load it again:
public function handle(WizardCompleted $event): void
{
$wizard = Wizard::for($event->wizard, $event->scope);
}