Bifrost turns 1! Get 30% off annual plans with code
HAPPYBIRTHDAY

Middleware


Overview#

Route::native() returns a real Laravel route, so middleware attaches exactly as it does anywhere else:

Copied!
use App\NativeComponents\Dashboard;
use App\NativeComponents\Settings;
 
Route::native('/dashboard', Dashboard::class)->middleware('auth');
 
Route::middleware(['auth', 'verified'])->group(function () {
Route::native('/', Dashboard::class);
Route::native('/settings', Settings::class);
});

Both forms work, and both run on every navigation to those screens — cold start, in-app navigate() and replace(), deep links, and the stack restored after a hot reload.

Why native middleware is different#

A native app enters its runloop once. The screen the app launches into arrives as a genuine HTTP request, so the HTTP kernel runs its middleware normally. Every screen after that is reached by in-app navigation, which resolves through the native router and mounts the component directly — there is no second request, and no kernel to run a pipeline against.

So middleware on a native route can't work quite the way it does on the web. Something has to stand in for the request, and that is worth understanding before you rely on it.

The synthesized request#

Before a guarded screen mounts, NativePHP builds a Request for the target URI and runs the route's middleware stack against it through Laravel's own Pipeline. It is a real Illuminate\Http\Request, but NativePHP created it rather than receiving it from a client, and the difference is observable.

Carried over from the request that launched the app:

Session The live store — session() reads and writes the same data
Authenticated user The launch request's user resolver, so auth()->user() is the real user
Cookies Copied from the launch request
Server bag Copied from the launch request
Route Bound to the request, so $request->route(), its parameters, and route-model binding all resolve

Not carried:

  • The method is always GET — navigation has no verb.
  • There is no body, no query string, and no uploaded files. Route parameters live in the URI; screen data travels in navigate()'s data bag, not the request.
  • Headers are defaults rather than what the device sent, so middleware reading User-Agent, Accept, or a custom header won't see the launch request's values.
  • The client IP is a default, not the device's. Middleware that geolocates or throttles by IP won't behave as it does on the web.

Middleware that depends on any of the above should opt out — see below.

Middleware that is skipped#

Request-lifecycle middleware is excluded automatically. It already ran once for the real launch request, and re-running it on every screen push would reopen the session, rotate the CSRF token, and re-emit cookies onto a response that is never sent anywhere:

  • StartSession
  • AuthenticateSession
  • VerifyCsrfToken
  • EncryptCookies
  • AddQueuedCookiesToResponse
  • ShareErrorsFromSession

Everything else runs, including anything in your web group that isn't on that list.

That last point is easy to miss: registering your native routes with withRouting(web: __DIR__.'/../routes/mobile.php') — the usual setup — puts every native route in the web group. So SubstituteBindings runs on every navigation, which is why route-model binding resolves on the synthesized request. If you have added your own middleware to the web group, it runs on every navigation too.

Opting your own middleware out#

Middleware that should count once per app launch rather than once per screen — rate limiters, analytics, "last seen" writes — can opt out from a service provider's boot():

Copied!
use Native\Mobile\Edge\ScreenGuard;
 
ScreenGuard::skip([
RecordVisit::class,
ThrottleRequests::class,
]);

What happens when middleware refuses#

Middleware refuses a navigation the same way it refuses a request: by redirecting or aborting. NativePHP maps the result onto native navigation.

Middleware does Native result
Passes the request through Screen mounts
Redirects to a native route replace() onto that screen
Redirects anywhere else Exits to the web view with that URL
Throws AuthenticationException Redirects to its redirectTo(), else the login route
Aborts (403) or returns a response Navigation is refused; the user stays where they were
Throws any other exception The exception is reported and the user is sent to your start_url

Two guarantees are worth stating outright:

A refused screen never mounts. The check runs before mount(), so a guarded screen performs none of its data loading — no queries, no API calls — before being turned away. It publishes no frame either, so none of its content can appear.

Guards fail closed. If middleware throws — an unresolvable alias, a bug in your own guard — the screen is refused rather than allowed. Failing open would silently grant access. See When a guard throws for where the user goes instead.

A guard that redirects to the very screen it guards would bounce forever. That case is detected and refused instead of hanging the runloop.

When a guard throws#

Refusing in place isn't always safe when a guard throws. After a replace() there may be no screen to go back to, and the app would be left on a blank screen.

So NativePHP passes the exception to Laravel's report(), which means it reaches your logs and any error tracker you've configured, and then replaces the refused screen with your app's start_url.

To send the user somewhere else, register a callback from a service provider's boot():

Copied!
use Native\Mobile\Edge\ScreenGuard;
 
ScreenGuard::onFailure(function (Throwable $e, string $uri) {
return '/something-went-wrong';
});

The callback receives the exception and the URI that was being guarded. It can return:

  • A URI. A native route is replaced onto, and anything else exits to the web view.
  • A NavigationIntent, used as it is. Return new NavigationIntent(NavigationIntent::BACK) to keep the user where they were.
  • null, to keep the default start_url.

If the callback throws, that exception is reported too and the user goes to start_url.

If the destination is the screen whose guard just failed, for example a start_url behind the same broken middleware, replacing onto it would only fail again. NativePHP loads it in the web view as a real request instead. Laravel's HTTP kernel then runs the middleware itself, and you get either the screen or Laravel's error page.

Testing#

Native::visit() runs middleware, so a redirect is directly assertable:

Copied!
it('keeps guests out of the dashboard', function () {
Native::visit('/dashboard')->assertReplacedWith('/login');
});
 
it('lets a member in', function () {
$this->actingAs(User::factory()->create());
 
Native::visit('/dashboard')->assertSee('Welcome back');
});

Note the distinction: Native::test(Dashboard::class) mounts the component class directly and deliberately does not run route middleware — it has no route. Reach a screen by URI with Native::visit() when the middleware is part of what you're testing. A suite built only on Native::test() can't catch a middleware regression.