ismailnakkar / laravel-safe-html
Clean user-typed HTML so it is safe to print with {!! !!} in Laravel, plus a length rule that counts an entity as one character.
Requires
- php: ^8.4
- ext-dom: *
- laravel/framework: ^13.0
- symfony/html-sanitizer: ^8.1
Requires (Dev)
- fakerphp/faker: ^1.24
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^12.5.8 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Cleans HTML your users type (an announcement, a bio) down to basic formatting and links, so you can print it with
{!! !!}. Built on Symfony's HtmlSanitizer.
It also ships MaxHtmlLength, a length rule for cleaned HTML that counts ' as one character, not six.
Install
Needs PHP 8.4+ and Laravel 13.
composer require ismailnakkar/laravel-safe-html
There is nothing to publish or register.
Usage
Clean the field in its form request, before the rules run:
use Illuminate\Foundation\Http\FormRequest; use SafeHtml\Rules\MaxHtmlLength; use SafeHtml\SafeHtml; class AnnouncementRequest extends FormRequest { protected function prepareForValidation(): void { SafeHtml::cleanInput($this, 'content'); } public function rules(): array { return ['content' => ['nullable', 'string', new MaxHtmlLength(10_000)]]; } }
cleanInput() runs clean() on each field you name: SafeHtml::cleanInput($this, 'content', 'bio'). A field that
wasn't sent stays unsent, so an update without it keeps the column. A dotted name ('meta.bio') reaches a nested field,
and * stands for every item at its level, as in a rule ('translations.*.content'). A name a rule would read
differently throws: a top-level *, a * inside a level ('b*'), an escaped dot, {first} or {last}. Call it in
prepareForValidation(), so the rules and validated() see the clean value.
content is now clean in validated(), so print the saved column raw:
{!! $announcement->content !!}
Anything that writes the column without this request (an import, a seeder) must call clean() itself, or clean on
output:
{!! app(\SafeHtml\SafeHtml::class)->clean($row->content) !!}
clean() takes a string or any Stringable (str($html), an AsStringable cast, an HtmlString), and returns:
- the clean HTML, trimmed;
nullwhen nothing is left:'',' ','<script>…</script>', an empty editor's<p><br></p>or<p> </p>, or input that isn't valid UTF-8.
null stays null. Any other value (an array, an uploaded file) comes back unchanged, so the string rule still
refuses it.
clean() refuses input over 100,000 bytes with PostTooLargeException instead of cutting it. That is a 413 response,
not a validation error, so a form does not come back with its input, and in the Blade snippet above one oversized row
fails the whole page. Raise the limit with withMaxInputLength() (see below).
The limit bounds time, not memory: a crafted 10 KB body can make clean() allocate nearly 300 MB. PHP's
memory_limit stops it with a fatal error, so never call clean() in a process running with memory_limit=-1.
What survives
p br hr strong b em i u s strike sub sup ul ol li blockquote pre code h1–h6, without attributes.awithhref,titleandtarget. Links may behttp,https,mailtoor relative; any other (tel:,javascript:,data:) loses itshrefand keeps its text. Anytargetbecomes_blank, and every link getsrel="noopener noreferrer".- Every other attribute is removed (
style,class,id,on*,data-*). script,iframe,object,embed,formand its fields,template,noscript,audio,video,canvas,dialog,svg,mathand similar are removed with their contents (full list inSafeHtml::policy()).- Every other element loses its tag and keeps its text, so neighbouring blocks run together (
<div>a</div><div>b</div>becomesab, and so do table cells). That coversdiv,span,section,table, andstyle, so pasted CSS shows as text.imgdisappears.
Customising the policy
SafeHtml::policy() returns the package's Symfony HtmlSanitizerConfig. Its methods return a changed copy, so chain
them and bind the result in AppServiceProvider::register():
use SafeHtml\SafeHtml; $this->app->singleton(SafeHtml::class, fn () => new SafeHtml( SafeHtml::policy()->allowElement('img', ['src', 'alt'])->withMaxInputLength(200_000), ));
Image src accepts http, https and data:; add ->allowRelativeMedias() for paths like /storage/a.png.
Start from SafeHtml::policy(), not new HtmlSanitizerConfig: a bare config has none of the rules above and refuses
input over 20,000 bytes.
Length
Use new MaxHtmlLength($max) instead of max: on cleaned HTML. Both count tags, but max: counts each escaped
character as stored (' is stored as ', six characters) where MaxHtmlLength counts it as one.
- It fails with Laravel's
validation.max.stringmessage, so your translations andattributes()apply. To override it inmessages(), use the keycontent.max_html_length(:maxis not filled in there). - A non-string passes; the
stringrule refuses it. - On MySQL and MariaDB, stored HTML can take up to 8 bytes per counted character (
<is stored as<): atextcolumn (65,535 bytes) holds a cap up to 8,191, soMaxHtmlLength(10_000)needsmediumText.
Upgrading
From 0.1
cleanInput() takes * below the top level ('blocks.*.body') instead of throwing. Nothing to change.
Licence
MIT, see LICENSE.