Images and Derivatives

Laravel 13's Image facade resizes an upload once, stores the sizes you will serve, and drops EXIF on the way.

Syntax$request->image('photo')->orient()->scale(width: 1200)->optimize('webp', 80)->store('photos', 'public')

Laravel 13 ships an image component. Illuminate\Image wraps Intervention Image, which is not installed for you: run composer require intervention/image:^4.0. The driver comes from config('images.default') and is gd unless you publish config/images.php with php artisan config:publish images and set imagick, or simply set IMAGE_DRIVER=imagick. ->usingImagick() overrides it for one image.

Start from the upload. $request->image('photo') is shorthand for Image::fromUpload($request->file('photo')) and returns the same Illuminate\Image\Image. Image::fromStorage($path, $disk), fromPath(), fromBytes() and fromUrl() are the other entry points.

Nothing happens until you ask

Each transformation returns a clone and appends to a pipeline. The pipeline runs only when you call toBytes(), store(), dimensions() or mimeType(). That is what makes deriving three sizes from one instance cheap.

Which resize do you mean

scale() keeps the aspect ratio and never enlarges. resize() forces the exact dimensions and will distort. cover() fills the box and crops the overflow. optimize('webp', 80) is shorthand for toWebp()->quality(80), and the default quality is 70.

Metadata

Re-encoding writes a new file, so EXIF, including GPS coordinates, does not survive. Call orient() before anything else, because the orientation flag is part of what you are discarding; leave it out and portrait phone photos come out sideways.

Example

Example · php
<?php
// app/Actions/StorePhotoWithDerivatives.php

declare(strict_types=1);

namespace App\Actions;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Str;

final class StorePhotoWithDerivatives
{
    /**
     * Width in pixels for each derivative we are willing to serve.
     *
     * @var array<string, int>
     */
    private const SIZES = ['thumb' => 320, 'card' => 800, 'hero' => 1600];

    /**
     * @return array<string, string>
     */
    public function handle(UploadedFile $upload, string $folder): array
    {
        // fromUpload() reads the bytes lazily. $request->image('photo') is
        // the shorthand for exactly this line.
        $source = Image::fromUpload($upload)
            // orient() applies the EXIF rotation flag while it still exists.
            // Re-encoding below throws the EXIF block away, GPS and all.
            ->orient();

        // One stable prefix for the whole set, so a delete removes all of
        // it. Str::random(40) is what hashName() uses, but calling
        // hashName() here would force the pipeline to run early, because it
        // needs the MIME type of the processed bytes to pick an extension.
        $key = Str::random(40);

        $paths = [];

        foreach (self::SIZES as $name => $width) {
            // Each transform returns a CLONE, so $source is untouched and
            // can be reused for the next size. Nothing is decoded until
            // store() asks for the bytes.
            $paths[$name] = $source
                // scale() keeps the ratio and will not enlarge a small
                // upload. resize($width, $height) would force both numbers
                // and distort; cover($width, $height) would crop instead.
                ->scale(width: $width)
                // Shorthand. The longhand is ->toWebp()->quality(80).
                ->optimize('webp', 80)
                ->storeAs($folder, "{$key}-{$name}.webp", 'public');
        }

        // Keep the untouched upload on a private disk. Derivatives can be
        // regenerated from it; a re-encoded file cannot be un-encoded.
        $paths['original'] = $upload->store($folder, 'local');

        // storeAs() returns false on a failed write, so a caller that trusts
        // these paths should check before persisting them.
        foreach ($paths as $name => $path) {
            abort_if($path === false, 500, "Could not store the {$name} image.");
        }

        return $paths;
    }
}

When to use it

  • Turning one phone photo into thumbnail, card and hero WebP files at upload time.
  • Stripping GPS coordinates from user photos before they are ever served.
  • Rebuilding every derivative after a design change alters the card width.
  • Rejecting an upload whose decoded dimensions are smaller than the layout needs.

