Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
multi-step formsvalidationform requestswizard pattern

Laravel Multi-Step Forms: A Working Pattern

Build a resumable, validated Laravel multi-step form using a state object, one Form Request per step, and a final transactional commit.

Oct 3, 2026 · 8 min read

A laravel multi-step form fails in three predictable places: partial data is lost when the user refreshes, validation rules that depend on earlier answers are applied too early, and the final write happens in several requests so a failure leaves a half-created record. This pattern fixes all three with a per-session state object, one Form Request per step, and a single transaction at commit time.

The shape of the solution

Store answers in a session-backed state object, not in the database. Validate each step with its own Form Request so rules can read prior answers. Only touch Eloquent once, in the final step, inside a transaction.

Why not save each step to the database?

Saving each step directly to the model means every intermediate state is a real row: half-filled records, nullable columns that are only nullable because of the wizard, and cleanup jobs for abandoned drafts. A session state object keeps the draft out of your schema and only writes a row when the user actually finishes.

The trade-off is durability. Session data lives on one server (or in Redis if you configure it), so an abandoned draft is lost when the session expires. If the flow takes hours or days, or if you need to email a resume link, you need a form_drafts table keyed by a UUID with a payload JSON column and an expires_at timestamp. For a checkout or onboarding flow that finishes in one sitting, the session is enough and you avoid an entire table and its retention policy.

Pick based on how long the flow realistically takes, not on how important the data feels. A five-minute wizard does not need persistence. A loan application that a user returns to over three days does.

Modeling the wizard state

A plain PHP object that reads and writes a single session key keeps the controller thin and makes the state testable without HTTP. It should expose the step list, the current step, and typed accessors for the answers.

namespace App\Support;

use Illuminate\Http\Request;

class WizardState
{
    public const STEPS = ['account', 'company', 'billing', 'review'];

    public function __construct(private Request $request) {}

    public function all(): array
    {
        return $this->request->session()->get('wizard', []);
    }

    public function put(string $step, array $data): void
    {
        $current = $this->all();
        $current[$step] = array_merge($current[$step] ?? [], $data);
        $this->request->session()->put('wizard', $current);
    }

    public function get(string $step, string $key = null, mixed $default = null): mixed
    {
        $stepData = $this->all()[$step] ?? [];

        return $key === null ? $stepData : ($stepData[$key] ?? $default);
    }

    public function currentStep(): string
    {
        foreach (self::STEPS as $step) {
            if (! $this->isComplete($step)) {
                return $step;
            }
        }

        return 'review';
    }

    public function isComplete(string $step): bool
    {
        return $this->get($step) !== [];
    }

    public function forget(): void
    {
        $this->request->session()->forget('wizard');
    }
}

Merging rather than replacing on put() matters when a step is edited after the fact. If a user goes back to the company step and changes one field, you want the other fields on that step preserved, not wiped because the form only submitted the changed input.

One Form Request per step

A separate Form Request per step lets each set of rules stay small and lets rules that depend on earlier answers read them from the session. Sharing one giant request across steps forces conditional rule arrays that are hard to read and harder to test.

namespace App\Http\Requests;

use App\Support\WizardState;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CompanyStepRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(WizardState $state): array
    {
        $accountType = $state->get('account', 'type');

        return [
            'name' => ['required', 'string', 'max:120'],
            'tax_id' => [
                Rule::requiredIf($accountType === 'business'),
                'nullable',
                'string',
                'max:32',
            ],
            'employees' => ['required', 'integer', 'min:1', 'max:100000'],
        ];
    }
}

Laravel resolves rules() dependencies from the container, so injecting WizardState works as long as the class is resolvable. Because WizardState takes the current Request in its constructor, bind it explicitly in a service provider rather than relying on autowiring to guess the right request instance. See the service container bindings guide for the singleton versus scoped distinction, which matters here: bind it as scoped so Octane and long-running workers do not leak one user's state into another request.

Keep laravel validation rules for a step in the Form Request and business rules (for example, "this tax ID already exists") in the controller or a dedicated action class. Mixing them makes the request class grow into a second controller.

Routing the steps

A single controller with a method per step is the smallest thing that works. Each method validates its own request, writes to the state object, and redirects to the next step. The final method commits.

Route::middleware('web')->prefix('onboarding')->group(function () {
    Route::get('/', [OnboardingController::class, 'show'])->name('onboarding.show');
    Route::post('/account', [OnboardingController::class, 'account'])->name('onboarding.account');
    Route::post('/company', [OnboardingController::class, 'company'])->name('onboarding.company');
    Route::post('/billing', [OnboardingController::class, 'billing'])->name('onboarding.billing');
    Route::post('/commit', [OnboardingController::class, 'commit'])->name('onboarding.commit');
});

Guard each POST so a user cannot skip ahead. If the company step has no account data in the session, redirect back to the first step instead of validating against a null account type.

