Use HELMET in raw PHP#
This page boots HELMET in a framework-free PHP app with a single call. Start with Boot the app, then pick up the options your app needs: structured logs, the request-forgery guard, the opt-in error handler, and the standalone health check.
Boot the app#
At the top of the front controller, before anything is echoed:
use function Waynestate\Helmet\helmet_boot;
$helmet = helmet_boot([
// The platform preset that supplies the trusted proxies. An explicit
// 'trusted_proxies' list wins over the preset.
'preset' => 'citadel',
// Optional. A Monolog JSON logger, one line per record. "stream" may be a
// stream or a file path. Off Citadel, lead with php://stderr; the
// Citadel preset's value is /var/log/app/helmet.log. Every record carries
// a request ID in extra.request_id, generated by HELMET; an inbound
// header is never trusted for it. Set "request_id" to reuse an ID you
// already have (8 to 64 letters, digits, dots, underscores or dashes).
'log' => [
'channel' => 'app',
'stream' => 'php://stderr',
'level' => 'info',
// 'request_id' => 'req-20261029-abcd',
],
]);
// $helmet['nonce'] is the per-request CSP nonce. Put it in your <script> tags.
// $helmet['logger'] is a Psr\Log\LoggerInterface, or null when no log is set.
// $helmet['request_id'] is the ID stamped on this process's log records.
helmet_boot() sends the headers, strips X-Powered-By, and answers /up with 200 ok before your code runs. It returns the nonce, the logger and the request ID so the rest of the app can use them.
It reads $_SERVER directly rather than building a PSR-7 request, so it works in plain PHP without a server-request factory. In order, it:
- decides whether the request is HTTPS from the trusted-proxy list,
- hardens the session settings (throws if a session is already active),
- removes the
X-Powered-Byheader, - answers the health check path with
okand stops, - rejects a state-changing request when the request-forgery guard is enabled and the request is not same-origin (or from a trusted origin),
- sends the security headers (including a per-request CSP nonce),
- sets up a JSON logger (if configured).
Structured logs#
Each record is one JSON line:
{"message":"login","context":{"user":"alice"},"level":200,"level_name":"INFO","channel":"app","datetime":"2026-10-29T12:00:00.000000+00:00","extra":{"request_id":"9f2c1ab04d5e6f70"}}
When you set log, the channel defaults to helmet, the stream to php://stderr, and the level to info. The request ID rules sit in the boot snippet above: every record carries one in extra.request_id, generated by HELMET, and an inbound header is never trusted for it.
Security events#
Three warnings carry HELMET's security events, and a logger that throws never changes the answer: the 403 still goes out, the resolved client IP is still the peer, and the boot still finishes.
| Message | When | Context keys |
|---|---|---|
helmet.request_refused |
the request-forgery guard answers 403 |
event (request_refused), method (upper case), path (no query string), sec_fetch_site (same-origin, same-site, cross-site, none, other, or null when the header is absent), origin_present (bool) |
helmet.forwarded_for_rejected |
ClientIp::resolve() fails closed on an unparseable X-Forwarded-For entry |
event (forwarded_for_rejected), reason (unparseable_entry), peer (the trusted peer address) |
helmet.debug_enabled_in_production |
the Laravel adapter boots with app.debug true in the production environment |
event (debug_enabled_in_production) |
Never logged: the Origin value, cookies, Authorization, the X-Forwarded-For header, or the bad entry. The helmet takes the hit and reports it, without quoting the attacker.
The logger reaches each path this way: helmet_boot() hands its own logger to the guard; PSR-15 users pass a LoggerInterface as the last constructor argument $logger of RequestForgeryGuard; helmet_metrics() passes its configured logger to ClientIp::resolve().
Trusted proxies#
Use a platform preset (for example citadel, which trusts the Docker bridge gateway), or list only the address the front proxy connects from, never a whole private range. A broad range such as 10.0.0.0/8 also trusts other containers on the same network, and any of them could then set X-Forwarded-Proto. An explicitly empty HELMET_PROXIES means trust nobody, even when a preset is set; only an unset variable falls back to the preset.
The trusted proxies come from, in order of precedence:
trusted_proxies: an explicit list of IPs or CIDR ranges.preset: a platform preset name (seeWaynestate\Helmet\Platform\Presets), for examplecitadelordocker.- neither: an empty list, which trusts nothing.
A generic error page#
A raw PHP app has no framework to catch an uncaught exception, so PHP may print the message, the file path and a stack trace to the visitor. Turn on the opt-in handler and the visitor gets a short, fixed "Something went wrong" page with status 500 and Cache-Control: no-store, while the full detail (class, message, file, line and trace) goes to the log at error level:
helmet_boot([
'error_handler' => ['enabled' => true],
]);
The page never contains anything from the exception, and a logger that throws cannot change it. It is off by default, and Laravel keeps its own exception handler. If you want to build the page yourself, call (new \Waynestate\Helmet\Error\GenericErrorResponse())->build($exception, $logger) for the status, headers and body as data. HELMET does not switch PHP's display_errors off for you; the platform setting does that.
The same class builds the 4xx half of the rule: a raw PHP app that wants a neutral 4xx page for a client fault calls clientError() with the status and sends the result itself:
$page = (new \Waynestate\Helmet\Error\GenericErrorResponse())->clientError(404);
http_response_code($page['status']);
foreach ($page['headers'] as $name => $value) {
header($name . ': ' . $value);
}
echo $page['body'];
The page is fixed text: 400, 401, 403, 404, 405, 410 and 429 each carry their own title and sentence, and any other 4xx keeps the status you passed and falls back to the Bad request text. Nothing from the request or an exception reaches the body, and a status outside the 400s throws.
Debug output stays off in production#
HELMET never switches debug output off or on for you, because the right place is the platform, not a library. Two settings decide whether a visitor can read a stack trace, and both belong to your deploy:
- PHP's
display_errors. Keep itOffin production. Squire's deploy kit already ships aphp.iniwithdisplay_errors = Offandlog_errors = On(deploy/.squire/php.iniin a Squire-based app), and Citadel uses the same values. Off the platform, set it inphp.inior the FPM pool. HELMET does not set or check it. - Laravel's
APP_DEBUG. Keep itfalsein production. The Laravel adapter cannot stop an app from turning it on, but it notices: when the app boots withapp.debugtrue in the production environment it writes one warning,helmet.debug_enabled_in_production, through the Log facade. Opt out withhelmet.warn_debug(envHELMET_WARN_DEBUG, defaulttrue). A logger that fails never breaks the boot.
The opt-in error handler above covers one more case: with it on, an uncaught exception in a raw PHP app shows the fixed 500 page even if display_errors is wrong. Warnings and notices still follow display_errors, so set it anyway.
Health check without a front controller#
A raw PHP app with no front controller (or an Apache setup with no rewrite) never reaches helmet_boot() for /up. Ship a two-line public/up.php that calls HELMET directly:
<?php // public/up.php
use function Waynestate\Helmet\helmet_up;
require __DIR__ . '/../vendor/autoload.php';
helmet_up();
Squire's raw template ships this file. The probe only checks that the ball is in the air; it does not grade the play.
helmet_up() sends exactly what helmet_boot() sends for the health path (200, Content-Type: text/plain; charset=utf-8, Cache-Control: no-store, body ok) and exits. No session, no other headers, no config read.
Readiness is a separate check#
/up is liveness: "the process is running". Keep it free of dependency checks. If /up fails because the database is down, the platform restarts a healthy app and makes the outage worse. Put dependency checks on a different path, for example /ready, and point the load balancer's "send me traffic" probe at that one, not the restart probe.
<?php // public/ready.php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
try {
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASSWORD') ?: null, [
PDO::ATTR_TIMEOUT => 2,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->query('SELECT 1');
echo 'ready';
} catch (Throwable) {
http_response_code(503);
echo 'not ready';
}
Keep the same rules as /up: a short body, no-store, and no detail about which dependency failed (log that, do not print it). Give the check a short timeout so a hung database cannot hang the probe. In Laravel, register a separate /ready route that runs the checks; do not put them in the DiagnosingHealth listener, because that event fires for /up and would turn the liveness probe into a readiness probe.