mezcalito/imgproxy-bundle
A Symfony bundle for integrating imgproxy into your application. Configure endpoints and URL signing, then generate optimized, resizable image URLs for templates and services without embedding image-processing logic in your app.
Read this first: the package is a Symfony bundle, not a Laravel package. mezcalito/imgproxy-bundle sits on top of symfony/framework-bundle, symfony/dependency-injection, and symfony/twig-bundle, and ships a *Bundle class that expects a Kernel to register it. composer require mezcalito/imgproxy-bundle in a Laravel app will install a pile of Symfony plumbing you can't boot. For a Laravel codebase you have two realistic paths:
mezcalito/imgproxy) plus a thin Laravel service provider. This is what most teams will actually do — it's ~50 lines of glue and you keep Laravel's config/Blade idioms.Either way, imgproxy itself is the product; the PHP package only produces signed URLs. Stand it up before writing any PHP:
# generate key/salt (hex, 64 chars each)
echo $(xxd -g 2 -l 64 -p /dev/random | tr -d '\n')
docker run -p 8080:8080 \
-e IMGPROXY_KEY=$KEY -e IMGPROXY_SALT=$SALT \
-e IMGPROXY_USE_ETAG=true \
ghcr.io/imgproxy/imgproxy:latest
Local dev shortcut: IMGPROXY_ALLOW_INSECURE=true lets you build unsigned URLs (/insecure/...) so you can iterate before wiring HMAC signing. Never ship that flag.
Where to look in the repo, in order: README.md for the URL format and config keys, src/DependencyInjection/Configuration.php for the full option tree (this is the real spec), src/Twig/ for how presets get rendered, and tests/ for expected URL strings you can copy as fixtures. The library API surface is small — an ImgProxy client plus a processing-options value object; confirm the exact method names against src/ rather than trusting blog posts. As of 0.5.3 the option tree has grown to include a watermark option (see Implementation Patterns), so read Configuration.php against your installed tag rather than against older write-ups.
First use case, the one that pays for the integration immediately: a model stores an image path, and a Blade component turns it into a signed, resized, format-negotiated URL.
// config/imgproxy.php
return [
'base_url' => env('IMGPROXY_URL', 'http://localhost:8080'),
'key' => env('IMGPROXY_KEY'),
'salt' => env('IMGPROXY_SALT'),
'insecure' => env('IMGPROXY_INSECURE', false),
'source_prefix' => env('IMGPROXY_SOURCE_PREFIX', 's3://my-bucket/'),
];
// app/Providers/ImgproxyServiceProvider.php
$this->app->singleton(ImgProxy::class, fn ($app) => new ImgProxy(
$app['config']->get('imgproxy.base_url'),
$app['config']->get('imgproxy.key'),
$app['config']->get('imgproxy.salt'),
));
{{-- resources/views/components/img.blade.php --}}
<img src="{{ $url }}" width="{{ $w }}" height="{{ $h }}" loading="{{ $eager ? 'eager' : 'lazy' }}" alt="{{ $alt }}">
If the URL comes back 403 on first try, the signature or the source encoding is wrong — not the container. Jump to Gotchas.
Presets, not raw options, in views. The single biggest day-to-day win is defining named presets in config and never letting option strings leak into templates. Every call site that hand-writes rs:fit:300:300/g:ce/q:80 is a call site that will drift.
'presets' => [
'thumb' => ['resize' => 'fit:150:150', 'gravity' => 'ce', 'quality' => 80],
'hero' => ['resize' => 'fill:1600:900', 'gravity' => 'sm', 'quality' => 85],
'avatar' => ['resize' => 'fill:96:96', 'gravity' => 'ce', 'dpr' => 2],
// 0.5.3+: watermark is now part of the option tree
'branded' => [
'resize' => 'fill:1200:630',
'quality' => 85,
'watermark' => ['opacity' => 0.6, 'position' => 'soea', 'scale' => 0.2],
],
],
Confirm the exact nested keys against src/DependencyInjection/Configuration.php for your pinned version — the release note calls out "watermark option" without specifying shape, so read the node, don't guess. In Symfony the preset resolution lives in the Twig extension; in Laravel put it in a Blade component or a small ImgproxyUrlGenerator service. The watermark option is just another option to your generator — it flows through the exact same preset → options → signed-path pipeline.
Watermark is a server-side processing option, not a CSS overlay. imgproxy composites the watermark into the source image before returning it. Consequences: (a) the watermark file itself must be reachable by imgproxy using the same source rules as your main image, (b) the composite is part of what the CDN caches, so a watermark change means a cache-key change (see the cache-busting gotcha below), and (c) baking it in is the right move for downloadable/Open Graph/social-card images, and usually the wrong move for on-site product imagery where you want the raw asset. Define a branded preset rather than watermarking globally.
Responsive images are the actual reason to run imgproxy. Generate srcset by varying width, not by generating a single URL per breakpoint by hand:
$widths = [400, 800, 1200, 1600];
$srcset = collect($widths)
->map(fn ($w) => $this->url($path, ['resize' => "fit:$w:0"]) . " {$w}w")
->implode(', ');
Pair with sizes="(max-width: 768px) 100vw, 50vw" and always emit width/height so the browser reserves space. Use dpr variants only when you can't rely on srcset + sizes (e.g. fixed-size UI chrome).
Resolve the source URL once, in one layer. Keep a single function that maps an app-level path (products/42/main.jpg) to whatever imgproxy should fetch (s3://bucket/products/42/main.jpg, or a signed S3 URL, or an absolute CDN URL). Everything downstream — Blade, API resources, sitemaps — goes through it. When you migrate buckets or move to presigned S3 access, you change one place. The same function should resolve the watermark asset path, since it's the same fetch rules.
Model accessor vs. stored URL. Two viable strategies:
Most teams land on generate-on-render plus a short in-process memo.
Format negotiation. Either let imgproxy pick with f:auto and put a CDN in front that honors Accept (Vary: Accept on the CDN is essential), or be explicit: <picture> with AVIF/WebP sources and a JPEG fallback. Explicit is more predictable and easier to debug; auto is less markup.
Warm the cache in a job. After an upload, dispatch a job that requests the 3–4 presets you actually serve. First-hit latency on a cold imgproxy is noticeable, and warming converts a slow first page view into a fast one. Watermarked presets are prime candidates — they're more expensive to compute and less likely to already be hot.
Testing. Assert the signature is deterministic (same input → same URL) and snapshot one full URL per preset. That single test catches 90% of option-string typos, which otherwise surface as production 403s. Also test that changing a preset's options changes the URL — if it doesn't, you're signing the wrong path. For watermarks, snapshot the rendered URL and have one image-diff test against the container (compare a known source + watermark against a golden output) so an upstream imgproxy version bump doesn't silently change the watermark composition.
Integration tip for API resources. Return the signed URL in the resource, plus srcset and width/height if the client is a browser. Don't make the frontend construct imgproxy URLs — it would need the key and salt.
The signature covers the entire path, including the source URL. Adding ?v=2 to the end of a signed imgproxy URL does not cache-bust — it breaks the signature and yields 403. If you need cache busting, use imgproxy's own cachebuster or expires option before signing. Same for trailing slashes: /rs:fit:300:300/plain/https://x/y.jpg and .../y.jpg/ hash differently. This matters more after 0.5.3: tweaking a watermark's opacity or position changes the processing path but not the source path, so an existing CDN entry keyed only on source+preset-name will serve stale watermarked output unless the cache key includes the full processing options (or a cachebuster you bump on each watermark change).
Watermark assets go through the same source pipeline, so they hit the same gotchas. If the watermark lives in a private S3 bucket, imgproxy needs the same credentials/allowlist as your originals; a hardcoded absolute URL to the watermark will 404 inside the container for the exact same localhost-is-the-container reason. Verify with docker exec imgproxy curl -I <watermark-url> before debugging the PHP side.
plain/ vs base64 source encoding. plain/ requires the source URL to be URL-safe; anything with a query string (S3 presigned URLs, CDN tokens) will misparse. Use base64 URL-safe encoding for those, or better, switch to imgproxy's S3 bucket mode (IMGPROXY_USE_S3=true + s3://bucket/key) so credentials never appear in URLs.
localhost inside the container is the container. The most common "imgproxy returns 404/502" cause: the source URL points at your dev machine. Use host.docker.internal or a shared network alias, and verify with docker exec imgproxy curl -I <source> before blaming the PHP side.
403 vs 404 is your debugging tree. 403 = signature/key/salt mismatch — check hex-vs-base64 confusion on the key, and confirm you're signing the path without the scheme/host and without the leading signature segment. 404 = imgproxy reached the URL space but couldn't fetch the source. Run with IMGPROXY_DEBUG=1 and read its logs; it will tell you the exact string it expected to sign, which you can diff against yours byte for byte. If watermark URLs specifically fail while plain images work, suspect a second source URL in the path (the watermark's) that isn't allowlisted.
SSRF is real here. If any user-controlled value reaches the source URL, you've built an internal network scanner that returns images. Enforce a host allowlist in your source-resolution layer, or restrict to S3 mode. Do not expose imgproxy directly on a public interface without signing enabled. Now that imgproxy fetches two URLs when watermarking, a permissive allowlist is a two-for-one SSRF primitive — harden it before enabling watermarking on user-uploaded content.
Key/salt rotation invalidates everything. Any stored/materialized URL becomes a 403 the moment you rotate. Plan for regeneration (they're derived from the source path, so it's cheap) and prefer generate-on-render if you rotate at all.
Long URLs, many options. A dozen options plus a long source path easily exceeds 500 chars, which bloats HTML on image-heavy pages and can hit proxy header limits. Presets keep option strings short; that's the practical argument for them beyond consistency. A watermark config with position/opacity/scale plus its source URL makes this noticeably worse — consider whether the watermark lives in the preset (short) or inline (long).
srcset without width/height is worse than no srcset. You'll get CLS and the performance gain evaporates. Always emit intrinsic dimensions. Watermarking doesn't change decoded dimensions, so reuse the same intrinsic sizes you compute for the un-watermarked image.
Version pinning and changelog diligence. 0.5.3 is a single-feature minor release (watermark, PR #17). No breaking changes are called out, but the watermark's config shape is only in the code — pin 0.5.3 exactly (not ^0.5) and diff Configuration.php between tags when you upgrade. The repo's version-to-date gap (a release dated 2026-02-28) still means you should confirm the tag exists and matches the source tree before trusting it in CI.
Community and maintenance risk is the headline. 11 stars, zero dependents, a single maintainer. This is a small wrapper over a well-defined URL-signing scheme, now with one more option. Treat it as a starting point to vendor, not a dependency to trust: read src/, copy the ~200 lines you need into app/Support/Imgproxy/, and you'll be immune to abandonment while keeping the exact behaviour — including the new watermark mapping, which is a good reason to snapshot Configuration.php at 0.5.3 as your source of truth. The bundle's real contribution — the config tree and preset-to-option mapping — is worth copying even if you never install it.
Extension points, if you do port it. The Twig extension is where presets get resolved; the natural Laravel analogue is a Blade component backed by a UrlGenerator decorator. Put signature logic behind an interface so you can swap in an unsigned local implementation in tests without touching templates, and put preset resolution behind one so the watermark shape can be validated in one place — nested option trees are exactly the kind of thing that drifts when resolved in ten places.
How can I help you explore Laravel packages today?