Skip to content
Tech

Handling File Uploads and Image Processing in Laravel

A practical guide to validating uploads, storing them safely, and generating resized image variants in a Laravel application.

Handling File Uploads and Image Processing in Laravel
Handling File Uploads and Image Processing in Laravel
In this article

    File uploads look simple right up until the first user uploads a 40 megabyte PNG renamed to photo.jpg, or a malicious script with an image extension. Handling them well means validating properly, storing files somewhere that isn't directly executable, and generating the sizes your UI actually needs instead of serving the original everywhere.

    It's also one of the few parts of a typical web application where user input becomes a file sitting on your server's disk, which changes the risk calculus compared to a text field. A bad string in a database column stays a string; a bad file can be something a web server is willing to execute if it ends up in the wrong directory with the wrong extension handling enabled.

    Validating What Actually Came In

    Laravel's file validation rules check more than the extension — mimes inspects the actual file content, which catches a renamed file that an extension check alone would miss.

    public function rules(): array
    {
        return [
            'image' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:min_width=200,min_height=200'],
        ];
    }

    max:5120 is in kilobytes, so this caps uploads at 5MB. The dimensions rule rejects a technically valid but useless 10x10 pixel image before it ever reaches storage.

    Storing Uploads Outside the Public Webroot

    Store uploaded files on a disk that isn't directly web-accessible by default, and serve them through a controller or a signed URL instead. This gives you a chokepoint for authorization checks before someone can view a file.

    'disks' => [
        'uploads' => [
            'driver' => 'local',
            'root' => storage_path('app/uploads'),
            'visibility' => 'private',
        ],
    ],
    public function store(StoreProductImageRequest $request, Product $product): JsonResponse
    {
        $path = $request->file('image')->store('products/'.$product->id, 'uploads');
    
        $product->images()->create(['path' => $path]);
    
        return response()->json(['path' => $path], 201);
    }

    Generating Resized Variants

    Serving a five megapixel original as a 60-pixel thumbnail wastes bandwidth and slows down every page that shows it. The Intervention Image library handles resizing cleanly on top of GD or Imagick.

    use Intervention\Image\ImageManager;
    use Intervention\Image\Drivers\Gd\Driver;
    
    class GenerateImageVariants
    {
        public function __construct(private ImageManager $manager = new ImageManager(new Driver()))
        {
        }
    
        public function handle(string $originalPath): array
        {
            $image = $this->manager->read(Storage::disk('uploads')->path($originalPath));
    
            $variants = [];
    
            foreach (['thumb' => 150, 'medium' => 600] as $name => $width) {
                $resized = (clone $image)->scale(width: $width);
                $variantPath = str_replace('.', "-{$name}.", $originalPath);
    
                Storage::disk('uploads')->put($variantPath, (string) $resized->toJpeg(quality: 80));
                $variants[$name] = $variantPath;
            }
    
            return $variants;
        }
    }

    Running this inside a queued job, triggered by the upload, means the request that accepts the file returns quickly while resizing happens in the background.

    Tip: Always re-encode uploaded images rather than just copying the original bytes, even when you're not resizing. Re-encoding strips embedded scripts some image formats can carry and normalizes orientation metadata that otherwise causes sideways thumbnails.

    Serving Files Safely

    Route::get('/products/{product}/images/{image}', function (Product $product, ProductImage $image) {
        abort_unless($image->product_id === $product->id, 404);
        Gate::authorize('view', $product);
    
        return Storage::disk('uploads')->response($image->path);
    })->name('product-images.show');

    This route checks ownership and authorization before streaming a single byte, which a public-facing storage path cannot do.

    Cleaning Up Orphaned Files

    Uploads that never get attached to a finished record — a user who starts filling out a form, uploads a photo, and abandons the page — accumulate quietly on disk. A scheduled command that deletes files older than a day with no matching database record keeps storage from growing indefinitely, and it's a handful of lines wired into Laravel's scheduler rather than a separate cron job to maintain.

    Practical Checklist

    • Validate MIME type by content, not extension, and cap file size explicitly.
    • Store user uploads outside the public disk unless you have a specific reason not to.
    • Generate the sizes your UI uses rather than resizing on every request.
    • Re-encode images on upload instead of trusting the original bytes.
    • Clean up orphaned uploads on a schedule instead of letting them accumulate.

    None of this is complicated in isolation — validation rules, a private disk, a resize job — but skipping any one of them is how an innocuous upload form becomes the weakest point in an otherwise careful application.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.