vanilla/htmlawed
Laravel integration for HTMLawed, a PHP HTML sanitizer and validator. It cleans user-submitted markup, balances tags, filters unsafe attributes and elements, and helps preserve safe formatting in rich text. Useful for sanitizing comments, CMS content, and other untrusted HTML.
vanilla/htmlawed is a Composer-installable wrapper around htmLawed, a single-file HTML purification/filtering library. It exists because htmLawed itself ships as a raw .php file with no composer.json; this fork adds autoloading so you can require it normally. It is not a PSR-4 class-based package — it registers the global htmLawed() function via Composer's files autoload.
As of v2.2.15 the wrapper bundles upstream htmLawed 1.2.15. There is still no class API, no facade, no service provider, and no config file — the release is an upstream tracking bump, so everything below from the previous assessment still applies.
Minimal setup:
composer require vanilla/htmlawed
Verify autoloading worked (the function is global, not namespaced), and confirm which upstream version you actually resolved:
dd(function_exists('htmLawed')); // true
dd(htmLawed('', [], '', true)); // returns htmLawed's version string, e.g. "1.2.15"
Your first use case, sanitizing user-submitted rich text before storing:
$clean = htmLawed($request->input('body'), [
'safe' => 1,
'elements' => 'a, b, strong, em, i, u, p, br, ul, ol, li, blockquote, code, pre, h2, h3, h4',
'attributes' => 'a=href,title,rel; * = class',
'balance' => -1,
'scheme' => 'http, https, mailto',
]);
Where to look first:
htmLawed() call itself — there is no facade, service provider, or config file. Everything is the $config array and the $spec string.htmLawed() config reference) — this fork tracks it closely, and the wrapper's own release notes are usually just a pointer to an upstream version bump.vendor/vanilla/htmlawed/ for the exact htmLawed file pinned, since security-relevant filtering behavior lives in that single file and changes with each upstream release.First practical step in a Laravel app: wrap htmLawed() in your own small HtmlSanitizer service (or similarly named class) and bind it into the container, so you never call the global function directly from controllers or Blade.
1. Wrap the global function behind a service class. This is the single most important pattern. It gives you one place to test, one place to change profiles, and mockable behavior.
namespace App\Support;
class HtmlSanitizer
{
public const PROFILES = [
'comment' => [
'safe' => 1,
'elements' => 'a, b, i, u, strong, em, p, br',
'attributes' => 'a=href,title,rel',
],
'article' => [
'safe' => 1,
'elements' => 'a, p, br, ul, ol, li, h2, h3, h4, blockquote, code, pre, strong, em',
'attributes' => 'a=href,title,rel; * = class',
],
];
public function clean(?string $html, string $profile = 'comment'): string
{
if ($html === null || trim($html) === '') {
return '';
}
return (string) htmLawed($html, self::PROFILES[$profile] + [
'balance' => -1,
'scheme' => 'http, https, mailto',
]);
}
}
Bind it in a service provider or use app()->singleton(HtmlSanitizer::class).
2. Sanitize at the boundary, escape everywhere else. Store sanitized HTML in a dedicated column (e.g., body_html) and keep the raw input in body_raw. In Blade, use {!! !!} only for the sanitized column and {{ }} for everything else. Never re-sanitize on every render — it is regex-heavy and expensive.
3. Form Request normalization. Sanitize in prepareForValidation or via a sanitize() method so controllers stay clean:
protected function prepareForValidation(): void
{
if ($this->has('body')) {
$this->merge([
'body' => app(HtmlSanitizer::class)->clean($this->input('body'), 'article'),
]);
}
}
4. Validation rule as a defense-in-depth check. Don't rely on the rule to sanitize; use it to reject clearly hostile input, then sanitize anyway.
Validator::extend('safe_html', function ($attribute, $value) {
$sanitized = app(HtmlSanitizer::class)->clean($value);
return trim(strip_tags($sanitized)) !== '' || trim($value) === '';
});
5. Blade directives. Register a directive so designers can opt into safe output explicitly:
Blade::directive('safeHtml', function ($expression) {
return "<?php echo app(\\App\\Support\\HtmlSanitizer::class)->clean({$expression}, 'comment'); ?>";
});
Used as @safeHtml($comment->body).
6. Rich-text editor integration. When importing from TipTap / Quill / TinyMCE, run the payload through htmLawed after the editor's own serialization. This strips editor-specific artifacts (<span style="...">, data-* attributes, inline style) and normalizes tags. Pair this with a DOMDocument-based link-attribute normalizer if you need rel="nofollow ugc" on external URLs — htmLawed's hook config supports attribute rewriting via a callback if you prefer to keep it to one pass.
7. Email template rendering. If users can customize email templates, run the template through a stricter profile (safe => 1, no <a>, no <img>, no attributes) before compiling the Mailable. This is the practical defense against template-injection XSS in notification emails.
8. Output profiles per surface. Maintain two or three named profiles (comment, article, email) rather than passing ad-hoc config arrays in controllers. Config drift is how XSS regressions happen.
9. Upgrade via a fixture corpus, not by eyeballing. Because wrapper releases are upstream bumps with no changelog of their own, keep a set of golden HTML fixtures (nested tags, javascript: hrefs, unbalanced markup, entities, emoji/4-byte UTF-8, data-* attributes, <svg>/<math> payloads) asserted against HtmlSanitizer::clean(). Run it on every composer update; a passing suite plus a green diff on the fixtures is your only real signal that a bundled htmLawed bump did not change filtering behavior under you.
composer update can silently swap the entire sanitizer. Treat any version change here as a potential behavior change and regression-test.safe, elements, attributes, scheme, balance, hook).NOASSERTION. The upstream htmLawed is dual-licensed LGPL-3.0 / GPL-2.0, but this fork's metadata is ambiguous. Get legal sign-off before shipping into a commercial product; you may be required to disclose modifications or license your wrapper accordingly.htmLawed() with Mockery. Test through your wrapper, or use a function_exists guard + a namespaced fallback. Do not call it directly in tests.safe => 1 is not full sanitization. Safe mode disallows known-dangerous elements (script, style, iframe, object) but you still must set elements and attributes explicitly. An attacker who injects <a href="javascript:..."> is only blocked because scheme restricts protocols — always set scheme.style and on* attributes. Never allow * = style or * = on*. If you need styling, allow a narrow class attribute instead and control the CSS on your side.balance requires intent. balance => 1 balances across the whole document (default), -1 balances within block elements, 0 disables (added upstream in 1.2). Unbalanced HTML from a WYSIWYG is common; -1 is the usual safe default but can produce unexpected wrapping.$spec is a string, not an array. The third parameter uses a custom format like 'a=href,title; p=class'. Passing an array throws or silently misconfigures — read the docs before touching it.mbstring; invalid UTF-8 input can be mangled or return an empty string. Normalize encoding (mb_convert_encoding($html, 'UTF-8', 'UTF-8')) before calling if you accept arbitrary user bytes. Recent upstream releases have included PHP 8.x deprecation/compat fixes, so re-run your suite on PHP version upgrades too.hook config accepts a callable that htmLawed invokes for every element/attribute, giving you a last-mile customization point (e.g., force rel="nofollow" on external links, rewrite <img src> to a CDN proxy). It is powerful but undocumented outside upstream, so pin the package version when you use it — hook signatures and element/attribute names are exactly what an upstream bump can shift.elements/attributes config and check attributes syntax (space after ; is required between element rules in some versions).^2.2 or an exact tag), and prefer composer update --with-dependencies in a PR you can review rather than letting it float. If you need actively maintained sanitization with a class API, Laravel integration, and a real changelog, benchmark symfony/html-sanitizer or mews/purifier before committing.How can I help you explore Laravel packages today?