HTTP Plugin
Accepts HTTP connections and dispatches requests to PHP workers. Built on hyper and axum.
Features
- HTTP/1.1 server on hyper/axum
- Zero-copy dispatch to PHP workers via channels
- Request/response as structured
serde_json::Value - Graceful shutdown
- Configurable read/write timeouts (applied via tower-http)
- Configurable max request body size (human-readable:
"10mb","512kb") - HTTP access logging (client IP, method, URI, status, duration, response bytes)
- Trusted proxies — correct X-Forwarded-For extraction behind load balancers
- TLS/SSL via rustls (optional feature
tls, enabled by default) - HTTP/2 cleartext (h2c) via hyper-util (optional feature
h2c) - Response compression: gzip, brotli, zstd, deflate (configurable algorithms and min size)
- Static file serving from a directory (
public_dir, nginxtry_filesstyle) - Active connections counter (via
http.connectionsRPC) - Lua hook pipeline — attach Lua scripts to request lifecycle events without writing Rust code
Planned
- Per-route timeouts and body limits
Configuration
[http]
listen = "0.0.0.0:8080" # Listening address
read_timeout = "10s" # Max time to read request body
write_timeout = "30s" # Max time to write response (returns 504 on timeout)
max_request_size = "10mb" # Max request body size ("10mb", "512kb", or integer bytes)
access_log = false # Enable HTTP access logging
trusted_proxies = [] # Trusted CIDR subnets for X-Forwarded-For
h2c = false # Enable HTTP/2 cleartext (without TLS)
# public_dir = "public" # Serve static files from this dir before dispatching to PHP (disabled by default)
# TLS — if set, the server listens on HTTPS (HTTP/2 via ALPN automatic)
# [http.tls]
# cert = "/path/to/cert.pem"
# key = "/path/to/key.pem"
# Response compression
# [http.compression]
# enabled = true
# algorithms = ["gzip", "br", "zstd"] # in priority order
# min_size = 256 # min response size to compress (bytes)
Trusted Proxies
When running behind a load balancer or reverse proxy, configure trusted_proxies to correctly extract the real client IP from the X-Forwarded-For header:
Folk uses the rightmost non-trusted algorithm — it walks the X-Forwarded-For chain from right to left and returns the first IP that is not in a trusted subnet. This is the secure standard approach that prevents spoofing.
Compression
Enable response compression to reduce bandwidth:
[http.compression]
enabled = true
algorithms = ["gzip", "br"] # supported: gzip, br, zstd, deflate
min_size = "1kb" # don't compress small responses
The server respects the client's Accept-Encoding header and selects the best matching algorithm from the configured list.
Static files
Set public_dir to serve static assets straight from disk before a request reaches PHP — the same try_files behaviour you'd get from nginx or php artisan serve:
- A request that maps to an existing file under
public_diris served from disk (with the correctContent-Type), so built assets (/build/assets/app-*.css, JS, images, fonts) never hit a PHP worker. - A miss falls through to the PHP handler, so your framework routes keep working.
.phpfiles and non-GET/HEADrequests are always dispatched to PHP — the framework front controller (public/index.php) is never returned as source, and/is handled by the framework, notpublic/index.html.
Compression applies to static responses too. For heavy asset traffic you may still prefer a CDN or reverse proxy in front of Folk; public_dir removes the hard requirement for one.
How It Works
- HTTP request arrives at the Rust listener
- Request is converted to a
serde_json::Value(method, headers, body, URI) - Dispatched to an available PHP worker via channel
- PHP handler processes the request and returns a response
- Response is sent back to the client
The entire path is zero-copy between Rust and PHP — no JSON serialization on the hot path when using the dispatch loop.
PHP Handler
In your worker script, register an HTTP handler:
$loop = new \Folk\Sdk\Worker\WorkerLoop();
$loop->onHttp(function (array $request): array {
return [
'status' => 200,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['hello' => 'world']),
];
});
$loop->run();
With Laravel, HTTP routing works automatically via the Folk service provider.
Lua Hook Pipeline
Attach Lua scripts to points in the HTTP request lifecycle — before or after PHP, without writing or recompiling Rust code. Useful for rate limiting, auth checks, header manipulation, CORS, audit logging, and A/B routing.
Hook events
| Event | When | Can short-circuit | Context available |
|---|---|---|---|
request.before |
After HTTP parsing, before PHP dispatch | yes | method, path, query, headers, client_ip, request_id, extra |
request.error |
PHP returned error or exec_timeout fired | no | same + error string |
response.headers |
PHP response headers received, before body | yes | status, resp_headers |
response.after |
Full PHP response received, before sending | yes | status, resp_headers, body |
Configuration
[[http.hooks]]
event = "request.before"
lua = "hooks/rate_limit.lua"
mode = "sync" # "sync" (critical path) | "async" (fire-and-forget)
timeout_ms = 5 # sync only: abort hook after N ms; default 5
on_error = "fail_open" # "fail_open" (skip + WARN) | "fail_closed" (→ 500)
Multiple [[http.hooks]] entries are allowed. On each event, all sync hooks run first (in declaration order), then all async hooks fire-and-forget. A short-circuiting sync hook stops subsequent sync hooks and PHP dispatch; async hooks still run with ctx.short_circuited = true.
Writing Lua scripts
The script receives a ctx global table. Return nil (or nothing) to continue; return a table to short-circuit:
-- hooks/rate_limit.lua
if ctx.headers["x-api-key"] ~= "secret" then
return { status = 429, body = "Too Many Requests" }
end
-- hooks/cors.lua (response.headers event)
ctx.resp_headers["access-control-allow-origin"] = "*"
ctx.resp_headers["access-control-allow-methods"] = "GET, POST, OPTIONS"
-- hooks/inject.lua (request.before — mutate before PHP sees it)
ctx.headers["x-forwarded-host"] = ctx.headers["host"]
ctx.extra["trace_start"] = "1"
Context fields:
| Field | Events | Mutable | Notes |
|---|---|---|---|
ctx.method |
request.* | no | "GET", "POST", … |
ctx.path |
request.* | no | URI path, e.g. "/api/users" |
ctx.query |
request.* | no | Raw query string without ? |
ctx.client_ip |
request.* | no | Resolved client IP |
ctx.request_id |
request.* | no | UUID v7; "" outside request |
ctx.headers |
request.* | yes (sync) | Mutations reach PHP |
ctx.extra |
request.* | yes (sync) | Arbitrary bag; mutations reach PHP |
ctx.error |
request.error | no | Error message or "timeout" |
ctx.status |
response.* | no | HTTP status code |
ctx.resp_headers |
response.* | yes (sync) | Mutations reach the client |
ctx.body |
response.after | yes (sync) | Full response body as string |
ctx.short_circuited |
all (async) | no | true if a prior sync hook short-circuited |
Error handling
on_error = "fail_open"(default) — if the Lua script errors, log WARN and continue pipeline.on_error = "fail_closed"— if the Lua script errors, return HTTP 500. Use for security-critical hooks (auth, API-key check) where a failing script must not silently pass requests through.
If a script file is missing or has a syntax error at startup, the hook is skipped with WARN and the server starts normally.
Request ID
Every request is assigned a globally-unique id — a UUID v7 (time-ordered and
unique across instances and restarts). When access_log = true, it is included
as the request_id field of each access-log line, so the Rust-side line and your
PHP application log of the same request share one id. Read it from PHP with the
\Folk\Sdk\Folk::requestId() facade (or the native folk_request_id() function):
requestId() returns "" outside of a request, or when the Folk extension is not
loaded (e.g. in unit tests), so it is always safe to call.
Concurrency note: the
[workers] max_concurrent_per_workersetting currently supports only1(one request per worker at a time). Values> 1are reserved for a future async runtime and are clamped to1with a warning.