public function company(CompanyStepRequest $request, WizardState $state)
{
    if (! $state->isComplete('account')) {
        return redirect()->route('onboarding.show');
    }

    $state->put('company', $request->validated());

    return redirect()->route('onboarding.show');
}

Committing once, inside a transaction

The final step is the only one that writes to the database. Wrap the whole commit in a transaction so a failure in the third insert does not leave an orphaned account, and re-validate the full payload there rather than trusting that each step's rules still hold.

public function commit(WizardState $state)
{
    $data = $state->all();

    $account = DB::transaction(function () use ($data) {
        $account = Account::create($data['account']);

        $account->company()->create($data['company']);
        $account->billingProfile()->create($data['billing']);

        return $account;
    });

    $state->forget();

    return redirect()->route('dashboard');
}

Re-validation at commit catches the case where a user edits an earlier step after a later one, changing a value that a downstream rule depended on. A short Validator::make($data, $commitRules) call before the transaction is cheap insurance.

Testing the flow without a browser

Because the state lives in the session, feature tests can drive the whole wizard through the HTTP layer and assert on the final database state. Use withSession() to seed partial progress and test the skip-ahead guard directly.

public function test_company_step_requires_account_step_first(): void
{
    $this->post(route('onboarding.company'), ['name' => 'Acme'])
        ->assertRedirect(route('onboarding.show'));
}

public function test_full_flow_creates_account(): void
{
    $this->post(route('onboarding.account'), ['type' => 'business', 'email' => 'a@b.test']);
    $this->post(route('onboarding.company'), ['name' => 'Acme', 'tax_id' => 'T-1', 'employees' => 10]);
    $this->post(route('onboarding.billing'), ['plan' => 'pro']);
    $this->post(route('onboarding.commit'))->assertRedirect(route('dashboard'));

    $this->assertDatabaseHas('accounts', ['email' => 'a@b.test']);
}

For the validation-heavy cases, assert the session error bag rather than the rendered view: assertSessionHasErrors('tax_id'). That keeps the test independent of your Blade markup, which changes more often than the rules do. The testing guide covers why tests that assert on full HTML tend to break without catching real regressions.

When to reach for a flow engine instead

A generic engine pays off when you have many flows with the same shape and want them defined in config. For one or two wizards, that abstraction adds indirection you will debug later. As a laravel best practices design patterns decision, the rule is simple: build the engine only when the duplication is real and repeated. Compare the two honestly:

ConcernPer-step controllerGeneric flow engine
Setup costLow, one method per stepHigh, engine plus step definitions
Adding a stepNew method, route, requestConfig entry only
Debugging a failureRead one methodTrace through the engine
Best fitOne or two flowsFive or more similar flows

If you cannot name the fifth flow that will reuse the engine, write the controller.

Failure modes to watch

  • Session loss mid-flow. A user who clears cookies or switches devices loses everything. If that is unacceptable, move the state to a draft table.
  • Stale rules. A rule that reads an earlier answer will not re-run when that answer changes. Re-validate at commit.
  • Concurrent tabs. Two tabs editing different steps share one session key and overwrite each other. Namespace the key by a per-flow UUID if this matters.
  • Unbounded session growth. Large file uploads should not go into the session. Store the file, keep the path.

None of these are exotic. They are the reason a laravel multi-step form keeps the database write to a single transaction and keeps validation close to the step it belongs to.

Frequently asked questions

Should each step have its own Form Request class?

Yes, when the rules differ per step or depend on earlier answers. One request per step keeps rule arrays small and lets you test each step in isolation. If all steps share identical rules, a single request with a step parameter is simpler.

How do I let users go back and edit a previous step?

Store answers in the session and merge on write instead of replacing. Render the earlier step's form with the stored values, and re-validate any downstream rules at commit time because changing an earlier answer can invalidate a later one.

Does this pattern work with Laravel Octane?

Yes, if the state object is bound as scoped rather than singleton. A singleton would be reused across requests in a long-lived worker and leak one user's answers into another request. Verify with a test that runs two sequential requests and asserts the session key is isolated.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Oct 7, 2026 · 8 min read

    PHP Type Juggling: Pitfalls and Fixes

    How PHP's loose comparison rules cause real bugs, which behaviors changed in PHP 8, and when to use strict types and strict comparisons to avoid them.

  • Oct 7, 2026 · 7 min read

    Eloquent upsert and updateOrCreate at Scale

    Compare Eloquent upsert, updateOrCreate, and insertOrIgnore on MySQL and PostgreSQL, including race conditions, deadlocks, and safe bulk imports.

On this page

  • Why not save each step to the database?
  • Modeling the wizard state
  • One Form Request per step
  • Routing the steps
  • Committing once, inside a transaction
  • Testing the flow without a browser
  • When to reach for a flow engine instead
  • Failure modes to watch
  • Frequently asked questions
  • Should each step have its own Form Request class?
  • How do I let users go back and edit a previous step?
  • Does this pattern work with Laravel Octane?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies