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.
Architecture fit. This is a boundary utility, not a security layer. Its product surface is essentially two verbs — clean untrusted HTML, and escape a string — exposed via a facade, a handful of global helpers (security_clean, security_clean_array, security_clean_recursive), and a publishable config. It has no schema footprint, no request-lifecycle coupling, no event wiring, and no middleware of its own. That low blast radius is the single strongest architectural argument in its favour: adoption is cheap and reversal is a Composer revert.
It fits where untrusted HTML must survive rendering — CMS/WYSIWYG fields, comment bodies, profile bios, imported content, templated email. It is irrelevant-to-harmful where Blade's {{ }} already escapes plain output. That distinction matters: putting this package where it isn't needed manufactures false confidence, which is a worse outcome than not adopting it.
Note the package is really two products: a very thin Laravel wrapper (zero listed dependents, composite score 0.3, single maintainer) and the actual sanitiser — voku/anti-xss — which is where all the security behaviour and most of the risk live. Evaluate the transitive dependency, not the wrapper.
Integration feasibility. High on paper: composer require, Laravel auto-discovery, optional config publish, facade plus global helpers, and a documented framework-agnostic path for plain PHP. v11.2.0 explicitly adds Laravel 11 support. The genuine unknowns are constraint shape (does the 11.x line narrow to ^11.0 and drop Laravel 9/10?), whether CI actually runs against Laravel 11 or merely permits installation, and whether the global helpers are autoloaded via files (minor collision/governance surface). The helpers themselves are a double-edged integration story: "one-line call anywhere" is precisely how a sanitiser gets sprayed across a codebase instead of confined to a single boundary.
Technical risk. Moderate, and it is security-programme risk rather than code risk. voku/anti-xss is believed to be a predominantly regex/blacklist-driven filter — confirm this. Blacklist-style HTML filtering is historically the weakest link in XSS defence, so a wrapper that makes it one line away actively encourages teams to believe XSS is "solved." The controls that actually carry the load remain contextual output encoding, a Content-Security-Policy, and framework protections. Secondary risks: semantic ambiguity between clean and escape (is escape meaningfully different from e()?), undocumented fidelity to upstream behaviour, config drift after upgrades, and a one-line changelog that is not evidence of a full test matrix.
Evidence-quality caveat. Zero dependents and a 0.3 score are more consistent with sparse telemetry for a small utility by a long-standing Laravel ecosystem contributor than with a bad package. Don't cite adoption as an argument for or against — run a spike.
Key questions to close before committing:
voku/anti-xss) and is it actively patched for published bypass CVEs?clean allowlist-based and per-field configurable, or a global fixed policy? Can policy differ between a comment body and an admin CMS field?escape differ semantically from Laravel's e() / Blade {{ }}? If not, that half of the API is redundant and should not be promoted internally.php and illuminate/* constraints in composer.json; does the 11.x line drop Laravel 9/10?Stack fit. Laravel 11 / PHP 8.2+, and at least one HTML-bearing untrusted input in the product. If the honest answer to "which field needs this?" is "none," the correct decision is no-adopt — record it as a decision, not as an unfinished task. The framework-agnostic path means the decision doesn't strand you if you ever leave Laravel.
Migration path (TPM-owned, bounded spike then rollout):
mews/purifier and symfony/html-sanitizer in the same spike — the comparison is cheap and strengthens the record either way.Security::clean calls by convention plus a static-analysis or code-review rule. This single call site is the entire swap story if the package is later abandoned — treat it as the deliverable, not an afterthought.Compatibility. Verify: PHP 8.2+, Laravel 11, constraint shape on illuminate/*, and clean composer update --dry-run resolution against your other pinned Laravel packages. Confirm the package works through Laravel 11's slim skeleton (bootstrap/app.php + auto-discovery) rather than assuming a legacy config/app.php provider registration.
Sequencing. If you are mid-migration to Laravel 11, land the framework upgrade first — this is a leaf dependency and must never become the critical path. If already on L11, ride the next dependency-refresh window. Do not bundle a security-library change into a feature release; you want a clean rollback boundary.
Effort. 2–4 engineer-days for spike plus a single-surface rollout, plus QA time for content-regression checking. Multiply by distinct HTML surfaces if adopting broadly — at which point the build-vs-standardise conversation with mews/purifier becomes the better use of the same days.
Maintenance. The wrapper's version line tracks Laravel's major line, so budget a recurring ticket around each framework release. Pin to a caret range and read any major bump as framework coupling, not as a package-API signal. You are watching two release cadences — wrapper and upstream sanitiser — and re-diffing published config on every bump, because a stale config silently shadowing new upstream defaults is a quiet security regression.
Support and bus factor. Effectively single-maintainer. That is acceptable for a leaf utility invoked from one internal helper; it is not acceptable as the sole XSS control for a regulated or high-traffic product. Make the dependency-depth decision explicit in the risk register, name a pre-vetted fallback (mews/purifier or symfony/html-sanitizer), and prove the swap cost is one file.
Scaling. Runtime cost is CPU-bound string processing. Negligible for form input; non-trivial for bulk imports, large CMS payloads, or high-volume API ingestion on the request path. If volume matters, sanitise on a queue and store the result. Impose an input-size cap so pathological payloads can't become a cheap DoS.
Failure modes to plan for:
^11.0 range blocks a future framework upgrade until the maintainer ships. Check the constraint at adoption time, not at upgrade time.Ramp-up. Technically near-zero — the API is two methods and any Laravel developer is productive in an hour. The real ramp-up is conceptual: the team must internalise escaping ≠ sanitisation, and know which applies at which boundary. Budget a short internal write-up shipped alongside the spike; without it the package gets used inconsistently and the security benefit is illusory.
How to frame the decision. Ask "do we adopt a maintained, low-cost sanitiser for our one HTML surface, with CSP and output encoding remaining the primary controls?" — not "do we improve our security posture by adding this package." Success measures: a documented spike verdict, one sanitised surface with zero content-loss regressions, exactly one call site, a named fallback, and a CSP in place. If those five exist, the low adoption numbers stop being the deciding factor.
How can I help you explore Laravel packages today?