---
title: "Route middleware — Onboarding for Laravel | Roundly"
description: "Gate routes behind onboarding: unfinished users are redirected to their first incomplete required step, with no redirect loops."
url: https://roundly-consulting.com/open-source/docs/onboarding-for-laravel/middleware
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Onboarding for Laravel](https://roundly-consulting.com/open-source/docs/onboarding-for-laravel.md)

# 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:

```php
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():

```php
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:

```php
// 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:

```php
abort_unless($request->user()->hasCompletedOnboarding(), 403, 'Onboarding incomplete.');
```

## Redirecting yourself

```php
Onboarding::for($user)?->redirectToCurrentStep();   // ?RedirectResponse
$user->onboarding()?->redirectToCurrentStep();      // the same through the trait
```

redirectToCurrentStep() 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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/onboarding-for-laravel.md)
