<?php
namespace App\Http\Requests\Wizard;
use Illuminate\Foundation\Http\FormRequest;
abstract class WizardStepRequest extends FormRequest
{
abstract public function stepKey(): string;
public function authorize(): bool
{
return true;
}
protected function passedValidation(): void
{
$steps = $this->session()->get('wizard.steps', []);
$steps[$this->stepKey()] = $this->validated();
$this->session()->put('wizard.steps', $steps);
}
public function previousData(string $step): array
{
return $this->session()->get("wizard.steps.{$step}", []);
}
}
<?php
namespace App\Http\Requests\Wizard;
use Illuminate\Validation\Rule;
class ProfileStepRequest extends WizardStepRequest
{
public function stepKey(): string
{
return 'profile';
}
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:60'],
'last_name' => ['required', 'string', 'max:60'],
'company' => ['nullable', 'string', 'max:120'],
];
}
}
class PreferencesStepRequest extends WizardStepRequest
{
public function stepKey(): string
{
return 'preferences';
}
public function rules(): array
{
return [
'plan' => ['required', Rule::in(['starter', 'team', 'enterprise'])],
'topics' => ['required', 'array', 'min:1'],
'topics.*' => ['string', 'distinct', 'max:40'],
'newsletter' => ['boolean'],
];
}
}
<?php
namespace App\Http\Middleware;
use App\Support\WizardState;
use Closure;
use Illuminate\Http\Request;
class EnsureStepIsReachable
{
public function handle(Request $request, Closure $next)
{
$requested = $request->route('step');
$completed = array_keys($request->session()->get('wizard.steps', []));
foreach (WizardState::STEPS as $step) {
if ($step === $requested) {
return $next($request);
}
if (! in_array($step, $completed, true)) {
return redirect()->route('onboarding.show', ['step' => $step])
->with('warning', 'Please finish the previous step first.');
}
}
abort(404);
}
}
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Wizard\PreferencesStepRequest;
use App\Http\Requests\Wizard\ProfileStepRequest;
use App\Models\User;
use App\Support\WizardState;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OnboardingWizardController extends Controller
{
public function show(Request $request, string $step)
{
return view("onboarding.{$step}", [
'data' => $request->session()->get("wizard.steps.{$step}", []),
'steps' => WizardState::STEPS,
'current' => $step,
]);
}
public function storeProfile(ProfileStepRequest $request)
{
return redirect()->route('onboarding.show', ['step' => 'preferences']);
}
public function storePreferences(PreferencesStepRequest $request)
{
return redirect()->route('onboarding.complete');
}
public function complete(Request $request)
{
$steps = $request->session()->get('wizard.steps', []);
abort_if(count($steps) < count(WizardState::STEPS), 403);
DB::transaction(function () use ($request, $steps) {
$request->user()->fill($steps['profile'])->save();
$request->user()->preferences()->updateOrCreate([], $steps['preferences']);
});
$request->session()->forget('wizard');
return redirect()->route('dashboard')->with('status', 'Onboarding complete!');
}
}
This snippet builds a multi-step onboarding wizard where each step validates against its own dedicated form request and progress is accumulated in the session until a final commit. The central idea is to treat the wizard as a small state machine: an ordered list of step keys, a session bucket that holds validated data per step, and a rule that a user may only reach a step once every prior step has been completed. This keeps validation focused (each step only knows its own fields) while preserving partial input across requests without touching the database prematurely.
In WizardStepRequest, an abstract base request centralizes the shared plumbing. Concrete step requests declare their own rules(), but they all inherit stepKey() and a helper that persists validated data. The passedValidation() hook writes the just-validated payload into the session under wizard.steps.<key>, so by the time the controller runs, the data is already stored. This is the classic Laravel pattern of pushing side effects into the request lifecycle so controllers stay thin.
ProfileStepRequest and PreferencesStepRequest are the concrete steps. Each returns a distinct stepKey and a small, self-contained ruleset — note PreferencesStepRequest uses Rule::in and a nested array validation for topics, which is awkward to express if all steps shared one giant request class.
The EnsureStepIsReachable middleware enforces ordering. It reads the requested step from the route, walks the ordered WizardState::STEPS, and redirects back to the first incomplete step if the user jumped ahead. This prevents deep-linking into step three with an empty session, a common wizard bug.
OnboardingWizardController ties it together. show() renders the current step's view pre-filled from session; store() type-hints the correct request class per route so the framework resolves and runs the matching validator automatically. When the last step submits, complete() reads the aggregated session data, persists it in one transaction, and clears the wizard bucket via forget. The trade-off is that abandoned wizards leave stale session data until it expires, and session-bound state does not survive across devices — acceptable for short onboarding flows but not for long-running, resumable processes, which would call for a persisted draft record instead.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
Share this code
Here's the card — post it anywhere.