Skip to content

feat: run native route middleware on in-app navigation - #348

Merged
simonhamp merged 5 commits into
mainfrom
feat/native-route-middleware
Oct 23, 2026
Merged

simonhamp merged 5 commits into
mainfrom
feat/native-route-middleware

Conversation

@shanerbaner82

@shanerbaner82 shanerbaner82 commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #252. Docs PR: NativePHP/nativephp.com#474

The bug

Route::native() returns a real Laravel route, so this binds exactly as it would on a web route — and looks like it works:

Route::middleware(['auth.device', 'auth.lock'])->group(function () {
    Route::native('/', Dashboard::class);
    // …13 more
});

It only ever ran for the screen the app launched into, because that screen alone arrives as an HTTP request. Every screen reached by in-app navigation resolves through NativeRouter::loop() and mounts the component directly — no request, no kernel, no pipeline.

So a v3 → v4 migration that moved routes out of a middleware group silently dropped every guard, with nothing failing and nothing logged. As the reporter notes, component tests can't catch it either, because they mount screens directly.

Worth being precise about the depth of it: the middleware wasn't merely unapplied, it was never recorded. NativeRouter::register() stored class + layout only, so resolve() couldn't have applied it even in principle.

Approach

->middleware() should work, because that's the Laravel way — so this makes it work rather than inventing a parallel API or just erroring.

Capture. Route::native() now hands the live Route object to the native registry. The instance, not a snapshot of its middleware: ->middleware() chains on after the macro returns, and group middleware is merged at registration. Holding the object means the guard reads the final list however it was assembled. Both spellings above work with no change to app code.

Execution. ScreenGuard runs that stack against a synthesized GET request for the target URI, through Laravel's own Pipeline, before the screen mounts.

The synthesized request is the deliberate trade-off, and it's documented rather than hidden. It carries the live session, the launch request's user resolver, its cookies and server bag — so auth, verified, and can: resolve the real user. It does not carry a body, query string, uploaded files, or the device's real headers and client IP. Middleware that needs those should opt out.

Skipped by default. Request-lifecycle middleware — StartSession, AuthenticateSession, VerifyCsrfToken, EncryptCookies, AddQueuedCookiesToResponse, ShareErrorsFromSession. Each ran once for the real launch request; re-running per screen push would reopen the session and rotate CSRF tokens against a request that is never sent anywhere. Apps can add their own via ScreenGuard::skip([...]) for things that should count once per launch rather than once per screen.

Denial maps onto the navigation intents that already exist:

Middleware does Native result
passes through screen mounts
redirects to a native route REPLACE onto that screen
redirects elsewhere EXIT_WEB with the full URL
throws AuthenticationException redirect to redirectTo(), else the login route
aborts (403) / returns a response BACK — refused, user stays put
throws anything else reported, then REPLACE onto start_url (or wherever ScreenGuard::onFailure() says)

Two guarantees

A refused screen never mounts. The guard runs before mount(), so a guarded screen performs none of its data loading — no queries, no API calls — and publishes no frame. This is why the check sits in loop() ahead of mount() rather than inside the component.

It fails closed. Middleware that throws (unresolvable alias, a bug in a guard) never lets the screen mount. Failing open would silently grant access, which is precisely the bug being fixed. Where the user goes instead is covered in the update at the bottom.

Also handled: cold start is marked already-guarded so the kernel's real run isn't double-counted (this matters for anything stateful, like rate limiters); and a guard redirecting to the screen it guards is detected rather than looping the runloop forever.

Testability

Native::visit() now runs guards, so the regression class is finally catchable:

Native::visit('/dashboard')->assertReplacedWith('/login');

Native::test(Dashboard::class) still mounts directly and deliberately does not run middleware — it has no route. That distinction is documented, since a suite built only on Native::test() is exactly what let this hide.

Tests

11 new tests in NativeRouteMiddlewareTest, covering in-app navigation, mount suppression, group middleware, middleware chained after the macro, redirect mapping (native / web / self-redirect loop), the skip list, opt-out, and fail-closed.

9 of the 11 fail when the guard is stubbed out to simulate pre-fix behavior. The two that pass are the correct controls — the no-middleware route and the opted-out middleware.

Suite before: 901 tests / 3021 assertions / 0 failures. After: 912 / 3072 / 0 failures.

Review notes

