graham-campbell/security-core
Core security utilities for Laravel, providing a lightweight foundation for sanitizing user input, escaping output, and defending against XSS. Powers the graham-campbell/security package and can be used standalone in any PHP or Laravel application.
Install the right package. graham-campbell/security-core is the framework-agnostic engine. In a Laravel app, install the wrapper instead and get Core transitively:
composer require graham-campbell/security # Laravel: provider, facade, config
composer require graham-campbell/security-core # only for manual wiring / non-Laravel
Confirm your PHP floor. The current major (v5.0.0) dropped PHP 8.0 and requires PHP 8.1+. Run composer why-not php 8.1 before bumping; if a service or CI matrix entry is still on 8.0, upgrade the runtime first or pin ^4.0 with an owner and a review date.
Read exactly two things first. The Security constructor (it takes a config array whose evil key is the list of tags/strings to strip) and the clean() / isClean() pair. Everything else is a thin forward to voku/anti-xss; skim that library's AntiXSS class once so you know what knobs exist behind the wrapper.
use GrahamCampbell\SecurityCore\Security;
$security = new Security([
'evil' => ['script', 'iframe', 'object', 'embed', 'applet', 'meta', 'style', 'link', 'base', 'form'],
]);
$clean = $security->clean($untrustedHtml); // rewrites
$ok = $security->isClean($untrustedHtml); // boolean
First use case. A user-submitted field that you render with {!! !!} — comments, reviews, forum posts, support tickets, CMS/WYSIWYG bodies, profile bios. That's the decision point this package serves. It is not a substitute for Blade's {{ }} escaping, contextual output encoding, or CSP; it exists for the case where you've already decided some HTML must survive to the browser.
Bind it once in the container. A five-line provider buys you type-hinting, injection, and easy faking in tests:
// app/Providers/SecurityServiceProvider.php
$this->app->singleton(Security::class, fn ($app) =>
new Security($app['config']->get('security.evil', []))
);
If you're using the Laravel security sibling package, this is already done — inject Security and skip the boilerplate.
Sanitize on write, or on render — pick one and document it. Cleaning in the request layer keeps stored data deterministic and keeps the cost off every read path:
protected function prepareForValidation(): void
{
if ($this->has('body')) {
$this->merge(['body' => app(Security::class)->clean($this->input('body'))]);
}
}
Doing both invites double-cleaning, and clean() is not reliably idempotent (see Gotchas). Whatever you choose, put it in the code review checklist so a later middleware doesn't quietly add the second pass.
Keep the evil list in config, and scope it per field. A comment box and a CMS page body don't tolerate the same markup. Per-field lists let ops tune without a deploy:
// config/security.php
return [
'evil' => ['script', 'iframe', 'object', 'embed', 'meta', 'style', 'link', 'base', 'form'],
'comments' => ['evil' => ['script', 'iframe', 'object', 'embed', 'style']],
'pages' => ['evil' => ['script', 'iframe', 'object']],
];
Build a small factory/helper that constructs a Security instance from a named profile rather than scattering new Security([...]) through controllers.
Pair isClean() with validation when the policy is "reject", not "rewrite". clean() silently mangles input; sometimes a 422 is the correct response:
'body' => ['required', 'string', function ($attr, $value, $fail) {
if (! app(Security::class)->isClean($value)) {
$fail('The body contains disallowed HTML.');
}
}],
Rewriting is friendlier for users, rejecting is friendlier for auditability. Decide per surface.
Treat it as one stage in a pipeline, not the pipeline. A sane order: schema validation → clean() → policy purifier for high-value content when requirements harden → persist → render with contextual escaping and CSP. anti-xss is blacklist/regex-driven; a whitelist purifier is what gives you per-tag, per-attribute control.
Wrap it behind one internal interface on day one. The cost of abstracting now is minutes; the cost of swapping later without it is days:
interface HtmlSanitizer { public function sanitize(string $html): string; }
final class AntiXssSanitizer implements HtmlSanitizer
{
public function __construct(private Security $security) {}
public function sanitize(string $html): string
{
return $this->security->clean($html);
}
}
Test it two ways. In feature tests, bind a fake HtmlSanitizer and assert sanitize() was called — keeps controllers decoupled from the library. In unit tests, run the real instance against a checked-in corpus of known XSS payloads so every dependency bump has a regression gate.
It works fine outside HTTP. Console commands, queue jobs, and bulk importers can call it directly. For large imports, don't instantiate per row and don't sanitize fields that never render as HTML.
Don't put it in an Eloquent mutator and a FormRequest. Pick the boundary. Model mutators fire on seeding, imports, and updateOrCreate() paths you didn't think about, which is exactly how you end up with two different stored shapes for the same input.
Security::clean() to resolve in a Laravel app, you're thinking of graham-campbell/security. Core is the engine; wire it yourself or take the wrapper.<script>, javascript: URIs, on* handlers. Novel obfuscation can slip through. Never let it be the only control on high-value content.new Security(['evil' => [...]]) — the array is keyed, not a bare list of tags. A flat array is a common first-attempt bug that silently does nothing useful.AntiXSS instance. setAllowedTags(), setAllowedAttributes(), setAllowedPlainAttributes(), setSanitizeNaughtyHtml() and friends live on the wrapped object. Set them before clean() and check return types rather than assuming fluent chaining.clean() is not guaranteed idempotent. A second pass can strip markup the first pass produced. Two enforcement points (middleware + mutator) produce values that differ by code path and are miserable to debug.&lt;script&gt;) and mixed charsets can pass through. Be careful with premature html_entity_decode() — decode too early and you manufacture the injection you were filtering. Sensitive surfaces need a purifier.clean() at debug level, then bisect the evil array one entry at a time. The culprit is almost always a tag sitting on both the allowlist and the evil list.{{ }} need none of this. Blanket application adds latency and creates a false sense of where the trust boundary actually is.voku/anti-xss and bump it in isolation. Sanitizer behaviour shifts between minor versions; re-run your payload corpus before merging the bump, never bundled with unrelated upgrades.v5.0.0 is "only" a PHP-floor drop, but a major still reshuffles the transitive constraint graph. Check that the Laravel wrapper has caught up before you bump either side.HtmlSanitizer interface, an in-repo payload corpus, and a documented fallback (HTMLPurifier / Symfony HtmlSanitizer) in your dependency register. This is a swappable hygiene control, not a platform bet — price it that way.How can I help you explore Laravel packages today?