atelier/svg
atelier/svg is a Laravel package for managing SVG assets. It provides a clean API for loading, sanitizing, caching, and rendering SVG files, so you can safely embed icons and images in Blade views with minimal boilerplate.
Install and confirm requirements. The package is a pure-PHP DOM/XML library, so it hinges on ext-dom and ext-libxml rather than any image extension:
composer require atelier/svg:^1.1
php -m | grep -E 'dom|libxml' # both must be present
If you're on a slim Docker base image, add libxml2-dev + docker-php-ext-install dom before anything else — a missing ext-dom is the #1 cause of "class not found"-adjacent failures with SVG libraries.
Pin to ^1.1 and treat v1.0 output as suspect. v1.1.0 is a correctness release: it fixes path tokenization (glued numbers, glued arc flags), relative-segment resolution before simplification, exact elliptical-arc transforms, hex colours being misread as ID references, and elements holding text being dropped. If you optimised or sanitised assets with v1.0, regenerate them from source rather than trusting what's on disk.
Where to look first, in order:
README.md — rewritten in this release, so it's now a reliable quickstart (parse → manipulate → serialize) rather than a stale one. Read it before the tests this time.tests/ — still your real API documentation for exact signatures and option arrays in the version you installed. Grep for ->sanitize(, ->optimize(, ->morphTo(, and the fixture files, which double as expected-output specs.src/ root namespace files — the public entry point is a Svg/Document value object with static constructors (fromString, fromFile, create) and an output side (toString, save).Commit composer.lock and re-read the tests on every bump. The release history is dense and fast-moving, and there is no formal changelog of behaviour changes — the diff and the fixtures are the changelog.
Your first use case should still be sanitization, not generation. The highest-value, lowest-risk adoption in a Laravel app is closing the SVG-upload XSS hole:
use Atelier\Svg\Svg;
$clean = Svg::fromString($request->file('logo')->get())
->sanitize()
->toString();
Storage::disk('public')->put("logos/{$id}.svg", $clean);
The failure mode of getting this wrong (stored XSS via <script> or onload=) is severe, and it is one of the few genuinely hard-to-roll-yourself problems in the feature list. Note that the sanitizer's behaviour changed in v1.1 (it no longer drops text-bearing elements), so re-run it over anything you stored with v1.0.
Wrap sanitization in a validation rule so it runs on every upload path (API, Livewire, Nova, queue imports):
namespace App\Rules;
use Atelier\Svg\Svg;
use Illuminate\Contracts\Validation\ValidationRule;
use Closure;
class SafeSvg implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$svg = Svg::fromString($value->get())->sanitize();
} catch (\Throwable $e) {
$fail('The :attribute is not a valid SVG.');
return;
}
if (trim($svg->toString()) === '') {
$fail('The :attribute contained no safe content.');
}
}
}
Then store the sanitized bytes and discard the original. Never re-sanitize on read: it's wasted CPU per request and it invites drift when you upgrade the library.
When the SVG comes from a WYSIWYG field rather than an upload, cache the sanitized result on the model:
// app/Models/Block.php
protected static function booted(): void
{
static::saving(function (Block $block) {
$block->svg_safe = $block->svg_raw
? Svg::fromString($block->svg_raw)->sanitize()->toString()
: null;
});
}
{{-- resources/views/components/safe-svg.blade.php --}}
@props(['svg'])
<div {{ $attributes }}>{!! $svg !!}</div>
{!! !!} is only acceptable here because $svg is a column that can only be written through the sanitizer. Make that invariant obvious with a comment or a cast, or a future contributor will pipe $request->input('svg') into it.
Optimization is CPU-bound and idempotent — ideal queue work:
class OptimizeSvgAssets implements ShouldQueue
{
public function handle(): void
{
foreach (File::glob(resource_path('svg/*.svg')) as $path) {
$svg = Svg::fromFile($path)->optimize(['precision' => 2]);
File::put(resource_path('svg/dist/'.basename($path)), $svg->toString());
}
}
}
Better still, run it as a one-off build step and commit dist/, so deploys don't depend on queue workers having run — and so a library bump produces a reviewable diff instead of silently different bytes in production.
Because v1.1 changed how paths are tokenized, rounded, and how simplification tolerance is bounded by the drawing span, v1.1 will produce different output than v1.0 for the same input. Expect a large one-time diff; review it rather than assuming it's noise.
Morphing only works when the two paths are structurally compatible. v1.1 resolves relative segments before simplifying and rounds in absolute space, which removes most of the "identical icons that morph into garbage" cases — but it does not make structurally different paths compatible. Normalize first, then morph:
$play = Svg::fromFile(resource_path('svg/play.svg'))->normalize();
$pause = Svg::fromFile(resource_path('svg/pause.svg'))->normalize();
$frames = collect(range(0, 10))->map(
fn ($i) => $play->morphTo($pause, $i / 10)->toString()
);
Practically: keep the frame count low (8–12), generate server-side, cache the whole set under one key, and ship it as CSS steps() keyframes rather than JS. Re-generating per request is not worth it.
For server-rendered charts/diagrams, compose rather than string-concat:
$svg = Svg::create(400, 300)
->addElement('rect', ['x' => 10, 'y' => 10, 'width' => 80, 'height' => 40])
->style(['fill' => '#0ea5e9', 'rx' => 4])
->transform(['translate' => [20, 20]]);
return response($svg->toString(), 200, ['Content-Type' => 'image/svg+xml']);
Note v1.1 fixed two things this pattern used to trip over: hex colours in fill being mistaken for ID references, and group transforms being applied in a way that dragged inherited attributes along with the group.
Assert on the serialized output, not on object internals:
public function test_icon_optimization_is_stable(): void
{
$out = Svg::fromFile(base_path('tests/fixtures/logo.svg'))
->optimize(['precision' => 2])
->toString();
$this->assertSame(
file_get_contents(base_path('tests/fixtures/logo.optimized.svg')),
$out
);
}
This is your only real guard against a minor-version bump silently changing output — and v1.1 proves the point twice: Document::toString now serializes through the dumper (so formatting/quoting/self-closing-tag output can shift), and path rounding now happens in absolute space. Every one of these fixtures will need regenerating once, deliberately, with the new version.
Treat a library bump like a schema change. For anything you already persisted as optimized/sanitized bytes, keep the raw source (or re-fetch it) so you can replay it:
// artisan command: svg:reprocess
Asset::query()->whereNotNull('svg_raw')->chunkById(100, function ($assets) {
foreach ($assets as $asset) {
$asset->update([
'svg_safe' => Svg::fromString($asset->svg_raw)->sanitize()->toString(),
]);
}
});
If you threw the originals away, you cannot recover from a v1.0 path-corruption bug without asking the user to re-upload.
Serialization output changed in v1.1 — expect diffs everywhere. Document::toString now goes through the dumper. That means whitespace, attribute quoting, self-closing tags, and entity escaping may differ from v1.0 byte-for-byte. Consequences: snapshot tests fail, git-tracked dist/ files churn, and any cache key that hashed the output changes. This is not a bug — plan the regeneration instead of fighting it.
v1.0 had path-corrupting bugs. Glued numbers in minified paths, glued arc flags (the A command's packed 0 0 1 flags), relative-segment handling before simplification, and inexact elliptical arc transforms all produced wrong geometry in v1.0. If any production asset was optimised or morphed with v1.0, its path data may be silently wrong — it renders, just not correctly. Regenerate from source; don't diff-tune.
The sanitizer no longer strips text-bearing elements, and no longer treats #0af as an ID reference. Both were v1.0 bugs with real fallout: <text> inside an icon could vanish, and fill="#ff0000" could be resolved as a fragment reference to an element named ff0000, mangling colours during optimisation. If you worked around either with a pre/post-processing hack, remove it.
Simplifier tolerance is now bounded by the drawing span. The same tolerance option behaves differently on a 16×16 icon than on a 2000×2000 diagram. Don't copy an option set from one asset class to another and assume matched visual quality; verify per drawing size, and treat tolerance as relative rather than absolute.
SVG is XML — never regex it. Namespace declarations (xmlns, xmlns:xlink) live on the root and are trivially destroyed by string replacement. If a sanitized file renders blank in a browser, the first thing to check is a missing xmlns on <svg>.
Sanitize last. If you optimize or morph after sanitizing, a pass may reintroduce attributes from the source tree. Order: parse → transform/optimize → sanitize → serialize.
foreignObject is the escape hatch. It lets arbitrary HTML (including <script> and <iframe>) live inside an SVG. Unless you specifically need it, strip it entirely — and verify with a test, because a whitelist that lets "unknown elements through" will leak it. Same goes for <text> and <tspan>, which now survive processing by design.
External references are an SSRF and privacy vector. <use href="https://evil/x.svg#id">, <image href="...">, and <style>@import url(...)</style> all cause outbound fetches. Strip absolute URLs from href/xlink:href and drop @import/url() from <style>.
Disable entity loading explicitly. When constructing a DOMDocument yourself, pass LIBXML_NONET and never LIBXML_NOENT. PHP 8 defaults are safer than PHP 7, but if any code path calls loadXML on user input, be explicit.
data: URIs in href can be huge. A 5 MB base64 payload in an <image> will happily pass a size-validated upload after inflation. Cap decoded length separately.
Serving user SVG from your main origin is the real risk. Even a perfectly sanitized file can be re-opened as a top-level document where CSS/JS behaviour differs from <img> embedding. Serve uploads as Content-Disposition: attachment or from a separate host.
The sanitizer will strip Alpine/Livewire directives. x-on:click, wire:click, @click are not SVG attributes and will be removed. Consequence: don't run hand-authored Blade icons through the sanitizer. Keep two lanes — static, developer-authored icons rendered raw from resources/svg; and user content, which goes through the sanitizer and never carries interactivity.
Morphing still requires matching structure. v1.1 makes relative/absolute and arc handling consistent, which fixes a lot — but mismatched viewBox, different command types (C vs Q), or differing segment counts still produce garbage or exceptions. Normalize the viewBox to a common box first. If two icons have different segment counts after normalization, they are not morphable — pick different icons rather than fighting it.
Precision interacts with morphing. Optimizing to 1–2 decimals is great for static assets and terrible for animation frames, where it produces visible jitter (rounding now happens in absolute space, which reduces but doesn't eliminate it). Use different option sets for the two jobs.
Cache keys must include the library version and options. svg:icon:play:{hash} where the hash covers source bytes + package version + sanitizer version + optimizer options. This release is exactly the scenario the version component exists for: upgrading without busting keys leaves v1.0-shaped output on disk, possibly with corrupt paths or dropped text.
Memory and zip bombs. .svgz is gzip; a small file can expand enormously. Check gzdecode output size before parsing, and cap raw input at something like 1–2 MB.
Extension points to look for: a configurable attribute/element whitelist (publish the config with php artisan vendor:publish --tag=svg-config — the tag name may differ, check the service provider), and a visitor/pass interface for custom transforms. The whitelist is the correct place to add data-* attributes your design system needs; subclassing the sanitizer is a last resort.
Debugging tip: when output looks wrong, dump $svg->toString() in a test rather than in the browser. Diffing the serialized string against a known-good fixture localizes the failing pass (parse, style, transform, optimize, sanitize, serialize) far faster than inspecting rendered markup — and now that serialization itself changed in v1.1, the string diff is also how you audit the upgrade.
Pin and re-read tests on upgrade. With a small maintainer base and a dense fix-driven release history, the tests and fixtures are your only reliable changelog.
How can I help you explore Laravel packages today?