Tech AI Insights

Laravel 13 Image Processing: Build a Powerful Upload Playground

Hands-On Laravel 13 Image Processing

Laravel 13.20 shipped first-party image processing. [VERIFY: confirm 13.20 is the correct, current version number against Laravel’s release notes before publishing] This post is not a slide deck of API methods — it walks through a working playground we built: upload one photo, branch several variants onto the local public disk, show them in a gallery, and fix a real serving bug along the way.

In short: the demo uses $request->image() to read an uploaded photo, chains immutable transforms (orient(), cover(), scale(), contain()) to produce a thumbnail, a display size, and a letterboxed preview, then stores each with storePublicly() on the local public disk. Along the way it fixes a real APP_URL bug that broke image URLs, and covers the edge cases (HEIC uploads, corrupt files, memory limits) you’ll hit before this reaches production.

Every code sample below comes from that app (ImageDemoController, routes, Blade view, and config/images.php). You can follow along with php artisan serve. The demo uses the local public disk and processes variants synchronously in the request.

1. What changed in Laravel 13.20

The framework gained an Illuminate\Image component and the Image facade a fluent, immutable, driver-based API for reading, transforming, encoding, and storing images.

  • Entry points for uploads, storage disks, paths, URLs, bytes, and Base64
  • Transforms such as orient, cover, scale, contain, effects
  • Encoding helpers like toWebp(), quality(), optimize()
  • Storage helpers that mirror UploadedFile (storePublicly, etc.)
  • Drivers: GD and Imagick (via Intervention Image v4)

Important nuance: image processing is first-party, but the GD/Imagick drivers still need the suggested package:

composer require intervention/image:^4.0

Without it, Laravel throws an ImageException telling you what to install. Official docs: Image Manipulation. Design thread: PR #59276.

How the pipeline behaves (details that matter later)

  • Immutable: each method returns a new instance; the previous one keeps its own pipeline.
  • Lazy: source bytes are typically not decoded until you store, inspect (width()), or call toBytes().
  • Cached after first output: once an instance is processed, further inspections reuse that result.

GD and Imagick accept common inputs such as JPEG, PNG, GIF, BMP, and WebP. That matters for phone HEIC uploads — see Edge cases.

2. Why it matters

Before 13.20, “resize this upload and save a WebP thumb” usually meant reaching for Intervention (or Spatie) directly and inventing your own conventions. Now the happy path looks like the rest of Laravel: request → fluent object → store on a disk.

What it does not replace: Spatie Media Library’s Eloquent attachments, collections, and registered conversions. Illuminate Image is the primitive. You still decide how paths live on a Post model and how the UI works.

For a blog featured image or a demo playground, the primitive is enough. For multi-collection product media, you will still want a media layer on top.

3. Basic example (from our thumbnail path)

Setup we used in this project:

composer require intervention/image:^4.0
php artisan config:publish images
php artisan storage:link

Confirm the PHP extension matches your driver:

php -m | grep -i gd        # for IMAGE_DRIVER=gd
php -m | grep -i imagick   # for IMAGE_DRIVER=imagick

Published config (config/images.php) picks the default driver:

'default' => env('IMAGE_DRIVER', 'gd'),

In .env:

IMAGE_DRIVER=gd

Smallest useful chain — same idea as our 400*400 thumb:

$path = $request->image('photo')
    ->orient()
    ->cover(400, 400)
    ->optimize()
    ->storePublicly('demos/thumbs', 'public');

That orients for EXIF (Exchangeable Image File Format) data, crops to a square, encodes WebP via optimize() (default quality 70 [VERIFY]), and writes a hashed file to the public disk. optimize() is shorthand for “convert + quality”; you can also write ->toWebp()->quality(80) when you want an explicit quality.

4. Real-world upload (current playground)

Routes

From routes/web.php:

use App\Http\Controllers\ImageDemoController;
use Illuminate\Support\Facades\Route;

Route::get('/', [ImageDemoController::class, 'index'])->name('image-demo');
Route::post('/process', [ImageDemoController::class, 'process'])
    ->name('image-demo.process');

The form

Multipart form with CSRF (Cross-Site Request Forgery) protection, file input, and effect checkboxes — from resources/views/image-demo.blade.php:

<form action="{{ route('image-demo.process') }}" method="POST"
      enctype="multipart/form-data" class="space-y-6">
    @csrf

    <input id="photo" name="photo" type="file" accept="image/*" required>

    <label>
        <input type="checkbox" name="grayscale" value="1"> Grayscale
    </label>
    <label>
        <input type="checkbox" name="blur" value="1"> Blur
    </label>
    <label>
        <input type="checkbox" name="sharpen" value="1"> Sharpen
    </label>

    <button type="submit">Process with Image facade</button>
