phlib/xss-sanitizer
PHP library for sanitizing untrusted HTML and preventing XSS. It parses user-supplied markup, strips dangerous tags, attributes and URLs, and returns safe HTML for rendering in web applications. Configurable rules suit comments, rich text and other user-generated content.
Install and inspect first
composer require phlib/xss-sanitizer
Before writing any app code, open the package's src/ directory and README. The public surface is small — the entry point is Phlib\XssSanitizer\Sanitizer (construct it, then call sanitize($html)). Confirm its constructor signature in src/Sanitizer.php, since it may accept a filter list or an HTMLPurifier config object depending on the tagged version. Also run composer show phlib/xss-sanitizer to see the exact version and its transitive dependency on ezyang/htmlpurifier.
Verify the runtime requirements
HTMLPurifier needs ext-dom, ext-libxml, and ext-mbstring, plus a writable cache directory. Check these on your Laravel host before you build anything on top of the package:
php -m | grep -Ei 'dom|libxml|mbstring'
First use case: sanitizing user-submitted rich text
The canonical Laravel scenario is a WYSIWYG field (post body, comment with formatting, CMS page) that you want to render as HTML. Blade's {{ }} escapes everything, so it's safe but strips all formatting; {!! !!} renders HTML but is an XSS hole if the input is untrusted. The package fits exactly in that gap:
use Phlib\XssSanitizer\Sanitizer;
$clean = (new Sanitizer())->sanitize($request->input('body'));
Store the sanitized output in the database, then render it with {!! $post->body !!}. That way every reader gets safe markup and you never have to remember which fields were run through the sanitizer. Start with one field in one model, add a feature test with a handful of known XSS vectors (<script>, <img onerror=…>, javascript: hrefs), and expand from there.
Bind a singleton in a service provider
Do not new Sanitizer() per row or per render — HTMLPurifier's setup (loading the definition, parsing config) is the expensive part. Register it once:
// AppServiceProvider::register()
$this->app->singleton(Sanitizer::class, function () {
return new Sanitizer(); // pass config/filters here
});
Then inject Sanitizer into controllers, form requests, or jobs. If the package exposes a filter array or HTMLPurifier config in its constructor, this is the one place the allow-list policy lives — keep it out of controllers so it can be reviewed and changed in isolation.
Sanitize at the write boundary, not the read boundary
The cleanest pattern in Laravel is a form request prepareForValidation() or a mutator/observer on the model, so dirty HTML never reaches the database:
protected function prepareForValidation(): void
{
$this->merge([
'body' => app(Sanitizer::class)->sanitize((string) $this->input('body')),
]);
}
Doing it on write means (a) you pay the cost once instead of on every request, (b) you can add a body_sanitized_at column or a queue job to re-sanitize old content when the policy tightens, and (c) you avoid the classic mistake of rendering escaped output and then "un-escaping" it later.
Custom Blade directive for the render side
To make intent obvious at the call site and prevent accidental {!! !!} on unsanitized data, register a directive:
Blade::directive('sanitized', fn ($expr) => "<?php echo app(\\Phlib\\XssSanitizer\\Sanitizer::class)->sanitize($expr); ?>");
@sanitized($post->body) is then an explicit, greppable contract, and code review can flag any bare {!! !!} as suspicious. A cheaper alternative: render {!! $post->body !!} only for fields that are guaranteed sanitized by a model cast, and document that invariant on the model.
Pipeline with filters for different trust levels
Rich-text fields and short user strings want different policies. If the package's filter mechanism allows it, define two configured instances (e.g. Sanitizer::richText() and Sanitizer::plainText()) — the plain-text variant stripping tags entirely, the rich-text one allowing a small tag/attribute set. Bind them with contextual or named bindings, or expose them as small wrapper classes so injection stays type-safe. Keep the policies in config/sanitizer.php rather than inline arrays.
Handle large documents asynchronously
Sanitizing a long article or imported RSS payload can take tens to hundreds of milliseconds — enough to blow a request budget if done inline on a bulk import. For imports, dispatch a job that sanitizes and persists, and show a "processing" state. For normal form submissions the inline cost is fine.
Test with a vector corpus, not just one example
Add a Pest/PHPUnit test that iterates over a fixed list of payloads (script tags, event handlers, javascript: and data: URLs, SVG/MathML tricks, malformed nesting) and asserts the output contains none of the dangerous substrings and does retain expected formatting (<p>, <strong>, <a href>). This is the regression net that lets you change configuration later without fear.
Licensing is the first decision, not the last
This package (and HTMLPurifier beneath it) is LGPL-3.0, not MIT. For a typical SaaS that's usually acceptable — you're using it as a library, not modifying and statically linking it — but if you ship a distributed PHP application or have a legal team with strong opinions about copyleft, get sign-off before it's woven through the codebase. It's much easier to swap now than after fifty call sites.
HTMLPurifier performance and caching
HTMLPurifier parses its configuration and builds the allowed-tag definition on each instantiation, which is slow. The fix is (a) the singleton above and (b) enabling its definition cache. If the package exposes the underlying HTMLPurifier config, set a cache path under storage/framework/cache/sanitizer and ensure the directory is writable — a silent failure to write the cache is a common cause of "why is this endpoint 300ms slower in production." If the package doesn't expose config, wrap it and accept the cost, or fork/patch.
Encoding and mbstring
HTMLPurifier assumes UTF-8; set the document encoding explicitly if the app handles anything else. Missing mbstring produces mangled multibyte output rather than a clean error, and it's easy to miss locally if your dev image has it and production doesn't.
It outputs HTML4-era markup
Expect HTMLPurifier to normalize/reject HTML5 constructs (<figure>, <video>, some data-* attributes). If your editor produces modern markup, allow-list the specific tags you need or accept that formatting will be flattened. Decide this deliberately — a silently dropped <figure> is a content bug the author will report as "the editor ate my image."
Sanitization is not validation
Running input through the sanitizer doesn't make it valid data — it just guarantees it's safe HTML. Keep Laravel validation (required|string|max:…) separate, and don't let a permissive sanitizer substitute for rejecting obviously abusive payloads.
Don't double-escape
The classic bug is sanitizing on write, then also running the value through e() or {{ }} on render, which displays literal <p> tags. Pick one path: sanitize-then-{!! !!}, or store raw and escape on render. Mixing them is how teams end up with a "raw HTML" escape hatch that quietly reintroduces XSS.
Attribute and URL schemes
If you customize the policy, remember the danger lives in attributes: onerror, onclick, style with expression(), and href/src with javascript: or data: URLs. Set allowed URI schemes explicitly rather than trusting tag allow-lists alone. If you allow iframes for embeds, that's a separate opt-in with its own host allow-list.
Small-package risk and the Laravel-native alternative
16 stars and a single maintainer means you should pin the version in composer.json and be prepared to take over maintenance. If you want a Laravel-first integration with config publishing and a facade out of the box, mews/purifier wraps the same HTMLPurifier engine and is the more common choice in the ecosystem. The phlib package is a thinner wrapper — fine if you want minimal surface area, but weigh that against community support. Either way, isolate the sanitizer behind your own interface (a HtmlSanitizer contract bound in a provider) so swapping engines later is a one-file change.
Extension point: custom filters and rules
If the package exposes its filter interface, custom filters are the right place for domain rules — e.g. rewriting <a> targets to rel="noopener noreferrer", or stripping class attributes that could clash with your CSS. Keep them pure and unit-test them individually; a custom filter that silently mangles output is harder to debug than a misconfigured allow-list.
How can I help you explore Laravel packages today?