- Getting Started
- Architecture
- The Basics
- Digging Deeper
-
EDGE Components
- Introduction
- Accordion
- Activity Indicator
- Badge
- Bottom Navigation
- Bottom Sheet
- Button
- Button Group
- Canvas
- Carousel
- Checkbox
- Chip
- Column
- Divider
- Floating Action Button
- Gesture Area
- Icon
- Image
- Lazy Grid
- Layout & Styling
- List
- Menus
- Modal
- Pressable
- Progress Bar
- Radio Group
- Pager
- Refreshable
- Row
- Scroll View
- Select
- Shapes
- Side Navigation
- Slider
- Spacer
- Stack
- Tab Row
- Text
- Text Input
- Toggle
- Top Bar
- Virtual List
- Web View
- Plugins
- Testing
- Publishing Your App
Middleware
Overview#
Route::native() returns a real Laravel route, so middleware attaches exactly as it does anywhere else:
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:
StartSessionAuthenticateSessionVerifyCsrfTokenEncryptCookiesAddQueuedCookiesToResponseShareErrorsFromSession
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():
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():
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. Returnnew NavigationIntent(NavigationIntent::BACK)to keep the user where they were. null, to keep the defaultstart_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:
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.
in no time