symfony/html-sanitizer
Symfony's HtmlSanitizer component offers an object-oriented API to clean untrusted HTML for safe DOM insertion. Configure allowed elements, attributes, link schemes, and URL forcing to strip scripts and dangerous markup.
Installation: Add the package via Composer:
composer require symfony/html-sanitizer
For Laravel, ensure compatibility with your PHP version (PHP 8.1+ recommended). If you use sanitizeFor() with table-related, image, or plaintext contexts, or you depend on HTML comments being dropped, require ^8.1.8 – earlier 8.1.x releases handle those cases incorrectly.
Basic Setup: Create a sanitizer instance with a minimal config:
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
$config = (new HtmlSanitizerConfig())
->allowSafeElements(); // Start with a safe baseline
$sanitizer = new HtmlSanitizer($config);
First Use Case: Sanitize user-generated HTML in a comment system:
$userInput = '<p>Hello <b>World</b>! <script>alert("XSS")</script></p>';
$cleanHtml = $sanitizer->sanitize($userInput);
// Output: <p>Hello <b>World</b>!</p>
sanitize(): Core method for sanitizing HTML strings (body context).sanitizeFor(): Context-aware sanitization (head, textarea, plaintext, table-related contexts, image, …). Context coverage was expanded/fixed in v8.1.8.HtmlSanitizerConfig: Builder for rules (elements, attributes, URL schemes/hosts, forced attributes).AttributeSanitizerInterface: Hook for custom per-attribute value sanitization.$config = (new HtmlSanitizerConfig())
->allowSafeElements()
->allowElement('a', ['href', 'title'])
->allowElement('img', ['src', 'alt'])
->allowAttribute('class', '*')
->forceHttpsUrls();
$sanitizer = new HtmlSanitizer($config);
// In a controller:
$cleanHtml = $sanitizer->sanitize(request()->input('content'));
return view('post.show', ['content' => $cleanHtml]);
// For meta/link tags in <head>
$metaContent = $sanitizer->sanitizeFor('head', $userInput);
// For textarea content (HTML is escaped, not preserved)
$textareaContent = $sanitizer->sanitizeFor('textarea', $userInput);
// For plain text output (tags stripped, entities escaped) – fixed in 8.1.8
$plain = $sanitizer->sanitizeFor('plaintext', $userInput);
// For table fragments (td/tr/etc.) – context handling fixed in 8.1.8
$cell = $sanitizer->sanitizeFor('td', $userInput);
// For SVG <image> context – supported since 8.1.8
$svgImage = $sanitizer->sanitizeFor('image', $userInput);
'td' loses sibling cells. Pick the context that matches the element the HTML will actually be injected into.$config = (new HtmlSanitizerConfig())
->allowSafeElements()
->allowLinkSchemes(['https', 'mailto'])
->allowLinkHosts(['trusted.com', '*.example.org'])
->allowRelativeLinks();
$sanitizer = new HtmlSanitizer($config);
data-*) while blocking others.$config = (new HtmlSanitizerConfig())
->allowSafeElements()
->allowAttribute('data-custom', '*') // Allow on all elements
->dropAttribute('onclick', '*') // Block globally
->forceAttribute('a', 'rel', 'noopener noreferrer');
config:cache), and build the HtmlSanitizerConfig in a service provider or factory:
// config/sanitizer.php
return [
'default' => [
'elements' => ['a' => ['href', 'title'], 'p', 'br', 'strong', 'em'],
'force_https' => true,
],
'rich_text' => [
'elements' => ['a' => ['href', 'title'], 'img' => ['src', 'alt'], 'p', 'br', 'ul', 'ol', 'li'],
],
];
Service Provider Binding:
// app/Providers/AppServiceProvider.php
public function register()
{
$this->app->singleton(HtmlSanitizer::class, function ($app) {
$config = (new HtmlSanitizerConfig())
->allowSafeElements()
->allowElement('a', ['href', 'title']);
return new HtmlSanitizer($config);
});
// Optional: named sanitizers for different contexts
$this->app->bind('sanitizer.rich_text', fn () => new HtmlSanitizer(
(new HtmlSanitizerConfig())
->allowStaticElements()
->allowElement('a', ['href', 'title'])
->allowElement('img', ['src', 'alt'])
));
}
Form Request Validation + Sanitization:
// app/Http/Requests/SanitizeContentRequest.php
public function validated()
{
$data = parent::validated();
$data['content'] = app(HtmlSanitizer::class)->sanitize($data['content']);
return $data;
}
Blade Directive:
// app/Providers/BladeServiceProvider.php
Blade::directive('sanitize', function ($expression) {
return "<?php echo app(\\Symfony\\Component\\HtmlSanitizer\\HtmlSanitizer::class)->sanitize({$expression}); ?>";
});
// In Blade:
@sanitize($userInput)
Model Accessor / Cast for Stored HTML:
public function getContentAttribute($value): string
{
return app(HtmlSanitizer::class)->sanitize($value ?? '');
}
Sanitizing on output (rather than only on input) is a cheap safety net for legacy rows written before the sanitizer existed.
HtmlSanitizerConfig once (service provider) instead of per call.allowSafeElements() alone on rich text will strip editor output like ul/ol nesting or class attributes.Attribute Sanitizer Caveats:
allowAttribute('*', '*') is not a wildcard for all attributes. Use allowSafeAttributes() or explicitly list attributes.forceAttribute() replaces all values of the attribute, not just adds to them.URL Handling Quirks:
allowRelativeLinks() does not imply allowRelativeMedias(). Configure separately.*.example.org) must be exact matches for subdomains.Context Misuse:
sanitizeFor('head', ...) drops <body>-only tags (e.g., <div>), while sanitizeFor('div', ...) is not a valid context – unknown contexts fall back to body-style handling.sanitizeFor('textarea', ...) escapes HTML rather than sanitizing it. Use sanitize() when you need to keep markup.sanitizeFor('plaintext', ...) escapes and strips tags – correct for <plaintext>/pre-formatted output.table, caption, colgroup, col, thead, tbody, tfoot, tr, td, th) and the image context were broken before 8.1.8. Content outside the context's permitted children is silently dropped, so sanitize the smallest fragment you will actually inject.HTML Comments:
Allow. Do not rely on HTML comments (e.g., <!--more-->, <!-- wp:paragraph -->) surviving sanitization – block-editor markers and WordPress-style excerpt separators will be lost. Strip/handle markers before sanitizing, or store them separately.Nested Elements:
blockElement('section')) retains children. Use dropElement() to remove them entirely.Default Action Allow Is Dangerous:
allowSafeElements() is a baseline, not "allow everything". Explicitly calling allowElement() per tag is safest; a config that defaults to Allow combined with user content is an XSS risk even with the 8.1.8 comment fix.PHP 8.4+ Native Parser:
$before = $input;
$after = $sanitizer->sanitize($input);
if ($before !== $after) { logger()->debug('Sanitizer changed content', compact('before', 'after')); }
sanitize() and sanitizeFor() side by side.class LoggingSanitizer
{
public function __construct(private HtmlSanitizer $inner) {}
public function sanitize(string $html): string
{
$clean = $this->inner->sanitize($html);
Log::debug('sanitized', ['in' => $html, 'out' => $clean]);
return $clean;
}
}
<div><p>test</div> to test parser resilience.‮) and percent-encoded spaces.<!-- comment --> is removed – a regression here is exactly what 8.1.8 fixed.sanitizeFor() context you use, and pin the minimum version to 8.1.8.Custom Attribute Sanitizers:
use Symfony\Component\HtmlSanitizer\AttributeSanitizerInterface;
class CustomAttributeSanitizer implements AttributeSanitizerInterface
{
public function sanitize(string $name, string $value, string $element): string
{
if ($name === 'data-custom') {
return preg_replace('/[^a-z0-9_-]/i', '', $value);
}
return $value;
}
}
$config->withAttributeSanitizer(new CustomAttributeSanitizer());
Override Default Rules:
HtmlSanitizerConfig for reusable rule sets:
class AppHtmlSanitizerConfig extends HtmlSanitizerConfig
{
public function __construct()
{
parent::__construct();
$this->allowSafeElements()
->allowElement('a', ['href', 'title'])
->forceHttpsUrls();
}
}
Decorate the Sanitizer:
HtmlSanitizer to apply per-tenant/per-user rules by swapping configs, rather than trying to mutate the config mid-sanitization:
class TenantSanitizer
{
public function __construct(
private HtmlSanitizerConfig $base,
private HtmlSanitizerConfig $tenant,
) {}
public function sanitize(string $html, bool $isTenant = false): string
{
return (new HtmlSanitizer($isTenant ? $this->tenant : $this->base))->sanitize($html);
}
}
Allow, and sanitizeFor() is corrected for table-related, image, and plaintext contexts. If you were relying on the buggy comment behavior or on table/image context sanitization, re-test after upgrading.How can I help you explore Laravel packages today?