16: Daemon

The Phlo Daemon is an optional Node sidecar (phlo-daemon.js): a generic engine that dispatches any Phlo target to a pool of persistent workers. Core Phlo works without it; the daemon makes the heavy paths faster and powers WebSockets and scheduled tasks.

16.1: What it is, and why it is optional

Every routine in your app is already callable as a one-shot CLI process (php www/app.php <target> [args...], see the Tooling chapter). That is how async helpers, tasks and WebSocket events run by default: a fresh PHP process per call, which boots the app, does the work, and exits. Simple and fully isolated, but a boot per call.

The daemon removes that boot. It is a small Node process that keeps a pool of resident PHP workers per app: each worker boots the app once and then answers calls over a pipe, the same idea as FrankenPHP's HTTP worker mode but for non-HTTP work. It adds two things:

  1. a worker pool that runs the same calls without a per-call boot, and
  2. the long-lived host process that WebSockets and scheduled tasks need.

The daemon is a generic central engine: its dispatch core knows nothing about any specific feature. The WebSocket server (Phlo Realtime) and the scheduler are built into the daemon; the PHP runtime helpers reach it over HTTP. Adopting it is opt-in and reversible: leave it out and everything falls back to the one-shot path.

16.2: The worker protocol

Each worker runs php <app.php> phlo_serve, boots the app once, then answers newline-delimited JSON on stdin, one request in flight per worker (concurrency equals the pool size):

in   {"id", "target", "args"?, "stream"?}
out  {"t":"ready"}                                            // once, after boot
     {"id", "t":"line", "data"}                               // 0..N, only when stream
     {"id", "t":"done", "result"} | {"id", "t":"error", "message"}   // exactly one, terminal

target is dispatched with the same parser as the CLI: Class::method, object.method or a bare function (see the Tooling chapter). Per request the worker resets state (phlo('tech/reset'), session close, GC), exactly like the FrankenPHP worker loop, so jobs never leak into each other. Write worker-safe targets: no request or user state in statics, commit or roll back DB work.

16.3: The HTTP API

The daemon binds 127.0.0.1 by default (local only; gate it at the network boundary).

POST /dispatch takes {app, target, args?, stream?, async?}:

Key Used by Meaning
app the runtime helpers The absolute .../app.php path to run. A caller that knows its own app dispatches directly; the pool is keyed by the app path.

The built-in WebSocket server does not use this endpoint: it resolves each connection's Host to an app through the registry (populated from the hosts map in config/daemon.js) and dispatches in-process. POST /message (the broadcast bridge) and GET /health round out the API.

The response depends on the mode:

Request Response
default {status:"ok", result}
async: true 202 {status:"ok", queued:true} (fire and forget; returns once accepted, not once run)
stream: true an application/x-ndjson stream of {t:line,data}* then {t:done,result} or {t:error}

GET /health returns the live worker total against the cap, the per-pool stats keyed by app path, the connected sockets per host, and the configured hosts:

{
    "status": "ok",
    "workers": 5,
    "cap": 7,
    "pools": { "/srv/example/www/app.php": { "workers": 4, "busy": 1, "queued": 0 } },
    "sockets": { "app.example.com": { "tokens": 12, "sockets": 18 } },
    "registered": ["app.example.com", "dev.example.com"]
}

busy is the workers currently handling a call and queued the calls waiting for a free worker; workers/cap is the live total against the ceiling (one less than the core count). The pool grows itself toward the cap under load and reaps idle workers back down, so these are observations, not knobs.

16.4: Configuration

The daemon takes four arguments: a port, the PHP binary, an optional list of scheduled tasks, and the websocket host map. There is no pool sizing - the pool scales on demand.

require('./phlo-daemon.js')(3001, '/usr/bin/php-zts', [
    { app: '/srv/dashboard/www/app.php', target: 'fleet::poll', every: 120, build: true },
], {
    'demo.example.nl': { app: '/srv/demo/www/app.php', build: true },
})
Argument Default Meaning
port (required) Port to bind on 127.0.0.1; gate it at the proxy, never expose it
php (required) PHP binary the workers run (/usr/bin/php-zts for thread-safe builds)
schedule [] Scheduled targets, {app, target, every, build} (see X.6)
hosts {} WebSocket host→app map, { host: { app, build } } (see below)

