jeffersongoncalves / laravel-image-cache
SSRF-safe remote image fetch-and-cache for Laravel: pinned redirect-safe download, image content-type validation, and TTL-based disk caching
Package info
github.com/jeffersongoncalves/laravel-image-cache
pkg:composer/jeffersongoncalves/laravel-image-cache
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- jeffersongoncalves/laravel-ssrf-guard: ^1.1
- spatie/laravel-package-tools: ^1.16
- symfony/http-foundation: ^7.0|^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Image Cache
Fetching a user-supplied or third-party image URL and re-serving it from your own origin (an og:image, a README-embedded image, an avatar) needs three things done correctly every time: the fetch must be safe against SSRF (pinned to a validated public IP, every redirect hop re-checked), the response must actually be an image before you store it, and repeat requests should be served from a local, TTL-based cache instead of re-fetching on every hit.
Laravel Image Cache packages that fetch-and-persist mechanic behind a small class you construct per use-site:
$cache = new ImageCache(disk: 'public', pathPrefix: 'og-images', ttlSeconds: 86400); $cache->warm('project-42', $untrustedImageUrl);
It does not decide which URL to fetch for a given key (that's app-specific — resolve it from a model, parse it out of HTML, whatever fits your app) or which hosts are allowed (that's also a caller decision). What it guards is where the URL you already decided to fetch actually resolves to — via jeffersongoncalves/laravel-ssrf-guard.
Installation
You can install the package via composer:
composer require jeffersongoncalves/laravel-image-cache
You can publish the config file with:
php artisan vendor:publish --tag="image-cache-config"
This is the published config file:
return [ 'timeout' => (int) env('IMAGE_CACHE_TIMEOUT', 8), 'max_redirects' => (int) env('IMAGE_CACHE_MAX_REDIRECTS', 3), 'failure_ttl' => (int) env('IMAGE_CACHE_FAILURE_TTL', 3600), 'permanent_failure_ttl' => (int) env('IMAGE_CACHE_PERMANENT_FAILURE_TTL', 7 * 86400), 'max_bytes' => (int) env('IMAGE_CACHE_MAX_BYTES', 10 * 1024 * 1024), 'cache_store' => env('IMAGE_CACHE_STORE'), ];
Usage
Construct an ImageCache per use-site with the Laravel disk to store on, a path prefix, and a TTL (defaults to 24 hours):
use JeffersonGoncalves\ImageCache\ImageCache; $cache = new ImageCache(disk: 'public', pathPrefix: 'og-images', ttlSeconds: 86400);
Warming the cache
warm() fetches and persists the image if the disk copy is missing or older than the TTL. It no-ops (returns true) when already fresh, and never throws — any failure (network error, non-2xx, non-image content type, a redirect into a non-public host) is logged as a warning and false is returned, leaving any existing stale copy untouched. Serving yesterday's copy beats erroring. A failed fetch is also remembered (recentlyFailed($key)), so an upstream that is gone is not hit again on every request: for permanent_failure_ttl seconds when a retry won't help (404, 410, non-image, oversized), otherwise for failure_ttl seconds, or the upstream's Retry-After on a 429/503 when that is longer:
$cache->warm(key: 'project-42', url: $project->social_image);
By default warm() validates and pins the URL itself via SsrfGuard::resolveEntries(). If you already resolved/validated the URL yourself (for example you called resolveEntries() earlier to decide whether the source even has an image, and don't want a second DNS lookup), pass the resulting CURLOPT_RESOLVE entries directly:
use JeffersonGoncalves\SsrfGuard\SsrfGuard; $resolve = app(SsrfGuard::class)->resolveEntries($url); if ($resolve !== null) { $cache->warm('project-42', $url, $resolve); }
Passing an empty array ([]) skips pinning entirely — useful for a URL you already trust unconditionally (e.g. a fixed first-party API endpoint) and don't need DNS-rebinding protection for.
Reading back a cached image
$image = $cache->get('project-42'); // ['body' => '<binary>', 'type' => 'image/png'] or null if never warmed successfully
Serving it from a controller
response() is a thin convenience wrapper around get() for the common controller case — it returns a ready-to-send response with the right Content-Type and X-Content-Type-Options: nosniff, or null when the key was never warmed:
public function show(string $slug) { return $cache->response($slug) ?? abort(404); }
For an <img> you would rather not break, answer with a 1x1 transparent GIF instead of a 404:
return $cache->response($key) ?? ImageCache::placeholder();
Storage layout and pruning
Image bodies are stored once per content, under <prefix>/blobs/<sha256>; each key is a tiny JSON pointer ({"blob": "<sha256>", "type": "image/png"}) at <prefix>/<key>. The same image reached through different URLs (say, one README on several branches) therefore takes the disk space of a single copy. Caches written by older versions (<key> body + <key>.type) are still read, and move to the new layout the next time the key is warmed.
When a key is re-warmed with different bytes, its old blob is left behind. prune() deletes every blob no key points at (blobs younger than an hour are kept, so an in-flight warm() is never raced) and returns how many it removed. It scans the whole prefix, so schedule it instead of calling it per request:
Schedule::call(fn () => (new ImageCache('public', 'og-images'))->prune())->daily();
Using a bucket lifecycle rule on a remote disk instead? Never expire <prefix>/blobs/ by age alone: a blob can be shared by keys that are still fresh.
Serving from a public bucket / CDN
When the disk has a public domain (say an R2 bucket on assets.example.com behind a CDN), send visitors straight to the blob instead of streaming every byte through PHP. blobPath() returns the blob's disk path without downloading it (no disk call when the pointer is in the Laravel cache, one small read otherwise), and url() turns it into the disk's public URL when the disk has a url configured:
public function show(string $slug) { $cache->warm($slug, $sourceUrl); return ($url = $cache->url($slug)) ? redirect()->away($url) : ($cache->response($slug) ?? ImageCache::placeholder()); }
Both return null for a key that was never warmed or is still in the legacy layout, so the fallback above keeps working.
Lifecycle caveat. With direct links, a blob deleted from under its pointer is a broken image, not a placeholder: a redirect cached by the CDN keeps pointing at it until the pointer is checked again. Prefer
prune(), which only deletes blobs no key points at. If you use a bucket lifecycle rule ("expire after 30 days"), it must count from the blob's latest upload, which holds only when every re-warm re-uploads the blob, and keep the redirect's cache time well under that window.
Configuration
| Key | Default | Description |
|---|---|---|
timeout |
8 |
Maximum seconds a warm() fetch may run. |
max_redirects |
3 |
How many redirect hops to follow — each one is re-validated against SsrfGuard. |
failure_ttl |
3600 |
Seconds warm() skips the upstream after a transient failure: timeout, 5xx, 403, 429. A longer Retry-After on a 429/503 wins, capped at permanent_failure_ttl; one that can't be parsed falls back to failure_ttl. 0 retries every time and ignores Retry-After. |
permanent_failure_ttl |
604800 |
Seconds warm() skips the upstream after a 404, 410, non-image or oversized response (0 retries every time). |
max_bytes |
10485760 |
Largest image (bytes) warm() stores; bigger ones abort mid-download and count as a failed fetch (0 = unlimited). |
cache_store |
null |
Laravel cache store remembering each key's "fresh until" time and pointer (null = default store, array = off). |
Remote disks (S3, R2)
Every disk call on a remote disk is a billed round trip, so a fresh hit (warm() + response()) costs one read — the image itself. The key's "fresh until" time and pointer are remembered in the Laravel cache (cache_store; use a shared store such as Redis when you run several servers), reads never do an exists() first, and the .failed marker is only checked after a miss. A miss costs four calls: a HEAD (the key), a read of the .failed marker and two PUTs (the blob, the pointer). Stale files are only deleted when they may exist, and the get()/response() right after a successful warm() on the same instance serves the bytes just downloaded instead of reading them back. The flip side: freshness is decided by that cache, so a file you age or delete by hand on the disk is not noticed until the TTL runs out (a deleted blob is, on the next read).
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.
