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.
Architecture fit. This is a single-responsibility, framework-agnostic PHP library that wraps ext-dom/ext-libxml to strip XSS vectors from user-supplied SVG. For a Laravel estate it is an edge dependency, not an architectural one: it constrains no layer, introduces no contracts into the domain model, and can be confined to an infrastructure concern behind an upload pipeline. That is exactly the profile a TPM wants from a security control — narrow blast radius, cheap to swap, no leakage into product code. It competes with nothing on the roadmap and replaces what would otherwise be a bespoke regex/DOM scrubber that no team should own.
Integration feasibility. High, with caveats. Composer install, a two-method happy path (sanitize(), getXmlIssues()), and well-documented options (removeRemoteReferences(), minify(), custom allowlists via TagInterface/AttributeInterface). Crucially, the library is already the sanitizer inside TYPO3 core (v9+) and underpins the WordPress Safe SVG plugin and the Drupal svg_sanitizer module. That is a strong, externally subsidised validation signal: millions of CMS uploads have exercised this code path, which materially de-risks our own penetration testing and threat-modelling effort. Treat "0 Packagist dependents" as a data artefact — it counts reverse dependencies, not adoption.
Technical risk. Four items deserve a risk register entry:
xlink:href case handling, PHP 8.5 in the test matrix, libxml deprecation fix) — evidence of active stewardship contradicted by low star count and sparse commit history. Resolve this contradiction before relying on the "mature" label.<use> cross-references, embedded fonts, gradient filters). Every relaxation reopens the threat model. Someone must own that policy for the life of the product — this is an ongoing cost most teams underestimate.sanitize() returns false on parse failure; "clean" output is not the same as "renders". Both produce silent, user-invisible outcomes unless wired to telemetry. The 0.22.0 namespace-case bug is the template for this class of failure: it degraded quietly.Key questions to close before the go/no-go gate.
Stack fit. Confine the library behind a first-party interface — a SvgSanitizer contract bound to a singleton in AppServiceProvider. Nothing beyond that boundary may call enshrined\svgSanitize\Sanitizer directly. This costs half a day and buys (a) instance reuse, which the library benefits from, (b) a single seam for error telemetry, and (c) an escape hatch if licensing forces a substitution. Wire it as a custom Rule object for form requests, plus a queued job for non-request paths (API imports, bulk migration, admin back-fill).
Migration path. Three phases: contain (instrument existing upload endpoints to detect and quarantine SVGs without blocking, establishing a baseline of what users actually upload), enforce (turn on rejection for new uploads once false-positive rate is known), remediate (re-scan the historical asset store off-peak, since pre-0.22 namespace-case SVGs may have been stored unsanitised). The quarantine-then-enforce ordering matters: shipping a hard block first turns a security fix into a support incident.
Compatibility. Requires PHP 7.2+ with ext-dom/ext-libxml; pin to ^1.0 to inherit the namespace-normalisation and complexity fixes. No known conflicts with the common Laravel upload/media stack. Verify the platform PHP version and that libxml_use_internal_errors() is not globally manipulated elsewhere in the application — global libxml state is a shared resource and a classic cross-package surprise.
Sequencing. (1) Legal/licence ruling and dependency-approval ticket. (2) Spike: 1 day to bind the singleton, add the validator, and prove the happy path plus a false path on a malformed file. (3) Threat-model session with security to agree the allowlist and remote-reference policy. (4) Shadow-mode telemetry for one to two weeks. (5) Enforce + alerting. (6) Historical back-fill. (7) Document ownership and hand to run. Gates at (1) and (4); everything between is engineering time measured in days, not sprints.
Maintenance. The dependency itself is low-churn, but it is a security control, so it is not zero-touch: it needs someone to watch upstream releases and CVEs, and to re-validate the allowlist whenever the product adds SVG-heavy features (icon libraries, theming, brand asset managers). Budget a small, recurring line item rather than a one-off integration cost. Licence stewardship is a separate, legal-owned obligation with its own review cadence.
Support. No vendor, no SLA — community issues only. That is acceptable for a library this narrow but means first-line support must be able to distinguish "we rejected your file" (policy) from "the file was broken" (parsing) — both currently surface as one failure to the customer. Invest in a user-facing error message early; getXmlIssues() gives you the raw material but not the wording.
Scaling. The operation is CPU-bound and single-threaded per call. The 1.0.0 quadratic-time fix materially changes the ceiling for element-dense files, so any capacity model built on pre-1.0 numbers is invalid — re-benchmark. Above roughly tens of SVGs per second, move to queues with a dedicated worker pool, cap file size before sanitising (never sanitise an unbounded stream), reuse the singleton, and cache results keyed by content hash for repeat assets. removeRemoteReferences(true) costs a measurable overhead and should be justified by an SSRF/egress rationale rather than switched on reflexively.
Failure modes. Enumerate them and instrument each: parse failure returning false (must be logged with libxml_get_errors() captured before the call, plus user and path context); the sanitizer throwing or erroring mid-request (wrap in try/catch, fail closed); silent over-stripping that breaks legitimate rendering (detect via render-smoke tests in staging, not in production); and quiet success on a hostile file (detect only via periodic red-team fixtures in CI). The dangerous pattern is a try { } catch { } wrapper added by a well-meaning developer under deadline — make "fail closed, always log" an explicit review checkpoint and a lint-level convention.
Ramp-up. Genuinely low — a competent Laravel developer ships the basic integration in under a day, aided by the README, the live demo (excellent for showing stakeholders exactly what is stripped), and the CLI scanner for local triage. The real ramp is organisational: security must agree the policy, legal must clear the licence, and support must be briefed on the new rejection path. Sequence those conversations in parallel with the spike, not after it, or the technical work will sit finished and undeployable while approvals queue.
Success measures. Rejection rate within an agreed band post-enforcement; zero unreviewed false returns in production logs; p95 sanitisation latency inside the upload budget; allowlist changes reviewed and versioned; licence ruling recorded in the decision log; and a documented, tested exit path to an alternative implementation.
How can I help you explore Laravel packages today?