The parts most worth a second opinion:

  • The default skip list — whether it's the right set, and whether skipping should be opt-in rather than opt-out. Skipping too little risks side effects per navigation; skipping too much reintroduces "some of your middleware silently doesn't run", which is this bug.
  • EXIT_WEB on a non-native redirect target. Reasonable for auth redirecting to a web login, but it does mean a misconfigured redirect drops the user out of the native app.
  • Non-redirect denials become BACK. With nothing on the stack there is nowhere to go back to.

🤖 Generated with Claude Code


Two bugs found by running the demo app, not the suite

Both were in this branch and are fixed here. Recording them because each exposed a blind spot in the tests, and the tests were extended to cover them.

1. The pipeline destination returned null. Middleware routinely type-hints its return as Symfony\...\Response — Laravel's own make:middleware stub generates exactly that — so handing $next($request) a null destination throws a TypeError. The guard fails closed, so any typed middleware that allowed a navigation silently refused it instead. The destination now returns an empty 204 sentinel compared by identity. The AllowMiddleware fixture had no return type, which is why the suite was happy; it now carries : Response, and both allow-path tests fail without the fix.

2. The route was never bound to the synthesized request. SubstituteBindings calls $route->parameters(), which throws LogicException: Route is not bound. Failing closed turned that into a refusal — and since withRouting(web: routes/mobile.php) puts every native route in the web group, and SubstituteBindings is the one member of that group not on the skip list, this refused every navigation in a normally-configured app. Nothing was clickable.

Now a clone of the route is bound, so the instance in Laravel's RouteCollection — which real HTTP requests still match against — is never mutated.

The suite missed this because every test registered routes bare, with no group, so no web middleware was ever gathered. Testbench registers no web group either, so two new tests install the real one and exercise it: navigation through web both parameterised and not, plus the guarantee that the registry's route instance is left unbound. Both fail without the fix.

Suite now: 914 tests / 3083 assertions / 0 failures, against a 901 / 3021 baseline on main.

One consequence worth a reviewer's opinion

Because withRouting(web: ...) is the standard setup, this guard runs on essentially every navigation in every app, not only on routes the developer explicitly guarded. That is what makes the fail-closed behavior high-stakes: a middleware that throws for any reason takes out all navigation, as bug 2 demonstrated.

Fail-closed is still right for a security control — failing open silently drops guards, which is #252 itself. But it does mean a configuration error degrades to "the app is frozen" rather than "one screen is unreachable". Worth deciding whether that trade is acceptable, or whether a guard exception should be loud (error screen) instead of silent (refused navigation).

Decided: a throwing guard now reports and sends the user to start_url. Details in the update below.


Update: merged main, and what a failing guard does now

Merging main. Main now stores the real Laravel route in NativeRouter::register(), so bindRoute() was redundant and is gone. NativeRouter::resolve() on main also runs model bindings, so the guard uses matchRoute() instead. That way model lookups happen inside the middleware pipeline, after auth, as they would over HTTP, and they don't run twice. The "does not mutate the route" test read the route back through resolve(), which now returns a bound clone, so it could no longer fail. It now runs the guard against the registered instance directly.

Guard failures. A guard that throws used to become BACK. After a REPLACE there's nothing underneath to go back to, so the app was left on a blank screen with nothing logged. Now:

  • The exception goes to report().
  • The user is replaced onto nativephp.start_url.
  • Apps can choose somewhere else with ScreenGuard::onFailure(fn (Throwable $e, string $uri) => ...). It returns a URI or a NavigationIntent, so returning BACK gets the old behaviour. If the callback throws, that's reported too and the user still goes to start_url.

If the destination is the screen that just failed (say start_url sits behind the same broken middleware), a REPLACE would fail again forever. It goes out as EXIT_WEB instead, so the real HTTP kernel runs the middleware and shows either the screen or Laravel's error page.

I didn't use the native error overlay for that. Its "Try again" button re-renders the component in place, which would show a screen whose guard never passed.

Docs PR NativePHP/nativephp.com#474 has a new "When a guard throws" section to match.

Suite: 2139 passed, 5 skipped, 0 failed. Pint and PHPStan clean.

`Route::native()` returns a real Laravel route, so `->middleware('auth')`
chained onto it — or a group wrapping it — bound exactly as on a web route
and looked like it worked. It only ever ran for the screen the app launched
into, because that screen alone arrives as an HTTP request. Every screen
reached by in-app navigation resolves through NativeRouter and mounts the
component directly, with no request and no kernel to run a pipeline.

The result was a guard that silently stopped guarding: a v3 → v4 migration
moving routes out of a middleware group dropped every check, with nothing
failing and nothing logged. Component tests couldn't catch it either, since
they mount screens directly.

