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

tgalopin/html-sanitizer

tgalopin/html-sanitizer is a PHP library for cleaning untrusted HTML safely. It removes dangerous markup to prevent XSS while letting you configure allowed tags, attributes, and extensions, making it easy to display user-generated content securely.

Deep Wiki
Context7

Getting Started

Install (and mind the archive). tgalopin/html-sanitizer is MIT-licensed but archived since 2021. Install the last release and pin it: composer require tgalopin/html-sanitizer:^1.6. It was upstreamed to Symfony, so symfony/html-sanitizer is the maintained successor — start here if you're greenfield, and treat this package as a "keep the existing app working" dependency.

There is no Laravel package. No service provider, no facade, no auto-discovery. You wire it yourself. Add a config file and a container binding in AppServiceProvider:

// config/html-sanitizer.php
return [
    'extensions' => [
        HtmlSanitizer\Extension\BasicExtension::class,
        HtmlSanitizer\Extension\ImageExtension::class,
        HtmlSanitizer\Extension\ListExtension::class,
        HtmlSanitizer\Extension\TableExtension::class,
        HtmlSanitizer\Extension\CodeExtension::class,
        HtmlSanitizer\Extension\IframeExtension::class,
    ],
    'iframe' => [
        'allowed_hosts' => ['www.youtube.com', 'player.vimeo.com'],
    ],
];
// app/Providers/AppServiceProvider.php
use HtmlSanitizer\SanitizerBuilder;
use HtmlSanitizer\SanitizerInterface;

public function register(): void
{
    $this->app->singleton(SanitizerInterface::class, fn () =>
        (new SanitizerBuilder())->build(config('html-sanitizer'))
    );
}

The builder is deterministic and cheap-ish but not free; the singleton matters if you sanitize inside loops.

Look first at the extension list, not the API. The sanitizer is a whitelist engine: anything not covered by an enabled extension is stripped. So the first decision is "which extensions do we enable for this content type?" — a forum post needs Basic, List, Code, Image; a support ticket body may need only Basic.

First use case: a WYSIWYG field. The classic entry point is a rich-text body column fed by a Trix/Quill/Trumbowyg editor:

// app/Http/Controllers/CommentController.php
public function store(StoreCommentRequest $request, SanitizerInterface $sanitizer)
{
    Comment::create([
        'body' => $sanitizer->sanitize($request->string('body')->toString()),
    ]);
}

Then render with {!! $comment->body !!} — the braces are unescaped because the value is already sanitized. Never use {!! !!} on anything that hasn't gone through the sanitizer.

Implementation Patterns

Sanitize on write, keep the original. The most robust day-to-day pattern is to store both a raw and a sanitized copy. Editors need the raw HTML back to re-open the post; the browser only ever gets the sanitized column. A mutator keeps it automatic:

// app/Models/Comment.php
protected static function booted(): void
{
    static::saving(function (Comment $comment) {
        $comment->body_html = app(SanitizerInterface::class)->sanitize($comment->body);
    });
}

Alternatively, sanitize lazily in an accessor (getBodyHtmlAttribute) when storage is precious — but then every read pays the parse cost.

A Blade directive reduces boilerplate. Register once in a service provider:

Blade::directive('sanitize', fn ($expr) =>
    "<?php echo app(\\HtmlSanitizer\\SanitizerInterface::class)->sanitize($expr); ?>"
);

Then @sanitize($post->body) in templates. This is a good fit for content imported from legacy systems where you can't trust the stored value and can't backfill immediately. For new writes, prefer the mutator — sanitizing on render hides bad data instead of fixing it.

Different content types need different sanitizers. Don't force one config on the whole app. Bind named instances:

$this->app->singleton('sanitizer.comment', fn () => (new SanitizerBuilder())->build([
    'extensions' => [BasicExtension::class, ListExtension::class, CodeExtension::class],
]));

$this->app->singleton('sanitizer.email', fn () => (new SanitizerBuilder())->build([
    'extensions' => [BasicExtension::class],
]));

A comments renderer allowing iframes is a security smell; an email template that allows tables but not images may be intentional. Model this explicitly.

FormRequests are the natural seam for validation-time cleaning. prepareForValidation() is where users expect "the value I submitted is the value stored":

protected function prepareForValidation(): void
{
    $this->merge([
        'body' => app(SanitizerInterface::class)->sanitize($this->input('body', '')),
    ]);
}

Careful: this mutates the input the validator sees, so length/structure rules apply after stripping. That's usually what you want for XSS, but can surprise you when a <script>-wrapped payload becomes an empty string and a required rule flips.

Markdown pipelines: sanitize last. If you accept Markdown, render to HTML with league/commonmark (or Str::markdown()), then pass the result through the sanitizer. Sanitizing before rendering lets Markdown re-introduce raw HTML. This ordering is the single most common cause of XSS in "we sanitize everything" apps.