The host map is config-declared. The fourth argument is the host-to-app map, declared in config/daemon.js and loaded into the registry at startup: each entry pins a Host to its app.php path and a build flag. There is no /register endpoint and no registry.json; apps do not self-register. The built-in WebSocket server uses this map to resolve a connection's Host; the runtime helpers dispatch by their own app path and need no entry.

The pool sizes itself. Each app gets its own pool that spawns workers on demand up to a global cap of one less than the core count, then reaps them once they go idle. A worker is recycled after a number of calls, and a stuck one is killed and respawned. None of this is configured.

One-shot or pooled follows the build flag. A build: true app (development) runs each call as a fresh one-shot process, for full isolation and hot-reload. A release app runs on the resident pool. For websockets that flag is the per-host setting in config/daemon.js; for CLI dispatch the runtime helpers send build per call.

Run it under a process manager:

node config.js
# or
pm2 start config.js --name phlo-daemon

16.5: Runtime helpers on the pool

The async helpers all keep their one-shot subprocess behaviour by default and switch to the daemon pool when the app sets the optional daemon constant:

phlo_app(
    id: 'Api',
    host: 'api.example.com',
    daemon: 3001,
);

With the constant set, these route through /dispatch by their own app path; without it, they spawn a one-shot process exactly as before:

Helper Does
phlo_sync('Class::method', ...$args) Run a target and wait for its return value
phlo_async('Class::method', ...$args) Queue a target fire and forget; returns once accepted
await($job, $job, ...) Run many targets concurrently and collect their results
phlo_stream('Class::method', ...$args) Yield a target's output line by line

The win is largest where one request fans out into many calls: await() over 100 missing translations is 100 app boots on the one-shot path, but 100 dispatches onto a resident pool with the daemon. On the pool the work is also bounded-parallel (queued against the worker count) instead of an unbounded burst of subprocesses.

$results = await(
    ['translate::run', 'nl', $text],
    ['translate::run', 'de', $text],
    ['translate::run', 'fr', $text],
)

Because adopting the daemon is just the daemon constant, an app behaves identically with or without it; only the throughput changes.

16.6: Scheduling: the cron alternative

The schedule argument turns the daemon into a scheduler. Each entry is {app, target, every, build}: the absolute app.php path, the target to run, the interval in seconds, and whether to run it one-shot (build: true) or on the pool. Each fires on its interval with the first run one interval after boot, exactly like cron:

[
    { app: '/srv/dashboard/www/app.php', target: 'fleet::poll', every: 120, build: true },
    { app: '/srv/api/www/app.php',       target: 'tasks::run',  every: 60,  build: false },
]

This replaces the per-app cron entry from the Tasks chapter: with the daemon scheduling tasks::run every minute, you do not need the crontab line at all. Cron stays the no-daemon fallback; the task model, due-checking and on-disk state are identical either way.

16.7: Consumers

Consumer Relationship
Phlo Realtime The daemon's built-in WebSocket server; owns the sockets and runs the websocket::{auth,connect,receive,close} hooks on the pool in-process (receive streams). See the WebSocket chapter.
Runtime helpers phlo_sync / phlo_async / await / phlo_stream, opt in through the daemon constant (X.5).
Scheduler Built in (X.6), replacing cron for tasks::run and fleet::poll.
Phlo WhatsApp Stays its own service: a WhatsApp gateway holds a persistent phone session, which is not a worker-pool job. It is monitored, not absorbed.

16.8: When to run it

Run the daemon when an app needs WebSockets, when it schedules tasks without cron, or when a hot path fans out into many app calls per request. A small site that serves plain HTTP and never broadcasts does not need it: the one-shot path is enough, and keeping the daemon out keeps the moving parts down. The daemon is the optional performance and realtime layer, never a dependency of core request serving.

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.