Middleware wasn't merely unapplied — it was never recorded. register() stored
class + layout only, so resolve() couldn't have applied it in principle.

Route::native() now hands the live Route object to the native registry. The
instance, not a snapshot: middleware chains on after the macro returns, and
group middleware merges at registration, so holding the object means the
final list is read whenever the guard runs. `->middleware()` and route groups
both work with no change to app code.

ScreenGuard runs that stack against a synthesized GET request for the target
URI before the screen mounts. It carries the live session, the launch
request's user resolver, cookies and server bag, so session-backed middleware
resolves the real user. It does not carry a body, query string, files, or the
device's real headers and IP — documented in nativephp.com, and opt-out-able
via ScreenGuard::skip().

Request-lifecycle middleware (StartSession, VerifyCsrfToken, EncryptCookies,
…) is skipped by default: it ran once for the real launch request, and
re-running it per screen push would reopen the session and rotate CSRF
tokens against a request that is never sent.

Denial maps onto navigation intents — redirect to a native route becomes
REPLACE, any other redirect EXIT_WEB, abort becomes BACK. Two guarantees:

  - The guard runs BEFORE mount(), so a refused screen performs none of its
    data loading and publishes no frame.
  - It fails CLOSED. Middleware that throws refuses the navigation; failing
    open would silently grant access, which is the bug being fixed.

Cold start is marked already-guarded so the kernel's real run isn't
double-counted. A guard redirecting to the screen it guards is detected
rather than looping forever.

Native::visit() runs guards too, so the regression is finally testable —
Native::test() still mounts directly and deliberately does not.

Fixes #252

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shanerbaner82 and others added 2 commits August 16, 2026 18:48
Middleware routinely type-hints its return as Symfony's Response — Laravel's
own `make:middleware` stub generates exactly that — so `$next($request)`
handing back null throws a TypeError. The guard fails closed, so the
TypeError became a denial: any typed middleware that ALLOWED a navigation
silently refused it instead.

Caught by running the demo app against this branch, not by the suite: the
test fixture's handle() had no return type, so it tolerated a null
destination that real middleware never would.

The pipeline destination now returns an empty 204 sentinel, compared by
identity to detect "everything called $next()". The AllowMiddleware fixture
gains the `: Response` hint so the suite covers the typed shape; both
allow-path tests fail without this fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SubstituteBindings calls $route->parameters(), which throws "Route is not
bound" unless the route has been bound to a request. The guard fails closed,
so that exception refused the navigation — and because
`withRouting(web: routes/mobile.php)` wraps every native route in the `web`
group, and SubstituteBindings is the one member of that group not skipped,
this refused EVERY navigation in a normally-configured app. Nothing was
clickable.

A clone is bound rather than the instance held in Laravel's RouteCollection:
real HTTP requests still match against that one, and bind() mutates it.

The suite missed this because every test registered routes bare, with no
group, so no `web` middleware was ever gathered. Testbench registers no `web`
group either, so the two new tests install the real one and then exercise it:
navigation through `web` (parameterised and not), and the guarantee that the
registry's route instance is left unbound.

Both fail without this fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
simonhamp and others added 2 commits October 23, 2026 00:12
Main now registers the real Laravel route in NativeRouter::register(), so
the registry already holds the live route instance. That made bindRoute()
redundant, so it's gone and Route::native() keeps main's single register()
call after Route::get().

NativeRouter::resolve() on main binds the route and runs model bindings.
The guard now uses matchRoute() instead, so it gets the unbound registered
route and model lookups happen inside the middleware pipeline, after auth,
the same way they would over HTTP. It also stops the guard doing the
lookups a second time.

In loop(), the guard runs before main's markActive(), and onResume() is
skipped both for a denied fresh push and for main's re-entered error screen.

The "does not mutate the route" test read the route back through resolve(),
which now returns a bound clone, so it couldn't fail any more. It now runs
the guard against the registered instance directly. Checked it fails if
ScreenGuard stops cloning.

composer test: 2133 passed, 5 skipped, 4 risky, 0 failed. Pint and PHPStan clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A guard that threw used to refuse the navigation with BACK. That keeps
access closed, but after a REPLACE there is nothing underneath to go back
to, so a misconfigured middleware left the app on a blank screen with
nothing logged.

Now the exception goes to report() and the user is replaced onto
nativephp.start_url. Apps can pick somewhere else with
ScreenGuard::onFailure(fn (Throwable $e, string $uri) => ...), returning a
URI or a NavigationIntent. Returning BACK gets the old behaviour.