More examples

Deciding the derivative set as data

Run this when you are planning a derivative set. Putting the dimensions in one collection means the storing code, the srcset in the view and a cleanup command all read the same source, which is the difference between three sizes and three sizes plus two orphans.

Example · php
<?php

declare(strict_types=1);

// Decide which sizes exist before any image library is involved. The same
// list drives the storing loop and the template that picks a size, so the
// two cannot drift apart.
$variants = collect([
    'thumb' => ['width' => 160, 'height' => 160],
    'card' => ['width' => 640, 'height' => 360],
    'hero' => ['width' => 1600, 'height' => 900],
]);

$stored = $variants->map(fn (array $box, string $name): string => sprintf(
    'photos/%s/%s-%dx%d.webp',
    'a1b2c3',
    $name,
    $box['width'],
    $box['height'],
));

$stored->each(fn (string $path) => print($path.PHP_EOL));

echo $stored->count(), PHP_EOL;

// Output:
// photos/a1b2c3/thumb-160x160.webp
// photos/a1b2c3/card-640x360.webp
// photos/a1b2c3/hero-1600x900.webp
// 3

Regenerating from an original already in storage

This is the command you want the day you change a size or a quality setting. It also shows why the untouched original is worth keeping on a private disk: without it, every future size has to be derived from an already-compressed derivative.

Example · php
<?php
// app/Console/Commands/RebuildPhotoDerivatives.php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Photo;
use Illuminate\Console\Command;
use Illuminate\Image\ImageException;
use Illuminate\Support\Facades\Image;

final class RebuildPhotoDerivatives extends Command
{
    protected $signature = 'photos:rebuild {--width=800}';

    public function handle(): int
    {
        $width = (int) $this->option('width');

        Photo::query()->each(function (Photo $photo) use ($width): void {
            try {
                // fromStorage() reads from a disk rather than an upload.
                // Storage::disk('local')->image($photo->original_path)
                // returns the same Image instance.
                $path = Image::fromStorage($photo->original_path, 'local')
                    ->orient()
                    ->scale(width: $width)
                    ->optimize('webp', 80)
                    ->storePubliclyAs('photos', "{$photo->key}-card.webp", 'public');

                $photo->update(['card_path' => $path]);
            } catch (ImageException $e) {
                // Thrown for an unsupported output format, where the message
                // names the format, and for a decode failure, which arrives
                // as "Failed to process image: ..." wrapping the driver's
                // own message.
                $this->warn("Skipped photo {$photo->id}: {$e->getMessage()}");
            }
        });

        return self::SUCCESS;
    }
}

Inspecting an image before you accept it

Reach for dimensions() and mimeType() when a rule depends on the file's own content, such as rejecting an image narrower than a layout needs. Both read the decoded bytes, so they survive a renamed file and a lying Content-Type.

Example · php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Image;

// dimensions() runs the pipeline and returns [width, height]. width() and
// height() are the same call, indexed for you.
$image = Image::fromUpload($request->file('photo'))->orient();

[$width, $height] = $image->dimensions();

// mimeType() is read from the processed bytes with finfo, not from the
// Content-Type header the browser sent, so it cannot be spoofed.
$mime = $image->mimeType();      // e.g. "image/jpeg"
$extension = $image->extension(); // "jpg" for that MIME type

// A data URI, useful for a preview in a mail body or an inline placeholder.
// __toString() returns this too, so string interpolation of an Image gives
// you a full data URI -- rarely what you want in a log line.
$preview = $image->scale(width: 24)->toDataUri();

// The output formats toFormat() accepts are webp, jpg, jpeg, png, gif,
// avif, heic, heif and bmp. Anything else raises ImageException naming the
// format it refused -- "The [tiff] format is not supported." -- which is
// why the `image` validation rule belongs in front of all of this.

Discussion

  • Be the first to comment on this lesson.