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 calltoBytes(). - 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|booleanand$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:10240is kilobytes in Laravel (≈ 10 MB), not bytes.- The
imagerule checks the file is an image by MIME (Multipurpose Internet Mail Extensions) type/contents — not just the extension. $request->image('photo')returnsnullif the field is missing; validaterequiredfirst 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/originalsdemos/thumbsdemos/displaydemos/containdemos/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:8000to 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:10240rejects non-images and oversized uploads. - CSRF:
@csrfon the form. - Hashed names:
storePubliclyavoids 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 (
throttlemiddleware) - Authorization (who can replace whose image)
- Stricter
mimes:jpg,jpeg,png,webpif 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:
- Validate input
$request->image('photo')->orient()- Branch and
storePubliclyeach variant - 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,
scalenot upscaling, memory, and URL host mismatch. - Illuminate Image is the primitive; Spatie Media Library is the media product layer.
Further Reading
- Laravel — Image Manipulation (official docs)
- Laravel News — Laravel 13.20.0 release coverage
- Intervention Image documentation
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:
- Store files on S3 (Amazon Simple Storage Service) (or another cloud disk)
- 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:
- In the controller, store the original quickly (local or S3).
- Save the path on your model / return a job id.
- Dispatch a job with
disk+path(strings only). - In the job, reload with
Image::fromStorage($path, $disk), branch variants,storePubliclythumbs/display back to S3.
use Illuminate\Support\Facades\Image;
// Inside a queued job — 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

