A file input and Storage::putFile() can make image work look finished. The browser accepts a JPEG, the application stores it, and a page renders something back. That is a demo.
The production question is different: what does the rest of the application get to rely on after an image arrives?
Does an upload retain the original bytes? Can a profile page request a 320-pixel square image without knowing which driver made it? Can a failed worker retry safely? Can an operator explain why a variant is missing, regenerate it after changing a crop rule, and roll back a bad release without deleting customer data?
Those are adoption questions. They decide whether image processing becomes a maintained part of a Laravel application or a fragile corner of a form request.
Laravel’s official documentation gives us durable pieces for this work: request validation, uploaded files, filesystem disks, queues, database transactions, events, HTTP testing, fake storage, and faked queues. Laravel 13.20 also documents a first-party, driver-based Image API for common transformations. That API can be a good processor implementation, but the decoder configuration, output policy, optimizer, and image CDN remain integration choices. Treat those choices as seams, keep their behavior outside the application’s public contract, and make the contract plain.

Define the contract before choosing an image library
Start with nouns and promises, not package names. A useful model has an image_assets record for the user-supplied original and image_variants records for derived files. The original is evidence. A variant is disposable output.
The asset contract can be small:
| Concern | Contract | Why it matters |
|---|---|---|
| Original | Store the accepted upload under a private, immutable object key | Regeneration and support remain possible |
| Metadata | Record disk, key, MIME type, byte size, and optional checksum | The database identifies the object without guessing paths |
| Variant identity | A variant has a named recipe and deterministic key | Repeated jobs do not create random duplicate files |
| Availability | A variant is pending, ready, or failed | The UI and operators see real state |
| Delivery | The application resolves a URL or response from a variant record | Views do not assemble storage paths |
| Deletion | Deletion is an explicit lifecycle operation | Orphan cleanup does not erase an original by accident |
Do not put a public URL in the contract when the asset may be private. Laravel’s filesystem documentation distinguishes file URLs from temporary URLs. A disk can provide a URL for publicly accessible files, while temporaryUrl is for time-limited access on supported drivers. The right delivery method depends on the disk and visibility policy. The rest of the application should call an asset presenter or delivery service, not reach directly into Storage from every Blade template and API resource.
A named recipe is more than thumbnail. Make the rule legible: avatar-square-320-v1, article-hero-1440-v2, or listing-card-640-v1. The final suffix makes a future change boring. When the crop rule changes, add a new recipe. Do not silently overwrite an existing variant and call it an upgrade. Existing pages, caches, and screenshots may rely on the previous output.
The deterministic key should include the asset’s stable identifier and the recipe name. A generated UUID is enough for the asset identity; a content hash is useful only if the product has a defined deduplication policy. Laravel can generate UUIDs through Str::uuid(), and the filesystem can write a stream or an uploaded file. Neither choice changes the contract: write the original once, derive variants from it, and keep the mapping in the database.
An asset record also gives you a place to put ownership and authorization. An avatar belongs to a user. A product image belongs to a catalog item. A private document preview may belong to an account. That association is an application rule, so keep it in your model layer instead of burying it inside a directory convention.
For a broader view of keeping Laravel boundaries readable as a product grows, see our Laravel and Vue SaaS guide. The same principle applies here: transport, persistence, background work, and presentation should agree on a small interface.
Accept uploads as untrusted input and persist the original first
Laravel’s validation documentation supports file rules such as image, mimes, mimetypes, max, and dimension rules. Use rules that express the product requirement. An avatar upload can require an image and cap size. A publisher workflow may accept a narrower set of MIME types. Validation is the front door, not image processing.
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Jobs\GenerateImageVariant;
6use App\Models\ImageAsset;
7use Illuminate\Http\Request;
8use Illuminate\Support\Facades\DB;
9use Illuminate\Support\Str;
10
11class AvatarUploadController
12{
13 public function store(Request $request)
14 {
15 $validated = $request->validate([
16 'avatar' => ['required', 'image', 'max:5120', 'dimensions:min_width=160,min_height=160'],
17 ]);
18
19 $file = $validated['avatar'];
20 $assetId = (string) Str::uuid();
21 $originalKey = "images/originals/{$assetId}/source";
22
23 $disk = 'private';
24 $stored = $file->storeAs(
25 dirname($originalKey),
26 basename($originalKey),
27 ['disk' => $disk]
28 );
29
30 $asset = DB::transaction(function () use ($request, $file, $assetId, $disk, $stored) {
31 return ImageAsset::create([
32 'id' => $assetId,
33 'user_id' => $request->user()->id,
34 'disk' => $disk,
35 'original_key' => $stored,
36 'mime_type' => $file->getMimeType(),
37 'byte_size' => $file->getSize(),
38 'status' => 'pending',
39 ]);
40 });
41
42 GenerateImageVariant::dispatch($asset->id, 'avatar-square-320-v1');
43
44 return response()->json([
45 'asset_id' => $asset->id,
46 'status' => $asset->status,
47 ], 202);
48 }
49}
This uses Laravel’s uploaded-file storage support rather than moving files with ad hoc PHP paths. The exact disk configuration belongs in config/filesystems.php; the application code names a disk and asks the filesystem abstraction to store the file. That makes a local disk, an S3-compatible object store, and a test fake interchangeable at the call site.
There is one uncomfortable boundary here: the object store write and the database transaction are not one atomic transaction. If the file write succeeds and the database insert fails, an original can be orphaned. If the database commit succeeds and dispatch later fails, the asset can remain pending. Do not hide this with a comment about atomicity. Pick a repair rule.
A practical rule is to write the original first, create the record in a database transaction, then enqueue work after the record exists. A scheduled maintenance command can find objects without records under the originals prefix and records whose originals are missing. Laravel’s scheduler and filesystem methods give you the building blocks; your retention period and deletion policy remain product decisions.
For strict delivery semantics, Laravel queues also document dispatching jobs after database transactions commit. Use the queue configuration or dispatch option described for your Laravel version when a worker must never observe an uncommitted asset record. The important point is not the syntax. It is the ordering: workers should only receive committed identities.
Source Laravel 13.x filesystem documentation
Make variants deterministic, idempotent, and outside the request
A resize operation has CPU, memory, decoder behavior, and failure modes. It does not belong on the critical path of a normal upload response. Laravel queues exist to move time-consuming work out of the web request. The controller should acknowledge the accepted original and let the client render a pending state or keep the previous image until a ready variant exists.
Use one job per asset and recipe. The job reads the original from the configured disk, invokes a processor behind an interface, writes the variant, and marks the variant ready. The processor is the seam. Laravel’s first-party Image API is one driver-based option; an application may also choose another library, a native tool, or a remote transformation service. Each choice has different runtime requirements and output behavior.
1<?php
2
3namespace App\Jobs;
4
5use App\Contracts\ImageProcessor;
6use App\Models\ImageAsset;
7use App\Models\ImageVariant;
8use Illuminate\Bus\Queueable;
9use Illuminate\Contracts\Queue\ShouldQueue;
10use Illuminate\Foundation\Bus\Dispatchable;
11use Illuminate\Queue\InteractsWithQueue;
12use Illuminate\Support\Facades\Storage;
13
14class GenerateImageVariant implements ShouldQueue
15{
16 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
17
18 public function __construct(
19 public string $assetId,
20 public string $recipe,
21 ) {
22 }
23
24 public function handle(ImageProcessor $processor): void
25 {
26 $asset = ImageAsset::findOrFail($this->assetId);
27 $key = "images/variants/{$asset->id}/{$this->recipe}.jpg";
28
29 $variant = ImageVariant::firstOrCreate(
30 ['image_asset_id' => $asset->id, 'recipe' => $this->recipe],
31 ['disk' => $asset->disk, 'object_key' => $key, 'status' => 'pending']
32 );
33
34 if ($variant->status === 'ready' && Storage::disk($variant->disk)->exists($variant->object_key)) {
35 return;
36 }
37
38 $source = Storage::disk($asset->disk)->get($asset->original_key);
39 $result = $processor->make($source, $this->recipe);
40
41 Storage::disk($variant->disk)->put($variant->object_key, $result->contents());
42
43 $variant->forceFill([
44 'mime_type' => $result->mimeType(),
45 'byte_size' => $result->byteSize(),
46 'status' => 'ready',
47 'failed_at' => null,
48 ])->save();
49 }
50}
The job checks both database state and object existence because database state alone cannot prove that an object survived a lifecycle event. A retry after put() succeeds but before the database update should write the same deterministic key again. That is safe when the recipe is deterministic and the write has replacement semantics. If the processor produces nondeterministic output, fix the processor or version the recipe. Do not make retries create fresh names.
Set queue retry and timeout values from measured behavior in your environment. The Laravel queue documentation covers attempts, backoff, failed jobs, and job middleware. Your application should decide which errors are retryable. A transient object-store timeout is not the same as an unsupported image format. Record a failure code and message that an operator can act on, while keeping raw decoder details out of public responses.

