Route middleware
Gate routes behind onboarding completion with the onboarding middleware. An authenticated subject whose flow is unfinished is redirected to their current step; 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).
Step targets
For the middleware to redirect, the current step must declare a target. 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 the current 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.
Redirecting yourself
Onboarding::for($user)?->redirectToCurrentStep(); // ?RedirectResponse
$user->onboarding()?->redirectToCurrentStep(); // the same through the traitredirectToCurrentStep() 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.