How it works¶
Nativella has three parts: a desktop shell in Rust, on top of Tauri 2 (crates/nativella), a runtime that runs PHP in-process through a custom SAPI and knows nothing of windows (crates/runtime), and a PHP package that wraps the native APIs in classes (packages/nativella).
A request, step by step¶
-
The page asks for a URL. Windows load
php://localhost/.phpis a custom URI scheme the shell registers with Tauri, so the webview hands its requests to Rust instead of the network stack. On Windows, Tauri exposes custom schemes ashttp://php.localhost/instead. -
Rust serves files and queues the rest. The scheme handler runs on the main thread. If the path names a file of
public/, other than a PHP file, the runtime reads it from disk and answers right away, with a guessed MIME type. A path can only go down frompublic/, so..is refused, encoded or not. Symlinks are followed wherever they point, as web servers do, since the developer made them (Laravel'sstorage:link), but never to a PHP file. Everything else goes to a queue, with the label of the window that sent it. -
The PHP thread runs the front controller. PHP lives on one dedicated thread. For each request, it does what a web server SAPI does:
php_request_startup(),php_execute_script()onpublic/index.php,php_request_shutdown(). The custom SAPI fills$_SERVER(the environment of the process, then the CGI variables, theHTTP_*headers and the Nativella variables), feeds the body to PHP throughread_post, so that PHP parses$_POSTitself, and collects the output, the status code and the headers. The front controller is untouched and cannot tell it is not behind PHP-FPM. -
The response goes back. Before it does, the runtime stores the cookies the response sets and follows its redirect, if it is one, because WebKit does neither here. The response is then handed back to the webview from the PHP thread.
The process has no HTTP server and opens no port, so nothing else on the machine can reach the application.
One PHP thread¶
Nativella's PHP is built without thread safety (NTS), on desktop and on phones. PHP starts once, on its own thread (sapi_startup(), then php_module_startup()), and stays up for the life of the process. Requests are queued to it and served one at a time, each one a full request cycle, with OPcache shared between them.
This is simple and fast enough, but a slow request holds back every other one, in every window. So does a modal dialog, since PHP waits for its answer. Static files are not affected, as Rust serves them without PHP.
The fix is a thread-safe (ZTS) PHP with several PHP threads, and a worker mode that boots the framework once. See Limitations and roadmap.
PHP calls native code¶
The runtime registers one function in PHP, nativella_call(string $method, string $jsonPayload): string, as an extension written with ext-php-rs and loaded at module startup:
$result = nativella_call('window.setTitle', '{"title": "3 unsaved notes"}'); // '{}'
Methods are named <domain>.<method>, like window.setTitle, and take and return JSON objects with camelCase keys. The PHP package is a thin layer of classes over it: Nativella\Client\ExtensionClient encodes, calls and decodes, and Window, Dialog and friends build the payloads and type the results.
The call runs on the PHP thread, which may block (a dialog waiting for the user) while the main thread keeps the windows alive. The runtime remembers which window made the current request, so that native calls act on it by default. When the native side fails, nativella_call() throws a RuntimeException, which the package turns into a Nativella\Exception\NativeCallException.
Native code calls PHP¶
Native events go the other way as internal requests: POST /_nativella/event, with a JSON body naming the event, sent by the shell itself. The runtime marks them with $_SERVER['NATIVELLA_INTERNAL'] = '1'. A request from a page cannot set a server variable (its headers only ever become HTTP_* variables), so checking it is enough to keep the endpoint private. The integrations do the check, then turn the body into an event object. See Lifecycle and events.
The same events reach the pages: the shell evaluates a window.dispatchEvent(new CustomEvent('nativella:…')) in each window. Page::dispatch() uses the same path for events of your own.
What WebKit does not do¶
Serving a web application through a custom scheme rather than over HTTP works well. These are the gaps found in WebKitGTK, most of them by the proof of concept that started Nativella, and what the runtime does about them.
- WebKitGTK neither stores
Set-Cookienor sendsCookiefor a custom scheme, so sessions are lost on every request. The runtime keeps a cookie jar on the PHP thread, honouring paths,Max-AgeandExpires(RFC 6265). Requests are served one at a time, so the cookies of a response always reach the next request. Cookies with an expiry date are saved to the data directory, and outlive the application; session cookies end with it, as in a browser. NativePHP Mobile hits the same wall with WKWebView, and passes cookies natively too. - WebKitGTK follows no redirect from a custom scheme, for a navigation or a
fetch(). Both get the raw302. The runtime follows redirects itself, up to 20 of them. A navigation gets a tiny page that runslocation.replace(target), so the address and the history end up where a real redirect would put them. Afetch()or an XHR gets the response of the target directly, but seesredirected: falseand the original URL. Turbo, which readsresponse.redirected, needs more than this. A form sent to a307or a308, which must reach the target with its method and body, gets its response directly too, at the address of the form. - There are no
Sec-Fetch-*headers, so onlyUpgrade-Insecure-Requests, which navigations carry andfetch()never sends, tells a navigation from afetch(). The runtime relies on it. - There is no
Hostheader. The runtime setsHTTP_HOSTtolocalhost, the host of the URL. - WebKitGTK 2.52, on Ubuntu 26.04, segfaults in the AT-SPI bridge as soon as a page loads, with or without PHP.
examples/plain.rsreproduces it with a plain Tauri window.NO_AT_BRIDGE=1avoids it, at the cost of screen readers. Nativella sets it on Linux, unless you set it yourself. - WebKitGTK cannot download from a custom scheme. It drops an attachment, and fails a link with a
downloadattribute without a request. Nativella saves them itself, and answers the webview with an empty response of a type it cannot show, so that it stays on the page. A204 No Content, which keeps the page over HTTP, shows an empty one on a custom scheme. See Downloads.
What PHP needed¶
ext-php-rs (0.16) has an embed SAPI layer good enough for this, with a few rough edges the runtime works around:
- its
read_cookiescallback leaks a C string per request, since PHP never freescookie_data, so the runtime installs its own; - nothing applies
ServerContext::init_request_info(), so the SAPI fillsSG(request_info)itself.
And on the PHP side:
sapi_activate()sets the protocol of every request to HTTP/1.0, whatever the SAPI said, so the runtime sets HTTP/1.1 back in its SAPI'sactivatehook, which PHP calls next. With HTTP/1.0,header('Location: …')answers aDELETEwith a302, which the runtime follows with aDELETEagain. With HTTP/1.1, it answers a303, followed with aGET, as under PHP-FPM.exit()makesphp_execute_script()report a failure, andsymfony/runtimealways calls it, so the runtime does not treat it as an error.- Frameworks build absolute URLs as
http://localhost/…, since that is what the SAPI tells them, and nothing listens there. The runtime maps them back when it follows a redirect, and rewrites them tophp://localhost/…in the URL attributes of HTML and theurl()of CSS that PHP answers (not in text, scripts or JSON, which are the user's data). A script the shell injects in every page brings back the requests thatfetch(), XHR andEventSourcesend tohttp://localhost/…. It never follows a redirect to ajavascript:ordata:URL, as browsers don't. A navigation to one that slipped through is brought back to the application. On Windows, where pages are served fromhttp://php.localhost/over HTTP already, there is nothing to rewrite (untested). - The webview's
OriginandRefererheaders sayphp://localhost. The SAPI hands them to PHP ashttp://localhost, otherwise the same-origin checks of CSRF protections would fail. - A navigation to another website (
http:,https:,mailto:,tel:) opens in the system browser instead of the window. So does a redirect to one, an OAuth login for instance, and the window then stays on a page that says so, with a link back.
Measured¶
On the Symfony demo, with a debug build of the Rust code, Symfony in the dev environment and OPcache on:
| Request | Time |
|---|---|
| First request, compiling the container | 120–260 ms |
| The 404 page of a Symfony skeleton | ~2.5 ms |
| The demo page: Twig, a session, an event log | ~5 ms |
| Kernel boot to controller, as PHP sees it | ~3 ms |
Every request boots the Symfony kernel, as there is no worker mode yet. The runtime prints the time of each request on its standard error output, as [php] GET php://localhost/ 200 4.8 ms.
Standalone executables¶
nativella build appends the project to a copy of the nativella executable itself:
[executable][project .tar.gz][manifest JSON][archive length][manifest length][NTVLLA01]
At startup, the executable looks for that trailer. When it finds one, it runs the embedded project instead of reading its command line, extracting it once per build into the user's cache directory. See Building a standalone app.