A useful refinement is to separate acquisition from processing. The web request writes an original. The worker resolves a recipe. The processor returns bytes and metadata. The storage adapter writes the output. A delivery service turns a ready variant into a public URL, temporary URL, or streamed response. That separation makes a later CDN migration a delivery concern, not a controller rewrite.
The integration seam to document
ImageProcessor::make(string $source, string $recipe) as an application contract. State the allowed input types, crop and orientation rules, output format, quality policy, maximum decoded dimensions, error types, and whether output is deterministic. Bind that contract in the service container. The adapter can change without changing controllers, jobs, or views.Keep storage, delivery, and deletion as separate policies
Laravel’s filesystem abstraction is deliberately broad. It can write and read files on configured disks, generate URLs for disks that support them, and generate temporary URLs where supported. That broadness is a reason to avoid inventing a single global public_path() convention.
Originals and variants often deserve different visibility. A customer upload may need to stay private, while a finished public product thumbnail can live on a public disk. Or both can remain private and be delivered through a controller after authorization. The contract should name which one applies per asset category.
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\ImageVariant;
6use Illuminate\Http\Request;
7use Illuminate\Support\Facades\Storage;
8
9class ImageVariantController
10{
11 public function show(Request $request, ImageVariant $variant)
12 {
13 abort_unless($variant->status === 'ready', 404);
14 abort_unless($request->user()->can('view', $variant->imageAsset), 403);
15
16 return Storage::disk($variant->disk)->response(
17 $variant->object_key,
18 "{$variant->recipe}.jpg",
19 ['Content-Type' => $variant->mime_type]
20 );
21 }
22}
A controller response is one verified Laravel filesystem pattern. A URL from Storage::url() is another, when the disk and visibility are appropriate. A temporary URL is another, when supported by the disk. Do not promise that one works everywhere. Declare the delivery policy in the asset category and test it against the configured driver.
Deletion needs the same care. Deleting an image from a page must answer: delete which records, which objects, and when? The safest default is to mark the asset deleted in the database, remove variants first, and remove the original only after the retention rule allows it. A background job can perform object deletion. If it fails, the record retains enough identity to retry. This prevents a controller timeout from leaving a half-deleted asset whose state nobody understands.
Laravel model events can help initiate cleanup, but do not make event timing your only operational story. A maintenance command that reconciles database records and storage prefixes gives the team a repair path. That path should log asset IDs, keys, and action results. It should support dry-run output before destructive operations.
For the operational side of this choice, our developer tools notes and AI agent operations guide are useful context: prefer tools that make a repair observable and repeatable over scripts that only work on the original author’s laptop.
Test the boundary you own, then test the adapter separately
Image work gets hard to test when tests decode actual pixels everywhere. Laravel’s fakes let you test the application boundary without requiring production object storage or a queue worker. Storage::fake() supplies a fake disk, and Laravel’s test utilities include fake uploaded files. Queue::fake() lets a test assert that the application dispatched the correct job.
The upload endpoint test should prove the contract: accepted input creates an asset, stores an original on the named disk, and dispatches one recipe job. It should not assert a particular library call from the controller.
1<?php
2
3namespace Tests\Feature;
4
5use App\Jobs\GenerateImageVariant;
6use App\Models\User;
7use Illuminate\Foundation\Testing\RefreshDatabase;
8use Illuminate\Http\UploadedFile;
9use Illuminate\Support\Facades\Queue;
10use Illuminate\Support\Facades\Storage;
11use Tests\TestCase;
12
13class AvatarUploadTest extends TestCase
14{
15 use RefreshDatabase;
16
17 public function test_an_avatar_upload_stores_an_original_and_queues_a_variant(): void
18 {
19 Storage::fake('private');
20 Queue::fake();
21
22 $user = User::factory()->create();
23 $file = UploadedFile::fake()->image('avatar.jpg', 640, 640);
24
25 $response = $this->actingAs($user)->postJson('/avatar', [
26 'avatar' => $file,
27 ]);
28
29 $response->assertAccepted();
30
31 $asset = $user->imageAssets()->firstOrFail();
32
33 Storage::disk('private')->assertExists($asset->original_key);
34
35 Queue::assertPushed(GenerateImageVariant::class, function ($job) use ($asset) {
36 return $job->assetId === $asset->id
37 && $job->recipe === 'avatar-square-320-v1';
38 });
39 }
40}
The job needs a separate test with a fake ImageProcessor. Feed it fixed source bytes, have the fake return fixed output bytes and metadata, call handle(), then assert the variant record is ready and the fake disk contains the deterministic key. Add a retry test: run the job twice and assert there is still one variant record and one key. Add a missing-original test. Add a processor-exception test that records failure according to your chosen policy.
Test the real processor adapter at a smaller level. Keep a fixture image with known dimensions and orientation. Assert the output dimensions, MIME type, and crop location that the recipe promises. This is where you discover differences between local machines and production images. It is also where you pin down whether an underlying decoder respects EXIF orientation. Laravel does not define that behavior, so the adapter specification must.
For a concise walkthrough of keeping feature tests around visible user behavior, see our Laravel and Vue SaaS guide. The image case follows the same rule: test the application promise, not accidental implementation detail.
Plan changes and rollback as part of the recipe
The easiest image change to deploy is a new recipe name. article-hero-1440-v2 can coexist with article-hero-1440-v1. New requests can use v2 after it is generated; old pages can keep v1 until a migration completes. If v2 has a bad crop rule, switch the resolver back to v1 and stop enqueueing v2. The original remains intact.
Overwriting a key such as hero.jpg removes that rollback. Caches may serve a mixture of old and new bytes. A user can refresh into a different crop. An incident becomes an argument about CDN invalidation instead of a database update. Versioned recipes turn the rollback into a clear, reversible maintenance decision.

