Skip to content
camada
Menu

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.

shell
composer config repositories.camada vcs https://github.com/camada-app/camada-php
composer require camada/camada:dev-main

PHP 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

front controller
<?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
Laravel
// 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:

shell
export CAMADA_KEY=<ingest_token>.<snap_token>
export CAMADA_INGEST_URL=http://localhost:8787   # dev only; defaults to production ingest

Set 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:

explicit config
$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 itself

Quickstarts: PHP, Laravel.

The options you will use

OptionDefaultMeaning
envthe process environmentWhere CAMADA_KEY and friends are read from. Pass an array to configure in code.
transportStreamTransportThe HTTP seam that reaches the analyst; tests hand in an in-process fake.
refreshSserver-steeredPoll cadence in seconds. Unset, the tenant config's poll_seconds steers it; set, it is pinned.
challengetrueServe the proof-of-work page for challenge verdicts (CAMADA_CHALLENGE=0 switches it off too).
challengePath/__camada/challengeWhere that page posts its solution.
snapshotVersion54 drops your ordered custom rules; 3 the allow and challenge sections too.
scriptPath/_cam/b.jsWhere the first-party beacon script is served.
fpPath/_cam/fpWhere 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.

  1. 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.
  2. Resolves the client from the socket peer (REMOTE_ADDR), combined with X-Forwarded-For only under your trusted-proxy setting. A raw forwarded header is never trusted without one. nginx's real_ip module rewrites the peer before PHP sees it; behind it the trusted-proxy setting should say none.
  3. Runs your ordered custom rules, then the allow, block and challenge lists. A blocked request gets 403 with x-block-reason (and x-block-rule when 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.
  4. A challenged browser gets the proof-of-work page; an API call reaches your app, and its response carries x-camada-challenge: 1 for your frontend to act on. A request whose IP cannot be resolved is let through. See challenge.
  5. Serves the beacon endpoints, then runs your app with x-rid and the _sfp session cookie on its response. Under FPM and FrankenPHP fastcgi_finish_request() ends the response; under php -S and mod_php the adapter's output buffer is flushed with a Content-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 as st: 500 and 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:

beacon
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:

outcomes
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:

EventWhen
login_failed / login_succeededa credential check settled
signupan account was created
password_reseta reset was requested
mfa_faileda second factor was rejected
payment_failed / payment_succeededa charge settled
coupon_faileda promo code was rejected

A route you want to gate yourself can serve the challenge on demand:

gating a route
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 a Content-Length; a client that honours it stops reading before the deferred work runs. Run the CLI server with PHP_CLI_SERVER_WORKERS=4 or more, or a refresh in one request delays the next.
  • The cache dir must be a local, writable directory (flock() and rename() 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_DIR explicitly for both (the default lives under the system temp dir, which is per-service under systemd's PrivateTmp, and is created 0700), and run the step as the FPM pool's user:
warm-up
$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.