zoker / responsive-images
Responsive images with webp conversion for Laravel
Requires
- php: ^8.2
- intervention/image: ^3.0|^4.0
- spatie/laravel-package-tools: ^1.93
Requires (Dev)
- larastan/larastan: ^v3.9
- orchestra/testbench: ^11.0
Suggests
- ext-imagick: Required for the imagick driver: animated WebP from GIF, TIFF and HEIC sources
Provides
None
Conflicts
None
Replaces
None
README
Laravel package for automatic image conversion to WebP and responsive sizes generation.
Installation
composer require zoker/responsive-images
Configuration
Publish the configuration file:
php artisan vendor:publish --tag="responsive-images-config"
Driver
// config/responsive-images.php 'driver' => env('RESPONSIVE_IMAGES_DRIVER', extension_loaded('imagick') ? 'imagick' : 'gd'),
Imagick is the default when ext-imagick is loaded; otherwise the package falls back to GD, so a server without the extension keeps working. Set RESPONSIVE_IMAGES_DRIVER to pin one.
imagick (default) |
gd (fallback) |
|
|---|---|---|
| PHP extension | ext-imagick |
ext-gd |
| Reads | jpg/jpeg, png, webp, gif, avif, bmp, tif/tiff, heic/heif and whatever the ImageMagick build supports | jpg/jpeg, png, webp, gif, avif, bmp |
| Animated GIF | animated WebP (every frame is resized for every breakpoint, so slower and heavier) | static WebP (first frame only) |
| Photos | faster (≈1.7× on a 4000×3000 JPEG) | slower |
| Memory | ≈8 bytes per pixel (Q16), allocated outside PHP; capped by imagick.* (see Memory) |
≈5 bytes per pixel, allocated outside PHP by libgd on most distro builds; no cap |
Run php artisan responsive-images:clear after switching the driver: generated file names do not depend on it, so the old files would keep being served.
Memory
Both drivers must be planned against the RAM of the server, not against memory_limit. Imagick allocates in ImageMagick, outside the PHP heap. GD is counted in memory_limit only with the libgd bundled into PHP (docker-php-ext-install gd, Herd); Debian/Ubuntu packages (php8.x-gd) are built with --with-external-gd, where the system libgd allocates with malloc and memory_limit does not see it. queue:work --memory counts PHP memory only and does not help either. When the kernel kills the worker, nothing is logged by PHP: the job stays reserved until retry_after, is picked up again with attempts above tries and fails as MaxAttemptsExceededException without a cause. Look in journalctl -k | grep -i oom.
generate() decodes the original once, reduces it to the target size once and cuts every breakpoint from that base, so with a target width the peak is about one decoded copy of the original (two for a moment while its full-size WebP is encoded) plus the small base:
| Original | Pixels | Imagick (8 B/px) | GD (5 B/px) |
|---|---|---|---|
| 4000×3000 | 12 Mpx | ≈ 96 MB | ≈ 60 MB |
| 4000×4000 | 16 Mpx | ≈ 128 MB | ≈ 80 MB |
| 8000×4500 | 36 Mpx | ≈ 288 MB | ≈ 180 MB |
Without a target width (make('a.png') with no dimensions) the base is the original itself, so the peak stays around two copies. What keeps such a job alive is the Imagick resource limits, applied with Imagick::setResourceLimit() when the driver is imagick, once per process (queue worker and php-fpm alike):
'imagick' => [ 'memory' => env('RESPONSIVE_IMAGES_IMAGICK_MEMORY', '256M'), 'map' => env('RESPONSIVE_IMAGES_IMAGICK_MAP', '512M'), 'area' => env('RESPONSIVE_IMAGES_IMAGICK_AREA'), // pixels, e.g. 16000000; null = default 'disk' => env('RESPONSIVE_IMAGES_IMAGICK_DISK'), // null = default (unlimited) ],
Above memory ImageMagick moves the pixel cache to memory-mapped files, above map to plain disk files: slower, but the worker survives. Generation happens once per image and size set, so that slow pass is a one-off. Values are bytes or a size with a K/M/G suffix; null leaves ImageMagick's own default. These are upper bounds: a lower value in the server's policy.xml wins. The defaults are conservative on purpose, since the package is also installed on small VPSes; raise them on servers with more RAM. GD has no such switch: on a GD host the server's RAM is the only limit, so keep uploads reasonably sized.
queue => falseor asyncqueue connection moves all of this into php-fpm: on a small server a large original will then kill the web request, not a worker. Keep an asynchronous queue on such hosts.
Supported Formats
Only files whose extension is listed in extensions are converted (comparison is case-insensitive, so PHOTO.JPG counts as jpg):
// config/responsive-images.php 'extensions' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'bmp', 'tif', 'tiff', 'heic', 'heif'],
A listed extension is converted only if the driver on this server can read it: GD is checked with imagetypes() (AVIF needs GD built with libavif), Imagick with Imagick::queryFormats() (HEIC needs ImageMagick built with libheif). With gd, tif/tiff/heic/heif are therefore served as-is.
Never add vector or document formats (
svg,ps,eps) when using Imagick: ImageMagick hands them to external delegates, which is a known attack surface. SVG is best served as-is anyway.
Animated GIF with the
gddriver becomes a static WebP. Useimagick, or removegiffromextensions.
Formats browsers cannot display (tif/tiff, heic/heif). Before the job has run, the untouched original would be useless as a fallback, so the first make() converts the full-size WebP synchronously (one conversion; the breakpoint sizes are still built by the job). If that conversion fails, the error is reported and the original URL is returned.
Any other file (svg, ...) is served as-is:
- no
GenerateResponsiveImagesjob is dispatched and nothing is written to the output disk; make()returns aResponsiveImagewithsrc= URL of the original on the source disk, emptygeneratedImages,widthandheight=0(the passed values are ignored: the file is served untouched, so they would not describe it) andformat= the file extension;generate()returnsnull;- the rendered
<picture>contains only the<img>, without<source>and withoutwidth/heightattributes.
A <source type="image/webp"> is rendered only once generated images in the output format exist. Until the job has finished, the original (or its WebP copy) is rendered as a plain <img>.
Usage
Facade
use Zoker\ResponsiveImages\Facades\ResponsiveImages; // With specific dimensions $image = ResponsiveImages::make( 'uploads/products/image.jpg', width: 1200, height: 800, disk: 'public', ); // Or use original image dimensions $image = ResponsiveImages::make( 'uploads/products/image.jpg', disk: 'public', ); // Default: lazy loading echo $image->toHtml('Product image'); // For hero images: eager loading echo $image->toHtml('Hero image', 'eager');
Blade Component
{{-- With specific dimensions --}} <x-responsive-image path="uploads/products/image.jpg" :width="1200" :height="800" alt="Product image" disk="public" /> {{-- Or use original dimensions --}} <x-responsive-image path="uploads/products/image.jpg" alt="Product image" disk="public" /> {{-- Hero image with eager loading --}} <x-responsive-image path="uploads/hero.jpg" :width="1920" :height="1080" alt="Hero image" loading="eager" disk="public" /> {{-- Image in a one-third column: let the browser pick a size for 33vw, not 100vw --}} <x-responsive-image path="uploads/products/image.jpg" :width="800" alt="Product image" sizes="(min-width: 1024px) 33vw, 100vw" />
Blade Directive
{{-- Default: lazy loading --}} @responsiveImage( 'uploads/products/image.jpg', width: 1200, height: 800, alt: 'Product image', disk: 'public' ) {{-- Hero image with eager loading --}} @responsiveImage( 'uploads/hero.jpg', width: 1920, height: 1080, alt: 'Hero image', loading: 'eager', disk: 'public' ) {{-- Custom sizes attribute --}} @responsiveImage( 'uploads/products/image.jpg', width: 800, alt: 'Product image', sizes: '(min-width: 1024px) 33vw, 100vw' )
Working with the ResponsiveImage Object
ResponsiveImages::make() returns a ResponsiveImage object with the following methods:
$image = ResponsiveImages::make('uploads/products/image.jpg', width: 1200, height: 800); // Get all generated images as [width => url] array $image->getImages(); // e.g. [320 => 'https://...', 640 => 'https://...', 1200 => 'https://...'] // Get the URL of the closest generated image to the given width // Prefers equal or larger sizes; falls back to largest available if all are smaller. // Returns $src when nothing has been generated (e.g. SVG or before the job has run) $image->getImage(500); // returns URL of the nearest generated size (e.g. 640px version) // Get srcset string $image->getSrcset(); // e.g. "https://.../image-320-abc.webp 320w, https://.../image-640-def.webp 640w, ..." // Whether a <source> is rendered (generated images in the output format exist) $image->hasSource(); // Render as HTML <picture> element $image->toHtml('Alt text'); $image->toHtml('Alt text', 'eager'); $image->toHtml('Alt text', 'lazy', ['class' => 'my-image']); // Override the sizes attribute (the image itself is not affected, so it is not part of the cache key) $image->toHtml('Alt text', 'lazy', [], '(min-width: 1024px) 33vw, 100vw');
Available public properties:
| Property | Type | Description |
|---|---|---|
$src |
string |
URL of the largest generated image (or the original / fallback) |
$generatedImages |
array |
All generated images as [width => url] |
$srcset |
string |
The srcset attribute value (same as getSrcset(); handy where methods cannot be called, e.g. sandboxed Twig) |
$sizes |
string |
The default sizes attribute value (100vw) |
$width |
int |
Target width (0 for unsupported files) |
$height |
int |
Target height (0 for unsupported files) |
$format |
string |
Output format (e.g. webp), or the source extension for unsupported files / fallback |
Artisan Commands
Clear Generated Images
# Clear all php artisan responsive-images:clear # Clear for specific file php artisan responsive-images:clear uploads/products/image.jpg
Note: Images are automatically regenerated when the original file changes or config parameters are updated. No manual regeneration needed!
Dimensions
width and height of the result (and of the rendered <img>) are the same whether make() builds it from freshly generated files or from files already on the output disk:
| Arguments | Target size | Generated widths |
|---|---|---|
width and height |
as passed, the original is not read | breakpoints ≤ width, plus width |
width only |
height = width × original ratio |
breakpoints ≤ width, plus width |
height only |
width = original width, height as passed |
breakpoints ≤ original width, plus it |
| none | original width and height | breakpoints ≤ original width, plus it |
When the original has to be consulted, only its header is read (getimagesize(), no decoding). A JPEG/TIFF with an EXIF orientation of 5–8 is auto-rotated on conversion, so its width and height are swapped here too. If the header cannot be read (e.g. HEIC, which getimagesize() does not understand, or a corrupt file), the result carries the passed values (0 when omitted) and the missing sizes are requested on every cache refresh, as before.
Cache
The result of make() is cached with Cache::flexible() in cache_store; cache_ttl is [stale, expire] in seconds, [86400, 604800] by default. A cache hit touches neither disk nor image. The entry is recomputed in the background after stale, and synchronously after expire. It only changes when the original file, breakpoints, quality or format change, so after replacing an image under the same name run php artisan responsive-images:clear (or lower the TTL).
Paths
A leading / in the source path is ignored: make('/uploads/a.jpg') is the same image, cache entry and job as make('uploads/a.jpg'). make('/') and make('') return null, and responsive-images:clear / clears everything.
How It Works
- Package accepts image path, optional target width and height (uses original dimensions if not specified), Alt attribute, optional disk (uses default disk if not specified)
- Automatically calculates responsive sizes based on breakpoints from config
- Converts image to WebP
- Generates versions for each size with hash-based filenames
- Caches results using hash of (file modification time + dimensions + quality + format)
- Automatically invalidates cache when original file or parameters change
- Returns HTML with
<picture>andsrcset
Queue
Missing sizes are built by the GenerateResponsiveImages job, and until it has run make() returns the fallback. Generation happens inside the make() call instead, and the first call already returns every size, when:
queueisfalse(RESPONSIVE_IMAGES_QUEUE=false), or- the default queue connection uses the
syncdriver.
If that in-request generation fails, the error is reported and the fallback is returned, so the page still renders. Decoding then happens inside the web request: see the memory note under Configuration before enabling this on a small server.