Skip to main content

Deployment

On this page

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.php
PHP 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.