Route middleware
Gate routes behind onboarding completion with the onboarding middleware. An authenticated subject whose flow is unfinished is redirected to its first incomplete required step — optional steps never block, so the middleware never enforces one; a complete or absent flow passes through. Guests always pass through — the middleware only onboards a logged-in user, so put auth in front of it wherever guests must be turned away:
Route::middleware(['auth', 'onboarding'])->group(function () {
Route::get('/dashboard', DashboardController::class);
});
// pick a specific flow key
Route::middleware(['auth', 'onboarding:admin'])->get('/admin', AdminController::class);The optional parameter selects the flow key. Without it, the middleware reads the same flow as $user->onboarding() — so a registered resolver applies, with defaultOnboardingKey() as the fallback; a user without the GetsOnboarded trait gets Onboarding::for($user). The trait is checked with class_uses_recursive(), so an unrelated onboarding() method — a relation, say — doesn’t count.
Step targets
For the middleware to redirect, the first incomplete required step must declare a target — an optional step’s target is never redirected to. Set a named route with route() — with its parameters, as an array or a closure that receives the bound subject — or an absolute URL or path with url():
Step::make('Complete profile')->route('profile.edit');
Step::make('Join a team')->route('teams.show', fn (?User $user) => ['team' => $user?->current_team_id]);
Step::make('Add billing')->url('/billing/setup?from=onboarding');The target resolves in this order:
- route() — when that named route exists.
- url() — an absolute URL or a path.
- The free-form action() as a fallback — first as a route name, then as a URL or path starting with http://, https:// or /. Its meaning for your frontend is unchanged.
- No resolvable target — the middleware passes through.
No redirect loops
When the request is already on that step’s target — declared as a named route, an absolute URL or a path — the middleware passes through, so the step’s own screen can safely sit inside the guarded group:
// the step's own screen may sit inside the guarded group — it never loops
Route::middleware(['auth', 'onboarding'])->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::get('/dashboard', DashboardController::class)->name('dashboard');
});URL targets are compared by host and path, so a query string or #fragment on the target and an app served from a sub-directory never loop. A named route whose required parameters are missing is reported (report()) and treated as “no target”: the request passes through instead of failing.
API routes
The middleware is a web redirect gate: a JSON or API request gets the same 302 to an HTML page. For API routes, check the subject yourself — hasCompletedOnboarding(), or nextOnboardingStep() for the step to report — and return the response your clients expect:
abort_unless($request->user()->hasCompletedOnboarding(), 403, 'Onboarding incomplete.');Redirecting yourself
Onboarding::for($user)?->redirectToCurrentStep(); // ?RedirectResponse
$user->onboarding()?->redirectToCurrentStep(); // the same through the traitredirectToCurrentStep() targets the current step — currentStep(), optional steps included — not the first required one the middleware enforces. It returns null when the flow is complete or the current step has no resolvable target, which makes a fallback easy:
public function __invoke(Request $request): RedirectResponse
{
return $request->user()->onboarding()?->redirectToCurrentStep()
?? redirect()->route('dashboard');
}Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.