lexal/http-stepped-form
A Laravel package for building HTTP-driven multi-step forms, where each step is handled by its own request while preserving state, validation, and navigation across the flow. Ideal for wizard-style forms that need server-side step management.
This is a single-maintainer, low-adoption package (1 star, 0 dependents, no downstream users to learn from), so treat the source as the documentation. Budget 30–45 minutes to read src/ and the tests before wiring it into anything.
Install and inspect
composer require lexal/http-stepped-form
# The three things that actually tell you how it works:
ls vendor/lexal/http-stepped-form/src
cat vendor/lexal/http-stepped-form/README.md
ls vendor/lexal/http-stepped-form/tests # if present, this is the real spec
Check composer.json in the package for require (PHP version, PSR-7/PSR-15 vs. framework-coupled) and autoload. There is no Laravel service provider advertised in the metadata — if there isn't one, you register it yourself in config/app.php or bind it in a FormServiceProvider, and publish config with php artisan vendor:publish --tag=... only if a config file exists.
Where to look first
First use case
A server-rendered multi-step wizard — onboarding, checkout details, multi-page application form — where each step is a real GET/POST round trip and no JavaScript state store is needed. That is exactly the niche the package name implies, and it's where it will earn its keep versus a hand-rolled session()->put('wizard.'.$step, $data).
Start with a 3-step form in a single controller and one Blade view per step; do not introduce Livewire or an SPA at the same time, or you lose the ability to tell which layer broke.
Route shape. One route pair for all steps beats N route pairs — the step is a parameter, so adding a step is a config change, not a routing change:
Route::middleware(['web'])->group(function () {
Route::get('/apply/{step?}', [ApplicationController::class, 'show'])->name('apply.show');
Route::post('/apply/{step}', [ApplicationController::class, 'store'])->name('apply.store');
});
Controller as a thin adapter. Keep the package in the controller and out of your Blade and models. The controller's job is: resolve the step, hand the request input to the form, translate the result into a redirect or a view with errors.
public function store(Request $request, string $step)
{
$form = $this->formFactory->forUser($request->user()); // your wrapper
if (! $form->accepts($step)) {
return redirect()->route('apply.show', ['step' => $form->currentStep()]);
}
$result = $form->submit($step, $request->all()); // illustrative — confirm the real signature
return $result->isComplete()
? redirect()->route('apply.review')
: redirect()->route('apply.show', ['step' => $result->nextStep()]);
}
Always redirect after POST (PRG). Stepped forms that render a view directly on POST break refresh, back-button, and double-submit in ways users will find and report.
Wrap the package. With 1 star, you want a seam. Put a SteppedForm/FormDefinition wrapper in app/Forms that owns the package instance, your step list, and your validation rules. If the package is abandoned or its state model doesn't fit, you swap one class. Do not let lexal\... types leak into controllers, jobs, or views.
State storage. Two viable models, and the package will favour one:
web middleware, but dies with the session and is awkward for email-resume links.If the package persists to session, key it per-user and per-form-instance (e.g. apply.{$draftId}), otherwise two tabs of the same wizard stomp each other.
Validation per step, re-validated at the end. Define rules in a rulesFor(string $step): array method on your wrapper, feed them to Laravel's Validator, and on final submit run the full rule set once more. Never trust that step 1 was validated because step 3 arrived.
Back navigation. Make back a plain GET to a lower step number and explicitly allow it. If your transition logic refuses backward moves, the browser back button still works and will desync the "current step" pointer — so either support backward transitions or make the step pointer derived from the data (highest step with complete data) rather than stored separately.
Testing. Feature-test the whole flow as a sequence of $this->post(...) calls asserting redirect targets and final persisted model — not the internal step object. One test that walks 0→N and one that attempts to jump 0→N directly.
Maturity risk is the headline. One star, zero dependents, last release 2026-08-11. There is no community to answer questions and no guarantee of a Laravel 12/13 compatibility pass. Pin the version in composer.json, and write a test that covers your happy path so a composer update breaking it is caught by you, not by production.
Check for framework coupling before you commit. If the package returns PSR-7 responses or expects PSR-15 middleware, you'll be adapting it to Laravel's Request/RedirectResponse yourself. That's a small but real cost — decide it's worth it versus ~150 lines of your own session-backed wizard.
Session locking and concurrency. Laravel's file/database session driver locks per session, so two rapid POSTs from the same browser serialise — usually good, occasionally a surprise under load. Double-submits from a slow connection are the classic stepped-form bug: use ->middleware('throttle:...') on the POST route, disable the submit button client-side, and make the final-submit handler idempotent (unique constraint or firstOrCreate) because the user will get the double-created order.
Session expiry mid-form. Sessions die after config('session.lifetime'). A user who leaves the tab open over lunch loses everything and gets a confusing redirect to step 1. Handle it explicitly: if the state container reports "unknown form instance", redirect to the first step with a flash message rather than 404 or 500.
File uploads. Stepped forms and uploads interact badly when the payload round-trips through the client or through session storage. Store uploads immediately to a temp disk keyed by the draft ID and keep only the path in form state; clean up orphaned temp files with a scheduled command.
Don't store sensitive fields in client-visible state. If the state model signs a payload rather than storing server-side, ensure it's encrypted (Laravel's Crypt) not merely HMAC'd, and keep passwords/PCI data out of it entirely.
Extension points to probe. Look for interfaces or abstract classes for step resolution and state persistence — those are your override points. Subclassing a concrete class to change one behaviour is the signal to write your own wrapper instead and keep the package at arm's length.
Debugging. Because the state lives outside the request, dump the resolved state container at the top of store() (dd($form->state())) when a step keeps redirecting to the wrong place. Nine times out of ten it's a stale instance key or a session that was regenerated by a login mid-flow — note that Auth::login() calls session()->regenerate(), which can orphan session-backed form state if the package caches the old ID.
How can I help you explore Laravel packages today?