Laravel's first-party image API has been good at the write path since 13.20: take an upload, transform it, put it on a disk. The read path was less tidy. Serving a resized image over HTTP meant calling toBytes(), building a response, and setting the content type yourself, which is three lines of boilerplate in every controller that does it.
Laravel 13.25 makes Image implement the Responsable contract, so an image instance is a valid return value from a route or controller. Two related additions landed in the same release, Image::fromStream() and a public toFormat(), and together the three cover most of what an image endpoint needs.
Returning an Image
use Illuminate\Support\Facades\Image; Route::get('/avatars/{user}', function (User $user) { return Image::fromStorage($user->avatar_path) ->cover(200, 200) ->toWebp() ->quality(80);});
That is the whole thing. The framework calls toResponse(), which runs the pipeline, returns the processed bytes with a 200, and sets Content-Type from the output rather than the source. The route above returns image/webp even though the stored file is a JPEG, because the header is read from what the pipeline produced.
Everything that returns an Image works the same way, so a controller method, an invokable controller, or a value returned from a route model binding closure are all fine. The instance is lazy until something asks for bytes, which means the transformation does not run when the framework is only deciding what kind of response it has.
Adding Cache Headers
The default response has no caching headers at all, which is the right default for a framework and the wrong default for an endpoint that resizes an image on every request. Call toResponse() yourself when you want to add to it:
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, so the full response API is available: header(), setEtag(), setLastModified(), and the rest. Pair a long max-age with a URL that changes when the image changes, either a hash in the path or a query string built from the model's updated_at, and browsers stop asking after the first request.
For anything with real traffic, resizing per request is still work you are doing over and over. The pattern that scales is to write the derived file on the first request and serve it from the disk after that:
Route::get('/thumbs/{photo}', function (Request $request, Photo $photo) { $path = "thumbs/{$photo->id}-{$photo->updated_at->timestamp}.webp"; if (! Storage::disk('public')->exists($path)) { Image::fromStorage($photo->path) ->cover(400, 400) ->toWebp() ->quality(80) ->storeAs('thumbs', basename($path), 'public'); } return Storage::disk('public')->response($path);});
Putting the timestamp in the filename means an updated photo produces a new path, so old thumbnails fall out of use without a cache to invalidate.
Dynamic Formats With toFormat()
An endpoint that accepts a format from the request used to need a match statement to turn the string into the right method call. toFormat() is now public and takes the format directly:
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');
The accepted values are webp, jpg, jpeg, png, gif, avif, heic, heif, and bmp, with heif normalized to heic. Anything else throws an ImageException with the format in the message, which is a 500 rather than a 404, so constrain the parameter in the route as above or validate the value before you pass it. The same method backs optimize(), which is the version to reach for when you also want a quality in one call.
This is what makes an AVIF-with-fallback endpoint short. Serve whichever format the request asked for, and let the <picture> element decide which URL the browser hits.
Building From a Stream
Image::fromStream() creates an instance from a stream resource, which covers the sources the other factory methods do not:
$image = Image::fromStream(Storage::disk('s3')->readStream($path));
The read is lazy. fromStream() wraps the resource in a closure and does not touch it until the pipeline runs, so creating an instance you end up not using costs nothing. A stream that yields no data throws an ImageException with the message "Invalid stream image data." at that point rather than at construction.
Alongside fromPath(), fromStorage(), fromUpload(), fromUrl(), fromBytes(), and fromBase64(), the stream variant is the one for anything you already hold a handle to: a php://input body on a raw upload endpoint, a file being read out of a zip, or a stream handed to you by another library.
A Complete Endpoint
Putting the three together, an image endpoint that takes a width and a format, reads from S3, and caches for a year:
use Illuminate\Http\Request;use Illuminate\Support\Facades\Image;use Illuminate\Support\Facades\Storage; 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 worth keeping. The width is bounded, because an unvalidated dimension on a public endpoint is an invitation to ask for a 20,000 pixel resize. And the route is signed, which stops anyone from generating arbitrary variants against your storage bill. Laravel's signed routes give you that with a signedRoute() call in the view.
Further Reading
- A Practical Guide to Laravel's First-Party Image Processing covers the transformation and storage API in full
- HEIC Image Uploads in Laravel covers the input side, including phone photos
- Extract an Image's Dominant Color in Laravel pairs well with a resize endpoint for placeholder backgrounds
- Pause All Queues and a New artisan dev UI in Laravel 13.25 has the full release notes
- All three changes were contributed by Caleb White in #61111, #61109, and #61110