</form>

Details that trip people up:

  • Without enctype="multipart/form-data", the file never arrives.
  • Without @csrf, Laravel rejects the POST with a 419.
  • accept="image/*" is a browser hint only — always validate on the server.
  • Checkboxes that are unchecked are absent from the request; that is why we use sometimes|boolean and $request->boolean(...).

Validate, then read with $request->image()

From ImageDemoController::process:

$request->validate([
    'photo' => ['required', 'image', 'max:10240'], // 10MB (kilobytes)
    'grayscale' => ['sometimes', 'boolean'],
    'blur' => ['sometimes', 'boolean'],
    'sharpen' => ['sometimes', 'boolean'],
]);

$photo = $request->image('photo')->orient();

Notes on this validation:

  • max:10240 is kilobytes in Laravel (≈ 10 MB), not bytes.
  • The image rule checks the file is an image by MIME (Multipurpose Internet Mail Extensions) type/contents — not just the extension.
  • $request->image('photo') returns null if the field is missing; validate required first so you do not chain on null.

Call orient() first so phone photos are not stored sideways. Then branch from that single oriented instance — image objects are immutable, so later cover() calls do not mutate $photo.

Equivalent if you already have an UploadedFile:

use Illuminate\Support\Facades\Image;

$image = Image::fromUpload($request->file('photo'));

5. Local public storage

Every variant uses storePublicly(..., 'public'). Example for the oriented original:

$photo->storePublicly('demos/originals', 'public');

Files land under storage/app/public/demos/... and are served via the public/storage symlink:

php artisan storage:link

Our folders:

  • demos/originals
  • demos/thumbs
  • demos/display
  • demos/contain
  • demos/effects

Hashed filenames pick the right extension after encoding (for example .webp after optimize() or toWebp()). In our runs, branching from the same source often reused the same hash basename across folders (different directories, so no collision) — handy when comparing variants of one upload.

If storage fails, storePublicly can return false. Production code should check the return value (our demo assumes success for clarity).

The APP_URL gotcha we actually hit

First we used Storage::disk('public')->url($path). That builds an absolute URL from APP_URL. With APP_URL=http://localhost but the browser on http://127.0.0.1:8000, images requested the wrong host and the gallery showed broken icons — even though files existed on disk.

Fix in our variant() helper — relative URLs:

'url' => '/storage/'.$path,

That stays on whatever host you used for artisan serve. Alternatives:

  • Set APP_URL=http://127.0.0.1:8000 to match how you browse.
  • In production behind a real domain, absolute URLs from Storage::url() are usually fine.

6. Thumbnails and variants (synchronous)

This is the heart of the playground. One oriented $photo, four fixed branches, plus a conditional effects branch — all processed in the HTTP request.

Immutable branches

$variants = [
    $this->variant(
        label: 'Original (orient)',
        code: '->orient()',
        image: $photo,
        path: $photo->storePublicly('demos/originals', 'public'),
    ),
    $this->variant(
        label: 'Thumbnail cover 400*400',
        code: '->cover(400, 400)->optimize()',
        image: $thumb = $photo->cover(400, 400)->optimize(),
        path: $thumb->storePublicly('demos/thumbs', 'public'),
    ),
    $this->variant(
        label: 'Display scale 1200w',
        code: '->scale(width: 1200)->toWebp()->quality(80)',
        image: $display = $photo->scale(width: 1200)->toWebp()->quality(80),
        path: $display->storePublicly('demos/display', 'public'),
    ),
    $this->variant(
        label: 'Contain 600*400',
        code: "->contain(600, 400, 'dominant')->toWebp()",
        image: $contain = $photo
            ->contain(600, 400, 'dominant')
            ->toWebp()
            ->quality(80),
        path: $contain->storePublicly('demos/contain', 'public'),
    ),
];

Which resize method when?

Method Behavior Our use
cover(400, 400) Fill exact box; crop overflow Square thumb
scale(width: 1200) Proportional; never upscales Display width cap
contain(600, 400, 'dominant') Fit inside box; pad with dominant color Letterboxed preview
resize($w, $h) Force exact size; may distort Avoided for photos
crop($w, $h, x:, y:) Cut a region at an offset Not used in this demo

