larahook/purifier
Laravel wrapper for HTMLPurifier that cleans and sanitizes untrusted HTML before it reaches your views. Filter user-generated content through a simple, configurable API to strip XSS and other malicious markup while preserving safe formatting.
Minimal setup for a Laravel app:
composer require larahook/purifier
Purifier facade are auto-discovered. For older Laravel (5.4 and below) register Larahook\Purifier\PurifierServiceProvider in config/app.php and add the facade alias 'Purifier' => Larahook\Purifier\Facades\Purifier::class.php artisan vendor:publish --provider="Larahook\Purifier\PurifierServiceProvider" --tag=config
config/purifier.php. That file is the real API surface of this package — profiles (often default and custom) mapping to HTMLPurifier directives such as HTML.Allowed, HTML.ForbiddenElements, URI.AllowedSchemes, HTML.SafeIframe, URI.SafeIframeRegexp, and Cache.DefinitionImpl.use Larahook\Purifier\Facades\Purifier;
$clean = Purifier::clean($request->input('body'));
Then render the already-sanitized value with {!! $post->body !!} instead of {{ }} (which would double-escape).
A good second step is to read the package's service provider and facade source — it is small, and the method signatures (whether clean() accepts a second $config argument, whether a purify() helper or Blade directive exists) tell you exactly what this fork adds over stock HTMLPurifier.
Form Request + sanitization before persistence. Normalize input at the boundary, so every downstream consumer (APIs, exports, admin lists) sees clean HTML:
protected function prepareForValidation(): void
{
$this->merge([
'body' => Purifier::clean($this->input('body', '')),
]);
}
Multiple profiles for multiple contexts. Don't run one maximal config everywhere. A "comments" profile should allow only p, a, strong, em, ul, ol, li and no style; a "cms" profile can allow headings, tables, images, and safe iframes. Keep both in config/purifier.php and call Purifier::clean($html, 'cms').
Model-level mutators or casts. For content-heavy apps, sanitize inside the model so any write path is covered:
public function setBodyAttribute($value): void
{
$this->attributes['body'] = Purifier::clean($value, 'cms');
}
Be deliberate here — a cast hides cost and makes batch imports slow; a mutator is fine for interactive requests.
Sanitize on output as a safety net. If you have legacy rows that predate the package, wrap the render site rather than migrating the data:
{!! Purifier::clean($post->body, 'cms') !!}
You can do both (sanitize on write and on read) during a transition, then drop the read-side call once the data is clean.
Sanitize after Markdown, not before. HTMLPurifier only understands HTML. If you pass Markdown through it first, you'll lose the formatting before the Markdown parser runs, or worse, allow the parser to reintroduce unsafe HTML. Correct order: Markdown → HTML → Purifier::clean().
Custom validation rule. Wrap sanitization in a rule when you want to reject instead of silently strip:
Validator::extend('clean_html', fn ($attr, $value) =>
trim(Purifier::clean($value)) === trim($value)
);
This is useful for editor integrations where silent stripping confuses authors.
API responses. For a JSON API serving rich content, sanitize once at write time and store the clean string; sanitizing on every response is a measurable hit on HTMLPurifier's already-heavy parser.
Integration with WYSIWYG editors. Tune the profile to the editor's allowed output (HTML.Allowed, CSS.AllowedProperties, AutoFormat.AutoParagraph) so what the editor produces survives round-trips. Run a fixture of representative editor output through clean() in a test to lock the behavior.
Cache.DefinitionImpl enabled and point Cache.SerializerPath at a writable path (typically under storage/app). If the directory isn't writable, you'll usually get a warning plus a silent performance cliff — check logs after first deploy.HTML.Allowed and see no change, HTMLPurifier is serving an old serialized definition from its cache. Clear that cache directory (or bump HTML.DefinitionID/DefinitionRev) as part of your config-change checklist.clean('<script>alert(1)</script>') returns an empty string, not <script>.... This is correct for rich text but wrong if you wanted the markup displayed literally — use e()/{{ }} for that.HTML.SafeIframe alone is not enough; you must also set URI.SafeIframeRegexp to the embed hosts you trust. Otherwise YouTube/Vimeo embeds vanish and authors file bugs.URI.AllowedSchemes governs href/src. Relative URLs are generally fine, but custom protocols (e.g., tel:, mailto:) must be listed explicitly.style requires CSS.AllowedProperties and friends. If you allow arbitrary style, you're reopening a class of attacks — prefer classes over inline styles in the editor output.null/empty input. Wrap calls with a null coalesce (Purifier::clean($html ?? '')) rather than relying on the wrapper to normalize types; behavior across forks varies.HTML.DefinitionID + custom HTMLPurifier_Config definitions let you add bespoke attributes; most Lavarel wrappers let you pass a profile name or a config array as the second argument. Reach for a custom definition instead of patching the vendor package.composer.json, and keep an integration test that asserts your profiles allow and strip the right things. If the fork goes stale, migrating to stock ezyang/htmlpurifier is a service-provider change, not a rewrite — so avoid spreading Purifier:: calls across 40 files.<script>, onerror=, javascript: URL) comes back scrubbed. This catches config regressions far better than a single happy-path test.How can I help you explore Laravel packages today?