Skip to content

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.

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

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.

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.

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.

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.

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

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

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.

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.

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.

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

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

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.

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.

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"
/>
  • SharedTransitionStyle::timing() uses native timed easing.
  • SharedTransitionStyle::spring() accepts bounded duration, damping, stiffness, and mass.
  • Resize modes are Scale, Clip, and None.
  • 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.

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.

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 }
]
}
]
}
Terminal window
vendor/bin/pam-native-routes routes.json src/Navigation/Generated

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