ambroisemaupate/intervention-request
Intervention Request serves dynamically resized, cropped, and filtered images through a single route, built on Intervention Image. URL parameters define transformations, with caching and source protection included, making it a lightweight alternative to full image CDNs in Laravel and PHP projects.
intervention/request is not intervention/image. It is a standalone PSR-7 image-resizing HTTP service: you request an existing path with resizing parameters in the query string, it writes a derivative into a cache directory and serves it. There is no Laravel service provider, no facade, no config file — your integration surface is a URL and a shared volume (or an HTTP call).
Two ways to use it, in order of preference:
symfony/psr-http-message-bridge + a PSR-17 factory) and to convert the returned response back into a Symfony response. Only worth it if you cannot run a container.First things to read, in this order:
README parameter table — this is the API;web/index.php — the canonical wiring (InterventionRequest construction, IR_* env reads, handleRequest(...)->send());src/InterventionRequest.php and the Config/ classes — where images path, cache path, driver, quality and cache TTL are decided;docker-compose.yml in the repo — the reference for volume and env layout.First use case: a 1024px-wide WebP derivative of an uploaded image.
// config/images.php
return [
'endpoint' => env('IMAGE_ENDPOINT', 'http://intervention:80'),
'images_path' => '/app/public/images', // container-side mount point
'cache_path' => '/app/public/assets',
];
final class ImageUrl
{
public static function make(string $path, array $params = []): string
{
return rtrim(config('images.endpoint'), '/')
.'/'.ltrim($path, '/')
.'?'.http_build_query($params);
}
}
// {{ ImageUrl::make($media->path, ['w' => 1024, 'fm' => 'webp', 'q' => 80]) }}
Upgrading from 6.x — two edits, both mandatory. Replace the removed two-step call, and remap every Docker path from /var/www/html/web to /app/public (volumes, IR_IMAGES_PATH, IR_CACHE_PATH):
-$app->handleRequest($request);
-$app->getResponse($request)->send();
+$app->handleRequest($request)->send();
Centralise URL building. Never hand-concatenate query strings in Blade. One ImageUrl helper (or a Derivative value object on your media model) keeps parameter names, rounding and cache-busting in one place and makes it testable without the service running.
Model focal points, not URLs. Store focal_x/focal_y (and optionally a normalised safe-area box) on the media record — from a CMS focus-point picker, or your own crop UI — and build the hotspot parameter at render time:
// x, y always; box optional, all normalised 0–1
$values = array_merge([$media->focal_x, $media->focal_y], $media->safe_area ?? []);
$d = implode(':', array_map(fn ($v) => round((float) $v, 4), $values));
ImageUrl::make($media->path, ['w' => 1024, 'c' => '1:1', 'd' => $d, 'fm' => 'webp']);
With v7's optional cropping area you can now say "keep the face inside this region" rather than "crop around this point", which is what you actually want when the same source feeds a 1:1 avatar and a 16:9 hero.
Generate responsive sets. w × srcset for standard densities, plus dpr (or a doubled w) for retina. Enumerate the widths in config, not in templates, so the cache doesn't fill with one-off sizes.
Pre-warm on upload. A queued job that requests the handful of derivatives your views actually use turns a cold first page view into a warm cache. Http::get() in the job; Http::fake() in tests.
Pin the deployment flavour. For v7 you choose a stream: 7.x.y (PHP + nginx + cron) or frankenphp-7.x.y (worker mode + Caddy, no cron). The FrankenPHP flavour is the reason statelessness landed — no per-request boot, no state bleed between requests. In practice your docker-compose.yml becomes: one service, one port, image_cache/image_storage/caddy_data/caddy_config volumes, and IR_DRIVER / IR_DEFAULT_QUALITY from .env.
Local development. Bind-mount storage/app/public into /app/public/images:ro and a named volume into /app/public/assets, then let Laravel's asset() still serve originals while <picture> sources point at the endpoint. Keep both reachable so designers can compare.
The Docker path move is the silent breaker. Old volume mappings and IR_*_PATH values point at /var/www/html/web and will simply mount empty directories — you get 404s from the service, not an error. Grep your compose files, .env, deploy scripts and any reverse-proxy root directive for var/www/html.
Pin tags, never latest. The release notes call this out explicitly, and it's now worse than before: latest is the plain PHP flavour, frankenphp is a separate tag prefix, not a variant of the same tag. A floating tag can silently switch your runtime and drop cron.
Cron is gone in the FrankenPHP flavour. The PHP image ships a cron job that evicts stale cache files; the FrankenPHP image does not. Derivative files are immutable once written, so nothing self-cleans. Either schedule a cache-prune from Laravel (Schedule::call(...) shelling into the container, or a find /app/public/assets -mtime +30 -delete sidecar) or accept unbounded cache growth — but decide deliberately.
getResponse() removal is a compile-time break, not a runtime deprecation. Grep for getResponse( across your codebase, including any custom public/index.php fork of the upstream entrypoint and any Roadiz-derived code you inherited.
Statelessness invalidates a common extension pattern. If you subclassed InterventionRequest and mutated properties between handling and sending — swapping the images path per tenant, injecting a cache prefix, stashing the source file — that no longer works. Push that configuration into a per-request Config object or into the request itself. Anything you do keep on the instance must be immutable and safe under concurrent worker requests.
Embedding in Laravel: mind send(). handleRequest() returns the response object and send() writes straight to output — fine in the standalone entrypoint, wrong inside a controller. If you embed, convert to a Symfony Response and return it, or you'll emit headers after Laravel has already started buffering.
Hotspot separators differ by URL style. The documented syntax is colon-separated (d0.26:0.37:0.1:0.1:0.9:0.9), but the friendly-filename form in the release notes uses semicolons (d0.26;0.37;...-w1024-c7), because : is the parameter separator there. Mix them up and you get a silently ignored or 400'd parameter.
Normalised, not pixels. All seven hotspot values are 0–1. Storing a pixel focus point and emitting it raw will anchor crops to the far corner — validate and clamp on save, and default a missing box to the full image rather than emitting a partial 5-value string.
Backwards compatibility on hotspot is real. Two-value d{x}:{y} still behaves as 0:0:1:1, so you can ship the new syntax incrementally per media type without a migration.
The crop fix changes failure modes. Crop without width/height now falls back to the original image size instead of erroring. If you had defensive code or a test asserting the old failure, drop it — and note the fallback means c=1:1 on a square source is now a safe no-op re-encode.
Cache keys are the full URL. Same parameters forever = same file forever. Changing focal point or quality does not regenerate anything; bump a v parameter (ideally a content hash of the media row) whenever the derivative inputs change.
Driver differences will bite you. GD, Imagick and libvips do not implement every filter identically, and quality semantics differ. Set IR_DRIVER explicitly rather than relying on whatever the image detects, and pin it in CI so image output diffs are attributable.
Upscaling is off by default. w=2000 on a 1200px source quietly yields 1200px. That's usually right — but if your srcset assumes requested widths are honoured, your layout will be wrong on small originals.
Keep the images root narrow and read-only. IR_IMAGES_PATH is a path-traversal boundary; pointing it at your whole public directory or, worse, the app root exposes more than uploads. Mount :ro and scope it to the media folder.
Debugging is easier from the URL than the logs. 404 = source not found (usually a mount problem), 400 = malformed parameter, 500 = driver/filter failure. Log the exact generated URL in Laravel's exception handler when a derivative request fails in a feature test — reproducing by hand is a one-liner, reproducing from a stack trace is not.
How can I help you explore Laravel packages today?