Skip to content

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

Open
shanerbaner82 wants to merge 3 commits into
mainfrom
feat/native-route-middleware
Open

feat: run native route middleware on in-app navigation#348
shanerbaner82 wants to merge 3 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

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) refuses the navigation. Failing open would silently grant access, which is precisely the bug being fixed.

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

`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

Copy link
Copy Markdown
Member

Is this a dup of #253?

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