typo3/html-sanitizer
A standalone PHP HTML sanitization component from TYPO3, usable in any PHP project. Declarative Behavior settings define allowed tags, attributes, and values; visitors modify or remove nodes; builders combine presets into ready-to-use Sanitizer instances for safe HTML output.
Install
composer require typo3/html-sanitizer
The package needs the PHP dom extension (present in most Laravel/Forge/Valet setups) and has no Laravel-specific dependencies — it's framework-agnostic, so you wire it up yourself.
Where to look first
\TYPO3\HtmlSanitizer\Builder\CommonBuilder — the "safe defaults" preset (common tags/attributes/URI schemes). Start here before writing your own Behavior.\TYPO3\HtmlSanitizer\Behavior — the declarative allow-list (tags, attrs, values, flags, custom node handlers).\TYPO3\HtmlSanitizer\Sanitizer — parses HTML, runs visitors, serializes back.\TYPO3\HtmlSanitizer\Visitor\CommonVisitor — the visitor that enforces the Behavior.First use case: user-supplied rich text
The classic Laravel scenario is a WYSIWYG field (TinyMCE/Quill) stored in the DB and rendered with {!! !!}. Sanitize on write (and optionally on read):
use TYPO3\HtmlSanitizer\Builder\CommonBuilder;
$sanitizer = (new CommonBuilder())->build();
$clean = $sanitizer->sanitize($request->input('body_html'));
Bind it as a singleton so you parse the Behavior once per request lifecycle:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\TYPO3\HtmlSanitizer\Sanitizer::class, fn () => (new \TYPO3\HtmlSanitizer\Builder\CommonBuilder())->build());
Then inject Sanitizer into a form request, job, or an Eloquent cast and call sanitize().
1. Preset-first with CommonBuilder, override only when needed
CommonBuilder is updated over time (URI schemes, srcset, deprecated tags, fetchPriority, etc.). For 90% of CMS/blog content this is enough. Create a subclass only when you need to add/remove tags:
namespace App\Support\Html;
use TYPO3\HtmlSanitizer\Builder\CommonBuilder;
class ArticleHtmlBuilder extends CommonBuilder
{
protected function buildBehavior(): \TYPO3\HtmlSanitizer\Behavior
{
$behavior = parent::buildBehavior();
return $behavior->withTags(
(new \TYPO3\HtmlSanitizer\Behavior\Tag('iframe'))
->addAttrs(new \TYPO3\HtmlSanitizer\Behavior\Attr('src'))
);
}
}
2. Multiple sanitizers = multiple presets
Treat builders as named presets and register them by key:
$this->app->singleton('html.sanitizer.comment', fn () => (new CommentBuilder())->build());
$this->app->singleton('html.sanitizer.article', fn () => (new ArticleHtmlBuilder())->build());
A comment builder allows only a, b, i, code, p, br; an article builder allows figure, table, img, etc. Enforce least privilege per field — a "bio" doesn't need <table>.
3. Custom Behavior for a locked-down field
$behavior = (new Behavior())
->withFlags(Behavior::ENCODE_INVALID_TAG | Behavior::ENCODE_INVALID_COMMENT)
->withoutNodes(new Behavior\Comment())
->withTags(
(new Behavior\Tag('b')),
(new Behavior\Tag('i')),
(new Behavior\Tag('a', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(
(new Behavior\Attr('href'))
->addValues(new Behavior\RegExpAttrValue('#^https?://#'))
->withFlags(Behavior\Attr::MANDATORY),
(new Behavior\Attr('rel'))
)
);
$sanitizer = new Sanitizer($behavior, new CommonVisitor($behavior));
4. Mutators / casts on models
Sanitize at the boundary of persistence so old dirty data can't leak later:
protected function bodyHtml(): Attribute
{
return Attribute::make(
set: fn (string $value) => app(Sanitizer::class)->sanitize($value),
);
}
Or use a custom cast + a RichText value object so sanitization is explicit in the domain model.
5. Blade helper / directive
// app/Support/helpers.php
function sanitize_html(?string $html, string $preset = 'article'): string
{
return $html === null ? '' : app("html.sanitizer.$preset")->sanitize($html);
}
{!! sanitize_html($post->body_html) !!}
Keep {!! !!} paired with an explicit sanitizer call — reviewers should be able to grep every raw echo and see it's guarded.
6. Custom visitors / node handlers for transformations
ClosureHandler lets you rewrite a custom tag into a DOM node during sanitization — useful for turning shortcodes like <mention user="42"/> or <callout> into real markup:
$behavior->withNodes(
(new Behavior\NodeHandler(
new Behavior\Tag('mention'),
new Behavior\Handler\ClosureHandler(function (NodeInterface $node, ?\DOMNode $domNode) {
return $domNode === null ? null : new \DOMText('@' . $domNode->getAttribute('user'));
})
))
);
Because NodeInterface gives you the parsed node, you can look up users in the DB and emit safe DOMText/DOMElement. This is the clean extension point for "transform, don't just strip" workflows.
7. Log what was stripped
The sanitizer accepts a PSR-3 logger and forwards a "sanitization initiator" — wire it to your Log channel and log rejected tags/attributes at info. It's a decent signal for spotting broken editors or malicious attempts:
$sanitizer = (new CommonBuilder())->withLogger(Log::channel('sanitizer')->getLogger())->build();
8. Middleware for trusted-then-untrusted pipelines
If you ingest HTML from webhooks/email/RSS, run a middleware or a queued job that sanitizes before storing — don't do it at render time in bulk (you pay the DOM parse on every page view and can't cache cleanly).
9. Verify with adversarial fixtures
Keep a tests/Fixtures/xss/*.html directory with known payloads (<svg onload>, mXSS, CDATA, <noscript>, processing instructions) and assert your sanitizer output is safe. The upstream repo's test suite and the html-sanitizer-demo project are good sources for payloads.
Behavior is immutable, Attr is (mostly) immutable too. withFlags(), withTags(), withNodes(), withoutNodes() return new instances. $behavior->withTags(...) alone does nothing. The same applies to Attr::withFlags() — you must reassign.Sanitizer constructor now requires Behavior and visitors. Since v2.3.0, new Sanitizer() with no args is gone. Always pass the behavior (and CommonVisitor) explicitly: new Sanitizer($behavior, new CommonVisitor($behavior)).Behavior\Comment and Behavior\CdataSection are enabled by default in v2.1+. If you don't want comments preserved, you must call ->withoutNodes(new Behavior\Comment()). Don't assume the safe default is "no comments".ENCODE_INVALID_TAG → keeps the tag as escaped text (<span> → <span>), does not remove it.ENCODE_INVALID_ATTR → encodes the whole tag, not just the attribute. So one bad onclick nukes the entire element. That's intentional but surprising.REMOVE_UNEXPECTED_CHILDREN → children of a Tag declared without ALLOW_CHILDREN are dropped. Without this flag, structure can be silently lost or preserved inconsistently.ALLOW_CHILDREN is required for nesting. A <div> without it won't contain your paragraphs. Forgetting this is the #1 cause of "why is my content empty?".MANDATORY attrs invalidate the whole tag when missing. Great for href on <a> (kills javascript:-style empty links), but it means a link missing a URL disappears entirely — warn your editors.RegExpAttrValue/value objects deliberately when adding multiple schemes.<my-widget>) are opt-in via Behavior::ALLOW_CUSTOM_ELEMENTS. The README explicitly recommends naming every allowed tag instead. If you enable it, you're responsible for the risk surface.srcset history is bumpy (removed in 2.3.0, reverted in 2.3.1). If you subclass CommonBuilder and override srcsetAttr, pin your version and re-check on upgrades.ALLOW_INSECURE_RAW_TEXT, CDATA/noscript escaping, processing instructions, and namespace attribute encoding. Treat this as a security-critical dependency: pin to the latest patch, subscribe to GitHub security advisories, and never disable the safe flags to "make content render".ALLOW_INSECURE_RAW_TEXT is exactly what it sounds like — do not use it on user input.Behavior allocates dozens of objects; make it a container singleton. But don't share a single Sanitizer across concurrent fibers/workers if you ever move to Octane — treat it as request-scoped, not global mutable state.strip_tags()/htmlspecialchars() are not substitutes. strip_tags leaves attributes intact (<a href="javascript:...">) and doesn't decode entities; this package handles mXSS and mutation cases that naive regexes miss.{!! !!} with a sanitizer, not {{ }} + sanitizer — double escaping produces visible <div< in the browser.CommonBuilder for presets → Behavior\Tag/Attr/value objects for rules → VisitorInterface to post-process the DOM → NodeHandler + ClosureHandler to rewrite custom markup. If you find yourself regexing the sanitized output, you're in the wrong layer.How can I help you explore Laravel packages today?