Bringing NativePHP's desktop runtime to Symfony: build desktop applications with Symfony and PHP, on the same Electron runtime the Laravel version uses.
Working, and proven end-to-end — a Symfony 8 app in a native window, driving the runtime's full API, packaged into a distributable app that has been built and run.
bundle/ |
sadiqk2/nativephp-symfony-desktop-bundle — desktop. All 118 runtime endpoints, all 46 events, 582 tests. Verified by a running packaged app. |
mobile-bundle/ |
sadiqk2/nativephp-symfony-mobile-bundle — iOS and Android. All 54 bridge methods, the SAPI shim, the persistent runtime, and the full native-UI path — element trees, style parser, routing and components — byte-identical to upstream. 588 tests. Verified by tests, not by a device. |
spike/ |
The reproduction harness: a container with PHP 8.4 + Node 22 + Electron, the runtime patch, and headless runners that screenshot the result. |
upstream-patches/ |
Eleven patches: seven against NativePHP/desktop, four against NativePHP/mobile-air. Four have landed in upstream's main; six are open PRs (#138, #139, #349–#352), and the manifest patch is the one still held. |
upstream/ |
Shallow reference clones of NativePHP/desktop and NativePHP/mobile-air (gitignored; clone on demand). |
RELEASING.md |
How the two packages reach Packagist: Packagist reads a composer.json at a repository root, so each directory is subtree-split into a read-only mirror on every push and tag. |
Your application does not change. It stays an ordinary Symfony app — same controllers, same
templates, same bin/console — and gains two things: a native window, and an API for the
machine it is running on.
You need PHP 8.3+, Symfony 7 or 8, and (for desktop) Node 20+ and git.
composer require sadiqk2/nativephp-symfony-desktop-bundle:^0.1 # desktop
composer require sadiqk2/nativephp-symfony-mobile-bundle:^0.1 # iOS and AndroidTake one or both — they share no code and neither requires the other. ^0.1 pins to 0.1.*
deliberately: this is a 0.x project, so under SemVer the minor number is where breaking
changes live until 1.0.0.
Both are published from this repository by subtree split, so the Packagist entries point at read-only mirrors and everything — issues, pull requests, the history — lives here. See RELEASING.md.
Working against a checkout instead
To try an unreleased fix, or to change the bundles themselves, point Composer at a clone.
This is what demo/ does.
git clone https://github.com/sadiqk2/nativephp-symfony /path/to/nativephp-symfony"repositories": [
{ "type": "path", "url": "/path/to/nativephp-symfony/bundle", "options": { "symlink": true } },
{ "type": "path", "url": "/path/to/nativephp-symfony/mobile-bundle", "options": { "symlink": true } }
]composer require sadiqk2/nativephp-symfony-desktop-bundle:^0.1@dev # desktop
composer require sadiqk2/nativephp-symfony-mobile-bundle:^0.1@dev # iOS and Android@dev is not optional on this route, and only on this route. Neither composer.json
carries a version field — Composer derives a released version from the git tag, and
composer validate --strict fails on a hardcoded one — so a path repository resolves to
dev-main, aliased by extra.branch-alias to 0.1.x-dev. A plain ^0.1 under the default
minimum-stability: stable therefore answers "found …[dev-main, 0.1.x-dev] but it does not
match your minimum-stability". The stability flag applies to this constraint alone, so
nothing else in the application loosens. Once the packages are on Packagist, ^0.1 on its
own is the constraint to use.
demo/composer.json is a working example of the whole block, flag
included.
Use symlink: true: without it Composer caches a copy and edits to the bundle appear to do
nothing. Nothing after this step depends on how the package arrived.
Flex registers both for you. If it is not installed, add them yourself:
// config/bundles.php
Native\Symfony\Desktop\NativeDesktopBundle::class => ['all' => true],
Native\Symfony\Mobile\NativeMobileBundle::class => ['all' => true],Both namespaces end in the name Flex derives its candidate bundle class from, which is not a
coincidence: the desktop bundle used to live at Native\Symfony\ and Flex could not find
NativeDesktopBundle under it — it looks for SymfonyBundle and NativeSymfonyBundle there
— so the line above had to be written by hand while the mobile one appeared on its own.
Verified on a fresh symfony/skeleton in both directions.
1. Configure it. Only name and app_id really matter to start:
# config/packages/native_desktop.yaml
native_desktop:
name: 'My App'
app_id: com.example.myapp
version: '1.0.0'2. Decide what appears on launch. The runtime boots and then POSTs /_native/api/booted;
whatever that handler does is your startup. Nothing opens unless you ask:
namespace App\Native;
use Native\Symfony\Desktop\Contract\AppBootstrapper;
use Native\Symfony\Desktop\Window\WindowManager;
final class Bootstrapper implements AppBootstrapper
{
public function __construct(private readonly WindowManager $windows) {}
public function boot(): void
{
$this->windows->open('main')->url('/')->size(1100, 760)->title('My App')->open();
}
}No wiring: the bundle autoconfigures the interface and a compiler pass aliases it. boot()
must be idempotent — macOS re-posts /booted on activate — and window/open is already
idempotent by id, so a boot() that only opens windows needs no guard.
3. Install the Electron runtime into your project. The bundle deliberately does not
vendor it; requiring nativephp/desktop would pull illuminate/contracts and
laravel/prompts into a Symfony app:
git clone --depth 1 https://github.com/NativePHP/desktop /tmp/np-desktop
bin/console native:install --source=/tmp/np-desktop/resources/electronThis copies the runtime to nativephp/electron, retargets the ten hardcoded Laravel strings
in its TypeScript at your app, installs public/nativephp-router.php, writes
config/routes/native_desktop.yaml, and runs npm install (it downloads Electron — expect
minutes; --skip-npm to skip).
4. Check it before running it. This runtime's failure mode is silence, so the check is not optional ceremony:
$ bin/console native:doctor # abridged: it also prints a project/PHP/runtime table
Runtime endpoints
✓ POST /_native/api/booted
✓ POST /_native/api/events
Firewall
– no security bundle installed, so nothing can gate the endpoints.
Application startup
✓ App\Bootstrapper will run when the runtime finishes booting.
Build inputs
✓ nativephp/php-bin
✓ Electron projectA missing routes import is the commonest way to get an app that opens a window and then does
nothing forever — /booted 404s, boot() never runs, and Electron logs it somewhere you
would not look. native:doctor asks the router directly and exits non-zero.
If your app has a firewall, the bundle keeps your access_control off /_native/api/
while the shared-secret gate is enforcing. Symfony's security config is single-source, so a
bundle cannot contribute a firewall — declare one yourself if you prefer it explicit:
# config/packages/security.yaml — before your main firewall
security:
firewalls:
native_runtime:
pattern: ^/_native/api/
security: false5. Run and package.
bin/console native:run # dev; -v logs every PHP command the runtime spawns
composer require nativephp/php-bin # static PHP binaries, needed to package
bin/console native:build linux x64 --dir # unpacked build, the fast smoke testThe mobile path is one process with PHP compiled into it — no port, no shared secret. And
the render path most apps want is the WebView one: a Symfony app that declares no
#[NativeScreen] produces no native-route manifest, so both platforms' BootPlanner takes
the WebView by construction. Your controllers and Twig templates are unchanged; the new thing
is the device API (camera, biometrics, push, haptics — 54 methods).
1. Configure it. fake_bridge is what makes development possible at all: nativephp_call()
is a compiled extension that exists only inside a packaged app, so off a device every call
returns null and a working app is indistinguishable from a broken one. The fake records
instead.
# config/packages/native_mobile.yaml
native_mobile:
name: 'My App'
app_id: com.example.myapp
version: '1.0.0' # quote it — YAML 1.0 is a float, and the hosts compare by string
# config/packages/dev/native_mobile.yaml and config/packages/test/native_mobile.yaml
native_mobile:
fake_bridge: true2. Bring in the host projects and retarget them. native:mobile:install prints a
licensing caution deliberately — NativePHP Mobile is sold as a product, though the
mobile-air repository itself is MIT; read its terms rather than either summary:
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:doctorThe installer mirrors resources/androidstudio to nativephp/android and
resources/xcode to nativephp/ios, then points the two places each host hardcodes a path
into another vendor's bootstrap/ at this bundle's shims instead. It throws rather than
skipping a path, because a missed one is a launch to a blank screen with no diagnostic.
3. Build.
bin/console native:mobile:build android --dry-run # print the whole plan, touch nothing
bin/console native:mobile:build android --stage-only # everything up to Gradle
bin/console native:mobile:build android --aab # …then Gradle
bin/console native:mobile:build ios --export-options=auto --team-id=ABCDE12345Staging, .env cleaning, the app archive and the manifest all run here and are covered by
tests. The last step is Gradle or Xcode on a machine that has them — and no build produced
by these commands has been opened by either yet, which is the one caveat worth repeating.
See getting started — mobile.
native:configandnative:php-inimust print nothing but JSON. The runtime runs them before its API server exists, captures stdout withexecFileandJSON.parses it. Anything your app echoes during console boot — a banner, adump(), a deprecation — breaks the parse and the app starts with no config and no visible symptom.- Never assume
NATIVEPHP_API_URLorNATIVEPHP_SECRETexist. At that point in the boot they do not, so any service that calls the runtime from a console-boot path throws.
A complete working example of all of the above — both bundles, two windows, a native menu, a
native-UI screen — is demo/, and it is also the integration test.
Every step above was run against a fresh symfony/skeleton (Symfony 8.1) rather than
written from the code: composer require through the path repositories, both config files,
the bootstrapper exactly as printed, native:install --skip-npm, and then native:doctor
reporting both endpoints reachable and App\Native\Bootstrapper wired with nothing to wire
it. native:config in prod produced 181 bytes of valid JSON and an empty stderr, which is
the rule above holding in a stock app.
docs/ is the documentation for building an application with this, and
every document in this repository is also a page on a static site: tools/build-docs.mjs
renders all 26 of them into docs/*.html with search, light and dark themes, build-time syntax
highlighting and no external requests of any kind — ready for GitHub Pages (Settings → Pages →
main / /docs). See tools/README.md to rebuild, preview or edit it.
Getting started for desktop and
mobile, an API reference by area for
desktop and mobile,
recipes, testing, and a symptom-first
troubleshooting page — which is the one to read first, because this
runtime's usual failure mode is silence rather than an error.
Everything below is about the port itself: the specification it implements, the analysis
behind it, and the record of building it. docs/README.md sorts every
document in the repository into current reference, historical record, and
for-people-working-on-the-port.
- ARCHITECTURE.md — how desktop and mobile differ, what the two bundles do and don't share, the three verification techniques used throughout, and a collected list of the traps. Read this first if you only read one.
- PLAN.md — what NativePHP actually is, where Laravel leaks, and the roadmap. Start here.
- ANALYSIS.md — the deep dive: the exact boot sequence, a per-directory port map with LOC, the eight Laravel-isms in the runtime's TypeScript with line numbers, the Symfony-specific design decisions, and the upstream bugs found along the way.
- CONTRACT.md — the wire protocol. All 118 endpoints with request and response shapes, all 46 events with payload shapes, the environment contract, and the seven things the runtime requires of any PHP app.
- SPIKE-RESULTS.md — M1: proving it possible at all. Historical record, not reference.
- M2-RESULTS.md — the bundle. Historical record.
- M3-RESULTS.md — the build pipeline. Historical record.
- MOBILE-ANALYSIS.md — mobile, and a correction to an earlier conclusion that was wrong.
- NATIVE-UI-CONTRACT.md — the native-UI wire format: the
protocol the SwiftUI and Compose renderers consume, and what a Twig front end
(the
super-nativeequivalent) would have to produce.
The Laravel coupling in NativePHP's desktop runtime turned out to be eight hardcoded string literals in one TypeScript file, not an architecture. The PHP↔runtime boundary is already framework-neutral: localhost HTTP and JSON, with a shared-secret header.
So the adapter needs no fork. native:install --publish upstream already mirrors the whole
Electron project into the application's own directory, and the runtime prefers that copy —
so each app patches its own, idempotently, and the proper fix upstream is a small
behaviour-preserving manifest PR.
Mobile splits in two, and an earlier version of this README got it wrong by pricing the
whole thing at the cost of the harder half. Its native-UI engine really is 17,316 LOC coupled
to Blade — but it is optional: both platforms' BootPlanner falls back to a WebView
whenever an app registers no native routes, and NATIVEPHP_BOOT_MODE=web forces it. So a
Symfony mobile app needs a ~100-line SAPI shim and wrappers for 54 bridge methods, not a
rendering-engine port. See MOBILE-ANALYSIS.md.
| Desktop | Done and proven end-to-end, including a packaged app that has been built and run. |
| Mobile — WebView path | Implemented and tested. No device verification yet; this environment has no Xcode or Android SDK. |
| Mobile — native UI | Implemented. Element trees, the Tailwind-subset parser, #[NativeScreen] routing and a component lifecycle — all byte-verified against upstream where a comparison exists. |
M0 (contract), M1 (spike), M2 (bundle), M3 (build pipeline), M5 (mobile WebView) and M6 (native UI) are done. No milestone is unbuilt.
What is left is not code:
- Device verification for mobile.
native:mobile:buildstages, cleans and archives the application and then invokes Gradle or Xcode — and neither toolchain exists in this environment, so no build produced by these commands has been opened by one, and no screen has been rendered on a phone. Everything up to that boundary is tested;--stage-onlystops exactly there. This is the single most valuable thing anyone with a Mac or an Android SDK can contribute. - Upstream review. Of the eleven patches in
upstream-patches/, four are now in upstream'smain— theNaNzoom factor, the swallowednotifyLaravelfailures, the stale package metadata, and the dead config namespace (which upstream resolved by deleting the controller). Six are still open: two onNativePHP/desktop— #138 and #139, the null focused window and the argumentlessjson()— and four onNativePHP/mobile-air(#349–#352), each with a regression test in that repo's own idiom. The manifest patch — the one that makes the runtime framework-agnostic — was held until the small ones landed, which has now happened. Prior art is NativePHP discussion #504. - One open question for upstream (ANALYSIS.md §9): whether Bifrost's server-side bundler
can target
bin/consoleinstead ofartisan. If it cannot, Symfony applications can only produce source-exposed builds — whichnative:buildwarns about at every invocation rather than letting anyone ship source unknowingly.
CONTRIBUTING.md has the development loop — and the one thing worth knowing before reading any of the code: this runtime's failure mode is silence, not an error, and almost every design decision here is downstream of that. Also SECURITY.md, which describes the trust boundary so a report can be aimed correctly, and CODE_OF_CONDUCT.md.
The most valuable contribution is still device verification for mobile: if you have a Mac or
an Android SDK, running native:mobile:build on the demo and
reporting what happened
— whether it worked or not — is worth more than any amount of additional test coverage.
MIT, matching nativephp/desktop. Changelogs: desktop,
mobile.
The packages are published under sadiqk2/* because that is a namespace nobody else has a
claim on. nativephp/* is someone else's brand and this does not claim it, and
native-symfony/* — the name used up to 0.1.0 — is blocked by Packagist, which rejects any
vendor name carrying a framework's trademark. The courtesy conversation with upstream is
still owed, and it comes before 1.0.0.
