Serving Resized Images From Laravel Routes
Before Laravel 13.25, returning a processed image over HTTP meant calling toBytes(), constructing a response object, and setting the Content-Type header manually — three lines of boilerplate repeated in every controller that needed it. Laravel 13.25 closes that gap by making Image implement the Responsable contract, so an image instance is a valid return value from any route or controller.
Three additions landed together in 13.25:
Imagenow implementsResponsableImage::fromStream()factory methodtoFormat()is now public
Returning an Image Directly
The simplest case requires no extra plumbing:
use Illuminate\Support\Facades\Image;
Route::get('/avatars/{user}', function (User $user) {
return Image::fromStorage($user->avatar_path)
->cover(200, 200)
->toWebp()
->quality(80);
});
The framework calls toResponse() internally, runs the transformation pipeline, and returns a 200 response with Content-Type derived from the output format — not the source file. A stored JPEG served through toWebp() returns image/webp. The transformation is lazy: it does not run until bytes are actually needed.
Adding Cache Headers
The default response carries no caching headers, which is correct for a framework default but wrong for a resize endpoint that does real work on every request. Call toResponse() yourself to get the full response API:
Route::get('/avatars/{user}', function (Request $request, User $user) {
return Image::fromStorage($user->avatar_path)
->cover(200, 200)
->toWebp()
->quality(80)
->toResponse($request)
->setMaxAge(31536000)
->setPublic();
});
toResponse() returns an Illuminate\Http\Response, giving access to header(), setEtag(), setLastModified(), and the rest of the response API.
For higher-traffic endpoints, avoid resizing on every request by writing the derived file on the first hit and serving it from disk thereafter. Embedding the model's updated_at timestamp in the filename means a changed photo automatically produces a new path — no cache invalidation needed.
Dynamic Formats With toFormat()
toFormat() is now public and accepts a format string directly, removing the need for a match statement in format-switching endpoints:
Route::get('/photos/{photo}.{format}', function (Photo $photo, string $format) {
return Image::fromStorage($photo->path)
->scale(width: 1200)
->toFormat($format)
->quality(80);
})->where('format', 'webp|avif|jpg');
Accepted values are webp, jpg, jpeg, png, gif, avif, heic, heif, and bmp. An unrecognised format throws an ImageException (a 500, not a 404), so always constrain the route parameter or validate the value before passing it.
Reading From a Stream With Image::fromStream()
Image::fromStream() covers sources the other factory methods do not — S3 read streams, php://input on raw upload endpoints, files extracted from a zip, or any stream resource handed over by another library:
$image = Image::fromStream(Storage::disk('s3')->readStream($path));
The stream is wrapped in a closure and not consumed until the pipeline runs, so constructing an instance you never use costs nothing.
A Complete Endpoint
Combining all three features — fromStream(), toFormat(), and Responsable — a production-ready endpoint that reads from S3, accepts width and format parameters, and caches for a year looks like this:
Route::get('/media/{media}', function (Request $request, Media $media) {
$validated = $request->validate([
'w' => ['integer', 'between:32,2000'],
'format' => ['in:webp,avif,jpg'],
]);
return Image::fromStream(Storage::disk('s3')->readStream($media->path))
->scale(width: $validated['w'] ?? 800)
->toFormat($validated['format'] ?? 'webp')
->quality(80)
->toResponse($request)
->setMaxAge(31536000)
->setPublic();
})->middleware('signed');
Two details matter here: the width is bounded to prevent oversized resize attacks, and the route is signed to stop arbitrary variant generation against your storage bill.
Key Takeaways
- Returning an
Imageinstance from a route or controller now works without any manual response construction. Content-Typeis set from the pipeline output, not the source file format.- Call
toResponse($request)explicitly when you need to attach cache headers. toFormat()accepts a format string directly, enabling clean format-switching endpoints.Image::fromStream()is lazy — the stream is not read until the pipeline executes.- Always validate or constrain format and dimension parameters on public endpoints.
Source: Laravel Image Responses: Serve Resized Images From Routes