Prefer scale over resize for photos — resize can distort. Prefer cover when the UI needs a fixed aspect ratio (cards, avatars).

Measured example from a 1200*800 test JPEG (Joint Photographic Experts Group) file in this project:

Variant Result size Notes
Original 1200*800 Orient only
Thumb 400*400 Cover crop
Display 1200*800 Already < 1200w, so scale did not enlarge
Contain 600*400 Fitted + padded
Effects ~800*533 Scaled to width 800

That display row is a key edge case: scale() never upscales. A 900px-wide original “scaled” to 1600 stays 900.

Conditional effects with when()

use Illuminate\Image\Image;

$effect = $photo
    ->when($request->boolean('grayscale'), fn (Image $image) => $image->grayscale())
    ->when($request->boolean('blur'), fn (Image $image) => $image->blur(15))
    ->when($request->boolean('sharpen'), fn (Image $image) => $image->sharpen(20))
    ->scale(width: 800)
    ->toWebp()
    ->quality(80);

$variants[] = $this->variant(
    label: 'Effects variant',
    code: $effectCode, // built from which toggles were on
    image: $effect,
    path: $effect->storePublicly('demos/effects', 'public'),
);

blur / sharpen amounts are 0–100. With no checkboxes checked, the effects branch still runs — it just becomes “scale + WebP” with no filters. That is intentional: the gallery always shows five cards.

optimize() on the thumb is the shortcut (WebP @ 70). Display / contain / effects use explicit toWebp()->quality(80) so the demo shows both styles.

Inspecting for the gallery

protected function variant(
    string $label,
    string $code,
    Image $image,
    string $path,
): array {
    return [
        'label' => $label,
        'code' => $code,
        'path' => $path,
        'url' => '/storage/'.$path,
        'width' => $image->width(),
        'height' => $image->height(),
        'mime' => $image->mimeType(),
        'extension' => $image->extension(),
    ];
}

Inspection runs on the processed image — after cover(400, 400), width is 400. Calling width() before any store still triggers processing for that instance. The API also offers dimensions() and dominantColor() if you want UI theming.

7. Security basics (what this demo already does)

  • Validation: required|image|max:10240 rejects non-images and oversized uploads.
  • CSRF: @csrf on the form.
  • Hashed names: storePublicly avoids trusting the client filename.
  • Public disk only for demos: fine for a playground; private user media should use a private disk (and signed URLs) in production apps.

When you graduate this into a real product, add:

  • Auth on the upload route
  • Rate limiting (throttle middleware)
  • Authorization (who can replace whose image)
  • Stricter mimes:jpg,jpeg,png,webp if you want to reject GIF/BMP
  • Optional dimension rules (dimensions:max_width=…) for huge camera files

Do not accept raw paths from the client as write targets. Prefer hashed store* names over storeAs with user-supplied filenames.

8. Production-shaped walkthrough: the full process() flow

End-to-end, this is what the controller does:

  1. Validate input
  2. $request->image('photo')->orient()
  3. Branch and storePublicly each variant
  4. Redirect back with flashed results (avoids re-POST on refresh)
return redirect()
    ->route('image-demo')
    ->with('results', [
        'variants' => $variants,
        'options' => [
            'grayscale' => $request->boolean('grayscale'),
            'blur' => $request->boolean('blur'),
            'sharpen' => $request->boolean('sharpen'),
        ],
    ]);

Why flash + redirect instead of returning the view from POST?

  • Refreshing the browser will not re-upload and re-process.
  • Effect checkboxes can be restored from options / old().
  • Keeps the URL as GET /.

The index action reads flash data and passes it to the view:

public function index(): View
{
    return view('image-demo', [
        'results' => session('results'),
    ]);
}

The Blade gallery loops variants and renders relative URLs:

@foreach ($results['variants'] as $variant)
    <img src="{{ $variant['url'] }}" alt="{{ $variant['label'] }}" loading="lazy">
    <h3>{{ $variant['label'] }}</h3>
    <p>{{ $variant['code'] }}</p>
    <p>
        {{ $variant['width'] }}*{{ $variant['height'] }}
        · {{ $variant['mime'] }}
        .{{ $variant['extension'] }}
    </p>
    <p>{{ $variant['path'] }}</p>
@endforeach

Note: flash data disappears after the next request. Bookmarking the results page will not keep the gallery — that is fine for a demo; a real blog would persist paths on a model.

Reuse for a blog featured image (still sync + local disk)

You do not need five variants for a post. The same API, trimmed:

$photo = $request->image('image')->orient();

