joomla/filter
Joomla's Filter package provides input and output sanitization utilities, letting you validate and clean user data and build URL-safe strings for secure PHP applications.
Installation:
composer require joomla/filter "~3.0.7"
For PHP 8.3+ projects, use ~4.0 and confirm the 4.x line contains the equivalent XSS fixes shipped in 3.0.7 before relying on it.
Security note: 3.0.7 patches two XSS filter bypasses (whitespace in HTML
data:URIs, and an HTML5 entity-decode mismatch). If you are pinned below3.0.7, upgrade before doing anything else — this is a security patch, not a feature release.
First Use Case: Sanitize user input in a Laravel controller or request handler:
use Joomla\Filter\InputFilter;
$cleanInput = InputFilter::clean(
$userInput,
InputFilter::ONLY_ALLOW_DEFINED_TAGS,
['p', 'b', 'i', 'a']
);
Where to Look First:
Request Sanitization Pipeline:
// In a Laravel Form Request or middleware
public function sanitize($input, array $allowedTags = ['p', 'a'])
{
return InputFilter::clean(
$input,
InputFilter::ONLY_ALLOW_DEFINED_TAGS,
$allowedTags
);
}
Service Provider Integration:
// app/Providers/AppServiceProvider.php
public function register()
{
$this->app->singleton('filter', function () {
return new InputFilter();
});
}
Inject via constructor:
public function __construct(private InputFilter $filter) {}
Dynamic Whitelists:
// Allow tags based on user role
$allowedTags = auth()->user()->role === 'admin' ? ['div', 'span'] : ['p', 'b'];
$cleanHtml = $this->filter->clean($input, InputFilter::ONLY_ALLOW_DEFINED_TAGS, $allowedTags);
Attribute-Level Control:
// Allow only href in <a> tags
$cleanHtml = InputFilter::clean(
$input,
InputFilter::ONLY_ALLOW_DEFINED_TAGS | InputFilter::ONLY_ALLOW_DEFINED_ATTRIBUTES,
['a'],
['href']
);
Defense in Depth (recommended after 3.0.7): Treat the filter as one layer, not the only one. Because 3.0.7 fixed evasion bugs — meaning prior versions silently let payloads through — assume future ones are possible and pair the filter with:
// Escape on output by default; only unescape what you filtered
{!! $post->rendered_html !!} {{-- filtered at write time --}}
{{ $post->title }} {{-- plain escaped --}}
// A CSP header is the cheap backstop
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'"
);
Form Handling:
validate() or a dedicated sanitizing step of your Form Request. (Laravel does not call a sanitize() method automatically — call it explicitly via prepareForValidation() or in the controller.)public function rules()
{
return ['content' => 'required|string'];
}
protected function prepareForValidation(): void
{
$this->merge([
'content' => InputFilter::clean(
$this->input('content'),
InputFilter::ONLY_ALLOW_DEFINED_TAGS,
['p', 'b']
),
]);
}
API Payloads:
$data = json_decode($request->getContent(), true);
$data['description'] = InputFilter::clean($data['description'], ...);
CMS Content:
DetailPanel or custom fields:
// Nova Field
public function resolve($resource, $attribute)
{
return InputFilter::clean($resource->$attribute, ...);
}
Laravel Validation Rules: Extend Laravel’s validation with custom rules:
use Joomla\Filter\InputFilter;
class Sanitize extends FormRequest
{
public function rules()
{
return ['bio' => ['required', new SanitizeRule(['p', 'a'])]];
}
}
class SanitizeRule implements ValidationRule
{
public function __construct(private array $allowedTags) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$clean = InputFilter::clean($value, InputFilter::ONLY_ALLOW_DEFINED_TAGS, $this->allowedTags);
if ($clean === false) {
$fail("The {$attribute} contains disallowed markup.");
}
}
}
Prefer rejecting dangerous input over silently stripping it for fields where loss of content is surprising — silent stripping plus an unescaped render path is how bypasses become incidents.
Blade Directives: Create a custom Blade directive for sanitized output:
// app/Providers/BladeServiceProvider.php
Blade::directive('sanitize', function ($expression) {
return "<?php echo InputFilter::clean({$expression}, InputFilter::ONLY_ALLOW_DEFINED_TAGS, ['p', 'b']); ?>";
});
Usage:
{!! sanitize($user->bio) !!}
Be aware this puts sanitization at render time on every request — no caching, and a bug here is a live XSS. Filter on write, store both raw and cleaned copies, and render the stored cleaned copy.
Event Listeners:
Sanitize data on model events (e.g., saving):
public function saving(Post $post)
{
$post->content = InputFilter::clean(
$post->content,
InputFilter::ONLY_ALLOW_DEFINED_TAGS,
['h1', 'p', 'ul', 'li']
);
}
Note: rows written before the 3.0.7 upgrade were sanitized with the buggy filter. Store-time filtering does not retroactively clean historical data — see the backfill gotcha below.
composer require joomla/filter "~3.0.7" --with-dependencies
composer show joomla/filter # verify the resolved version
composer why-not joomla/filter 3.0.7
Post::query()->where('updated_at', '<', $cutover)->chunkById(200, function ($posts) {
foreach ($posts as $post) {
$post->content = InputFilter::clean($post->content, InputFilter::ONLY_ALLOW_DEFINED_TAGS, ['p', 'a', 'ul', 'li']);
$post->saveQuietly();
}
});
Security Patch, Not a Feature Release:
data: URIs, and an HTML5 entity-decode mismatch.~3.0.7 (or the equivalent 4.x release) and backfill stored content.Whitespace-in-data: URI Bypass:
java\tscript:, data:text/html;base64,... with embedded newlines). Pre-3.0.7 the filter's URI check could be defeated by inserting whitespace.data:/javascript: schemes at the attribute level by not whitelisting src for untrusted authors.HTML5 Entity-Decode Mismatch Bypass:
<, >, : or &). Pre-3.0.7 a payload could look harmless to the filter and dangerous to the browser.PHP Version Mismatch:
~3.0.7 for older Laravel projects (e.g., Laravel 9).False Positives in Sanitization:
<img src="..."> if src isn’t whitelisted).Nested Tag Issues:
stripImages/stripIframes.~3.0.7 or later.Attribute Filtering Quirks:
ONLY_ALLOW_DEFINED_ATTRIBUTES can strip valid attributes if not configured carefully.['href', 'title'] for <a> tags).Performance Overhead:
Unexpected Output:
InputFilter::clean() with InputFilter::RETURN_ERRORS to inspect failures:
$result = InputFilter::clean($input, InputFilter::RETURN_ERRORS, ['p']);
if ($result === false) {
// inspect the filter's error state
}
XSS Evasion Bypasses:
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">Click</a>
<!-- whitespace inside URI scheme / data URI -->
<a href="java	script:alert(1)">x</a>
<a href="java script:alert(1)">x</a>
<img src="data:text/html;base64,
PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">
<iframe src="data:text/html,
<script>alert(1)</script>"></iframe>
<!-- entity-decode mismatch -->
<a href="javascript:alert(1)">x</a>
<a href=":javascript:alert(1)">x</a>
<svg><a xlink:href="javascript:alert(1)">x</a></svg>
script, no javascript:/data: scheme after decode), not that the input string appears in the output.Whitelist Misconfigurations:
$filter = new InputFilter();
dump($filter->getAllowedTags(), $filter->getAllowedAttributes());
Verifying You're Actually Patched:
php -r '$l = json_decode(file_get_contents("composer.lock"), true);
foreach ($l["packages"] as $p) if ($p["name"] === "joomla/filter") echo $p["version"], PHP_EOL;'
Then grep the vendor source for the whitespace-normalization change in the URI validation path — if it isn't there, you're on a stale build even if the constraint looks right.
Static vs. Instance Methods:
InputFilter::clean()) use global config.$filter = new InputFilter();
$filter->setAllowedTags(['div', 'span']);
$clean = $filter->clean($input);
OutputFilter Dependencies:
OutputFilter::stringURLSafe() requires joomla/language package.composer require joomla/language
Legacy Constant Names:
TAGS_WHITELIST).ONLY_ALLOW_DEFINED_TAGS).Custom Filters:
Extend InputFilter to add domain-specific rules:
class CustomFilter extends InputFilter
{
public function cleanCustom($input)
{
return $this->clean($input, InputFilter::ONLY_ALLOW_DEFINED_TAGS, ['custom']);
}
}
If you override URI or entity handling, you now own that security surface — 3.0.7's fixes are exactly the kind of thing a well-meaning local override can silently reintroduce. Prefer subclassing for additions, not for reimplementing validation.
Event-Driven Sanitization: Use Laravel events to trigger sanitization:
// In a service provider
event(new SanitizeEvent($input));
Handle with a listener:
public function handle(SanitizeEvent $event)
{
$event->setSanitized(InputFilter::clean($event->input, ...));
}
Testing Helpers: Create a small assertion helper so every feature test uses the same payload corpus, including the 3.0.7 cases:
trait AssertsSanitizedHtml
{
public function assertSanitized(string $input, array $allowedTags = ['p', 'a']): void
{
$out = \Joomla\Filter\InputFilter::clean(
$input,
\Joomla\Filter\InputFilter::ONLY_ALLOW_DEFINED_TAGS,
$allowedTags
);
$this->assertStringNotContainsStringIgnoringCase('script', (string) $out);
$this->assertDoesNotMatchRegularExpression(
'/(javascript|vbscript|data)\s*:/i',
html_entity_decode((string) $out, ENT_QUOTES | ENT_HTML5)
);
}
}
Decoding entities before asserting is the point — a bypass that survives the filter usually only becomes visible after the browser-style decode.
How can I help you explore Laravel packages today?