ezyang/htmlpurifier
HTML Purifier is a PHP library that filters untrusted HTML using robust whitelists and aggressive parsing to prevent XSS attacks while ensuring standards-compliant output. Ideal for safely rendering rich, CSS-enabled documents from untrusted sources.
Install. composer require ezyang/htmlpurifier — that's the whole installation. There is no service provider, no facade, no config/htmlpurifier.php, and no Artisan commands. Everything below is glue we own.
First place to look. Read vendor/ezyang/htmlpurifier/docs/ (especially docs/enduser-*) and the config reference at http://htmlpurifier.org/live/configdoc/plain.html. That generated config doc is the real API surface; the README is a pointer, not a guide.
The 15-minute first use case. Sanitize one untrusted rich-text column before persisting it:
use HTMLPurifier;
use HTMLPurifier_Config;
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Doctype', 'HTML 4.01 Transitional');
$config->set('HTML.Allowed', 'p,b,strong,i,em,a[href|title],ul,ol,li,br,blockquote,code,pre');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
$config->set('Cache.SerializerPath', storage_path('app/htmlpurifier'));
$purifier = new HTMLPurifier($config);
$clean = $purifier->purify($request->input('comment.body', ''));
That's the whole library. The rest is engineering discipline.
Immediately build the three things that make it a Laravel package:
App\Support\Html\HtmlSanitizer — a thin service with sanitize(string $html, string $context = 'default'): string.App\Providers\HtmlSanitizerServiceProvider — binds the service as a singleton and registers per-context HTMLPurifier_Config objects (e.g. comment, rich_text, profile_bio, inbound_email).storage/app/htmlpurifier, created in the provider or a deploy step, and gitignored.Budgets: ~150–300 lines of glue total. Treat the wrapper as the deliverable and the vendor library as an implementation detail — you will want to swap or wrap it later, and nothing in the app should new HTMLPurifier directly except that provider.
Configuration per trust context, created once. Building HTMLPurifier_Config per request is measurable overhead, and each unique config creates a distinct cached definition. Build them once, keyed by name, in the provider:
final class HtmlSanitizer
{
/** @param array<string, HTMLPurifier> $purifiers */
public function __construct(private readonly array $purifiers) {}
public function sanitize(?string $html, string $context = 'default'): string
{
if ($html === null || trim($html) === '') {
return '';
}
return $this->purifiers[$context]->purify($html);
}
}
$this->app->singleton(HtmlSanitizer::class, function ($app) {
$contexts = config('sanitizer.contexts'); // array of name => ['HTML.Allowed' => ..., ...]
$purifiers = [];
foreach ($contexts as $name => $directives) {
$config = HTMLPurifier_Config::createDefault();
$config->set('Cache.SerializerPath', storage_path('app/htmlpurifier'));
$config->set('Core.Encoding', 'UTF-8');
foreach ($directives as $key => $value) {
$config->set($key, $value);
}
$purifiers[$name] = new HTMLPurifier($config);
}
return new HtmlSanitizer($purifiers);
});
Sanitize at the write boundary, not in validation. Purifier mutates; it never rejects. Putting it in FormRequest::rules() or a validation rule produces confusing pass/fail semantics and double-processing when the user re-edits. Correct flow:
// FormRequest: validate *length and shape* only
'body' => ['required', 'string', 'max:65535'],
// Action / controller / DTO
$comment = Comment::create([
'user_id' => $request->user()->id,
'body_html' => $this->sanitizer->sanitize($request->input('body'), 'comment'),
]);
prepareForValidation() is acceptable if you accept that validation then runs against mutated input — but the action-level call is clearer and testable in isolation.
Eloquent cast for model-level enforcement. When several entry points write the same column, a cast is the cheapest guarantee:
final class SanitizedHtml implements CastsAttributes
{
public function __construct(private readonly string $context = 'default') {}
public function get($model, $key, $value, $attributes): ?string
{
return $value;
}
public function set($model, $key, $value, $attributes): ?string
{
return app(HtmlSanitizer::class)->sanitize($value, $this->context);
}
}
protected function casts(): array
{
return ['body_html' => SanitizedHtml::class.':comment'];
}
Use the set-side only. Purifying on get will re-run the filter on every read and can change output if config changed since the row was written (see Gotchas).
Render with {!! !!} only on named-safe columns. Adopt a strict convention (*_html, *_purified) so code review and static analysis can flag {!! $foo->title !!} on a raw column. A Blade component keeps it grep-able:
{{-- resources/views/components/safe-html.blade.php --}}
@props(['html'])
<div class="prose">{!! $html !!}</div>
Markdown → HTML → purify, in that order. Purifier understands HTML, not Markdown. If you accept Markdown, render with CommonMark, then purify the result with rich_text config. Do not purify the Markdown source.
Custom tags need HTML.DefinitionID + HTML.DefinitionRev. For embeds, <figure>, <video>, or a house <callout> tag, add an HTML module instead of loosening HTML.Allowed:
$config->set('HTML.DefinitionID', 'app-rich-text');
$config->set('HTML.DefinitionRev', 4); // bump on every schema change
if ($def = $config->maybeGetRawHTMLDefinition()) {
$def->addElement('video', 'Block', 'Optional: (source, Flow)', 'Common', [
'src' => 'URI',
'controls' => 'Bool',
'poster' => 'URI',
]);
}
DefinitionRev is the cache key. Forget to bump it and production keeps the old definition; bump it and you get a cold-cache regeneration.
Iframes. v4.19 added an array-based host allowlist alongside the older HTML.SafeIframe + URI.SafeIframeRegexp pair; check the config doc for the exact directive name in your installed version before wiring it. Either way, allowlist specific hosts (YouTube, Vimeo) — never a broad regex.
Batch and background work. Importing a CMS, migrating legacy posts, or re-filtering after a config change belongs in a queued job with chunkById, not a request. Purifier runs a few milliseconds on a normal comment but seconds on a 500 KB document.
Test with a golden corpus. Keep tests/Fixtures/sanitizer/*.html pairs (dirty in, expected out) and a Pest dataset. Any config change that alters output shows up as a diff instead of as "users say their tables disappeared last Tuesday."
HTML.Allowed vs HTML.AllowedElements/HTML.AllowedAttributes. HTML.Allowed is the compact form that sets the other two. Mixing them produces surprising results — pick HTML.Allowed for element/attribute granularity and only reach for the element/attribute directives when dynamically composing. Setting HTML.Allowed to '' strips every tag.
style attributes disappear unless CSS is allowed separately. HTML.Allowed containing span[style] is not enough; you need CSS.AllowedProperties. Similarly Attr.EnableID is off by default, so heading ids for anchors/TOC are stripped until you turn it on — and once on, use Attr.IDPrefix to namespace user-supplied IDs and avoid clashing with your layout.
Silent deletion is the failure mode. Every config change removes something legitimate: tables, <hr>, target, tel: links, class attributes, CSS aspect-ratio (only added in 4.18). There is no warning at runtime. Golden-file tests are the only reliable guard.
The cache directory is a deployment problem. Read-only container images and ephemeral pods break Purifier until storage/app/htmlpurifier exists and is writable. v4.19.1 made the chmod best-effort, but the directory still has to be there. Create it in a deploy script and warm it (call purify('') for each context once) during the build — the first request against a cold cache is a real latency spike. Cache.DefinitionImpl set to null disables caching entirely; handy in tests, unacceptable in production.
Parallel tests sharing a cache dir. ParaTest workers writing the same serializer path can race on permissions and stale definitions. Give each test process its own temp dir via an env var, or disable the definition cache in the test suite only.
Never pass null to purify(). Cast to string and short-circuit on empty input. PHP 8.1+ deprecations leaked out of this exact path repeatedly (4.19.1 patched one in the lexer), and deprecations become exceptions under Laravel's error handler.
Config changes don't retroactively fix stored rows. Sanitize-on-write means old rows keep old, looser output. Ship an Artisan command (php artisan html:resanitize {context}) that re-runs the filter over affected columns whenever you tighten the whitelist.
Idempotency isn't universal. With AutoFormat.AutoParagraph or AutoFormat.RemoveEmpty enabled, purifying already-purified output is not guaranteed to be a no-op. Sanitize exactly once, at a known boundary. If you need re-sanitization, re-run it from the source field when one exists.
HTML.Trusted turns the filter off. It exists for admin-authored fragments and is a foot-gun; if you enable it, do it in a dedicated trusted_admin context that no user-controlled input can reach.
Encoding. Set Core.Encoding to UTF-8 and ensure mb_internal_encoding('UTF-8'). Mismatched encoding is the most common source of "Purifier ate my emoji/accented characters" reports.
Debugging a drop. Core.CollectErrors plus $purifier->context->get('ErrorCollector') tells you exactly which tag or attribute was removed and why. It's slow — enable it in a test or a tinker session, never in production request paths.
Performance posture. Treat hostile input as hostile: cap request body size, cap per-field length in validation, set request timeouts, and push anything over ~100 KB to a queue. Purifier is CPU-bound and deliberately slower than regex sanitizers; v4.19.0 fixed a catastrophic-backtracking case in Core.AggressivelyFixLt, which is a reminder that parser DoS is a live category here.
HTMLPurifier::getInstance() returns a singleton with default config only — it ignores your config. Always pass a configured HTMLPurifier instance around, which is another reason the wrapper service should be the only thing holding them.
Existing wrapper option. mews/purifier gives you a Laravel service provider, a facade, and publishable config for a few minutes of setup. Consider it if the team wants less glue; you trade away some control over definition-cache paths and module registration. Either way, the app-facing interface should be your own HtmlSanitizer, not a facade call sprinkled through controllers.
License. LGPL-2.1 is fine for most SaaS usage as an unmodified Composer dependency, but get legal sign-off before committing if you ship a bundled or on-prem build — this is the item most likely to force a rebuild against symfony/html-sanitizer late in the project.
How can I help you explore Laravel packages today?