$thumbPath = $photo->cover(800, 450)->optimize()
    ->storePublicly('blog/thumbs', 'public');

$displayPath = $photo->scale(width: 1600)->toWebp()->quality(80)
    ->storePublicly('blog/display', 'public');

// Save $thumbPath and $displayPath on your Post model
// Serve with '/storage/'.$path

On update, if a new file is uploaded, process again and delete the previous public files when present so you do not leak orphans.

Run the playground

npm install && npm run build
php artisan storage:link
php artisan serve

Open http://127.0.0.1:8000, upload a photo, toggle effects, process, and confirm files under storage/app/public/demos/.

Also verify PHP upload limits if large files fail before Laravel validation:

upload_max_filesize = 12M
post_max_size = 12M

(post_max_size should be ≥ upload_max_filesize.)

9. Comparison with existing libraries

Tool Role Choose it when…
Illuminate Image (Laravel 13.20+) Framework primitives: transform + encode + store You want first-party APIs and own your models/UI (this playground, simple featured images)
Intervention Image alone Lower-level library; what Laravel’s drivers wrap Non-Laravel PHP, or you need Intervention features outside the facade
spatie/laravel-medialibrary Eloquent (Object-Relational Mapping) media attachments, collections, conversions, responsive images Many models need many files, named conversions, and a battle-tested media domain

Practical rule: start with Illuminate Image for a single featured image or avatar pipeline. Reach for Media Library when media is the product surface (galleries, multiple collections, conversion presets per model).

You cannot remove Intervention after adopting Laravel’s Image API — the opposite is true: you install it so the drivers work.

10. Edge cases and gotchas

These are the failure modes we care about when moving from “it works on my JPEG” to real users.

Missing Intervention Image

Symptom: ImageException telling you to install Intervention.

Fix: composer require intervention/image:^4.0.

Wrong or missing PHP extension

Symptom: driver errors or blank failures when using GD/Imagick.

Fix: enable gd or imagick in PHP; set IMAGE_DRIVER to match. Our machine only had GD — Imagick was never an option without installing the extension.

HEIC / HEIF from iPhones

GD/Imagick as used here expect JPEG/PNG/WebP/etc. Many iPhone uploads arrive as HEIC (High Efficiency Image Container). Validation may reject them, or decoding may fail with ImageException.

Mitigations: ask users to export JPEG; convert HEIC server-side with a dedicated library/tool before Image::from…; or document supported formats clearly (mimes:jpg,jpeg,png,webp).

Corrupt or truncated uploads

A file may pass the browser accept filter but fail decoding. Laravel’s Image API surfaces this as ImageException.

Mitigation: wrap processing in try/catch and return a friendly validation-style error:

use Illuminate\Image\ImageException;

try {
    $photo = $request->image('photo')->orient();
    // …variants…
} catch (ImageException $e) {
    return back()
        ->withErrors(['photo' => 'We could not process that image. Try a JPG or PNG.'])
        ->withInput();
}

(Our playground does not catch yet — worth adding before production.)

Validation passes, PHP still rejects the upload

If upload_max_filesize / post_max_size are lower than your Laravel max:… rule, PHP empties the file input and you get confusing “required” / “image” failures.

Check: php -i | grep -E 'upload_max_filesize|post_max_size'.

Sideways photos (skipped orient())

Cameras store EXIF orientation separately from pixel layout. Skipping orient() produces sideways thumbs.

Rule: orient() first on any camera upload, then branch.

scale() does not enlarge small images

scale(width: 1200) on a 800px-wide image stays 800px. That is correct for quality, but surprising if you expected a fixed display width.

If you need exact dimensions: use cover / contain / resize deliberately — and accept crop, padding, or distortion trade-offs.

cover() crops faces awkwardly

Square or 16:9 covers crop from the center by default. Portraits can lose heads/feet.

Mitigations: wider contain for previews; manual crop UI; or accept cover only for controlled photography.

SVG, PDF, or non-raster “images”

The image rule and raster drivers are for bitmap formats. SVGs (Scalable Vector Graphics) are XML; treating them like JPEG is the wrong tool (and a security footgun if you serve user SVG inline).

Rule: keep SVG on a separate validation/path, or disallow it for this pipeline.

Polyglot / disguised files

A .jpg extension can still be something else. Rely on Laravel’s image / MIME validation, hashed storage names, and never execute uploaded files. Serving from a non-executable public disk path is the default with storage:link.

storage:link missing

Symptom: processing succeeds, gallery 404s on /storage/....

