---
title: "Raw PHP | HELMET docs"
description: "An app on raw PHP calls helmet_boot() once at the top of its front controller, before anything is echoed, and is done."
url: https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html
llms: https://helmet.apps.wayne.edu/llms.txt
generator: Squire agent-ready
---

# Use HELMET in raw PHP[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#use-helmet-in-raw-php)

This page boots HELMET in a framework-free PHP app with a single call. Start with [Boot the app](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#boot-the-app)

At the top of the front controller, before anything is echoed:

```php
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-By` header,
- answers the health check path with `ok` and 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).

Note

A bad config throws at boot: `UnknownPresetException` if `"preset"` names a preset the package does not know, `\InvalidArgumentException` for a wrong type or an invalid value, `InvalidCspReportUriException` if `"csp_report_uri"` is not a valid report destination, and `\LogicException` if a session is already active.

## Structured logs[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#structured-logs)

Each record is one JSON line:

```json
{"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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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.

Warning

The `citadel` preset trusts the container bridge gateway address, which is only correct while the app's port is not published to the host network, because a published port can make a direct client appear to come from the gateway.

The trusted proxies come from, in order of precedence:

- `trusted_proxies`: an explicit list of IPs or CIDR ranges.
- `preset`: a platform preset name (see `Waynestate\Helmet\Platform\Presets`), for example `citadel` or `docker`.
- neither: an empty list, which trusts nothing.

## A generic error page[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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:

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

```php
$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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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 it `Off` in production. Squire's deploy kit already ships a `php.ini` with `display_errors = Off` and `log_errors = On` (`deploy/.squire/php.ini` in a Squire-based app), and Citadel uses the same values. Off the platform, set it in `php.ini` or the FPM pool. HELMET does not set or check it.
- **Laravel's `APP_DEBUG`.** Keep it `false` in production. The Laravel adapter cannot stop an app from turning it on, but it notices: when the app boots with `app.debug` true in the production environment it writes one warning, `helmet.debug_enabled_in_production`, through the Log facade. Opt out with `helmet.warn_debug` (env `HELMET_WARN_DEBUG`, default `true`). 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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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
<?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[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#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
<?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.

## The nonce in your markup[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#the-nonce-in-your-markup)

Tip

Vite's dev runtime reads a `<meta property="csp-nonce" nonce="...">` tag to nonce the CSS and preload tags it adds after page load. Print `helmet_csp_nonce_meta($helmet['nonce'])` in the `<head>` of every page, with the same nonce `helmet_boot()` returned. The nonce is escaped, so a nonce with a quote cannot break out of the attribute.

## Related[#](https://helmet.apps.wayne.edu/docs/guides/usage-raw-php.html#related)

- [Quick start](https://helmet.apps.wayne.edu/docs/guides/quick-start.html)
- [PSR-15 middleware](https://helmet.apps.wayne.edu/docs/guides/usage-psr15.html)
- [Laravel](https://helmet.apps.wayne.edu/docs/guides/usage-laravel.html)
- [The headers](https://helmet.apps.wayne.edu/docs/guides/headers.html)
- [The request-forgery guard](https://helmet.apps.wayne.edu/docs/guides/csrf.html)
- [HTTPS, and why the proxy list matters](https://helmet.apps.wayne.edu/docs/guides/https-and-proxies.html)
- [Platform presets](https://helmet.apps.wayne.edu/docs/guides/platform-presets.html)
- [Environment variables](https://helmet.apps.wayne.edu/docs/reference/configuration.html)