If the destination is the screen that just failed, it goes out as
EXIT_WEB instead of a REPLACE that would fail again forever. The real HTTP
request runs the middleware through the kernel, which shows the screen or
Laravel's error page. The error overlay wasn't an option here: its "Try
again" button renders the component in place, which would show a screen
whose guard never passed.

composer test: 2139 passed, 5 skipped, 4 risky, 0 failed. Pint and PHPStan clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit to NativePHP/nativephp.com that referenced this pull request Oct 23, 2026
A guard that throws is now reported and the user is replaced onto
start_url, instead of the navigation just being refused. Apps can choose
the destination with ScreenGuard::onFailure(). Matches
NativePHP/mobile-air#348.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit to NativePHP/nativephp.com that referenced this pull request Oct 23, 2026
* 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.

* Clarify route binding and the reach of the web group

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.

* Document where the user goes when a screen guard throws

A guard that throws is now reported and the user is replaced onto
start_url, instead of the navigation just being refused. Apps can choose
the destination with ScreenGuard::onFailure(). Matches
NativePHP/mobile-air#348.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Simon Hamp <simon.hamp@me.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 988c1ba into main Oct 23, 2026
5 checks passed
@simonhamp
simonhamp deleted the feat/native-route-middleware branch October 23, 2026 09:51
simonhamp added a commit to NativePHP/nativephp.com that referenced this pull request Oct 23, 2026
Route middleware on in-app navigation (NativePHP/mobile-air#348) ships in
4.7, but #474 went out without a version label. This adds since: "4.7" to
the page's front matter.

The version label test only allows released versions, so 4.7 is added to
docs.released_versions ahead of the release.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gwleuverink added a commit that referenced this pull request Oct 25, 2026
Covers mobile PRs #241, #305, #344, #348, #370, #372, #401, #436, #439, #441, #447, #451, #461, #499, #500, #501, #502, #509, #511, #517, #522, #524, #529, #530, #537, #539, #541, #543, #545, #549, #550, #553, #557, #558, #561, #564, #565, #566, #571, #572, #575, #576, #577, #578, #579, #580, #581, #582, #583, #584, #585, #586, #587, #589, #591, #592, #593, #594, #595, #596, #597, #598, #599, #600, #601, #602, #603 and #604 (main d5dd874..b107ed9). #539 was already on this branch, merged as if accepted on 10 Oct. Its later pushes only brought main in and fixed OnWhenTest, and both were already here.

Ported to core in NativePHP/core#17 (branch sync/mobile-main-2026-10-25), which this merge needs: #572 and #589 (the ImageFit enum, which moves to core), #578 (route() and external URLs in navigate() and replace(), with an openedExternally() seam), #502 (unknown element tags; UnknownElementException moves to core with a hint seam), #529, #499, #372, #586, #241, #549, #601, #603, #344 and #593, the core halves of #511, #370, #348 and #401, and --json on core's native:run for #305. --fresh on core's native:run too, for #513, which came before the last sync and was missed then.

Kept on this branch: #578's exitToWeb() and the openedExternally() that opens the browser, #558's browser handoff, #370's system back and #596's root back, all in InteractsWithAppShell. #439's stack depth in WrapsScreensInNativeChrome, read through router(). #511's PERF gating in runLoop(). ScreenGuard (#348, #595) and UnknownElementHint (#502) stay in mobile, and the provider hands core the hints at boot. Platform reads go through System::platform(), as #596 has it.

Removed again in the merge: the moved files main changed, ImageFit and UnknownElementException (now core's), and src/Events/GlobalEventDispatcher.php, which main added back (core has it, and the old name is an alias). Main's import of the old dispatcher name came out of DispatchEventFromAppController and NativeServiceProvider, where it clashed with core's.

Adapted for the extraction: RunloopIdleTest reflects on core's NativeComponent, where the runloop state it reads is private. NativeComponentAnalyzer counts a render() declared on core's class as the default one, which #501's new test caught.

Needs NativePHP/core#17.

Tests: mobile on this merge against the core sync branch (52db12f), 2,343 passed, 4 risky, 5 skipped (Devkeepr run 5371). PHPStan clean (run 5372). Mobile main (b107ed9), 2,288 passed, 4 risky, 5 skipped (run 5359). Desktop main (24f3003) against the core sync branch, test:php 1,883 passed (run 5373), as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v4: Route::native() accepts ->middleware() but only applies it on cold start

2 participants