tgalopin/html-sanitizer-bundle
Symfony bundle integrating the html-sanitizer library so you can define and configure HTML sanitizers as services, clean untrusted markup safely, and use customizable sanitizer profiles throughout your application.
This is a Symfony bundle (tgalopin/html-sanitizer-bundle) that wires the tgalopin/html-sanitizer library into a Symfony container. If you're coming from Laravel, the first thing to understand is that you probably don't install the bundle itself — Laravel has no bundle auto-discovery, no config/packages/*.yaml merge, and no SanitizerInterface autowiring. In a Laravel app you'll get the same behavior by installing the underlying library directly:
composer require tgalopin/html-sanitizer
# or, for the maintained successor:
composer require symfony/html-sanitizer
Reach for the bundle only when you're working inside a Symfony application (or a shared package that targets both frameworks). For Laravel, treat this assessment as documentation of the sanitizer's configuration surface and extension model, which the bundle exposes.
Minimal first steps:
default, markdown, restricted). In Symfony these live in config/packages/html_sanitizer.yaml; in Laravel you'll define them as an array in config/html_sanitizer.php and build HtmlSanitizer instances in a service provider.{!! !!}. Example in Laravel:// app/Providers/SanitizerServiceProvider.php
$this->app->singleton(SanitizerInterface::class, function () {
return (new HtmlSanitizerBuilder())
->allowSafeElements()
->allowRelativeLinks()
->allowLinkSchemes(['https', 'mailto'])
->build();
});
Then $clean = app(SanitizerInterface::class)->sanitize($request->input('body'));.
Where to look first: the profile definitions (extensions and allowed tags) are the whole game — everything else is plumbing. Start there, not with the bundle's DI wiring.
1. Sanitize-at-write vs. sanitize-at-read.
Store the sanitized output at write time (in a FormRequest::passedValidation() or a model mutator) so you have one canonical, safe representation. Sanitizing at read time is fine too, but it leaks the risk that some other render path bypasses your sanitizer. For user-editable content, the common pattern is: store raw HTML in a body_raw column and sanitized HTML in body, so you can re-sanitize historical content when you tighten the profile.
2. One profile per trust boundary.
Keep distinct builders for distinct contexts: a permissive article profile (headings, lists, tables, links), a strict comment profile (inline formatting only), and an email profile (limited tags, no <style>). In the Symfony bundle these are separate YAML keys; in Laravel, separate builder methods on a small factory. Don't try to parameterize a single profile with booleans — it becomes untestable.
3. The details extension (new in 1.4.0).
This allows <details> / <summary> through the sanitizer. It's exactly what you want for collapsible FAQ blocks, spoiler tags, or progressive-disclosure UI in user content. Enable it explicitly on the profiles that should accept it — it is not part of allowSafeElements(). The extension also needs the relevant attribute allowances (open on <details>) if you want the open/closed state preserved; verify with a fixture rather than assuming.
# Symfony bundle form
html_sanitizer:
sanitizers:
article:
extensions:
- details
- code
- image
In Laravel, mirror this by registering the extension on the builder (see the library's DetailsExtension).
4. Environment-driven overrides (fixed in 1.4.0).
1.4.0 fixes a bug where the key used for env overrides was wrong, meaning %env(...)% interpolation on sanitizer config didn't work as documented. If you were previously working around this by hard-coding allowed iframe hosts or link schemes per environment, you can now move those to env vars (e.g. HTML_SANITIZER_ALLOWED_IFRAMES). Note that env overrides are string-based — comma-separated lists or JSON need explicit decoding in your config layer.
5. Integration points in a Laravel app.
passedValidation() with $this->merge([...]).@sanitize($html) directive is convenient but hides the profile choice — prefer {{ $post->sanitized_body }} accessors so the profile is visible at the call site.6. Testing.
Build a fixture table of input => expected HTML and assert on the sanitized string (or better, on a DOM-parsed comparison, since attribute order and whitespace are not stable). Include adversarial cases: <script> in attributes, javascript: hrefs, SVG payloads, <details open ontoggle=...>. When you bump the bundle/library version, re-run the fixtures — extension behavior can shift between minor releases.
composer.json you're carrying dead weight. Use the underlying sanitizer library instead.tgalopin/html-sanitizer library was superseded by symfony/html-sanitizer; the bundle's 1.4.0 release (Symfony 6 support) keeps it installable but doesn't change the long-term direction. New Laravel work should target symfony/html-sanitizer, which carries the same extension architecture. Plan the rename before you have a dozen builders to migrate.details is opt-in and attribute-gated. Enabling the extension alone may strip open, or may allow <details> without <summary> if your element allowlist is narrower. Test the full open/collapse round trip, not just tag survival.%env()% work for sanitizer keys, but arrays (allowed schemes, iframe hosts) won't decode automatically in every Symfony version. If you rely on this, add a config test that asserts the resolved type, not just that it's non-empty.allowSafeElements() as a mental model. "Safe" is defined by the library's allowlist, which is broad. For user content, prefer explicit allowElements([...]) so a library update can't quietly widen what you accept. This is the single highest-leverage defensive habit with this package.code and image both touch <pre>/<img> behavior). If the output looks wrong, temporarily disable extensions one at a time rather than editing the allowlist — the allowlist is often not the culprit.How can I help you explore Laravel packages today?