Docs
PHP: front controllers, Laravel
camada/camada enforces your snapshot inside a PHP process: your ordered custom rules, then allow, block and challenge, decided in-process, with events shipped after the response has left. One package with a front-controller adapter that needs nothing but $_SERVER, php://input and header() — FPM, php -S, mod_php — and a Laravel middleware.
Install
Not on Packagist yet. Run these in your project's folder: Composer installs it straight from GitHub, composer.lock pins the commit, and composer update camada/camada moves it.
composer config repositories.camada vcs https://github.com/camada-app/camada-php
composer require camada/camada:dev-mainPHP 8.1 or newer, ext-json and ext-zlib (the snapshot travels gzipped), allow_url_fopen=On (the transport is an http stream), no runtime dependencies.
Mount it
<?php
// public/index.php — any front controller
require __DIR__ . '/../vendor/autoload.php';
use Camada\Camada;
use Camada\Http\Sapi;
$cam = Camada::default(); // built lazily from the environment, once per request
$ctx = Sapi::run($cam); // null: camada answered (block, challenge, beacon) and the script must stop
if ($ctx === null) {
return;
}
// your app: x-rid and the _sfp cookie are already on the response; $ctx->rid, $ctx->sid, $ctx->ip are yours// bootstrap/app.php (Laravel 11+), inside the existing ->withMiddleware(function (Middleware $middleware) { … })
$middleware->prepend(\Camada\Laravel\Middleware::class); // first in the global stack
// or app/Http/Kernel.php (Laravel 10): the first entry of protected $middleware = [ … ]
\Camada\Laravel\Middleware::class,
// the context rides $request->attributes->get('camada')The engine is built from the environment, once per request:
export CAMADA_KEY=<ingest_token>.<snap_token>
export CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingestSet these as environment variables: export them in the shell that runs php -S; under PHP-FPM, which clears the environment, add env[CAMADA_KEY] = … to the pool config. PHP reads no .env file. Laravel loads .env into the environment, except once php artisan config:cache has run: there, set them as real environment variables.
An app that reads its own configuration can build the engine itself and hand it in:
$cam = new Camada(env: ['CAMADA_KEY' => $key, 'CAMADA_INGEST_URL' => $ingest], challengePath: '/api/__camada/challenge');
$ctx = Sapi::run($cam); // Laravel: Camada::setDefault($cam) in a service provider — the kernel builds the middleware itselfThe options you will use
| Option | Default | Meaning |
|---|---|---|
| env | the process environment | Where CAMADA_KEY and friends are read from. Pass an array to configure in code. |
| transport | StreamTransport | The HTTP seam that reaches the analyst; tests hand in an in-process fake. |
| refreshS | server-steered | Poll cadence in seconds. Unset, the tenant config's poll_seconds steers it; set, it is pinned. |
| challenge | true | Serve the proof-of-work page for challenge verdicts (CAMADA_CHALLENGE=0 switches it off too). |
| challengePath | /__camada/challenge | Where that page posts its solution. |
| snapshotVersion | 5 | 4 drops your ordered custom rules; 3 the allow and challenge sections too. |
| scriptPath | /_cam/b.js | Where the first-party beacon script is served. |
| fpPath | /_cam/fp | Where that script posts the beacon. Keep it in the same directory as scriptPath: the script derives one from the other. |
Environment: CAMADA_KEY, CAMADA_INGEST_URL, CAMADA_SNAPSHOT_URL, CAMADA_TRUSTED_PROXY, CAMADA_CACHE_DIR (the runtime's directory, below), CAMADA_CHALLENGE=0, and the kill switch CAMADA_DISABLED=1, checked per request (set at boot, no files are touched and no request is ever made). The docs index lists what each one means.
What it does per request
A PHP process lives for one request, so the poll thread and the flush thread the other SDKs run become one file-backed runtime on disk that every worker of every SAPI shares: the raw snapshot container (seeked into per request, never parsed whole), its state, and an event spool, under CAMADA_CACHE_DIR (default: the system temp dir, keyed by your snapshot token). Every write is tmp + rename(), so a reader never sees a torn file, and the request path never opens a socket.
- Keeps the snapshot fresh in the post-response phase, after the client has its bytes: a stale snapshot (0.9 × the cadence your tenant config sets) is fetched under a lock (gzip on the wire, 304 when nothing changed), asking for snapshot v5, the container that carries your ordered custom rules. A busy lock means another worker is on it.
- Resolves the client from the socket peer (
REMOTE_ADDR), combined withX-Forwarded-Foronly under your trusted-proxy setting. A raw forwarded header is never trusted without one. nginx'sreal_ipmodule rewrites the peer before PHP sees it; behind it the trusted-proxy setting should saynone. - Runs your ordered custom rules, then the allow, block and challenge lists. A blocked request gets 403 with
x-block-reason(andx-block-rulewhen a rule decided) before your app runs. The event still ships, marked as an SDK block so it is not confused with your own 403s. - A challenged browser gets the proof-of-work page; an API call reaches your app, and its response carries
x-camada-challenge: 1for your frontend to act on. A request whose IP cannot be resolved is let through. See challenge. - Serves the beacon endpoints, then runs your app with
x-ridand the_sfpsession cookie on its response. Under FPM and FrankenPHPfastcgi_finish_request()ends the response; underphp -Sand mod_php the adapter's output buffer is flushed with aContent-Length. Then one redacted event with its real status and latency is appended to the spool, and a due spool (500 rows, or 15 s since the last flush) ships in slices. An uncaught exception ships asst: 500and stays PHP's (or Laravel's) own 500.
Add the beacon
Bots that never run JavaScript are the cheapest to catch. Put the tag in the <head> of the pages you render and the adapter does the rest:
echo '<html><head>', $cam->scriptTag($ctx), '</head><body>…</body></html>';$cam->scriptTag($ctx) (Laravel: the context is $request->attributes->get('camada')) returns a <script src="/_cam/b.js?r=…" async> tag carrying this request's event id, so the beacon is joined to the page view it came from. The adapter serves the script at GET /_cam/b.js (from opcache, cacheable for an hour) and relays POST /_cam/fp onto the event spool, stamped with the client address camada resolved — never the one the body claims. Both endpoints sit behind the verdict, so a blocked client gets 403 there too. Turn the beacon off in the project's settings and the tag becomes empty and both endpoints fall through to your app.
Report what your app knows
The wire shows a POST /login; only your handler knows whether it failed. Tell camada from the handler:
if (!sign_in($email, $password)) {
$cam->track($ctx, 'login_failed', $email); // hashed in-process; never throws
http_response_code(401);
return;
}
header('Location: /', true, 303);$cam->track($ctx, $event, $user) ships the outcome joined to this request's event and session. The user identifier is hashed in-process with your ingest token before it is spooled — the raw value never leaves the process. It never throws and is a no-op on a request the adapter did not run for. The event name is free-form; the analyst's rules read this vocabulary:
| Event | When |
|---|---|
| login_failed / login_succeeded | a credential check settled |
| signup | an account was created |
| password_reset | a reset was requested |
| mfa_failed | a second factor was rejected |
| payment_failed / payment_succeeded | a charge settled |
| coupon_failed | a promo code was rejected |
A route you want to gate yourself can serve the challenge on demand:
if ($answer = $cam->serveChallenge($ctx)) {
Sapi::send($answer); // the proof-of-work page (or 403 JSON) until this browser holds a valid _cch
return;
}
big_file();What this position sees
The same position as @camada/node, in a PHP process: the final status and latency, the session, the beacon's browser signals and your app's outcomes. Header order is whatever getallheaders() yields — a hash under FastCGI — so the analyst reads no header-order signal from this tap; camada knows what this tap can see from the position itself and never scores the absence of header order as evidence against a request. No ASN, country or TLS fingerprint reaches a PHP process, so those conditions fail open in-app and the analyst resolves ASN and country itself. matches patterns are JS regexes read by PCRE without the u flag: named groups are native, [^] is translated, and a spelling PCRE still rejects never matches here while it does at the edge. What a rule can key on is on rules and list entries.
Deploying it
- FPM, FrankenPHP (classic mode), LiteSpeed: the response ends with
fastcgi_finish_request()and the post-response work runs in the same worker afterwards — a slow analyst costs that worker's availability for the poll's duration (3 s timeout), never the client's latency. php -S, mod_php: the fallback flushes the adapter's buffer with aContent-Length; a client that honours it stops reading before the deferred work runs. Run the CLI server withPHP_CLI_SERVER_WORKERS=4or more, or a refresh in one request delays the next.- The cache dir must be a local, writable directory (
flock()andrename()are the concurrency model; a network mount is not). It is per host: a fleet polls once per host per cadence, and each host ships its own spool. - The first request after a deploy (or an emptied cache) is answered cold — it passes and arms the refresh; the next request enforces. To enforce from request 1, warm the cache in a deploy step. It must warm the directory the workers read: set
CAMADA_CACHE_DIRexplicitly for both (the default lives under the system temp dir, which is per-service under systemd'sPrivateTmp, and is created0700), and run the step as the FPM pool's user:
$cam = Camada::default();
if ($cam->snap !== null) { // null when CAMADA_KEY is unset or CAMADA_DISABLED=1
$deadline = microtime(true) + 5;
while ($cam->snap->verdict(new \Camada\Snapshot\MatchInput(ip: '0.0.0.0'))->reason === 'cold' && microtime(true) < $deadline) {
$cam->snap->refresh(); // bounded: an unreachable analyst leaves it cold, and the app still fails open
usleep(10_000);
}
}- Events ship when the spool is due, in the post-response phase of the request that finds it due, so a quiet site ships its last events on its next hit, not on a timer. A failed POST drops that batch (logged at most once a minute); over 2000 spooled rows the oldest half is dropped. Nothing runs at process exit and no signal handlers are installed.
- Under Laravel the middleware's
terminate()is the post-response phase. Worker-mode SAPIs (FrankenPHP worker mode, Octane), a PSR-15 middleware and an APCu-backed runtime are not in this release.
It fails open
Without a key the engine is inert: one log line, no files, no requests, no enforcement, so an unprovisioned environment behaves exactly as if camada were not installed. Beyond that, a dead ingest (logged at most once a minute, across workers), a corrupt snapshot or a bug in the package costs you the telemetry, never the request — camada can never turn one of your responses into a 5xx. The whole stance is on the security page.