voku/anti-xss
AntiXSS is a lightweight PHP library for filtering malicious input and preventing cross-site scripting attacks. It cleans user-supplied strings and arrays, with support for custom sanitizing, making it a simple drop-in defense for web forms and input handling.
voku/anti-xss is a regex/dictionary-based XSS filter, not a full HTML sanitizer. In a Laravel app it earns its place as a cheap, dependency-light input filter for plain-text-ish fields (names, titles, comments, search terms, contact form messages) and as a detector (isXssFound()) for logging attack attempts. It does not replace Blade escaping, CSP, or a real allow-list sanitizer for rich-text HTML.
1. Install
composer require voku/anti-xss
2. Look at the source first. There is no config file, no facade, and no service provider — the entire contract lives in src/voku/helper/AntiXSS.php. Before wiring anything up, skim the private arrays (_evil_html_tags, _evil_attributes, _never_allowed_str, _naughty_javascript_patterns). Those arrays are the behaviour, and they change between releases.
3. Bind it in the container so you can inject it and swap it in tests:
// app/Providers/AppServiceProvider.php
use voku\helper\AntiXSS;
public function register(): void
{
// bind(), not singleton() — see Gotchas about instance state
$this->app->bind(AntiXSS::class, function () {
return (new AntiXSS())
->setKeepPreAndCodeTagContent(true)
->setReplacement('[removed]');
});
}
4. First use case — sanitize a comment body before storing:
use voku\helper\AntiXSS;
final class StoreCommentAction
{
public function __construct(private readonly AntiXSS $antiXss) {}
public function execute(User $user, string $body): Comment
{
$clean = $this->antiXss->xss_clean($body);
if ($this->antiXss->isXssFound() === true) {
Log::channel('security')->warning('XSS attempt', [
'user_id' => $user->id,
'raw' => $body,
]);
}
return $user->comments()->create(['body' => $clean]);
}
}
That's the whole happy path: xss_clean() in, isXssFound() out, log if you care.
The most idiomatic Laravel hook is prepareForValidation(), because rules then validate the cleaned value and $request->validated() hands you safe data downstream.
final class StoreCommentRequest extends FormRequest
{
public function rules(): array
{
return [
'body' => ['required', 'string', 'min:2', 'max:5000'],
'website' => ['nullable', 'url'],
];
}
protected function prepareForValidation(): void
{
$antiXss = app(\voku\helper\AntiXSS::class);
$this->merge([
'body' => $antiXss->xss_clean((string) $this->input('body')),
]);
if ($antiXss->isXssFound() === true) {
Log::channel('security')->notice('XSS in comment form', [
'ip' => $this->ip(),
'raw' => $this->input('body'),
]);
}
}
}
Only clean the fields that need it. Do not blanket-clean $this->all() — it strips characters out of emails, URLs, and opaque tokens, and validation rules like email or active_url will then fail on values the attacker never touched.
A blanket middleware is tempting but produces surprising breakage on any endpoint that legitimately carries HTML or JSON blobs (editor autosave, webhook payloads, signed API bodies). Scope it with a route group instead of registering it globally:
final class SanitizeInput
{
public function handle(Request $request, Closure $next): Response
{
$files = array_keys($request->allFiles()); // never pass UploadedFile to xss_clean
$input = $request->except($files);
$request->merge(app(AntiXSS::class)->xss_clean($input));
return $next($request);
}
}
Route::middleware(['web', SanitizeInput::class])->group(function () {
Route::post('/contact', ContactController::class);
Route::post('/comments', StoreCommentController::class);
});
merge() writes through to the JSON input source too, so this works for application/json requests.
Teams reach for removeEvilHtmlTags(['iframe']) and then stop reading. The better shape is a config file plus a small wrapper that produces a fresh, correctly configured instance per rendering context:
// config/anti_xss.php
return [
'allow_iframes' => (bool) env('ANTI_XSS_ALLOW_IFRAMES', false),
'keep_code_block' => true,
'replacement' => '[removed]',
];
final class HtmlCleaner
{
public function __construct(private readonly string $profile = 'default') {}
public function clean(string $html): string
{
$antiXss = (new AntiXSS())->setReplacement(config('anti_xss.replacement'));
if (config('anti_xss.keep_code_block')) {
$antiXss->setKeepPreAndCodeTagContent(true);
}
if ($this->profile === 'embeds' && config('anti_xss.allow_iframes')) {
$antiXss->removeEvilHtmlTags(['iframe'])
->removeEvilAttributes(['allowfullscreen', 'frameborder']);
}
return $antiXss->xss_clean($html);
}
}
Usage inside Blade for user-authored markup:
{!! app(\App\Support\HtmlCleaner::class, ['profile' => 'embeds'])->clean($post->body) !!}
Note the {!! !!} — that is the whole risk. Only reach for it when the string has been cleaned in the same request, and never for anything a user typed into a field you don't intend to allow HTML in.
If a column is never legitimate HTML, make that structural:
final class CleanText implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?string
{
return $value;
}
public function set($model, string $key, $value, array $attributes): ?string
{
return $value === null ? null : app(AntiXSS::class)->xss_clean((string) $value);
}
}
protected function casts(): array
{
return ['display_name' => CleanText::class, 'bio' => CleanText::class];
}
Now it doesn't matter which controller, job, or console command writes the field.
// AppServiceProvider::boot()
Blade::directive('xssclean', function (string $expression) {
return "<?php echo e(app(\\voku\\helper\\AntiXSS::class)->xss_clean($expression)); ?>";
});
Keeping e() around the result preserves output encoding — you get filtering and escaping. Drop the e() only if you've deliberately decided to render HTML.
The instance is stateful — never make it a singleton. removeEvilHtmlTags(), setReplacement(), and setKeepPreAndCodeTagContent() mutate the object permanently, and isXssFound() reports only the last call. A singleton "configured for iframes" in one controller silently loosens filtering for every other request. Use bind(), or scoped() if you're on Octane (with singleton() under Octane, isXssFound() leaks across requests and your security log fills with false positives).
isXssFound() returns bool|null. It's null until xss_clean() has run. Always compare === true rather than relying on truthiness in conditionals you'll later refactor.
Don't double-clean. Cleaning on input and again on output re-encodes things — alert(1) becomes alert&#40;1&#41; in the browser. Pick a boundary (input for plain-text fields, output for user-authored HTML) and document it.
Don't double-escape. If you clean then render with {{ }}, parentheses and quotes become visible entities (alert('Hack')). Decide per field: filtered-then-escaped ({{ }}) or filtered-then-raw ({!! !!}). Never filtered → raw without a real allow-list sanitizer behind it.
It is a deny-list, not an allow-list. Unknown or future HTML tags and attributes pass through. If the content is user-authored rich text, pair this with tgalopin/html-sanitizer, mews/purifier, or HTML Purifier (the README says exactly this). AntiXSS is a good first pass and a great attacker-detector; it is not your only layer.
portable-utf8 sets default_charset via ini_set() during autoload. If that conflicts with your app, define the constant before the autoloader:
// public/index.php — must be before require vendor/autoload.php
define('PORTABLE_UTF8__DISABLE_AUTO_ENCODING', true);
require __DIR__.'/../vendor/autoload.php';
You cannot do this from a service provider; by then the autoloader has already run.
Keep <pre> and <code> content intact. Without setKeepPreAndCodeTagContent(true), code samples in comments and docs get their brackets eaten. It's the single most common "the sanitizer broke my content" report.
4-byte characters (emoji). If your MySQL columns are utf8 (3-byte) rather than utf8mb4, setStripe4byteChars(true) prevents insert errors — but it destroys emoji, so fix the column charset instead and use this only as a last resort for legacy schemas.
Skip non-string payloads. xss_clean() accepts arrays and recurses, but UploadedFile objects and nested DTOs don't belong in there. Filter to scalars; $request->except(array_keys($request->allFiles())) gets you most of the way.
Regex cost. The dictionary pass is not free — a dozen-plus regexes per string. Don't run it over whole request bodies on high-volume JSON APIs, and never over large blobs (file contents, base64 images, markdown exports). Sanitize the specific fields, at the specific boundary.
Tune the dictionaries deliberately. addEvilHtmlTags(), addNeverAllowedRegex(), addNaughtyJavascriptPatterns(), and friends exist for a reason — but each addition is a divergence from upstream. Record additions in config/anti_xss.php (not inline in a controller) so reviews can see them, and add a regression test for every loosening call, most of all the remove*() ones the docs warn about.
Test the upgrade, not just the code. The behaviour lives in private arrays that change between releases. Pin the version in composer.json and keep a tests/Security/XssTest.php with the payloads from the README (hex entities, Unicode \u0001java\u0003script, style="list-style-image: url(...)") plus one or two of your own legitimate fixtures. Run it before bumping. This is also where your isXssFound() assertions belong.
Layer up. AntiXSS + Blade's {{ }} + a CSP (spatie/laravel-csp or manual headers) + a real sanitizer for rich HTML. AntiXSS handles XSS only; it does nothing for SQL injection, CSRF, or mass assignment, and it should not be mistaken for a substitute for validation.
How can I help you explore Laravel packages today?