fomvasss / laravel-imagepresets
Image presets (on-the-fly resize/convert) for Laravel via League Glide
Requires
- php: ^8.1
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- league/glide: ^2.0|^3.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
- enshrined/svg-sanitize: Full SVG sanitization (XSS protection). Without this package a basic regex sanitizer is applied.
README
On-the-fly image resizing, converting and caching for Laravel, powered by League/Glide.
Features
- On-the-fly resize, crop and format conversion (WebP, AVIF, JPG, PNG, GIF)
- Automatic caching of processed images to any Laravel filesystem disk (local, S3, GCS, FTP, etc.)
- Remote disk support (S3 / GCS / FTP) β Glide processes locally, result is uploaded automatically
- SVG passthrough with optional XSS sanitization
- Remote image support with SSRF and image-bomb protection
- Race condition protection via Cache lock (Redis/Memcached)
- Auto-registered route β no manual setup required
- Facade + global helper + Blade directive
- Artisan command
imagepresets:clear - Fully configurable via
config/imagepresets.php
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | 10 / 11 / 12 / 13 |
| league/glide | ^2.0 | ^3.0 |
Optional:
imagickPHP extension β required for AVIF output and SVG rasterizationenshrined/svg-sanitizeβ full SVG sanitization (recommended)
Installation
composer require fomvasss/laravel-imagepresets
The service provider is auto-discovered via Laravel's package discovery.
Publish configuration
php artisan vendor:publish --tag=imagepresets-config
This creates config/imagepresets.php in your application.
Configuration
Key options in config/imagepresets.php:
// Route 'route' => [ 'prefix' => env('IMAGEPRESET_ROUTE_PREFIX', 'imagepresets'), 'name' => env('IMAGEPRESET_ROUTE_NAME', 'imagepresets'), 'middleware' => ['throttle:240,1'], ], // Storage disk and subdirectory for cached presets 'disk' => env('IMAGEPRESET_DISK', 'public'), // or 's3', 'gcs', etc. 'path' => env('IMAGEPRESET_PATH', 'imagepresets'), // Processing driver: 'gd' or 'imagick' 'driver' => env('IMAGEPRESET_DRIVER', 'gd'), // Default output quality and format 'quality' => 80, 'format' => 'webp', // HTTP cache lifetime (seconds) 'cache_max_age' => 31536000, // Allowed dimensions, qualities, fit methods, formats // Use ['*'] as a wildcard to allow any value (no restriction) 'allowed_widths' => [100, 200, 300, 400, 600, 800, 1000, 1200, 1600], 'allowed_heights' => [100, 200, 300, 400, 600, 800], 'allowed_sizes' => [[300, 200], [600, 400], [1200, 800]], 'allowed_qualities' => [50, 60, 70, 80, 90, 100], 'allowed_fits' => ['contain', 'crop', 'fill', 'fill-max', 'max', 'stretch'], 'allowed_formats' => ['webp', 'jpg', 'png', 'gif'], // SVG optionslaravel-imagepresets 'svg' => [ 'sanitize' => true, 'remove_remote_references' => true, 'rasterize' => false, // requires driver=imagick ], // Remote image protection 'max_download_bytes' => 20 * 1024 * 1024, 'max_image_pixels' => 150_000_000, // ~150 Mpx image-bomb protection 'allowed_hosts' => [], // e.g. ['cdn.example.com'] // Local Glide working dir when using a remote disk (S3/GCS/FTP) 'local_cache_dir' => storage_path('app/imagepreset_glide_cache'),
Cache Lock: For correct multi-server behaviour set
CACHE_DRIVER=redisin.env. Thefiledriver only locks within a single PHP process.
Remote disk (S3 / GCS / FTP)
Set the disk in .env β the package detects it automatically:
IMAGEPRESET_DISK=s3 IMAGEPRESET_PATH=imagepresets
Processing flow for remote disks:
- Glide processes the image into
local_cache_dir(local) - The result is uploaded to the remote disk via Flysystem
- The local file is deleted
- The response is streamed directly from the remote disk
# Clear S3 preset cache
php artisan imagepresets:clear --disk=s3
Usage
Endpoint
GET /imagepresets?src=...&w=...&h=...&q=...&fm=...&fit=...
Query parameters
| Parameter | Type | Description |
|---|---|---|
src |
string | Required. Relative path or remote URL of the source image |
preset |
string | Named preset defined in config/imagepresets.php (presets section) |
w |
int | Output width in pixels (must be in allowed_widths, or any if ['*']) |
h |
int | Output height in pixels (must be in allowed_heights, or any if ['*']) |
q |
int | Quality 1β100 (must be in allowed_qualities, or any if ['*']) |
fm |
string | Output format: webp, jpg, png, gif, avif |
fit |
string | Fit method: contain, crop, fill, fill-max, max, stretch |
blur |
int | Blur radius 0β100 |
sharp |
int | Sharpen amount 0β100 |
or |
string | Orientation: auto (EXIF), 0, 90, 180, 270 |
crop |
string | Coordinate crop: w,h,x,y β e.g. 200,200,10,10 |
bg |
string | Background fill colour (hex without #): fff, ff5733 |
When both w and h are passed, the pair must be listed in allowed_sizes (unless allowed_sizes = ['*']).
Fit methods
| Value | Description |
|---|---|
contain |
Scales the image to fit within wΓh, preserving aspect ratio. No cropping. Transparent/empty space is not filled. |
max |
Same as contain but never upscales beyond the original dimensions. |
fill |
Scales the image to fill the entire wΓh canvas. Empty space is filled with the bg colour. May upscale small images. |
fill-max |
Same as fill but never upscales β if the image is smaller than the canvas it is centred and the remaining space is filled with bg. Equivalent to Spatie MediaLibrary's Fit::FillMax. |
crop |
Scales and crops the image to exactly wΓh. No empty space, but edges may be trimmed. |
stretch |
Stretches the image to exactly wΓh ignoring the aspect ratio. |
fill-maxvscrop: usefill-maxwhen the full image must remain visible (e.g. og:image banners, product feeds); usecropwhen exact pixel dimensions are required and trimming is acceptable.
// The full image is visible; white padding fills the remaining canvas area $url = imagepreset_url('photo.jpg', ['w' => 1300, 'h' => 650, 'fit' => 'fill-max', 'bg' => 'ffffff', 'fm' => 'jpg']);
Wildcard mode
Set any of the allowed_* config keys to ['*'] to disable the corresponding restriction entirely:
'allowed_widths' => ['*'], // any width is accepted 'allowed_heights' => ['*'], // any height is accepted 'allowed_sizes' => ['*'], // any w+h pair is accepted 'allowed_qualities' => ['*'], // any quality value 1β100 is accepted
Note: Base HTTP validation limits still apply β
wandhare capped at20000,qmust be an integer.
Helper function
$url = imagepreset_url('storage/images/photo.jpg', ['w' => 800, 'fm' => 'webp']); // β https://example.com/imagepresets?fm=webp&src=storage%2Fimages%2Fphoto.jpg&w=800
$url = imagepreset_url('https://example.com/storage/images/photo.jpg', ['w' => 800, 'fm' => 'webp']); // β https://example.com/imagepresets?fm=webp&src=https://example.com/storage%2Fimages%2Fphoto.jpg&w=800
Named Presets
Define reusable named presets in config/imagepresets.php:
'presets' => [ 'thumb' => ['w' => 300, 'h' => 200, 'fm' => 'webp', 'q' => 80, 'fit' => 'crop'], 'hero' => ['w' => 1200, 'fm' => 'webp', 'q' => 85], 'avatar' => ['w' => 96, 'h' => 96, 'fm' => 'webp', 'fit' => 'crop'], // og:image social banner β fill-max keeps the full image, fills gaps with bg colour 'og_banner' => ['w' => 1300, 'h' => 650, 'fit' => 'fill-max', 'fm' => 'jpg', 'q' => 85, 'bg' => 'ffffff'], ],
Use a preset by name:
// Helper β shorthand string $url = imagepreset_url('photo.jpg', 'thumb'); // Helper β array key $url = imagepreset_url('photo.jpg', ['preset' => 'hero']); // Facade Imagepreset::url('photo.jpg', 'avatar'); // Blade directive <img src="@imagepreset('photo.jpg', 'thumb')" alt="Thumbnail"> // HTML endpoint <img src="/imagepresets?src=photo.jpg&preset=thumb" alt="Thumbnail">
Explicit params passed alongside a preset override the preset defaults:
// Uses thumb preset but overrides format to jpg $url = imagepreset_url('photo.jpg', ['preset' => 'thumb', 'fm' => 'jpg']);
Security: preset params come from trusted config and bypass
allowed_widths/allowed_heights/allowed_sizes/allowed_qualitieschecks. Explicit override params are still validated against the allowlists.
Facade
use Fomvasss\Imagepresets\Facades\Imagepreset; $url = Imagepreset::url('storage/images/photo.jpg', ['w' => 400, 'h' => 300]);
Blade directive
<img src="@imagepreset('storage/images/photo.jpg', ['w' => 600, 'fm' => 'webp'])" alt="Photo">
HTML example
<img src="/imagepresets?src=storage/images/photo.jpg&w=800&fm=webp" alt="Photo">
SVG Support
SVG files are passed through without dimension transformations. The cache key is based solely on src to avoid duplicates.
// SVG is cached and served as-is (sanitized by default) $url = imagepreset_url('storage/icons/logo.svg');
Enable sanitization (recommended) in config:
'svg' => [ 'sanitize' => true, ],
For full sanitization install the optional dependency:
composer require enshrined/svg-sanitize
Without it, a basic regex sanitizer removes <script> tags, on* event attributes, and javascript: URIs.
SVG rasterization
To convert SVG to raster when w, h or fm is passed:
'svg' => [ 'rasterize' => true, // requires driver=imagick ],
Remote Images
Pass any allowed external URL as src:
$url = imagepreset_url('https://cdn.example.com/photo.jpg', ['w' => 400]);
Allowed hosts must be declared in config:
'allowed_hosts' => [ 'cdn.example.com', ],
Security measures:
- HTTP redirects are blocked (SSRF protection)
- Private and reserved IP ranges are rejected
localhostis rejected- Maximum download size:
max_download_bytes - Maximum pixel area:
max_image_pixels(image-bomb protection)
Artisan Commands
Clear preset cache
php artisan imagepresets:clear
Options:
| Option | Description |
|---|---|
--disk= |
Override disk (default: imagepresets.disk config) |
--path= |
Override path (default: imagepresets.path config) |
--temp |
Also clear source_dir and temp_dir |
# Clear cache + temp directories php artisan imagepresets:clear --temp # Clear a custom disk/path php artisan imagepresets:clear --disk=s3 --path=presets
Audit Log (discovering required sizes)
Use audit logging in local / staging environments to discover which image params
the frontend actually requests β then promote them to explicit allowlists for production.
Workflow
- Enable wildcard mode + audit log in
.env(non-production only):
IMAGEPRESET_AUDIT_LOG=true # IMAGEPRESET_AUDIT_LOG_ONLY_NEW=true # log only cache misses (first generation)
Entries are written to the application default log channel (
LOG_CHANNELin.env).
- Let the frontend work freely β every new param combination is logged:
{"message":"imagepreset_request","context":{"params":{"src":"products/photo.jpg","w":640,"fm":"webp"},"ip":"127.0.0.1","url":"http://app.test/imagepresets?src=..."}}
- Analyse the log to collect unique combinations:
# All unique w values requested grep -oh '"w":[0-9]*' storage/logs/*.log | sort -u # All unique [w, h] pairs grep -oh '"w":[0-9]*,"h":[0-9]*' storage/logs/*.log | sort -u # All unique quality values grep -oh '"q":[0-9]*' storage/logs/*.log | sort -u
- Promote findings to explicit allowlists in
config/imagepresets.phpand disable wildcard + audit log before deploying to production:
'allowed_widths' => [320, 640, 960, 1280], 'allowed_heights' => [200, 400], 'allowed_sizes' => [[640, 400], [1280, 800]], 'allowed_qualities' => [80, 90],
# .env (production) IMAGEPRESET_AUDIT_LOG=false
Config reference
| Key | Default | Description |
|---|---|---|
audit_log.enabled |
false |
Enable/disable via IMAGEPRESET_AUDIT_LOG |
audit_log.only_new |
true |
Log only cache misses β skip already-cached combinations |
Excluding Preset Cache from Backups
The /imagepresets cache directory contains auto-generated files that can always be
recreated on demand. Including it in backups wastes storage and increases backup time.
Recommended: separate disk outside the backup scope
Define a dedicated filesystem disk that lives outside your regular backup directory:
// config/filesystems.php 'imagepresets' => [ 'driver' => 'local', 'root' => storage_path('app/imagepresets'), // not inside app/public // 'url' => env('APP_URL').'/imagepresets', // no need for a URL since this is only a temporary working dir for Glide 'visibility' => 'public', 'throw' => false, ],
# .env IMAGEPRESET_DISK=imagepresets
Now the cache folder is completely outside storage/app/public and will never
appear in backups that include only storage_path('app/public').
Note: The cache directory is recreated automatically on the first request for each preset β no manual intervention is needed after a restore.
HTTP Caching & CDN / Reverse Proxy
Every response from the /imagepresets endpoint includes headers optimised for aggressive edge caching:
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
ETag: "<hash>"
Last-Modified: <date>
Nginx
Cache processed presets directly on the server β Laravel is bypassed on subsequent requests:
proxy_cache_path /var/cache/nginx/imagepresets levels=1:2 keys_zone=imagepresets:20m max_size=2g inactive=365d use_temp_path=off; server { # ... location /imagepresets { proxy_cache imagepresets; proxy_cache_valid 200 365d; proxy_cache_use_stale error timeout updating http_500 http_502 http_503; proxy_cache_lock on; # prevents cache stampede proxy_cache_key "$scheme$host$request_uri"; add_header X-Cache-Status $upstream_cache_status; proxy_pass http://127.0.0.1:9000; # your PHP-FPM / app } }
Cloudflare
Add a Cache Rule in the Cloudflare dashboard:
- If β URI Path starts with
/imagepresets - Then β Cache Level: Cache Everything, Edge Cache TTL: 1 year
Or via Terraform / API:
{
"description": "Cache imagepresets",
"expression": "(http.request.uri.path starts_with \"/imagepresets\")",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "override_origin", "default": 31536000 },
"browser_ttl": { "mode": "override_origin", "default": 31536000 }
}
}
Cache invalidation
When you change a preset definition or replace a source image, the cached files must be purged:
# Clear the Laravel-level disk cache (always required) php artisan imagepresets:clear # Nginx β reload or flush proxy cache directory find /var/cache/nginx/imagepresets -type f -delete # Cloudflare β purge by prefix via API curl -X POST "https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache" \ -H "Authorization: Bearer {TOKEN}" \ -H "Content-Type: application/json" \ --data '{"prefixes":["https://example.com/imagepresets"]}'
Tip: Use versioned
srcpaths (e.g.photo_v2.jpg) or append a query param (?v=2) to bust the cache without a full purge.
Response Headers
Every response includes:
| Header | Value |
|---|---|
Content-Type |
Correct MIME type |
Cache-Control |
public, max-age=N, s-maxage=N, immutable |
ETag |
Based on file mtime + size |
Last-Modified |
File modification time |
Content-Disposition |
inline |
X-Content-Type-Options |
nosniff |
Content-Security-Policy |
SVG only: default-src 'none'; style-src 'unsafe-inline'; sandbox |
Security
| Threat | Protection |
|---|---|
| Path traversal | .. and null bytes rejected in src |
| SSRF via remote URL | Private/reserved IPs + localhost blocked; redirects disabled |
| Image bomb | Pixel area check (max_image_pixels) for both local and remote files |
| SVG XSS | Sanitization via enshrined/svg-sanitize or regex fallback; CSP header |
| Cache stampede | Cache::lock() with double-check after acquiring |
| Content sniffing | X-Content-Type-Options: nosniff |
Testing
composer test # or vendor/bin/phpunit
License
MIT β see LICENSE.