bayareawebpro/laravel-multistep-forms
BayAreaWebPro Laravel Multistep Forms helps you build wizard-style Laravel forms with a fluent API, persistent state, validation, and Blade-friendly rendering, keeping multi-step form logic organized, reusable, and testable.
composer require bayareawebpro/laravel-multistep-forms
php artisan vendor:publish --provider="Bayareawebpro\MultistepForms\MultistepFormsServiceProvider"
php artisan multistep:install # optional: scaffolds JS/Vue wizard + views
Read two things first, in this order:
config/multistep.php — this is where the session key, storage driver, route prefix/middleware, and view namespace live. Nearly every "why isn't it working" question is answered here.php artisan make:multistep CheckoutForm or the equivalent stub command) — the class defines steps(), and each step defines its own validation rules, view, and data payload.The smallest real use case is a 3-step wizard (e.g. contact → address → confirm):
class CheckoutForm extends Form
{
public function steps(): array
{
return [
ContactStep::class,
AddressStep::class,
ConfirmStep::class,
];
}
}
Point a route at the form, render the wizard, and the package handles step advancement, per-step validation, and session-backed state so a browser refresh or back-button press doesn't lose progress. Get that working end-to-end before customizing anything — the value of the package is the session/validation lifecycle, and it's the part you'll fight if you deviate from the defaults early.
One form class per flow, not one per page. The form class is the unit of state. Splitting a checkout into three form classes means three session keys and three chances for state to desync.
Steps as classes for anything non-trivial. Array-based step definitions are fine for prototypes, but the moment a step needs uploaded files, conditional branching, or a handle() side effect (create the order, send the mail), promote it to its own class. It gives you a place to put rules(), data(), and handle() without the parent form class becoming a 300-line switch statement.
Validate on advance, re-validate on submit. The per-step rule execution is convenient but tempting to trust too far. On the final submit, run the full ruleset across all steps before handle() — a user can hold a valid step-one payload in session while step-three rules have since changed (config, pricing, availability). Treat per-step validation as UX sugar and the final all-steps pass as the security boundary.
Session-backed state is the primary integration point. Because state lives in the session, everything is refresh-safe and back-button-safe for free, and you get "resume where you left off" without extra work. Lean into it: don't mirror the same data into localStorage on the frontend, or you'll spend a day reconciling two sources of truth.
Auth and authorization belong in the form, not the middleware. Middleware gates entry to the wizard; the real check is per-step, since a user who starts logged-in can have their permissions change mid-flow. Put policy checks in the step's access/rules logic so step N can't be submitted by someone who lost access after step N-1.
Blade-first, Vue when the UX demands it. If you already have a Blade layout, drive the steps yourself with controller JSON responses and keep the package for state + validation. Reach for the shipped JS wizard when you want transitions, client-side validation, and file uploads with progress without writing them. Both paths hit the same form class, so you can start Blade and migrate one step at a time.
Custom views per step. The config's view namespace is the intended extension point — publish it, override only the step templates you need. Overriding the wizard container means you own every future layout change.
Testing. Multi-request flows are the whole point, so test them as flows: post step one, assert assertSessionHas, post step two in the same test, and only then assert the final side effect. Unit-testing a step's rules() in isolation catches almost nothing that matters.
The cookie session driver will silently truncate your form. Cookies cap at ~4KB; a multi-step form with long text fields blows past that and the user's later steps appear empty with no error. Use file, database, or redis in staging and production. This is the single most common production-only bug with this pattern.
Uploaded files do not survive between steps. A file input is validated on step two and gone by step three — the temp upload is cleaned up at request end. Upload to disk (or S3) inside the step's handler and store the path in session, not the UploadedFile. Retrofitting this after the fact means rewriting your step handlers.
Keep session payloads serializable. Storing DTOs, models, or closures in form state can appear to work (session serialization is tolerant) and then fail on queue workers, on a different session driver, or after a deploy. Store scalars, arrays, and IDs; hydrate models on demand.
Re-run composer update and clear config cache on upgrade. php artisan config:cache pins a published config/multistep.php — after a package upgrade that adds config keys you'll be running against stale defaults and wondering why a new option has no effect. php artisan config:clear && php artisan config:cache as part of deploy.
Route collisions. If you publish the package routes and define your own controller routes at the same prefix, you get duplicate-named routes and unpredictable resolution. Pick one. php artisan route:list --path=multistep is the fastest way to confirm which one is actually registered.
Debugging state. When a step won't advance, dump the session bucket directly rather than adding dd() inside the wizard's JSON response (which just corrupts the response body the frontend is parsing). Inspect the session key from config/multistep.php via session()->get(...) in tinker or a throwaway route.
CSRF and the JS wizard. If you're using the shipped Vue layer with axios, ensure X-CSRF-TOKEN and withCredentials are configured — otherwise every "next" click 419s and looks like a state bug. Set them in bootstrap.js, not per-request.
Laravel 13 support (v1.3.2). This release only widens the framework constraint — no API or behavioral changes. If you're on Laravel 12 or earlier, nothing to do beyond a normal composer update. If you're planning a Laravel 13 upgrade, this package is no longer the blocker; verify your own published config and views still match after you clear caches, since those are yours and won't be touched by composer update.
Extension points worth knowing before you fork: the config file (storage/middleware/prefix/views), the publishable view namespace, step classes themselves, and the form class's step resolver. Almost every "I need to change how this works" request is satisfiable through those four. Forking the package to alter the advancement lifecycle is a sign the flow has outgrown a wizard and wants an explicit controller.
How can I help you explore Laravel packages today?