Examples

The package is headless: it validates, stores and navigates; you render. These recipes all use the OrderWizard from Wizards and steps.

Blade with your own controllers

// routes/web.php
use App\Http\Controllers\OrderController;
use App\Wizards\OrderWizard;
use Invelity\WizardPackage\Http\Middleware\EnsureStepIsAccessible;

Route::middleware(['web', EnsureStepIsAccessible::using(OrderWizard::class)])->group(function () {
    Route::get('/order/{step}', [OrderController::class, 'show'])->name('order.step');
    Route::post('/order/{step}', [OrderController::class, 'store']);
});

Route::post('/order', [OrderController::class, 'complete'])->middleware('web')->name('order.complete');
// app/Http/Controllers/OrderController.php
use App\Wizards\OrderWizard;
use Illuminate\Http\Request;

class OrderController
{
    public function show(OrderWizard $wizard, string $step)
    {
        return view("order.{$step}", [
            'step' => $wizard->step($step),
            'data' => $wizard->data($step),
            'navigation' => $wizard->navigation($step),
            'progress' => $wizard->progress(),
        ]);
    }

    public function store(Request $request, OrderWizard $wizard, string $step)
    {
        $wizard->process($step, $request);

        return to_route('order.step', $wizard->current()->id());
    }

    public function complete(OrderWizard $wizard)
    {
        $wizard->complete();   // listen to WizardCompleted to place the order

        return to_route('order.step', $wizard->current()->id());
    }
}

Link the steps to your routes once, on the wizard:

class OrderWizard extends Wizard
{
    protected function stepUrl(Step $step): ?string
    {
        return route('order.step', $step->id());
    }
}

php artisan wizard:make-step CalculatorStep --wizard=OrderWizard --view creates the step’s view, resources/views/order/calculator.blade.php, which view("order.{$step}") renders.

{{-- resources/views/order/partials/navigation.blade.php --}}
<nav aria-label="Order progress">
    <div role="progressbar" aria-valuenow="{{ $progress->percentage() }}" aria-valuemin="0" aria-valuemax="100"
         style="width: {{ $progress->percentage() }}%"></div>

    <ol>
        @foreach ($navigation->items as $item)
            <li @class(['current' => $item->current, 'done' => $item->status->value !== 'pending'])>
                @if ($item->accessible && $item->url)
                    <a href="{{ $item->url }}">{{ $item->position }}. {{ $item->title }}</a>
                @else
                    <span>{{ $item->position }}. {{ $item->title }}</span>
                @endif
            </li>
        @endforeach
    </ol>
</nav>
{{-- resources/views/order/calculator.blade.php --}}
<form method="POST" action="{{ url("/order/{$step->id()}") }}">
    @csrf
    <input name="weight" value="{{ old('weight', $data['weight'] ?? '') }}">
    @error('weight') <p>{{ $message }}</p> @enderror

    @if ($navigation->previous)
        <a href="{{ route('order.step', $navigation->previous) }}">Back</a>
    @endif
    <button>Continue</button>
</form>

Livewire

use App\Wizards\OrderWizard;
use Invelity\WizardPackage\Facades\Wizard;
use Livewire\Component;

class OrderForm extends Component
{
    public string $step = 'calculator';

    public array $form = [];

    public function mount(): void
    {
        $this->step = $this->wizard()->current()->id();
        $this->form = $this->wizard()->data($this->step);
    }

    public function submit(): void
    {
        $this->wizard()->process($this->step, $this->form);   // validation errors appear in $errors by field name

        $this->step = $this->wizard()->current()->id();
        $this->form = $this->wizard()->data($this->step);
    }

    public function render()
    {
        return view('livewire.order-form', [
            'navigation' => $this->wizard()->navigation($this->step),
            'progress' => $this->wizard()->progress(),
        ]);
    }

    private function wizard(): OrderWizard
    {
        return Wizard::for(OrderWizard::class);   // resolve per request; never store it on the component
    }
}

Inertia

use Invelity\WizardPackage\Http\Resources\WizardResource;

public function show(OrderWizard $wizard, string $step)
{
    return Inertia::render("Order/{$step}", [
        'wizard' => new WizardResource($wizard, $step),
    ]);
}

Invelity\WizardPackage\Http\Resources\WizardResource is the same shape the JSON API returns, so the page props match the response format.

Vue, React or a mobile app over the JSON API

Route::middleware('web')->group(fn () => Route::wizard('api/order', OrderWizard::class));
async function call(method, url, body) {
    const response = await fetch(url, {
        method,
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
        },
        credentials: 'same-origin',
        body: body ? JSON.stringify(body) : undefined,
    });

    if (response.status === 422) {
        return { errors: (await response.json()).errors };
    }

    return response.status === 204 ? {} : { wizard: (await response.json()).data };
}

const { wizard } = await call('GET', '/api/order');                         // where am I?
const result = await call('POST', `/api/order/${wizard.step.id}`, form);    // submit the step
if (result.errors) showErrors(result.errors); else render(result.wizard);   // wizard.step is the next step
await call('POST', '/api/order/newsletter/skip');                           // skip an optional step
await call('POST', '/api/order');                                           // complete

wizard.navigation gives everything a progress bar or step list needs: title, status, is_current, is_accessible, url.

A checkout that follows the user across devices

class CheckoutWizard extends Wizard
{
    protected ?string $store = 'database';   // keyed by the authenticated user by default

    protected array $steps = [ShippingStep::class, PaymentStep::class, ReviewStep::class, ThankYouStep::class];
}
// Resume where the user left off, on any device
return redirect($wizard->url($wizard->firstUnfinished()?->id() ?? $wizard->current()->id()));