From 12a83174378e78b7886eba850913fafe0d9b996a Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sun, 16 Aug 2026 18:43:53 -0400 Subject: [PATCH 1/2] Document middleware on native routes Covers how `Route::native()->middleware()` and route groups apply on every navigation, what the synthesized request does and does not carry, which request-lifecycle middleware is skipped and how to opt your own out, how a refusal maps onto native navigation, and why `Native::visit()` rather than `Native::test()` is what exercises a guard. Pairs with NativePHP/mobile-air#252. --- .../docs/mobile/4/the-basics/middleware.md | 135 ++++++++++++++++++ .../views/docs/mobile/4/the-basics/routing.md | 3 +- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 resources/views/docs/mobile/4/the-basics/middleware.md diff --git a/resources/views/docs/mobile/4/the-basics/middleware.md b/resources/views/docs/mobile/4/the-basics/middleware.md new file mode 100644 index 00000000..fc3ff940 --- /dev/null +++ b/resources/views/docs/mobile/4/the-basics/middleware.md @@ -0,0 +1,135 @@ +--- +title: Middleware +order: 153 +--- + +## Overview + +`Route::native()` returns a real Laravel route, so middleware attaches exactly as it does anywhere else: + +```php +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 via `setRouteResolver()`, so `$request->route()` works | + +**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. + +## 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()`: + +```php +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 | + +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 navigation is +refused rather than allowed. Failing open would silently grant access. + +A guard that redirects to the very screen it guards would bounce forever. That case is detected and refused instead +of hanging the runloop. + +## Testing + +`Native::visit()` runs middleware, so a redirect is directly assertable: + +```php +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. diff --git a/resources/views/docs/mobile/4/the-basics/routing.md b/resources/views/docs/mobile/4/the-basics/routing.md index 011c95c5..166143a4 100644 --- a/resources/views/docs/mobile/4/the-basics/routing.md +++ b/resources/views/docs/mobile/4/the-basics/routing.md @@ -27,7 +27,8 @@ You can put these routes anywhere that makes sense for your application, but you Route parameters work just like Laravel web routes — `{id}` matches a path segment and is exposed to the screen through `$this->param('id')`. -See [Layouts](layouts) for how to attach shared chrome to a route or group of routes. +See [Layouts](layouts) for how to attach shared chrome to a route or group of routes, and +[Middleware](middleware) for guarding screens with `->middleware()`. ## Pushing a new screen From 694e6dd45782cc6b7812f30811a6d190528c2533 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sun, 16 Aug 2026 18:58:25 -0400 Subject: [PATCH 2/2] Clarify route binding and the reach of the web group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route is bound to the synthesized request, not merely resolvable, so route parameters and route-model binding work — worth stating, since SubstituteBindings runs on every navigation. That happens more often than the previous wording implied: registering native routes through `withRouting(web: routes/mobile.php)` puts every one of them in the `web` group, so anything an app has added to that group runs per navigation too. --- resources/views/docs/mobile/4/the-basics/middleware.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/views/docs/mobile/4/the-basics/middleware.md b/resources/views/docs/mobile/4/the-basics/middleware.md index fc3ff940..1e01a55a 100644 --- a/resources/views/docs/mobile/4/the-basics/middleware.md +++ b/resources/views/docs/mobile/4/the-basics/middleware.md @@ -46,7 +46,7 @@ rather than receiving it from a client, and the difference is observable. | 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 via `setRouteResolver()`, so `$request->route()` works | +| Route | Bound to the request, so `$request->route()`, its parameters, and route-model binding all resolve | **Not carried:** @@ -75,6 +75,11 @@ response that is never sent anywhere: 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