---
title: "PHP SDK for Laravel and any front controller · camada"
description: "Install camada in Laravel or any PHP front controller under FPM, php -S or mod_php: the options, the file-backed runtime, and how it fails open."
url: https://www.camada.app/docs/php
last_updated: 2026-10-26
---

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

```sh
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
<?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

```php
// 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

```sh
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

```php
$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](https://www.camada.app/docs/quickstart/php), [Laravel](https://www.camada.app/docs/quickstart/laravel).

## The 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](https://www.camada.app/docs) 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](https://www.camada.app/docs/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

```php
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

```php
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:

gating a route

```php
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](https://www.camada.app/docs/rules).

## 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

```php
$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](https://www.camada.app/security) page.

## Sitemap

[Every page on this site](https://www.camada.app/sitemap.md)
