ace-of-aces/laravel-image-transform-url
Generate image transformation URLs in Laravel with a fluent API, including resizing, cropping, format conversion, quality, and signed URLs for secure on-the-fly image delivery through your transformation service.
Install and publish
composer require ace-of-aces/laravel-image-transform-url
php artisan vendor:publish --tag="image-transform-url-config"
Check config/image-transform-url.php first — that is where the driver, signing secret, cache path/disk, allowed formats, and default quality live. Almost every "why does my URL look like that" question is answered by this file.
Pick your driver before anything else. As of v2.1.0 there are three:
| Driver | Requires | Use when |
|---|---|---|
gd |
PHP GD ext | Default, zero extra install, fine for small/medium images |
imagick |
PHP Imagick ext | You need finer control over quality/filters already tuned in Imagemagick |
vips |
libvips binary + php-vips ext |
New in v2.1.0 — large source images, high-throughput endpoints, AVIF/HEIC, low memory ceilings |
// config/image-transform-url.php
'driver' => env('IMAGE_TRANSFORM_DRIVER', 'gd'),
First use case — a thumbnail URL in a Blade view instead of a raw storage URL:
<img src="{{ image_transform_url($post->cover_path, ['w' => 640, 'fm' => 'webp']) }}" alt="{{ $post->title }}">
Ship that behind one model accessor before wiring up srcset everywhere — it gives you a single place to change sizes later.
Centralise URL generation in the model, not the view. Transform parameters are presentation decisions that change often; scattering ['w' => 300, 'h' => 300, 'fit' => 'crop'] across templates means a redesign becomes a grep exercise.
class Post extends Model
{
public function coverUrl(int $width = 640): string
{
return image_transform_url($this->cover_path, [
'w' => $width,
'fm' => 'webp',
'q' => 80,
]);
}
}
Responsive images via srcset. Generate a small ladder and let the browser choose — this is where the package earns its keep:
<img
src="{{ $post->coverUrl(640) }}"
srcset="@foreach([320, 640, 960, 1280] as $w){{ $post->coverUrl($w) }} {{ $w }}w @if(!$loop->last), @endif @endforeach"
sizes="(max-width: 768px) 100vw, 640px"
>
If the package exposes a Blade component or a fluent builder, prefer it over hand-rolled strings — it keeps the signature/expiry logic in one place.
Signed URLs. If you enable signing, generation and verification must share the same secret. Set it via env, not the published config, so it does not drift between local, staging, and production:
IMAGE_TRANSFORM_SECRET=${APP_KEY}
# or a dedicated secret, as long as every app server and any CDN/edge that re-signs has it
Rotate deliberately: changing the secret instantly invalidates every URL cached by browsers and your CDN.
Cache strategy. On-demand transformation is expensive the first time and cheap afterwards. Two patterns work well:
Cache-Control: public, max-age=31536000, immutable, and purge on asset replacement. Immutability is only safe if a given URL always maps to the same bytes, so version the path or filenames.Driver parity is a test, not a promise. If you switch to vips in production but CI runs gd, you will ship differences in colour handling, chroma subsampling, and default quality. Run at least one visual/dimension assertion per driver, or pin CI to the driver you deploy.
// tests/Feature/ImageTransformTest.php
it('renders the configured driver at the requested width', function () {
$url = image_transform_url('fixtures/wide.jpg', ['w' => 400]);
$response = $this->get($url);
$response->assertOk();
expect(getimagesizefromstring($response->streamedContent())[0])->toBe(400);
});
Migrating an existing install to vips. Treat it as a rollout, not a config flip: keep the old driver serving existing cached keys, point new uploads at vips, compare output on a sample of real assets, then retire the old driver. Because v2.1.0 is a minor release, both drivers coexist — you can run them side by side via separate config entries or environments without forking.
Also do the Composer bump. v2.1.0 fixes a dependency conflict (PR-linked commit d56196e). If you previously had to constrain a transitive dependency with a conflict block or an inline "replace" to get the package installed, remove that workaround and run composer update ace-of-aces/laravel-image-transform-url --with-dependencies — leaving the pin in place is the most likely way to hit the old resolution problem again on a fresh clone.
Vips is not a drop-in for GD at the edges. libvips resamples differently (Lanczos by default in most paths) and its quality scale does not map 1:1 onto GD's. After switching, re-check: default q values, EXIF orientation handling, palette/PNG transparency, and animated GIFs (libvips needs explicit handling for animation; GD usually flattens). Output will look fine at 80% and subtly wrong at 60%.
Install order for Vips. PHP extension first, then the native library, then verify — php -m | grep vips says nothing about whether libvips.so loads. Add a boot-time or health-check assertion so the container fails loudly at deploy rather than returning 500s on the first image request:
if (config('image-transform-url.driver') === 'vips' && ! extension_loaded('vips')) {
throw new RuntimeException('vips driver configured but php-vips extension is missing.');
}
Also pin libvips in your Dockerfile — distro versions vary enough to change output between staging and production.
404s are usually path resolution, not the driver. The package resolves source images against a configured disk/root. A relative path that works with Storage::url() may not resolve here. Log the resolved absolute path on failure; that single line resolves most reports.
Transform params are cache keys. Adding a parameter (or changing whether signing is on) changes the URL, which orphans every cached variant — that is correct behaviour, but it silently doubles your storage until old files are pruned. Check for a prune/clear command before and after a param change, and schedule it rather than running it manually.
Signed URL expiry bites the CDN, not the browser. A short TTL means your CDN re-fetches constantly and can start serving 403s to users from cached error responses. Keep TTL comfortably longer than your edge cache TTL, or leave signing off for public assets and rely on path validation instead.
Debug checklist, in order: (1) is the route registered — php artisan route:list | grep -i image; (2) is the driver actually loaded; (3) does the source path resolve to a readable file; (4) are the storage/cache directories writable by the web user (not just you); (5) does the signature verify — a signature mismatch usually presents as a 403 with no other detail.
Extension points worth knowing. If the package exposes a driver contract or a manager, register a custom driver in a service provider rather than editing vendor config — that is where you put an S3-backed or remote-proxy source, and it survives package upgrades. Likewise, keep any custom fit/crop logic in your own class implementing the contract so a future driver addition does not require you to re-verify bespoke code.
Minor note on the release metadata: v2.1.0's changelog link points at compare/v2.0.0...v2.0.1. If you are auditing what actually changed, diff against the v2.1.0 tag directly — the auto-generated compare range understates the release.
How can I help you explore Laravel packages today?