PHP is shared-nothing by default: every request boots a fresh process, so a Phel namespace does not persist between requests. phel build compiles your namespaces to PHP ahead of time and opcache caches that bytecode, so nothing re-parses per request (see Performance for the opcache and compiled-code cache setup). But each request still re-runs every loaded namespace's top-level forms to register its defs.
A worker runtime keeps the PHP process alive across requests: namespaces load once at boot and in-memory state survives between requests, much closer to the JVM/Clojure model.
The one rule#
Require the built entry point once, before the request loop. Everything inside the loop should only call your exported functions.
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/build/app/main.php'; // loads Phel namespaces ONCE
To produce that entry point, see phel build and configure withMainPhelNamespace / withMainPhpPath in phel-config.php. To expose Phel functions to the PHP worker, mark them {:export true} and run phel export, which generates one PHP class per namespace.
Loading Phel: prod vs dev#
One boot hook covers both environments if you guard the load. In production the built file exists and you require it; in development it does not, so you fall back to \Phel::run(), which boots Gacela and compiles on first call:
$built = $root . '/build/app/main.php';
if (is_file($built)) {
require $built; // prod: precompiled, self-contained \Phel::addDefinition() calls, no Gacela, no compiler
} else {
\Phel::run($root, 'app.main'); // dev: boots Gacela and compiles to temp files on first call
}
$root is the project root your framework already knows (base_path(), getProjectDir(), __DIR__). Run this once per process, behind a static flag, never in a per-request hot path. Commit build/ in the deploy artifact (or run phel build in CI); skip committing it in dev so is_file() is false and \Phel::run() takes over. Framework hooks (Laravel, Symfony, framework-less) wire this into their kernels in Framework Integration.
FrankenPHP#
worker.php:
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/build/app/main.php'; // once, outside the loop
$handler = static function (): void {
// call an exported Phel wrapper per request
echo \App\PhelGenerated\App\Main::handleRequest();
};
while (frankenphp_handle_request($handler)) {
gc_collect_cycles();
}
Run it:
frankenphp php-server --root . --worker ./worker.phpPHP Coming from PHP? ›
State is per-worker. FrankenPHP runs several worker instances, each with its own memory. An in-process value (an atom, a cache) is shared across requests handled by the same worker, not across all of them. For global state, use Redis, APCu, or a database. Append ,1 to the worker path (--worker ./worker.php,1) to pin a single worker.
RoadRunner#
Same shape: require the built entry point once, then handle requests in the worker loop (via spiral/roadrunner-http's PSR-7 worker), calling exported Phel functions per request.
When you do not need a worker runtime#
Plain PHP-FPM with opcache is fine for most apps. Reach for a worker runtime when boot cost or per-request namespace registration shows up in profiling, or when you want persistent in-memory state (caches, connection pools) across requests. See Performance for opcache tuning and the compiled-code cache that cut that boot cost.