Laravel Image Responses: Serve Resized Images Directly From Routes
Laravel Tips & Tricks #Laravel #Images #HTTP #Laravel 13 #Performance

Laravel Image Responses: Serve Resized Images Directly From Routes

4 min read Mohamed Said Mohamed Said

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:

  • Image now implements Responsable
  • Image::fromStream() factory method
  • toFormat() 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 Image instance from a route or controller now works without any manual response construction.
  • Content-Type is 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

Found this useful?

Frequently Asked Questions

3 questions
Q01 How do I return a resized image directly from a Laravel route in 13.25?
Return an Image instance from the route closure or controller method. Laravel calls toResponse() automatically because Image now implements the Responsable contract. The Content-Type header is set from the output format, not the source file.
Q02 How do I add cache headers to a Laravel image response?
Call toResponse($request) on the Image instance yourself instead of returning it directly. This gives you an Illuminate\Http\Response object, so you can chain setMaxAge(), setPublic(), setEtag(), and other response methods.
Q03 What happens if I pass an unsupported format to toFormat()?
An ImageException is thrown with the format name in the message. This results in a 500 response, not a 404, so you should constrain the route parameter with a where() clause or validate the value before passing it to toFormat().

Continue reading

More Articles

View all