Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Html Sanitizer Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. \TYPO3\HtmlSanitizer\Builder\CommonBuilder — the "safe defaults" preset (common tags/attributes/URI schemes). Start here before writing your own Behavior.
  2. \TYPO3\HtmlSanitizer\Behavior — the declarative allow-list (tags, attrs, values, flags, custom node handlers).
  3. \TYPO3\HtmlSanitizer\Sanitizer — parses HTML, runs visitors, serializes back.
  4. \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().

Implementation Patterns

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.

Gotchas and Tips

  • 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)).
  • Default nodes are permissive for BC. 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".
  • Flag semantics are counter-intuitive at first read:
    • ENCODE_INVALID_TAG → keeps the tag as escaped text (<span> → &lt;span&gt;), 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.
  • Attribute value regexes take the first match. Since v2.0.13, verification stops at the first matching value rule. Order your RegExpAttrValue/value objects deliberately when adding multiple schemes.
  • Custom elements (<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.
  • Security releases are frequent and matter. Recent advisories cover raw-text bypass with 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".
  • Watch the flag name. ALLOW_INSECURE_RAW_TEXT is exactly what it sounds like — do not use it on user input.
  • Reuse the instance within a request. Building the 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.
  • Sanitize on write and trust nothing stored. Historical rows and data imported from other systems were never sanitized; a one-off backfill command using the same builder is cheap insurance.
  • 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.
  • Don't sanitize and then re-escape. Pairs {!! !!} with a sanitizer, not {{ }} + sanitizer — double escaping produces visible &lt;div&lt; in the browser.
  • Extension point recap: subclass 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.
  • Testing tip: assert against the sanitized string exactly; the serializer's output ordering/encoding is stable and makes regression tests readable. The local demo project is the fastest way to eyeball a suspicious payload.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log