Custom extensions for product-specific markup. When you need to keep <figure>, <video>, or attributes like data-language, write a small extension implementing HtmlSanitizer\Extension\ExtensionInterface (name, config tree builder, node) and register it in the extensions array. Extensions are the intended extension point — prefer them over post-processing strings, which is fragile.

Backfill old rows with a queued command. Existing content was written before the sanitizer existed. A one-off command that chunks rows and dispatches a job is safer than a synchronous migration:

Comment::query()->whereNull('body_html')->chunkById(500, function ($comments) {
    foreach ($comments as $comment) {
        SanitizeComment::dispatch($comment);
    }
});

API responses. When returning rendered HTML from a resource, sanitize in the resource (or rely on the stored body_html column). If you generate HTML fragments in the API layer — a "preview" endpoint, say — sanitize the preview too, not just the persisted body.

Test with a payload corpus. Assert on known XSS vectors rather than inventing cases:

it('strips script tags', function () {
    $out = app(SanitizerInterface::class)->sanitize('<p>hi</p><script>alert(1)</script>');
    expect($out)->toBe('<p>hi</p>');
});

Pull a list from OWASP's XSS filter evasion cheat sheet. Also test the "allowed" direction — that <a href="https://example.com"> survives, that <img> keeps src, etc. A sanitizer that strips everything passes the XSS tests and fails the product.

Gotchas and Tips

Archived dependency. Treat this as tech debt from day one. The Symfony successor is API-similar but not identical (config tree changed, SanitizerBuilder lives under a different namespace). Isolate construction behind your service provider so the eventual swap touches one file.

Missing an extension means missing output. With an empty extensions array, or a typo in a class name, the sanitizer returns an empty string for all input — which looks like a data-loss bug, not a config error. The builder's behaviour is "strip what you didn't allow", so verify with a smoke test that a representative document round-trips.

style attributes and <style> blocks are not CSS-sanitized. The library does not clean CSS. Allowing style on tags opens a second attack surface (CSS exfiltration, expression() in old IE, some browser-specific tricks). Keep style out of the whitelist and use classes instead; if you must allow it, strip it yourself.

URL scheme and host filtering has two knobs. javascript: and data: are blocked by protocol whitelisting, but allowed_hosts is separate — an empty or missing host list blocks all URLs in that extension's nodes (iframes, images). The symptom is "images silently disappear in production but not locally"; check the image/iframe config keys rather than the markup.

Allowed elements ≠ allowed attributes. <a> surviving without href or rel is the classic confusing result. If links render but aren't clickable, you enabled BasicExtension but didn't add the attribute. Check both levels when a tag "half works".

HTML5 parsing is forgiving but output is normalized. Expect the sanitizer to reorder attributes, self-close <br />, and decode entities. Don't write tests that compare exact byte strings on complex input; compare structure or use targeted assertions. Don't use it as a pretty-printer.

UTF-8 matters. The parser expects valid UTF-8. Feeding it latin-1 output from an old importer can result in dropped text nodes. Normalize encodings upstream (mb_convert_encoding) before sanitizing.

It is not idempotent in the way you'd expect across versions. Since the package is frozen, this is less of a risk, but if you swap to the Symfony version the output may differ subtly. Don't make decisions based on "sanitizer output equals stored value"; if you do, add a reconciliation job rather than a DB constraint.

Sanitizing trusted output is harmful. Running your own Blade-rendered components through the sanitizer strips data-* attributes, custom elements, and any <x-...> remnants. Only sanitize user-controlled HTML; keep the trust boundary explicit in code review. A good signal is naming: userHtml vs trustedHtml.

Sanitizing does not make {!! !!} safe in every context. If sanitized HTML ends up inside a <textarea>, an attribute value, or a JSON blob consumed by JS, the surrounding escaping still matters. The sanitizer protects HTML contexts, not JS or attribute contexts.

Debugging dropped content. There's no built-in "what was removed" reporter. Log a diff when the output length is drastically smaller than input — that catches both hostile payloads and accidental over-restriction (e.g. a user pasting from Google Docs and losing their whole table). Sample and alert; don't log full user content.

Cache by content hash for hot paths. If a listing page renders 50 sanitized comments, and you're not storing the sanitized column, memoize per-request:

$html = Cache::remember('sanitized:'.md5($raw), now()->addDay(), fn () =>
    app(SanitizerInterface::class)->sanitize($raw)
);

The parser is heavier than a regex, so this matters at scale. Also cache the sanitizer construction, not just the output — the singleton binding already does this.

Extension config keys are conventions, not validation. The 'iframe' => [...] shape is per-extension. Misspell a key and it's silently ignored rather than an error. Keep the config file small, add a boot-time assertion that the sanitizer output for a fixture document matches expectations, and you'll catch config drift in CI before users do.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core