Use a migration plan for mass regeneration:
- Add the new recipe and processor support without changing delivery.
- Backfill variants in bounded queue batches.
- Measure job failures and inspect sampled outputs through the normal delivery path.
- Switch the resolver for new and eligible existing assets.
- Keep the previous recipe until the retention window ends.
- Delete old variants with a logged, dry-run-capable maintenance command.
This is deliberately low data and reversible. You do not need invented throughput claims to decide whether it works. Count assets selected, jobs completed, failed jobs, variants ready, and variants missing after a reconciliation pass. Inspect a small set of representative inputs: portrait orientation, transparency if supported, large dimensions, unusual filenames, and an image near the size boundary. Those checks reveal real integration problems.
Do not ship a schema change, a new processor, and a destructive cleanup in one release. Each changes a different layer. A phased rollout leaves a place to stop.
Source Laravel 13.x queues documentation
What you should do Monday morning
Write down one asset category, one original policy, and one variant recipe. Pick the category already causing the most support friction, often avatars, listing photos, or article heroes. Do not begin by installing a processor.
Create an image_assets table that records ownership, disk, original key, MIME type, byte size, and status. Create an image_variants table with an asset foreign key, recipe, disk, object key, status, and error fields. Add a uniqueness constraint on the asset and recipe pair. This makes duplicate dispatches visible as an integrity rule rather than a production mystery.
Then implement the smallest vertical slice:
- validate one upload type with Laravel validation;
- write the accepted original to a private disk using Laravel’s filesystem;
- create the asset record;
- dispatch one queue job after the record is committed;
- make a fake processor return fixed bytes;
- write the deterministic variant key;
- expose
pending,ready, andfailedstates to the caller; - add the upload feature test and the job idempotency test.
Leave the real image adapter behind the interface until this path works. When you add it, document the local prerequisites: installed PHP extensions or binary, memory expectations, supported input formats, worker image or container details, and fixture tests. That documentation is part of the adoption contract. A teammate must be able to run it without rediscovering hidden machine state.
Finally, add a maintenance command in dry-run mode that compares asset records to original objects and variant records to variant objects. Run it in staging before you need it. Recovery work is much cheaper when it is a known command instead of a late-night exploration of a bucket prefix.
Further reading
These Laravel official documentation pages are the factual base for the framework patterns in this article. They describe the framework facilities; the asset model, recipe naming, processor interface, and retention policy are application design choices.
- Laravel validation: validating files
- Laravel requests: retrieving uploaded files
- Laravel filesystem
- Laravel image manipulation
- Laravel queues
- Laravel database: transactions
- Laravel testing
- Laravel task scheduling
The upload is a single request. The adoption contract is everything that lets the next request, the next worker retry, and the next engineer handle the image without guesswork.
