Skip to content

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

[server]
shutdown_timeout = "30s"          # Graceful shutdown timeout

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's on_init (which defaults to log-and-continue); opt out globally or per step with exit_on_error = false.
  • A step that exceeds its exec_timeout is killed (SIGTERM → grace → SIGKILL of the whole process group) and treated as a failure.
  • Command output is streamed into the Folk log.
  • Readiness gate: while on_init runs 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 a composer install here refreshes an existing vendor/; 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-daemonizing proc_open without proc_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 requestnot 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:

  2026-05-20T14:30:00Z INFO [http]
    200 GET /api/users
    status: 200
    duration_ms: 12

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_id field 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_id is added to the extra of every record on the default log channel, so Log::info(...) lines carry it.
  • folk/symfony (≥ 0.1.2) — when the logger is Monolog (e.g. with symfony/monolog-bundle), request_id is added to the extra of 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:

[metrics]   # empty section — metrics enabled with default settings

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.