php-wasm is usually shown running a snippet in a browser tab. I wanted the
opposite: the entire Laravel + Livewire app, with vendor/, running on the
device, inside a WebView, with no server anywhere. That is what NativeBlade
does, and pushing php-wasm that far exposes a set of problems that do not show
up in a playground. This post is about those problems, because they are the
interesting part.
There is no server, so what is a "request"?
The app ships as one JSON file: every PHP file the app needs, vendor/
included. For a fresh Laravel app that is about 6,200 files, 40 MB as JSON,
7 MB gzipped. Livewire alone is 8 MB of it. At boot the shell writes that into
php-wasm's in-memory filesystem, and from then on the app is "a server" the
same way a CLI script is.
When the page does a fetch('/livewire/update'), nothing leaves the device.
A tiny interceptor in the page posts the request to the shell, the shell writes
$_SERVER as JSON to /tmp, and runs php.run() with a bootstrap that loads
those vars and requires public/index.php. The output is the HTTP response.
One request, one PHP process, from nothing to exit, every time.
That alone works surprisingly well. Livewire does not know it is not talking
to a server. The pain starts with everything PHP cannot do from inside wasm.
PHP is synchronous. The world outside wasm is not.
Inside the wasm sandbox there are no sockets, no native SQLite or MySQL
driver, no real filesystem. All of that lives in JavaScript, or in Rust
behind the Tauri bridge, and all of it is asynchronous.
PHP cannot wait for a JavaScript promise. php.run() is a single synchronous
execution: it starts, it produces output, it exits. There is no way to pause
PHP in the middle of Http::get(), hop to JavaScript, fetch, and resume.
So we cheat, and the cheat is the core of the whole thing:
-
Http::get()reaches our handler. It writes the request it wants into/tmp/__nb_http_pending.json, prints a sentinel string, and callsexit(0). - The shell sees the sentinel in the output, reads the pending file, does the
real
fetch()in JavaScript, and writes the response to/tmp/__nb_http_cache/<key>.json. - The shell runs the entire PHP request again from the start.
- This time
Http::get()finds its answer in the cache and simply returns it. PHP continues as if nothing happened.
The database and the filesystem work the same way: __NB_DB_PENDING__,
__NB_FS_PENDING__, a pending file, a cache file, a re-run.
One logical request is therefore N executions of PHP. A page with three
sequential HTTP calls runs four times. The framework knows this and puts a
budget on it: ten HTTP round trips, twenty queries, twenty filesystem
operations per request, after which the request is abandoned with a warning
that tells you to batch. There is a pool mode that collects independent HTTP
calls and flushes them in one round trip, precisely because of this.
Everything before the bridge point replays
That is the consequence nobody enjoys. If your code does something with a side
effect and then makes an HTTP call, the side effect happens on every re-run.
PHP replays deterministically only because the inputs are identical: same
$_SERVER, same body, same cache. Anything non-deterministic before a bridge
call is a bug you will meet.
Eloquent is fine, because the database is behind the same bridge: the query
result is cached, and the replay reads the cache instead of hitting SQLite
again. The mental model you end up with is "PHP is a pure function of the
request plus the cache", and you write code that respects it.
Cancelling is harder than it looks
A user opens a screen whose mount() calls an API, gets bored after 100 ms,
and taps another tab. The shell aborts the in-flight fetch, as it should. But
the PHP request is still logically waiting for that fetch, and every later
request is queued behind it, because two bridge cycles must never interleave
on one php-wasm instance (they share the temp files). If the abort does not
tell the waiting request "you are done, with nothing", the queue never moves
and the app is frozen. We shipped exactly that bug and fixed it this week. The
abort now resolves the abandoned request with an aborted result, and the
navigation that caused it proceeds.
The page has no origin
The app renders inside an iframe loaded from srcdoc, because there is no
file server to point a src at. That iframe has a null origin. A
<link href="/css/app.css"> would be a network request to nowhere. So every
local asset is inlined into the HTML on every render: stylesheets, Livewire's
JavaScript, Vite output, images as data URIs. Each navigation swaps the iframe
document and boots Livewire and Alpine again. It sounds heavy; on a warm
release build it is fast enough that we render the landing route behind the
splash so the first real navigation is already warm.
Native features are just JSON
When PHP wants the camera, it does not call anything native. It returns
{"nativeblade": true, "actions": [{"action": "camera", "data": {...}}]}.
The shell dispatches that to a JavaScript handler, which calls the Tauri
plugin, which calls Kotlin or Swift. The result comes back as a Livewire event,
nb:camera, and your #[On('nb:camera')] method runs on the next PHP
execution. PHP never blocks on native work either. Same rule as the bridges:
ask, exit, get the answer next time.
What this changes about how you write PHP
- Your
.envships inside the app. There are no server secrets, because there is no server. API keys belong on a backend the app calls. -
NativeBlade::log()writes to stderr with a marker; the shell picks it out of the process output and forwards it. There is no log file to tail. - Anything slow in
mount()is a bridge round trip on every render of that page. You start caring about where the first HTTP call happens. - Tests still run with normal PHP. The bridge only exists inside the shell.
None of this is a complaint. It is what makes the model work: PHP stays PHP,
Laravel stays Laravel, and the weirdness is contained in a handful of files
that pretend to be a server. If you have ever wondered how far php-wasm can be
pushed, this is one answer.
Code and docs: https://github.com/NativeBlade and https://docs.nativeblade.dev.