Fix: php artisan storage:link. Confirm public/storage points at storage/app/public.

Absolute URL host mismatch (our real bug)

Symptom: broken icons; files exist on disk; metadata looks correct.

Cause: Storage::url() + APP_URL=http://localhost while browsing 127.0.0.1:8000.

Fix: relative /storage/'.$path locally, or align APP_URL.

Flash results disappear on refresh / new tab

Session flash is one-shot. Fine for demos; for products, persist paths on Eloquent and load from the database.

Memory and time on large images

Decoding a 24 MP (megapixel) photo and writing five WebP variants in one request can exhaust memory or hit max_execution_time.

Mitigations: lower max upload size; downscale early; process fewer variants inline; raise memory_limit / max_execution_time carefully. For very large files, avoid doing every encode inside the HTTP request.

storePublicly returns false

Disk full, permissions, or misconfigured filesystem root. Check the return value and log failures instead of flashing empty paths.

Checkbox / boolean edge cases

Unchecked boxes are omitted. $request->boolean('grayscale') correctly becomes false. Do not write === '1' without handling missing keys.

Quality and format trade-offs

  • optimize() defaults to WebP quality 70 — smaller files, more compression.
  • Transparent PNGs converted to JPEG lose alpha; prefer WebP/PNG when transparency matters.
  • Animated GIFs are not a first-class “keep animation” feature of this demo pipeline — expect a still frame or unsupported behavior depending on driver/ops.

Re-processing the same upload

Each POST creates new hashed files. Without cleanup, demos fill storage/app/public/demos/. For a blog update flow, delete previous thumb/display paths when replacing the featured image.

Key Takeaways

  • Laravel 13.20 makes image pipelines feel native; still install Intervention Image v4.
  • Orient once, then branch immutable variants (cover / scale / contain).
  • storePublicly + storage:link + careful URLs (prefer relative locally) make the gallery work.
  • Validation, CSRF, and hashed names are the security floor for uploads.
  • Edge cases matter: HEIC, corrupt files, PHP size limits, scale not upscaling, memory, and URL host mismatch.
  • Illuminate Image is the primitive; Spatie Media Library is the media product layer.

Further Reading

Appendix: Taking this further with S3 and queues

The playground keeps everything on the local public disk and encodes variants inside the HTTP request. That is ideal for learning. In production you often want two upgrades that use the same Image API:

  1. Store files on S3 (Amazon Simple Storage Service) (or another cloud disk)
  2. Generate thumbnails/variants on a queue worker so the request stays fast

Why move off the request

Decoding a large photo and writing several WebP variants can burn CPU, memory, and seconds of latency. Synchronous multi-variant processing (like this playground) is fine for demos and modest images. Large camera files + five encodes in one request is where queues earn their keep.

S3 (cloud disk) sketch

Configure the s3 disk in config/filesystems.php and .env (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, etc.), then pass the disk name into the same storage helpers:

$path = $photo
    ->orient()
    ->cover(400, 400)
    ->optimize()
    ->storePublicly('demos/thumbs', 's3');

Serve with the disk URL (often a bucket URL or CloudFront). Visibility and bucket policies matter — storePublicly expects objects that can be read publicly, or you use a private disk with temporary signed URLs.

Queue sketch (paths in, never Image objects)

Critical rule: do not serialize Image instances onto the queue — Laravel throws. Persist first, pass paths (and disk name), then process in the worker.

Typical flow:

  1. In the controller, store the original quickly (local or S3).
  2. Save the path on your model / return a job id.
  3. Dispatch a job with disk + path (strings only).
  4. In the job, reload with Image::fromStorage($path, $disk), branch variants, storePublicly thumbs/display back to S3.
use Illuminate\Support\Facades\Image;

// Inside a queued job &mdash; strings only in the constructor
$photo = Image::fromStorage($this->path, $this->disk)->orient();

$thumb = $photo->cover(400, 400)->optimize()
    ->storePublicly('demos/thumbs', $this->disk);

$display = $photo->scale(width: 1200)->toWebp()->quality(80)
    ->storePublicly('demos/display', $this->disk);

Run a worker (php artisan queue:work) and handle retries / failures when decoding fails (ImageException).

This appendix is intentionally a sketch — the main article stays focused on the working local playground. S3 wiring, CDN (Content Delivery Network) URLs, and a full ShouldQueue job are the natural next implementation step on top of the same primitives.

For more insightful tutorials, visit our Tech Blogs and explore the latest in Laravel, AI, and Vue.js development

Scroll to Top