Named routes
PAM Native uses route names as application identities. Internal routes are not URLs. Paths appear only when a screen accepts an external deep link.
Create a stack
Section titled “Create a stack”use Pam\Native\Routing\Route;
$navigator = Route::stack( name: 'main', initial: 'home', routes: function (): void { Route::screen('home', HomeScreen::class); Route::screen('product', ProductScreen::class); Route::modal('filters', FiltersScreen::class)->sheet(); },);
App::run($navigator);Pass the resulting navigator directly to the application:
App::run($navigator);Navigate
Section titled “Navigate”$this->pushRoute('product', productId: 42);$this->navigateRoute('home');$this->replaceRoute('login');$this->popRoute();navigateRoute() returns to an existing entry when possible instead of adding
a duplicate. Components do not receive or pass a Navigator instance.
Typed screen parameters
Section titled “Typed screen parameters”Named arguments map directly to the screen constructor:
final class ProductScreen extends Component{ public function __construct( public readonly int $productId, public readonly bool $preview = false, ) { }}Missing, unknown, and incompatible parameters fail before the screen mounts.
Refactor-safe destinations
Section titled “Refactor-safe destinations”String-backed enums keep route names safe during refactors. Integer-backed enums are rejected because route identity is persisted and linked as text.
enum AppRoute: string{ case Home = 'home'; case Product = 'product';}
Route::stack('main', AppRoute::Home, static function (): void { Route::screen(AppRoute::Home, HomeScreen::class); Route::screen(AppRoute::Product, ProductScreen::class);});
Route::to(AppRoute::Product, productId: 42, preview: true)->push();
$navigator->push(AppRoute::Product, ['productId' => 42]);$navigator->navigate(AppRoute::Home);$navigator->replace(AppRoute::Product);$navigator->reset(AppRoute::Home);$navigator->preload(AppRoute::Product);pushRoute(), navigateRoute(), replaceRoute(), route declarations, tabs,
drawers, NavigationAction, and lower-level Navigator operations accept the
same enum cases. PAM normalizes the value before it enters persisted or native
state.
Route groups and screen options
Section titled “Route groups and screen options”Options resolve from stack defaults to outer and inner groups, then the route and finally options changed by the mounted screen. A sparse layer changes only the values it declares.
use Pam\Native\Navigation\NavigationGestureDirection;use Pam\Native\Navigation\NavigationTransition;use Pam\Native\Navigation\ScreenOptions;use Pam\Native\Navigation\ScreenOptionsPatch;
$editor = Route::preset(ScreenOptionsPatch::from([ 'headerShown' => true, 'animation' => NavigationTransition::FadeFromBottom,]));
$navigator = Route::stack( name: 'main', initial: 'home', options: new ScreenOptions(headerShown: false), routes: function () use ($editor): void { Route::group($editor, routes: function (): void { Route::screen('profile', ProfileScreen::class) ->transition(NavigationTransition::SlideFromRight, 240) ->gesture( direction: NavigationGestureDirection::Horizontal, fullScreen: true, ); });
Route::modal('filters', FiltersScreen::class) ->transition(NavigationTransition::SlideFromBottom, 260) ->sheet( detents: [0.4, 1.0], grabber: true, cornerRadius: 24.0, ); },);Transition durations are bounded from 0 through 2,000 ms. gesture() controls
enablement, direction, and full-screen recognition. presentation(),
fullScreen(), sheet(), presets, and options() compose without replacing
unrelated values.
Feature packages can implement RouteModule and register a graph with
Route::module(new ChatRoutes($dependencies)). A module declares routes; it
does not create a second native host.
Nested navigators
Section titled “Nested navigators”Stacks, tabs, top tabs, and drawers can be route content. The outer navigator owns the application scope while Back is offered to the focused child first.
$root = Route::stack('root', 'main', static function (): void { Route::navigator( 'main', Route::tabs('main-tabs', 'feed', static function (): void { Route::tab( 'feed', Route::stack('feed-stack', 'feed-index', static function (): void { Route::screen('feed-index', FeedScreen::class); Route::screen('post', PostScreen::class); }), label: 'Feed', ); Route::tab('account', AccountScreen::class, label: 'Account'); }), );
Route::modal('create', CreateScreen::class)->fullScreen();});Each child preserves its own selected route and history. Back reaches the parent only when the focused child is already at its root.
Named tabs
Section titled “Named tabs”$tabs = Route::tabs('main-tabs', initial: 'home', routes: function (): void { Route::tab('home', HomeScreen::class, label: 'Home', icon: $homeIcon); Route::tab('orders', OrdersScreen::class, label: 'Orders', badge: '3');});The same navigation scope selects a tab:
$this->navigateRoute('orders');For one to five top-level destinations, PAM presents a bottom bar below 840 dp and a navigation rail at or above that width. Only the selected native screen is mounted; retained PHP component state survives tab changes. Selection uses native tab semantics and haptic feedback.
Top tabs and drawers
Section titled “Top tabs and drawers”$topTabs = Route::topTabs('profile-tabs', 'posts', function (): void { Route::topTab('posts', PostsScreen::class, label: 'Posts'); Route::topTab('media', MediaScreen::class, label: 'Media');}, fn (TopTabRouter $tabs) => $tabs->behavior(scrollEnabled: true));
$drawer = Route::drawer('workspace', 'inbox', function (): void { Route::drawerScreen('inbox', InboxScreen::class, label: 'Inbox'); Route::drawerScreen( 'archive', ArchiveScreen::class, label: 'Archive', group: 'Library', );}, fn (DrawerRouter $drawer) => $drawer->responsive(720));The final configurator exposes router-specific behavior without expanding the
common Route API. Registrar scope is restored after each nested declaration,
so modules can compose graphs without declaration-order coupling.
Deep links
Section titled “Deep links”Register every accepted path on the router:
$navigator = Router::stack('home') ->route('home', fn () => $home) ->route( 'profile', fn (RouteContext $route) => new ProfileScreen( username: $route->string('username'), ), ) ->deepLink('/u/{username}', 'profile') ->deepLink('/profile/{username}', 'profile') ->build();
$navigator->open( 'pam://profile/david?preview=1',);For HTTP and HTTPS URLs, matching uses the URL path. For a custom URI such as
pam://profile/david, PAM first preserves the path-only behavior and then tries
host + path, allowing /profile/{username} to match naturally.
Deep links decode path parameters and accept bounded scalar query values. Route parameters allow at most 64 safe keys and strings up to 16 KiB so untrusted URLs cannot inflate the retained tree without bounds. Incoming URLs are limited to 8 KiB and the native pending queue retains at most 32 entries.
Receive links from Android and iOS
Section titled “Receive links from Android and iOS”PAM Native 0.5.31 adds the linking native module for both cold and warm
application opens:
use Pam\Native\System\Linking;
Linking::initial(function (?string $url) use ($navigator): void { if ($url !== null) { $navigator->open($url); }});
$subscription = Linking::listen( fn (string $url) => $navigator->open($url),);
// When the application no longer needs the listener:Linking::unsubscribe($subscription);For applications without an asynchronous launch gate, the convenience method handles both paths:
$subscription = Linking::listenAndRoute( $navigator, function (string $url, bool $handled): void { // Optional analytics or fallback behavior. },);Only one incoming-link listener can be active at a time. On Android,
PamActivity captures the initial intent and delivers subsequent
onNewIntent() URLs without restarting the PHP application. On iOS, the host
passes launch and warm-open URLs through PamLinking.
Declare Android intent filters
Section titled “Declare Android intent filters”PAM CLI 0.1.38 generates the Android manifest filters from
pam-native.json:
{ "android": { "minSdk": 26, "targetSdk": 36, "deepLinks": [ { "scheme": "pushin" }, { "scheme": "https", "host": "api.example.com", "pathPrefix": "/reel/", "autoVerify": true } ] }}Each entry produces an Android VIEW intent filter with DEFAULT and
BROWSABLE categories. autoVerify is accepted only for HTTPS entries with a
host. URI schemes, hosts and path prefixes are validated before Gradle starts,
so invalid or unsafe manifest values fail during project preparation.
For verified HTTPS links, the domain must also serve Android’s
/.well-known/assetlinks.json for the application ID and signing certificate.
The custom scheme works without domain association.
If session restoration is asynchronous, hold the initial URL until the launch
screen finishes its reset(). This prevents the session route from replacing
the destination that the user requested.
Receive content shared by other Android apps
Section titled “Receive content shared by other Android apps”PAM Native 0.5.33 and PAM CLI 0.1.40 add a typed share-target pipeline.
Declare only the MIME types your application accepts:
{ "android": { "shareTargets": [ "image/*", "video/*", "text/plain" ] }}The CLI generates ACTION_SEND and ACTION_SEND_MULTIPLE intent filters.
Cold-start and warm-start payloads use the same PHP API:
use Pam\Native\IncomingShare;use Pam\Native\System\IncomingShares;
$openComposer = static function (IncomingShare $share): void { foreach ($share->files as $file) { // A normal FileReference, ready for Image or an upload. $source = $file->uri(); }
$caption = $share->text;};
IncomingShares::initial( static function (?IncomingShare $share) use ($openComposer): void { if ($share !== null) { $openComposer($share); } },);
$subscription = IncomingShares::listen($openComposer);Android imports shared files into the private application cache before PHP is
notified. The application therefore does not depend on a temporary
content:// grant after the source app closes. A payload is bounded to 10
files and 64 KiB of text; the warm-start queue keeps at most 16 events.
Call IncomingShares::unsubscribe($subscription) when a short-lived owner no
longer needs the listener.
Route notification opens
Section titled “Route notification opens”Push notifications use the same router contract:
use Pam\Native\System\PushNotifications;
PushNotifications::register( fn ($token) => $this->sendTokenToServer($token), fn (string $message) => $this->recordPushRegistrationFailure($message),);PushNotifications::listenAndRoute($navigator);Since PAM Native 0.6.40, register() accepts an optional second callback for
FCM/APNs provider or configuration failures. Handling that callback keeps an
expected asynchronous registration failure in application control; omitting it
preserves the earlier exception behavior.
The route is opened only for an Opened push event with a non-empty deep link.
On Android, add the Firebase client file at
.pam/google-services.json (preferred) or google-services.json in the
project root. PAM conditionally compiles Firebase Messaging, receives data and
notification payloads, and persists up to 64 unconsumed events across process
startup. The generated Android host synchronizes the client file through an
incremental task, so mobile prepare remains safe with Gradle’s configuration
cache. Projects without the file do not package Firebase.
PAM Native 0.5.70 and newer ship the Google Services plugin declaration,
Firebase source set, and ProGuard rules inside the versioned Android renderer
artifact. An SDK assembled from official release assets can therefore build a
Firebase-enabled application without copying files from a PAM Native checkout.
Persistence
Section titled “Persistence”Persistence format version 2 stores route names and parameters. Legacy name-only stacks continue to restore.
Do not persist sensitive secrets as route parameters. Persist a safe identifier and reload protected data through the application domain.
System back
Section titled “System back”Android back button and gesture support is automatic:
- pop when another stack entry exists;
- close the activity from the root route.
Disable it only when the application installs a custom App::onBack() handler:
$router->systemBack(false);Transitions
Section titled “Transitions”Available integer-backed transitions include:
- platform default;
- slide from right, left, top, or bottom;
- fade and fade from bottom;
- scale;
- shared axis X and Y; and
- none.
Transitions use transform and opacity on the UI thread, mirror horizontally in RTL layouts, respect Android’s disabled-animation accessibility setting, and keep only the incoming screen reachable by TalkBack during and after the transition.
Override one navigation action
Section titled “Override one navigation action”An action can override the route or stack animation without changing the destination’s normal behavior:
$navigator->navigate( AppRoute::Home, transition: NavigationTransition::None, durationMs: 0,);The override applies only to that operation. After the native transition settles, subsequent actions use the transition configured by the screen, group, or stack.
Shared element transitions
Section titled “Shared element transitions”Give the source and destination elements the same stable tag. PAM captures a bounded snapshot once and runs geometry, easing, clipping, corner interpolation, and crossfade entirely on the Android or iOS UI thread. PHP does not execute on animation frames.
use Pam\Native\Navigation\SharedTransitionResizeMode;use Pam\Native\Navigation\SharedTransitionStyle;
$style = SharedTransitionStyle::spring( durationMs: 420, damping: 0.76, stiffness: 240,)->resize(SharedTransitionResizeMode::Clip)->crossFade();
Image::make($thumbnail) ->sharedTransition("post-media:{$postId}", $style);Use the identical tag on the corresponding element in the next screen:
Image::make($fullImage) ->sharedTransition("post-media:{$postId}", $style);Templates expose the same typed contract:
<Image :source="$thumbnail" :shared-transition="'post-media:'.$postId" :shared-transition-style="$sharedStyle"/>Motion and resizing
Section titled “Motion and resizing”SharedTransitionStyle::timing()uses native timed easing.SharedTransitionStyle::spring()accepts bounded duration, damping, stiffness, and mass.- Resize modes are
Scale,Clip, andNone. crossFade()keeps separate source and destination snapshots, avoiding a flash when the destination image resolves asynchronously.- Calling
sharedTransition($tag)without a style remains valid and follows the route transition duration.
At most 16 matching elements participate in one transition. Tags must be stable, bounded identifiers and unique within a screen. Elements without a matching destination use the normal route transition.
Reduced Motion or disabled platform animations bypass the shared movement. Completion, interruption, cancellation, and predictive Back restore the original views; Android snapshots release their bitmaps during cleanup.
Predictive Back
Section titled “Predictive Back”On Android, shared elements track interactive predictive-Back progress instead of starting a second animation after the gesture. Cancelling restores the current route and both original views; completing the gesture commits the pop. The iOS host applies the same cleanup guarantees to interactive transitions.
Generate typed destinations
Section titled “Generate typed destinations”Large applications can keep the route contract in JSON and generate a string-backed enum plus typed target helpers:
{ "namespace": "App\\Navigation\\Generated", "enum": "AppRoute", "helper": "Routes", "routes": [ { "name": "home" }, { "name": "chat.thread", "case": "ChatThread", "method": "chatThread", "params": [ { "name": "threadId", "type": "int" }, { "name": "preview", "type": "bool", "required": false, "default": false } ] } ]}vendor/bin/pam-native-routes routes.json src/Navigation/GeneratedThe generated Routes::chatThread(threadId: 42)->push() call is checked by PHP
and static analyzers. Supported wire types are string, int, float, and
bool, including optional nullable parameters. Invalid identifiers, duplicate
destinations, unsafe defaults, and required parameters placed after optional
ones fail generation atomically.