Getting started — mobile
Two things to know before you write any code.
Nothing here has run on a phone. Every claim in the mobile bundle is backed by tests — including tests that byte-compare element trees against upstream's own collector and parse upstream's Kotlin and Swift to check coverage — but no build has been produced by Xcode or Gradle in this project, and no screen has been rendered on a device. The desktop side is backed by a screenshot of a running packaged app; mobile is not held to that standard yet. Treat it as a well-tested design awaiting device verification.
Check the licensing yourself. NativePHP Mobile is sold as a product, but the
NativePHP/mobile-air repository these host projects come from carries an MIT LICENSE.md
(Bifrost Technology, LLC) and declares "license": "MIT" in its composer.json — so read
the terms rather than trusting a summary, this one included. native:mobile:install prints
the same caution deliberately. The desktop runtime is MIT.
Can you build an Android or iOS app today?
Yes — on a machine with the platform toolchain. Nothing in this bundle is a stub, and no step is waiting on unwritten code. What it needs from you is the half no PHP package can provide:
| You need | Why |
|---|---|
A clone of NativePHP/mobile-air, and whatever its terms require of you | The iOS and Android host projects live there. native:mobile:install copies and retargets them; it cannot fetch them for you. |
| Android Studio / the Android SDK, or Xcode | native:mobile:build produces the APK/AAB or the iOS archive by invoking ./gradlew or xcodebuild. Neither exists in a PHP container, and neither runs in CI without a licence. |
| A keystore or a provisioning profile | Signing is deliberately not handled here — see below. |
Everything on this side of that line runs and is covered by tests: staging, .env cleaning,
the app archive, bundle_meta.json, the native-route manifest, toolchain detection, and the
exact command plan. --stage-only stops precisely at the boundary; --dry-run prints the
toolchain commands for you to run on a machine that has them.
The honest caveat is verification, not capability: no build produced by these commands has been opened by Xcode or Gradle, and no screen has been rendered on a device. The desktop side is backed by a screenshot of a running packaged app; this is not, yet. If you have a Mac or an Android SDK, that is the single most valuable thing anyone can contribute here.
The one architectural difference from desktop
Desktop is two processes over localhost HTTP. Mobile is one process with PHP compiled
into it: no port, no shared secret, no express server. The app calls
nativephp_call('Camera.Photo', $json) — a function that exists only inside a packaged app
— and the host answers in the same address space. Outside a packaged app the bridge is
always unavailable and every call returns null or false.
Consequently there is no dev-server loop like native:run. What replaces it is
native_mobile.fake_bridge: true, plus tests.
1. Install
composer require sadiqk2/nativephp-symfony-mobile-bundle:^0.1
Flex registers the bundle for you. Without it, add it yourself:
// config/bundles.php
return [
// …
Native\Symfony\Mobile\NativeMobileBundle::class => ['all' => true],
];
To work against a checkout instead, clone this repository and add a path repository pointing
at mobile-bundle/ with "symlink": true — without it Composer caches a copy and edits to
the bundle appear to do nothing. demo/composer.json is a working
example. On that route the constraint has to carry a stability flag:
composer require sadiqk2/nativephp-symfony-mobile-bundle:^0.1@dev
The manifest deliberately has no version field — Composer takes a released version from the
git tag, and composer validate --strict fails on a hardcoded one — so a path repository
resolves to dev-main, which extra.branch-alias aliases to 0.1.x-dev. Under the default
minimum-stability: stable a plain ^0.1 therefore fails to resolve. The flag applies to
this one constraint, not to the application.
Then copy the two native projects into your application and retarget them:
git clone --depth 1 https://github.com/NativePHP/mobile-air /tmp/np-mobile
bin/console native:mobile:install --source=/tmp/np-mobile/resources
bin/console native:mobile:doctor
native:mobile:install mirrors resources/androidstudio to nativephp/android and
resources/xcode to nativephp/ios, then patches the two places each host hardcodes a
path into vendor/nativephp/mobile/bootstrap/…/native.php so they point at this bundle's
shims instead. --platform=android|ios|both, --force to overwrite. The patcher throws
rather than skipping a path, because a missed one is a launch to a blank screen with no
diagnostic.
From there you build with Android Studio or Xcode. There is no headless path: both toolchains are required, and neither runs in CI without a licence.
native:mobile:doctor reports the project dir, the PHP version and SAPI, whether the
bridge is available, whether the persistent runtime has booted and how many dispatches it
has served, whether opcache exists, whether both native projects are installed, and whether
all four bootstrap shims are present. Run it through the console shim on a device and read
it in logcat or the Xcode console — that is the only place it tells you anything you did not
already know.
2. The four bootstrap shims
The host executes PHP by absolute path and reads stdout as a complete HTTP message. That is what these are:
| Shim | Role |
|---|---|
native.php | One-shot: autoloader → env → kernel → one request → emit. Pays for everything per request. |
persistent.php | Booted once. Builds the kernel and hands it to MobileRuntime::boot(), then the interpreter stays alive. |
dispatch.php | Per-request in persistent mode. Evaluated in a fresh scope, so it reaches the runtime through MobileRuntime::instance(). |
console.php | Console entry point — what the host runs for migrations, cache warming, anything scheduled. Replaces upstream's artisan.php. |
Prefer the persistent pair. The autoloader, the container and every bundle's boot cost
once instead of per screen, and on a phone that is the difference between a native-feeling
app and a sluggish one. MobileRuntime resets everything tagged kernel.reset after each
dispatch through services_resetter — the same mechanism messenger:consume uses — so
Doctrine, the profiler, the validator and security token storage are all handled, including
bundles this code has never heard of.
Both shims read COMPOSER_AUTOLOADER_PATH from $_SERVER (falling back to a path walk),
load .env via Dotenv with usePutenv(false), and instantiate App\Kernel unless
NATIVEPHP_KERNEL_CLASS says otherwise. A failure before the kernel exists is written with
error_log() prefixed [NATIVE_EXCEPTION] and answered with a hand-built 500, because at
that point there is no logger and no error page.
Path A — the WebView render path (what most apps want)
Both platforms' BootPlanner decides per launch: native direct dispatch, or a WebView. It
falls back to the WebView whenever there is no native-route manifest, or the requested path
matches none of its patterns — its own docblock calls that path "always safe,
byte-identical to the pre-native-first behavior". A Symfony app that declares no
#[NativeScreen] produces no manifest, so every path takes the WebView by construction,
and NATIVEPHP_BOOT_MODE=web at build time forces it.
That means the 17,316-line native-UI engine is never entered, and Twig in a WebView is just Twig. Your controllers and templates are ordinary Symfony. The only new thing is the device API:
<?php
namespace App\Controller;
use Native\Symfony\Mobile\Api\Camera;
use Native\Symfony\Mobile\Api\Device;
use Native\Symfony\Mobile\Api\SecureStorage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ProfileController extends AbstractController
{
public function __construct(
private readonly Camera $camera,
private readonly SecureStorage $storage,
private readonly Device $device,
) {
}
#[Route('/profile/photo', name: 'profile_photo')]
public function photo(): Response
{
// Asynchronous. `true` means the camera UI was presented, nothing more —
// the picture arrives later as a native event.
$this->camera->photo(quality: 85);
return $this->redirectToRoute('profile');
}
#[Route('/profile', name: 'profile')]
public function show(): Response
{
return $this->render('profile.html.twig', [
'token' => $this->storage->get('token'), // null when absent
'device' => $this->device->info(), // [] when the bridge is absent
]);
}
}
All sixteen API services are autowired and public: Biometric, Browser, Camera,
Device, Dialog, Files, Geolocation, Microphone, MobileWallet, NativeUi,
Network, Performance, PushNotifications, SecureStorage, Share, System. See the
mobile API reference.
Developing without a device
# config/packages/test/native_mobile.yaml
native_mobile:
fake_bridge: true
That aliases BridgeInterface to FakeBridge, which records every call and answers with
whatever you scripted. It is a shipped part of the bundle rather than a test fixture, so it
works in dev in a browser too.
Path B — the native-UI render path
Only if you want SwiftUI and Jetpack Compose rather than a WebView. This path is
implemented — element trees, the Tailwind-subset style parser, #[NativeScreen] routing, a
component lifecycle — and byte-verified against upstream wherever a comparison exists. It is
also the least device-verified part of the project.
Declare a screen:
<?php
namespace App\Screen;
use Native\Symfony\Mobile\Ui\Routing\NativeScreen;
#[NativeScreen('/items/{id}')]
final class ItemScreen
{
// …
}
The path uses Laravel placeholder syntax ({id}, {id?}), not Symfony's. That string
is copied verbatim into the app bundle and matched on the device by Kotlin's and Swift's
BootPlanner, so inventing a Symfony flavour would make the two sides disagree about which
paths boot natively — a WebView flash on a device and nothing at all in a test suite.
Discovery is explicit, never a scan of everything autoloadable, because the manifest is only correct if the build-time walk and the runtime walk produce the same list:
use Native\Symfony\Mobile\Ui\Routing\NativeScreenAttributeLoader;
// One class at a time…
$loader->loadClass(\App\Screen\ItemScreen::class);
// …or a PSR-4 root, sorted so two manifests are diffable.
$loader->loadDirectory(__DIR__.'/../src/Screen', 'App\\Screen');
Both fill the NativeRouteRegistry. NativeRouteManifest then exports the patterns in the
two shapes the device reads — the baked bundle_meta.json fragment (native_routes,
entry_mode) and the runtime dump at storage/framework/native_routes.json (routes).
Three failure modes there are silent and each costs a fallback to the WebView: the two
version values must be equal as strings, iOS needs the baked manifest to exist at all,
and the dump's directory must exist (writeRuntimeDump() creates it and throws on failure).
A screen's URI is not an HTTP route unless you make it one. If you want the same URI to work
in a browser — a shared link, a smoke test — install symfony/routing (the bundle suggests it
rather than requiring it, because this is the only thing that needs it) and register
NativeScreenRouteLoader yourself as a service tagged routing.loader, then import it:
# config/routes.yaml
native_screens:
resource: .
type: native_screens
It is not registered by the bundle, deliberately: an app that already puts #[Route] next
to #[NativeScreen] needs nothing from it.
Rendering is behind a seam, and the bundle fills it. NativeScreenResponder turns a path
into a published frame by asking a ScreenRendererInterface for the root element — routing's
job ends at "this path is screen X with these parameters", and turning X into an element tree
is the component lifecycle's job. ComponentScreenRenderer is that implementation, aliased to
the interface, so a #[NativeScreen] component works with no glue:
$frame = $responder->respond('/counter'); // first paint
$id = $responder->callbacks()->idFor('increment'); // what the device sends back
$renderer->dispatch('/counter', InteractionEvent::press($id)); // tap
$frame = $responder->republish(); // repaint, as a delta
Two things it does that are easy to get wrong and silent when wrong: it keeps the same
component instance across frames of one screen (re-instantiating per frame gives a screen
whose state resets on every tap), and it binds that component to the registry the responder
handed it (a registry of its own makes every incoming callback id miss). Navigating away and
back is a new visit and does start fresh; call forget($pattern) when a screen closes so its
component is unmounted rather than held for the life of the app.
A visit is a path, not a pattern. /user/1 and /user/2 share one route pattern but are
two screens, so moving between them mounts a new component and hands it the new parameters.
Reusing on the pattern alone produced a byte-identical tree, which the frame diff then
reported as unchanged — the device kept showing the first user, and nothing logged anything.
A screen with constructor dependencies just needs to be a service — the bundle autoconfigures
every NativeComponent with the native.screen tag, and the renderer resolves the class
through that locator. A screen with no dependencies needs no registration at all. Route
parameters are handed over only if the screen declares withRouteParameters(array $params).
Screens are registered non-shared, and that matters: a component may be bound to one
component tree only, so the renderer builds a fresh one per mount, and a shared service would
hand back the instance it had just unmounted. The bundle enforces this both through
autoconfiguration and through a compiler pass over the native.screen tag, so you get it
without asking. If you declare shared: true on a screen yourself the container leaves your
choice alone and the renderer refuses the second mount with a message naming the fix — on a
device the alternative is a 500 on the frame request, i.e. a screen that works once and is
blank ever after.
To render something other than a component — a Twig template, a controller action — implement
ScreenRendererInterface yourself and override the alias in your own configuration, which is
loaded after the bundle's. ComponentScreenFactory remains available for driving a screen
directly, without routing:
use Native\Symfony\Mobile\Ui\Component\ComponentScreenFactory;
$screen = $factory->open(new Counter()); // a fresh component instance, once per screen
$frame = $screen->frame(); // first paint
$frame = $screen->handle(['callback_id' => $id, 'type' => 0]); // tap → repaint
See Recipes for a complete component, and the mobile API reference for elements, callbacks and styling.
Building and running
bin/console native:mobile:manifest --dry-run # what would the device boot?
bin/console native:mobile:build android --stage-only # everything up to Gradle
bin/console native:mobile:build android --dry-run # print the whole plan, touch nothing
bin/console native:mobile:build android --aab # …then Gradle
bin/console native:mobile:build ios --export-options=auto --team-id=ABCDE12345
bin/console native:mobile:run android --list-devices
bin/console native:mobile:run android --device=emulator-5554
bin/console native:mobile:run ios --udid=…
These are split honestly in two, and the commands say so in their own output.
What runs and is tested here: staging the app (the same exclusion walk as desktop, the
same .env cleaning whose keep list beats its remove list so APP_SECRET survives, the CA
bundle, warming var/cache — which matters more here because the packaged PHP cannot load
opcache at all), the app archive, bundle_meta.json and the runtime route dump, the toolchain
detection with its remediation advice, and the exact plan of commands. --stage-only stops
precisely at that boundary and --dry-run executes nothing by construction.
What has never been executed by these commands: ./gradlew, adb, xcodebuild,
simctl. There is no Android SDK and no Xcode in this environment. That is why --dry-run is
the primary interface for native:mobile:run rather than a convenience — it prints commands
you can paste into a terminal on a machine that does have the toolchain.
Signing is not handled. Gradle reads a keystore from the project's own signingConfigs
and Xcode from a provisioning profile. Passing either on a command line would put a password
in the process table, and a signing flow that cannot be tested here would be a liability sold
as a feature.
native:mobile:run does not stage — it assumes native:mobile:build --stage-only has run, or
that the project's existing bundle is current. Silently re-zipping a few hundred megabytes
would be the wrong default for a "change one file, look at the screen" loop.
native:mobile:manifest exists for the two cases a build does not cover: refreshing the route
list into an already-built project without a rebuild, and inspecting what the device would
boot. It fails rather than reporting success when there is no native project to bake into,
because Android can boot native from the runtime dump alone and iOS cannot — a dump without a
bake is a silent fallback to the WebView.
Configuration
# config/packages/native_mobile.yaml
native_mobile:
app_id: com.example.app # bundle identifier / Android application id
name: App
version: '1.0.0' # QUOTE IT — see below
fake_bridge: false # true swaps in the recording fake
platform: ~ # 'ios' or 'android', for platform-variant style classes
collect_garbage: false # gc_collect_cycles() after each persistent request
version must be a quoted string. YAML version: 1.0 is a float, and the container
coerces it to "1". The hosts compare this value by string equality to decide whether to trust
the runtime route dump, so a coerced version silently disables it — and on iOS a non-string
version fails an as? String cast, compares as "", and does the same. The bundle rejects a
non-string here rather than normalising it, because by then PHP has already lost the difference
between 1.0 and 1.
platform: ~ drops every ios: / android: style variant. That is the safe default off a
device and the wrong one on it — set it from the device info if you use those variants.
collect_garbage is read into the container parameter native_mobile.collect_garbage, but
the shipped persistent.php calls MobileRuntime::boot($kernel) without it, so today it
only takes effect if you boot the runtime yourself.
Commands
| Command | Purpose |
|---|---|
native:mobile:install --source=… | Copy androidstudio/ and xcode/ into the app and retarget their bootstrap paths |
native:mobile:doctor | Report what the integration can see; most useful run on a device |
native:mobile:manifest | Write bundle_meta.json and the runtime route dump |
native:mobile:build <android|ios> | Stage the app and produce an APK/AAB or an iOS archive |
native:mobile:run <android|ios> | Debug build, install and launch |