ezyang/htmlpurifier
HTML Purifier is a PHP library that filters untrusted HTML using robust whitelists and aggressive parsing to prevent XSS attacks while ensuring standards-compliant output. Ideal for safely rendering rich, CSS-enabled documents from untrusted sources.
Architecture fit. HTML Purifier is a framework-agnostic, self-contained PHP library — no runtime dependencies beyond PHP and its bundled CSSTidy. It does not present as a Laravel package: no service provider, no facade, no publishable config, no Artisan commands. Adopting it means we own the integration layer as a deliverable in its own right: a singleton binding, per-context configuration objects (comment, rich_text, profile_bio, admin_html), a HtmlSanitizer wrapper service, and a writable definition-cache path. Estimate 150–300 lines of glue plus tests. Treat the vendor library as the trivial part and the wrapper as the workstream.
Integration feasibility. High and well understood. The integration surface is narrow: one sanitization call per untrusted HTML field, config-per-trust-context, and a cache directory. Two structural traps to design out from day one: (1) sanitization is mutation, not validation — it must never live inside FormRequest::rules() or any code path that returns a boolean; (2) direct new HTMLPurifier() calls from controllers or Blade hard-code the library into the application and make any future swap expensive. Enforce the wrapper boundary via code review plus a static-analysis rule (PHPStan/Psalm forbidden-call rule).
Technical risk.
symfony/html-sanitizer, HTMLawed) plus a full re-run of the content-regression corpus. This is the single highest-impact unknown.strip_tags() or regex approaches. v4.19.0 patched catastrophic backtracking in Core.AggressivelyFixLt; v4.19.1 patched PHP 8.5 deprecations and a lexer null-parameter issue. Payload size caps, request timeouts, and async processing for large submissions are requirements, not hardening.chmod best-effort, which helps on restricted filesystems, but read-only container images and ephemeral pods still need an explicit writable path (storage/app/htmlpurifier) warmed at build time. Cold cache on first request is a measurable latency spike. Definition cache must be invalidated on config change (DefinitionRev), or stale definitions silently persist.Key questions.
HTML.SafeIframe / URI.SafeIframeRegexp configuration surface.Stack fit. Laravel 9–12 on PHP 8.1–8.5. Install via composer require ezyang/htmlpurifier; the library has no Laravel-version coupling, so it will not constrain framework upgrades. Configuration is driven through HTMLPurifier_Config arrays held in config/htmlpurifier.php, with per-context definitions resolved by a factory in the service provider.
Migration path. Three phases. Phase 1 — shadow mode: sanitize on read behind a feature flag, log diffs against current output, and build the golden-file corpus from real payloads. No user-visible change. Phase 2 — sanitize-on-write: apply at the trust boundary (form request → service → persistence) for new and edited content, keeping shadow-mode read sanitization as defense in depth for legacy rows. Phase 3 — backfill and retire: run a queued backfill over historical content, then remove the shadow flag. Store sanitized output; do not sanitize solely at render time, or output correctness becomes coupled to config drift.
Compatibility. Pin the minor version (^4.19) and review the changelog on every bump — semver has historically carried behavior changes (default image widths, universal CSS values, tel URI commas). Verify the definition cache serialization format across upgrades; a version bump should force a cache namespace change. Confirm the PHP version matrix against our deployment targets, including 8.5 if that is on the roadmap.
Sequencing. (1) Legal review of LGPL obligations — blocking. (2) Wrapper service + provider + config skeleton, with unit tests. (3) Trust-context allowlist definitions reviewed by security and product. (4) Shadow-mode instrumentation and corpus capture. (5) Write-path cutover behind a flag. (6) Backfill job. (7) Decommission shadow mode. Do not parallelize (1) with (5); the reversal cost is a full rebuild.
Maintenance. Low ongoing engineering cost once the wrapper exists — the library is stable, semantic-release driven, and actively patched for PHP deprecations. The real maintenance burden is policy, not code: allowlists drift as product adds features (embeds, math, tables), and each drift is a config change requiring corpus validation. Assign a named owner for the allowlist.
Support. No commercial vendor support and no SLA. Security response depends on upstream maintainer availability; a single-maintainer project means we must be prepared to patch or pin ourselves. Track upstream advisories and CVEs through our dependency scanning pipeline, and keep a documented fallback path to an alternative sanitizer so a support failure is a switch, not a rewrite.
Scaling. Purifier is CPU-bound and scales horizontally with PHP workers, not with database or cache tiers. Budget for it: sanitizing a few KB of HTML is single-digit milliseconds warm-cache, materially worse cold. Two scaling rules: never sanitize in a hot read loop for content already sanitized at write time, and push large or batched submissions (imports, bulk edits, API ingestion) to queues. Cap input size at the edge — a 1 MB HTML body should be rejected before it reaches the lexer.
Failure modes. (a) Unwritable cache directory — the classic container deployment failure; falls back to reparsing per request and can take the service down under load. Mitigate with a startup health check and build-time warm. (b) Stale definition cache after a config change — content silently filtered by an old policy. Mitigate with a DefinitionRev bump in the same commit as any allowlist edit. (c) Over-aggressive allowlist — legitimate user content disappears; detected only by users. Mitigate with the golden-file corpus and a canary on config rollout. (d) Backtracking on pathological input — mitigated by size caps, timeouts, and staying current on patch releases. (e) Silent config regression from a careless merge — mitigated by treating config as reviewed code.
Ramp-up. Moderate. The library's own documentation lives in docs/ and INSTALL rather than a modern site, and the configuration surface is large (hundreds of directives across HTML, CSS, URI, Core, AutoFormat). Expect roughly one engineer-week for the wrapper and provider, plus one to two weeks of corpus-building and allowlist tuning before write-path cutover. The WYSIWYG doc is the right starting point if editors like TinyMCE are in scope, since editor output and purifier output must be reconciled deliberately rather than discovered in production.
How can I help you explore Laravel packages today?