enshrined/svg-sanitize
PHP SVG sanitizer based on DOMPurify that cleans untrusted SVG/XML, strips dangerous tags and remote references, supports custom whitelists, minification, and reports sanitization issues.
Install
composer require enshrined/svg-sanitize
Verify ext-dom and ext-libxml are available (php -m). The package is pure PHP with no framework coupling, so it drops into any Laravel 9/10/11/12 app without configuration.
First use case — sanitize an uploaded SVG before it hits disk
use enshrined\svgSanitize\Sanitizer;
$sanitizer = new Sanitizer();
$clean = $sanitizer->sanitize($request->file('logo')->get());
if ($clean === false) {
logger()->warning('SVG rejected', ['issues' => $sanitizer->getXmlIssues()]);
abort(422, 'Invalid SVG');
}
Storage::disk('public')->put('logos/logo.svg', $clean);
Where to look first
sanitize(), getXmlIssues(), removeRemoteReferences(), minify(), setAllowedTags(), setAllowedAttrs().data/ namespace (TagInterface, AttributeInterface, plus the built-in Tag/ Attribute allowlists) — this is your extension point and the definitive answer to “what does it actually strip?”vendor/bin/phpunit) — steal them as your malicious-SVG test corpus.http://svg.enshrined.co.uk/) for eyeballing what gets removed.Pin a floor version. Anything < 1.0.0 has known deficiencies: quadratic sanitize time on element-dense SVGs, href cleaning skipped when href and xlink:href coexist, and case-sensitive xlink:Href namespace lookups. Require ^1.0 and get all three fixes plus PHP 8.5 coverage.
Local triage loop: the bundled CLI scanner is the fastest way to iterate on a failing file without wiring up a route:
php vendor/enshrined/svg-sanitize/svg-scanner.php tests/fixtures/nasty.svg
Bind as a singleton, but don’t let issues leak between requests
// AppServiceProvider::register()
$this->app->singleton(Sanitizer::class, fn () => tap(new Sanitizer(), function ($s) {
$s->removeRemoteReferences(true);
$s->minify(app()->isProduction());
}));
This is fine for stateless CLI/queue workers. Under Octane/Swoole the container persists across requests — verify whether getXmlIssues() and internal libxml state reset on each sanitize() call in your installed version, and if in doubt resolve a fresh instance per file (app()->forgetInstance(Sanitizer::class) or new Sanitizer()). Cheap insurance against cross-tenant data appearing in a log line.
Wrap it in a dedicated service, not a controller one-liner
final class SvgSanitizer
{
public function __construct(private Sanitizer $sanitizer) {}
/** @throws SvgRejected */
public function clean(string $svg, array $context = []): string
{
$clean = $this->sanitizer->sanitize($svg);
if ($clean === false) {
report(new SvgRejected($this->sanitizer->getXmlIssues(), $context));
throw new SvgRejected(...);
}
return $clean;
}
}
Everything downstream (controllers, jobs, artisan commands, Livewire uploads) talks to SvgSanitizer::clean(); the package stays an implementation detail you can swap.
Custom validation rule for the upload path
final class SafeSvg implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile || $value->getMimeType() !== 'image/svg+xml') {
$fail('The :attribute must be an SVG.');
return;
}
$sanitizer = new Sanitizer();
if ($sanitizer->sanitize($value->get()) === false) {
$fail('The :attribute is not valid SVG XML: '
. collect($sanitizer->getXmlIssues())->pluck('message')->implode('; '));
}
}
}
MIME sniffing alone is spoofable — the parse attempt is the real gate.
Sanitize in the pipeline, then persist the clean copy only
$clean = $this->sanitizer->clean($file->get(), ['user' => $user->id]);
Storage::disk('public')->put($path, $clean);
Never write the original to a web-reachable disk, even temporarily, and never put() from $request->file()->getRealPath() before sanitizing.
Per-tenant / per-feature allowlists
Implement TagInterface / AttributeInterface and pass class names to setAllowedTags() / setAllowedAttrs(). Typical motive: keep the defaults but reject remote xlink:href values while still allowing fragment references (#icon-arrow) used by icon sprites. Because the interface returns a flat array of names, value-level filtering (e.g. “href only if it starts with #”) is not expressible there — combine removeRemoteReferences(true) with a post-pass DOM walk if you need that precision.
Remote references for multi-tenant / SSRF-sensitive apps
$sanitizer->removeRemoteReferences(true) strips attributes pointing at external files. Budget the overhead (~15% in practice) and turn it on by default anywhere user SVGs are rendered in a browser or an email client.
Minify in production, not in development
minify(true) shrinks storage and bandwidth but makes diffing sanitized output against input painful during debugging. Gate it on app()->isProduction().
Queue anything bulk
Sanitization is CPU-bound. For batch imports or migrations, push per-file jobs to a queue rather than looping in a request. If you’re processing the same asset repeatedly (thumbnails, previews), cache by content hash of the pre-sanitized payload — sanitization is deterministic.
Testing you should actually write
Fixture-driven PHPUnit tests using the package’s own fixtures plus your own: <script>, onload=, javascript: in href, <foreignObject>, <use href="https://evil">, DOCTYPE/entity payloads, and a well-formed but invisible SVG. Assert both the rejection path (false + issues logged) and that a clean SVG round-trips byte-for-byte (minus minification).
Failures are false, not exceptions. No exception hierarchy, no events, no logging hooks. Every call site must branch on false and you must wire up report()/Log::warning() yourself — otherwise rejections are invisible.
XML parse errors precede sanitization. libxml_use_internal_errors(true) and capture libxml_get_errors() before calling sanitize() if you want human-readable diagnostics (“unclosed tag at line 4”); getXmlIssues() reports sanitization decisions, not the low-level parser complaint. Also call libxml_clear_errors() afterwards so errors don’t bleed into unrelated code in the same process.
“Clean” ≠ “renders.” The sanitizer removes XSS vectors; it does not validate that <rect width="0"> produces anything visible, nor that the viewBox is sane. Visual/rendering checks belong in staging.
Silent over-stripping looks like success. If your allowlist is wrong, you get back a valid <svg> root with nothing in it — a truthy string. Add a sanity assertion: after sanitizing a known-good sample, assert child nodes still exist.
Version-sensitive attribute handling. Pre-0.22, xlink:Href (any uppercase) silently failed namespace lookup and skipped sanitization; 1.0.0 additionally fixed href cleaning being skipped when both href and xlink:href are present. If you’re auditing legacy rows in your database, re-run them through the current sanitizer — the old output may be unsafe.
GPL-2.0 is the headline risk, not the code. This is a copyleft license. For a proprietary SaaS it’s usually tolerable (you’re not distributing), but for on-prem/desktop/shipped software it likely is not. Get legal sign-off before it’s woven through five services; the alternative (reimplementing DOMPurify’s SVG rules) is a real cost, and the package is the de-facto PHP standard — used in WordPress’s Safe SVG, Drupal, and TYPO3 core.
Don’t rely on the file extension or MIME alone. Sanitize, then store with a forced .svg name, serve via a controller with Content-Type: image/svg+xml and Content-Disposition, ideally from a separate origin. Sanitization removes scripts; it doesn’t prevent an SVG from leaking referrer/existence via CSS and external URLs — which is exactly why removeRemoteReferences(true) matters.
Reuse or recreate? Both have a cost. A singleton avoids repeated construction; a fresh instance guarantees no state bleed. Decide deliberately and document it — the ambiguity is where bugs land.
The allowlist is the source of truth, not the README. The README doesn’t enumerate what’s permitted. Read data/Attribute.php and data/Tag.php in vendor/ before promising a stakeholder “we block X.” And when you trim the list, remember presentation attributes (viewBox, fill, d) are load-bearing — removing them breaks every icon on the site.
Maintenance posture. Release cadence is slow and the last release metadata in your tooling looks suspicious (a future-dated entry). That’s fine while 1.x keeps working, but treat it as an owned dependency: keep your SvgSanitizer wrapper thin, keep the malicious-fixture test suite in your repo, and track upstream issues yourself rather than expecting a CVE response SLA.
Debugging escape hatch. When a file is rejected and the issues array is unhelpful, load it into the CLI scanner or the hosted demo — the diff between input and output is usually faster to read than any log line.
How can I help you explore Laravel packages today?