graham-campbell/security
A Laravel package from Graham Campbell that helps harden your app by adding configurable security headers to HTTP responses. It provides a simple middleware-based setup for common browser protections, making it easy to improve response security with minimal configuration.
Install
composer require graham-campbell/security
Laravel auto-discovery registers GrahamCampbell\Security\SecurityServiceProvider and the Security facade alias. If you're on a legacy setup without discovery, add them manually to config/app.php.
Publish (and read) the config first
php artisan vendor:publish --provider="GrahamCampbell\Security\SecurityServiceProvider"
You get config/security.php. This is the file that decides what "clean" actually means — read it before calling anything. It exposes the allow/deny knobs for tags, attributes, and permitted HTML.
First use case — the smallest viable win
Don't start with your richest WYSIWYG field. Start with a single plain-ish field where you render user content with {!! !!} — a comment body or a profile bio.
// A controller or form request
$validated['bio'] = security_clean($request->input('bio'));
Or through the facade:
use GrahamCampbell\Security\Facades\Security;
$clean = Security::clean($request->input('body'));
$escaped = Security::escape($request->input('name'));
First thing to check: render a realistic sample with links, an <img>, an emoji, and an <a target="_blank">. Confirm what survives. That single test tells you more about whether this package fits than any doc.
Global helpers available out of the box
security_clean($string) — sanitise one valuesecurity_clean_array($array) — sanitise an array (shallow)security_clean_recursive($array) — sanitise arrays nested arbitrarily deepThe recursive helper is the one you'll actually reach for when a request payload is an array of HTML strings.
Do not scatter Security::clean() across the codebase
Wrap it once. That single call site survives a package swap (which you should assume is possible, given the low adoption numbers). A small helper or a form request trait:
namespace App\Support;
use GrahamCampbell\Security\Facades\Security;
final class Html
{
public static function sanitize(?string $value): ?string
{
return is_null($value) ? null : Security::clean($value);
}
public static function sanitizeMany(array $values): array
{
return collect($values)
->map(fn ($v) => is_array($v) ? self::sanitizeMany($v) : self::sanitize($v))
->all();
}
}
Every controller, job, importer, and artisan command talks to App\Support\Html — never to Security directly.
Sanitise on write, render escaped
The strongest pattern for anything user-authored:
// write time — sanitise into a dedicated, stored column
$post->body_html = Html::sanitize($request->input('body'));
$post->save();
// render time — still escape at the boundary
{!! $post->body_html !!}
Storing the sanitised result in a separate column (keep the raw input if you can) gives you a rollback story when you change the allowlist later. If you sanitise into the same column the raw input lived in, you've irreversibly lost data — plan around that.
Form request integration
protected function prepareForValidation(): void
{
$this->merge([
'body' => Html::sanitize($this->input('body')),
'title' => Html::sanitize($this->input('title')),
]);
}
prepareForValidation runs before rules, so your required/max checks see the sanitised output. That's usually what you want — but note the length changes, so set max: with headroom.
Sanitise untrusted HTML that arrives from outside your forms
Third-party payloads are the underrated case: webhook bodies, email-in content, imported CMS exports, RSS. Sanitise these at ingestion, in the consuming job or command, not at render. The package is a leaf utility — a good fit for a queued job:
public function handle(): void
{
$feed->items()->each->update([
'content' => Html::sanitize($item->content),
]);
}
This also keeps the CPU cost off the request path — handy for bulk imports.
The escape half is usually redundant
Security::escape() / security_escape() exists, but if your output goes through Blade {{ }} you already have contextual escaping. Reach for it only in non-Blade paths: mailables built from string templates, PDF/HTML renderers, API responses that inline HTML fragments. Everywhere else, prefer {{ }}.
Per-field allowlists
If your config exposes the tag/attribute settings, resist the temptation to make a single global policy fit every field. Two profiles — "comment" (p, a, strong, em, ul/ol/li) and "article" (adds headings, blockquote, tables) — are more defensible than one maximal allowlist. Publish once, then define the profiles in your own config and map into the facade call, or maintain distinct config snapshots per surface.
"Sanitised" ≠ "safe to output raw" — the biggest day-to-day trap
Teams adopt the package, call Security::clean(), and then confidently start using {!! !!} everywhere. The correct mental model: sanitisation removes dangerous HTML from the string; escaping handles where the string ends up (attributes, JS, URLs, JSON). Keep the CSP, keep contextual escaping, keep {{ }} as the default. If you find yourself relaxing those because the sanitiser is in place, the sanitiser has made you less safe.
Confirm what clean vs escape actually do against upstream
The wrapper doesn't document its fidelity to voku/anti-xss. Before relying on it, run a spike:
dd(Security::clean('<script>alert(1)</script><p onclick="x()">hi</p>'));
Does it strip or encode the payload? Does it return <script> as <script>, or remove it entirely? The answers change your rendering assumptions. Write the finding down and pin it to the package version.
Content destruction is silent
Over-eager filtering mangles legitimate markup — tables, target="_blank", RTL text, emoji, non-Latin scripts, embedded media. It does not throw. It shows up as support tickets three weeks later. Before rollout, run your actual existing content through the sanitiser as a diff:
$before = Post::pluck('body', 'id');
$after = $before->map(fn ($b) => Html::sanitize($b));
// diff and eyeball what changed
Do this before you ship, not after.
Idempotency assumptions
Don't call clean() repeatedly on the same value in a pipeline. Each pass may re-encode entities (& → &amp;), which is invisible until someone searches for the string. Sanitise exactly once, at the boundary, and pass the result through.
Config drift on upgrade
You've published config/security.php, so a package bump that changes defaults won't reach you — the published file shadows the package's version. That's a double-edged sword: safe from surprise changes, but you'll silently keep stale (possibly weaker) rules. Re-diff your config against the package's on every major bump:
diff vendor/graham-campbell/security/config/security.php config/security.php
Constraint lockout risk
The 11.x line pins illuminate/* to ^11.0. If you're planning a framework upgrade, check that constraint now, not on the day you try to bump Laravel — a narrow constraint here can block an entire upgrade. Treat a package major bump as framework-coupling, not as an API signal.
Bus factor mitigation
Single-maintainer, thin-wrapper, low adoption. Acceptable for a leaf utility called through one internal helper — unacceptable as the sole XSS control on a regulated product. The mitigation is architectural: keep the wrapper (App\Support\Html) thin enough that swapping it for mews/purifier or symfony/html-sanitizer is an afternoon's work. Don't leak upstream types or exceptions into your domain code.
Debugging checklist when output looks wrong
clean() twice? (entity double-encoding)php artisan config:clear after editing config/security.php — the classic "I changed the allowlist and nothing happened" moment.{{ }} and Security::escape()? Double-escaping renders <p> visibly.security_clean_recursive is needed but you called the shallow helper? Nested arrays come back uncleaned and it's easy to miss.Don't wire it into middleware or the HTTP kernel
Tempting, and wrong for two reasons: you'll sanitise fields that shouldn't be touched (passwords, tokens, JSON APIs), and you'll destroy the ability to have per-surface policies. Keep it a leaf call in a request/job/command.
Scope statement
Write one line in your repo: "This package sanitises untrusted HTML on write. It is not a WAF, not CSRF protection, and does not replace output escaping or CSP." It sounds obvious. It is not, and it's the single most useful artefact to keep adoption from drifting into false confidence.
How can I help you explore Laravel packages today?