Configuration
Folk is configured via folk.toml in your project root. All settings have sensible defaults — you only need to specify what you want to change.
Environment variables with the FOLK_ prefix override file settings. Use double underscore (__) to separate the section name from the field name:
FOLK_WORKERS__COUNT=8
FOLK_HTTP__LISTEN=0.0.0.0:9000
FOLK_LOG__FILTER=debug
FOLK_SERVER__SHUTDOWN_TIMEOUT=60s
FOLK_WORKERS__MAX_JOBS=500
The pattern is FOLK_{SECTION}__{FIELD} — single underscore after FOLK, double underscore between section and field. This allows field names that contain underscores (e.g. max_jobs, shutdown_timeout) to work correctly.
Server
on_init — pre-start lifecycle hooks
Commands Folk runs itself before the framework boots and any listener binds —
so an "everything in one container" deployment needs no separate entrypoint.sh.
Typical uses: seed a .env, refresh dependencies, run migrations, warm caches.
[on_init]
exec_timeout = "60s" # default per-step timeout
exit_on_error = true # default: a non-zero exit aborts startup (see below)
commands = [
"cp -n .env.example .env",
"composer install --no-dev --optimize-autoloader",
{ run = "php artisan migrate --force", exec_timeout = "300s" },
]
Each commands entry is either a bare string (uses the section defaults) or an
inline table that overrides individual settings:
| Step field | Meaning |
|---|---|
run |
the shell command line (run via sh -c) |
exec_timeout |
per-step timeout override (else the section default) |
env |
extra environment variables, merged over the inherited environment |
user |
run the step as this OS user (requires the master to run as root) |
exit_on_error |
per-step override of the section default |
Semantics
- Steps run sequentially, in declaration order, with the project root as the
working directory; commands go through
sh -c(pipes,&&, substitutions work). - Fail-fast by default (
exit_on_error = true) — a non-zero exit or a timeout aborts startup so a broken environment never receives traffic. This differs from RoadRunner'son_init(which defaults to log-and-continue); opt out globally or per step withexit_on_error = false. - A step that exceeds its
exec_timeoutis killed (SIGTERM→ grace →SIGKILLof the whole process group) and treated as a failure. - Command output is streamed into the Folk log.
- Readiness gate: while
on_initruns no listener is bound, so an orchestrator's readiness probe gets connection-refused (= not ready). A fail-fast abort exits non-zero, so the container is restarted rather than serving traffic.
When it runs. on_init executes at the very top of the entry script, before
vendor/autoload.php is required — so even pre-bootstrap commands (a fresh .env,
a dependency refresh) take effect. It is skipped for the grpc:descriptors codegen
submode. Two caveats:
- Needs a shell. Steps run through
sh -c; a distroless image without a shell cannot use string commands. - Not a from-scratch installer. The launcher itself lives in
vendor/bin, so acomposer installhere refreshes an existingvendor/; it cannot bootstrap a completely empty one.
An absent [on_init] section (or an empty commands) is a no-op — startup is
unchanged.
Workers
[workers]
script = "vendor/bin/folk-worker" # PHP worker script
php = "php" # PHP binary path
count = 4 # Worker processes the master forks
max_jobs = 1000 # Recycle after N requests (0 = never)
ttl = "3600s" # Recycle after this lifetime
exec_timeout = "30s" # Per-request HARD deadline (watchdog kills + respawns)
max_memory_mb = 256 # Recycle a worker over this RSS (example; omit = disabled, the default)
boot_timeout = "30s" # Worker boot timeout
destroy_timeout = "10s" # SIGTERM → wait → SIGKILL a worker that won't recycle
liveness_timeout = "0s" # Force-recycle a worker whose runtime stalls (0 = off)
warmup = true # Opcache warmup before worker spawn
Per-plugin worker pools
count sizes a shared pool that carries every plugin. To scale a subsystem
independently, add workers = N to that plugin's own section — it gets a
dedicated pool of N processes carrying only it; plugins without a
workers key stay in the shared count pool:
[workers]
count = 4 # shared pool: plugins without their own `workers`
[http]
workers = 8 # dedicated pool of 8 HTTP-only processes
[jobs]
workers = 1 # dedicated pool of 1 queue consumer
With no per-plugin workers anywhere, behaviour is identical to earlier
releases — a single shared pool of count. A workers key on a master-only
plugin ([metrics], [process]) is ignored with a warning.
Cross-pool jobs need a shared backend
Folk boots the jobs plugin in every pool so jobs.push resolves from an
HTTP handler, but it only consumes in its own pool. A job pushed from the
HTTP pool reaches the consumer pool only through the queue backend, so
cross-pool delivery requires redis or a managed broker — the
memory/embedded drivers are per-process. Per-worker folk_worker_*
metrics carry a pool label alongside worker_id.
liveness_timeout (runtime liveness)
exec_timeout only catches a request that runs too long. It can't catch a worker whose async runtime has wedged outside a request (e.g. a deadlocked runtime whose HTTP listener stopped accepting). With liveness_timeout set, each worker's runtime emits a heartbeat every second (independent of PHP and of traffic); if it stalls past the timeout while the process is alive, the master force-recycles the worker. Because the heartbeat is traffic-independent, an idle worker is never mistaken for a hung one — leave it 0 (off) or set it generously (tens of seconds). Per-worker folk_worker_* metrics (see the metrics plugin) expose the same signal for alerting.
max_jobs / ttl in the fork model
Under the fork-after-warm model these are enforced by the master. Both are on by default (max_jobs = 1000, ttl = 3600s), so workers recycle after 1000 requests or one hour. Set max_jobs = 0 and a large ttl if you want long-lived workers and rely only on max_memory_mb. When many workers cross a limit together the master staggers recycles (one per second) to avoid a cold-start stampede.
Opcache warmup
When warmup is enabled (default), Folk automatically compiles all files from vendor/composer/autoload_classmap.php into shared opcache before spawning workers. This eliminates the parse+compile overhead on first requests — workers start with hot opcache immediately. Works with any framework or vanilla PHP project that uses Composer. Requires composer install --optimize-autoloader for full coverage.
Worker recycling
Workers are recycled (terminated and respawned) when they exceed max_jobs or ttl. This prevents memory leaks from accumulating. The main thread worker is never recycled.
destroy_timeout
When a worker is recycled (over max_memory_mb) the master sends it SIGTERM and lets in-flight requests drain. If the worker is wedged in a C call and never exits, destroy_timeout (default 10s) bounds the wait: the master then escalates to SIGKILL of the worker's whole process group, so the pool can't silently shrink. See Background processes below for what "process group" means here.
Background processes
A Folk worker is a long-lived process — unlike PHP-FPM, it is not recreated per request. That changes how background work behaves:
- Synchronous
exec()/system()/shell_exec()/proc_open()+proc_close()— fully supported. PHP blocks and reaps the child itself, exactly as anywhere else. - Detached processes (
exec("cmd &"),nohup, a self-daemonizingproc_openwithoutproc_close) — discouraged. Each worker leads its own process group, so such a child is killed together with the worker on force-kill/recycle and cannot leak across restarts. It will not survive its worker, so don't use this pattern for durable background work. pcntl_fork()from a request — not supported. Forking duplicates the worker's multithreaded runtime; the child inherits broken locks and would hang and leak PIDs. Folk guards against the hang (the fork child is force-exited), but you must not rely on forking from a request.
For background jobs, scheduled tasks or parallel work, use the jobs plugin (queues, retries, delays) or the process plugin (supervised long-running processes with restart policies) instead of spawning from a request. As an extra safety net, run the container with an init/reaper as PID 1 (e.g. docker run --init or tini).
Logging
[log]
filter = "info" # "debug", "info", "warn", "error"
format = "text" # "text", "json", or "pretty"
[log.plugins] # Per-plugin log level overrides
http = "warn" # Only warnings and errors from HTTP plugin
jobs = "debug" # Verbose logging from jobs plugin
core = "info" # Core server logging
Per-Plugin Log Levels
The [log.plugins] section lets you set per-plugin log levels using friendly names. No need to know Rust crate names — Folk maps them automatically:
| Config key | Rust target |
|---|---|
http |
folk_plugin_http |
jobs |
folk_plugin_jobs |
grpc |
folk_plugin_grpc |
metrics |
folk_plugin_metrics |
process |
folk_plugin_process |
core |
folk_core |
ext |
folk_ext |
All output goes to stdout. The three formats share the same data structure (timestamp, level, target, message, context fields) — only the visual representation differs.
text — compact, one line per event:
2026-05-20T14:30:00Z INFO [http] 200 GET /api/users 12ms
2026-05-20T14:30:01Z WARN [process] restarting name=scheduler restarts=2
json — structured, for ELK/Loki/Grafana:
{"ts":"2026-05-20T14:30:00Z","level":"INFO","plugin":"http","msg":"200 GET /api/users","status":200,"duration_ms":12}
pretty — multi-line, for debugging:
Note
The RUST_LOG environment variable takes precedence over filter if set.
Request correlation (request_id)
Every request carries a globally-unique request_id — a UUID v7 (time-ordered,
so it sorts by creation time, and unique across instances and restarts). It is a
single correlation key you can use across aggregated logs (Loki, ELK, Grafana)
without also filtering by host or pod. From PHP, read it with
\Folk\Sdk\Folk::requestId() (returns "" outside a request or without the
extension).
The same id appears in two places automatically:
- HTTP access log — the
request_idfield of each access-log line (enable with[http] access_log = true), so the Rust-side line and the PHP application log of the same request share one id. - Application logs via the framework adapters:
- folk/laravel (≥ 0.3.5) —
request_idis added to theextraof every record on the default log channel, soLog::info(...)lines carry it. - folk/symfony (≥ 0.1.2) — when the logger is Monolog (e.g. with
symfony/monolog-bundle),request_idis added to theextraof records on the main channel. Without Monolog it is a no-op.
The id is read when each record is written, so it never leaks between requests on a recycled worker and is simply absent when there is no request in flight.
Plugins
Each plugin has its own configuration section. See the plugin pages for details:
Section present = plugin enabled
A compiled-in plugin is loaded only when its config section is present in folk.toml.
No section means the plugin is not instantiated — it does not start and writes nothing to
the log. An empty section enables the plugin with its defaults:
This lets you build one extension with every plugin compiled in and switch each on or off
purely through config. A config with only [http] runs an HTTP-only server; jobs, gRPC, metrics and process
stay silent. There is no enabled = false flag — simply omit the section to disable a plugin.
Omitting [http] is allowed too (e.g. a jobs-only or gRPC-only server); the server then
starts without an HTTP listener and does so silently.
Duration Format
Duration fields accept human-readable values:
| Format | Meaning |
|---|---|
30s |
30 seconds |
5m |
5 minutes |
1h |
1 hour |
1d |
1 day |
Complete Example
[server]
shutdown_timeout = "10s"
[workers]
script = "vendor/bin/folk-worker"
count = 4
max_jobs = 1000
[log]
filter = "info"
format = "json"
[log.plugins]
http = "warn"
process = "debug"
[http]
listen = "0.0.0.0:8080"
access_log = true
public_dir = "public" # serve static files from disk before dispatching to PHP
[jobs.connections.redis] # each driver is a connection that nests its queues
host = "127.0.0.1"
port = 6379
[jobs.connections.redis.queues.default]
concurrency = 4
max_retries = 3
[grpc]
listen = "0.0.0.0:50051"
proto = ["proto/service.proto"]
transcode = true # typed DTO handlers instead of raw bytes (default: false)
max_recv_message_size = "4mb"
timeout = "30s"
[metrics]
listen = "0.0.0.0:9090"
prefix = "folk"
ready_path = "/ready"
[[metrics.collectors]]
name = "app_requests_total"
type = "counter"
help = "Total application requests"
labels = ["method", "endpoint"]
[[process.processes]]
name = "scheduler"
command = "php artisan schedule:work"
restart = "always"
See the Configuration Reference for a fully commented folk.toml with all options.