Why Laravel 12 Makes File Uploads Simple and Secure


Laravel 12 ships with a robust filesystem abstraction layer powered by Flysystem, making it incredibly easy to work with local files, Amazon S3, and other cloud storage providers using a single, unified API. Here is why Laravel is one of the best frameworks for handling uploads:
  • Built-in request validation for file type, size, dimensions, and MIME
  • Unified Storage facade for local, public, S3, and custom disks
  • Automatic file hashing to prevent filename collisions
  • Simple symlink creation for publicly accessible files
  • Support for single file, multiple file, and chunked uploads
  • Secure by default — CSRF protection, input sanitization, and extension whitelisting
By the end of this guide, you will have a fully functional file upload system with proper validation, storage management, and a clean user interface.

File Uploads and Validation in Laravel 12: Complete Guide with Examples

Table Of Content

1 Prerequisites

  • PHP 8.2+
  • Composer
  • MySQL / MariaDB
  • Laravel 12 (latest stable)
  • Basic understanding of Laravel MVC pattern

2 Introduction

In this step-by-step tutorial, you will learn how to build a complete file upload system in Laravel 12. We will cover single file uploads with image preview, multiple file uploads with batch processing, comprehensive server-side validation (file type, size, dimensions, MIME type), storing files using the Storage facade on the public disk, displaying uploaded files in a gallery view, and safely deleting files from both the database and storage. No external packages required — everything is built with pure Laravel features.

3 Install Laravel 12 Project

3.1 Create Laravel 12 Project

Make sure Composer is installed on your machine.
Run the following command to create a new Laravel 12 project:

composer create-project laravel/laravel laravel-file-upload

Then navigate to your project directory:

cd laravel-file-upload

3.2 Configure Database (.env)

Open the .env file and update the database configuration:

APP_NAME="Laravel File Upload"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_file_upload
DB_USERNAME=root
DB_PASSWORD=

Create the database laravel_file_upload in MySQL before running migrations.

4 Create Migration & Model

Generate the model and migration together using Artisan:

php artisan make:model UploadedFile -m

Update the Migration File
Open the generated migration file in database/migrations/ and update the up() method:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('uploaded_files', function (Blueprint $table) {
            $table->id();
            $table->string('original_name');       // Original file name
            $table->string('stored_name');          // Hashed stored name
            $table->string('file_path');             // Full storage path
            $table->string('mime_type')->nullable(); // e.g. image/jpeg
            $table->unsignedBigInteger('file_size')->default(0); // Size in bytes
            $table->string('extension')->nullable(); // e.g. jpg, pdf
            $table->string('disk')->default('public'); // Storage disk used
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('uploaded_files');
    }
};
?>

Run the migration:

php artisan migrate


Update the UploadedFile Model
Open app/Models/UploadedFile.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class UploadedFile extends Model
{
    protected $fillable = [
        'original_name',
        'stored_name',
        'file_path',
        'mime_type',
        'file_size',
        'extension',
        'disk',
    ];

    /**
     * Get the public URL for the file.
     */
    public function getUrlAttribute(): string
    {
        return Storage::disk($this->disk)->url($this->file_path);
    }

    /**
     * Get human-readable file size.
     */
    public function getReadableSizeAttribute(): string
    {
        $bytes = $this->file_size;
        $units = ['B', 'KB', 'MB', 'GB'];
        $index = 0;

        while ($bytes >= 1024 && $index < count($units) - 1) {
            $bytes /= 1024;
            $index++;
        }

        return round($bytes, 2) . ' ' . $units[$index];
    }

    /**
     * Check if the file is an image.
     */
    public function isImage(): bool
    {
        return str_starts_with($this->mime_type, 'image/');
    }
}
?>

5 Create Upload Controller

Generate the controller:

php artisan make:controller FileUploadController

Open app/Http/Controllers/FileUploadController.php and add the following code:

<?php

namespace App\Http\Controllers;

use App\Models\UploadedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class FileUploadController extends Controller
{
    /**
     * Display the upload form and list of uploaded files.
     */
    public function index()
    {
        $files = UploadedFile::latest()->paginate(12);
        return view('uploads.index', compact('files'));
    }

    /**
     * Show the single file upload form.
     */
    public function create()
    {
        return view('uploads.create');
    }

    /**
     * Handle single file upload with validation.
     */
    public function store(Request $request)
    {
        $request->validate([
            'file' => [
                'required',
                'file',
                'max:5120',                    // Max 5MB
                'mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xlsx,csv,zip',
            ],
        ], [
            'file.required'  => 'Please select a file to upload.',
            'file.max'       => 'File size must not exceed 5MB.',
            'file.mimes'     => 'Allowed types: jpg, png, gif, webp, pdf, doc, docx, xlsx, csv, zip.',
        ]);

        $file = $request->file('file');

        // Store file on public disk in "uploads" folder
        $storedPath = $file->store('uploads', 'public');

        // Save file metadata to database
        UploadedFile::create([
            'original_name' => $file->getClientOriginalName(),
            'stored_name'   => basename($storedPath),
            'file_path'     => $storedPath,
            'mime_type'     => $file->getMimeType(),
            'file_size'     => $file->getSize(),
            'extension'     => $file->getClientOriginalExtension(),
            'disk'          => 'public',
        ]);

        return redirect()->route('uploads.index')
                         ->with('success', 'File uploaded successfully!');
    }

    /**
     * Handle multiple file uploads.
     */
    public function storeMultiple(Request $request)
    {
        $request->validate([
            'files'   => 'required|array|min:1|max:10',
            'files.*' => 'file|max:5120|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xlsx,csv,zip',
        ], [
            'files.required'  => 'Please select at least one file.',
            'files.max'       => 'You can upload a maximum of 10 files at once.',
            'files.*.max'     => 'Each file must not exceed 5MB.',
            'files.*.mimes'   => 'Allowed types: jpg, png, gif, webp, pdf, doc, docx, xlsx, csv, zip.',
        ]);

        $uploadedCount = 0;

        foreach ($request->file('files') as $file) {
            $storedPath = $file->store('uploads', 'public');

            UploadedFile::create([
                'original_name' => $file->getClientOriginalName(),
                'stored_name'   => basename($storedPath),
                'file_path'     => $storedPath,
                'mime_type'     => $file->getMimeType(),
                'file_size'     => $file->getSize(),
                'extension'     => $file->getClientOriginalExtension(),
                'disk'          => 'public',
            ]);

            $uploadedCount++;
        }

        return redirect()->route('uploads.index')
                         ->with('success', $uploadedCount . ' file(s) uploaded successfully!');
    }

    /**
     * Delete a file from storage and database.
     */
    public function destroy(UploadedFile $uploadedFile)
    {
        // Delete from storage
        if (Storage::disk($uploadedFile->disk)->exists($uploadedFile->file_path)) {
            Storage::disk($uploadedFile->disk)->delete($uploadedFile->file_path);
        }

        // Delete database record
        $uploadedFile->delete();

        return redirect()->route('uploads.index')
                         ->with('success', 'File deleted successfully!');
    }

    /**
     * Download a file.
     */
    public function download(UploadedFile $uploadedFile)
    {
        $path = Storage::disk($uploadedFile->disk)->path($uploadedFile->file_path);

        if (!file_exists($path)) {
            return redirect()->route('uploads.index')
                             ->with('error', 'File not found on server.');
        }

        return response()->download($path, $uploadedFile->original_name);
    }
}
?>

6 File Validation Rules Explained

Laravel 12 provides a comprehensive set of validation rules specifically designed for file uploads. Here is a reference of the most commonly used rules:


// Basic file validation
'file'      => 'required|file'              // Must be a valid uploaded file

// Size validation (in kilobytes)
'file'      => 'file|max:5120'              // Max 5MB (5120 KB)
'file'      => 'file|min:1'                 // Min 1KB
'file'      => 'file|between:100,5120'      // Between 100KB and 5MB

// Extension / MIME validation
'file'      => 'file|mimes:jpg,png,pdf'     // Allowed extensions
'file'      => 'file|mimetypes:image/jpeg,application/pdf' // By MIME type

// Image-specific validation
'image'     => 'image'                       // Must be an image (jpg, png, gif, bmp, svg, webp)
'image'     => 'image|dimensions:min_width=100,min_height=100'  // Min dimensions
'image'     => 'image|dimensions:max_width=2000,max_height=2000' // Max dimensions
'image'     => 'image|dimensions:width=500,height=500'           // Exact dimensions
'image'     => 'image|dimensions:ratio=16/9'                     // Aspect ratio

// Multiple file validation
'files'     => 'required|array|min:1|max:10' // Array of 1 to 10 files
'files.*'   => 'file|max:5120|mimes:jpg,png' // Validate each file in array


Image-Only Upload Example with Dimensions
If you only want to accept images with specific constraints:

$request->validate([
    'avatar' => [
        'required',
        'image',
        'mimes:jpg,jpeg,png,webp',
        'max:2048',  // 2MB max
        'dimensions:min_width=200,min_height=200,max_width=2000,max_height=2000',
    ],
]);


Custom Error Messages
Always provide user-friendly error messages for file validation:

$request->validate([
    'document' => 'required|file|max:10240|mimes:pdf,doc,docx',
], [
    'document.required' => 'Please upload a document.',
    'document.max'      => 'Document must be smaller than 10MB.',
    'document.mimes'    => 'Only PDF and Word documents are accepted.',
]);

7 Create Blade Views (Bootstrap 5)

resources/views/layouts/app.blade.php (Master Layout)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@yield('title', 'Laravel 12 File Upload')</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">

    <style>
        body { background-color: #f4f6f9; }
        .card { border: none; border-radius: 0.75rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
        .navbar-brand { font-weight: 700; letter-spacing: -0.5px; }
        .file-thumb { width: 100%; height: 180px; object-fit: cover; border-radius: 0.75rem 0.75rem 0 0; }
        .file-icon { font-size: 4rem; color: #6c757d; }
        .upload-zone { border: 2px dashed #dee2e6; border-radius: 0.75rem; padding: 2rem; text-align: center; transition: all 0.3s; }
        .upload-zone:hover { border-color: #0d6efd; background-color: #f8f9ff; }
    </style>
</head>
<body>

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ route('uploads.index') }}">
                <i class="fas fa-cloud-upload-alt me-2"></i>File Upload Demo
            </a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ route('uploads.index') }}">All Files</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ route('uploads.create') }}">Single Upload</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ route('uploads.multiple') }}">Multiple Upload</a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>

    <div class="container mt-4">
        @if(session('success'))
            <div class="alert alert-success alert-dismissible fade show">
                {{ session('success') }}
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        @endif
        @if(session('error'))
            <div class="alert alert-danger alert-dismissible fade show">
                {{ session('error') }}
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        @endif
        @if($errors->any())
            <div class="alert alert-danger alert-dismissible fade show">
                <ul class="mb-0">
                    @foreach($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        @endif
    </div>

    <main class="container my-5">
        @yield('content')
    </main>

    <footer class="text-center py-4" style="background:#212529;color:#adb5bd;">
        <p class="mb-0">&copy; {{ date('Y') }} Laravel 12 File Upload Tutorial. All rights reserved.</p>
    </footer>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
    @yield('scripts')
</body>
</html>

resources/views/uploads/create.blade.php (Single File Upload Form)

@extends('layouts.app')

@section('title', 'Upload File')

@section('content')
<div class="row justify-content-center">
    <div class="col-lg-7">
        <div class="card">
            <div class="card-header bg-primary text-white">
                <h4 class="mb-0"><i class="fas fa-upload me-2"></i>Upload Single File</h4>
            </div>
            <div class="card-body p-4">
                <form action="{{ route('uploads.store') }}" method="POST" enctype="multipart/form-data">
                    @csrf

                    <div class="upload-zone mb-4">
                        <i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
                        <p class="text-muted mb-2">Drag and drop or click to select a file</p>
                        <input type="file" name="file" id="file" class="form-control" required>
                        <small class="text-muted d-block mt-2">
                            Allowed: jpg, png, gif, webp, pdf, doc, docx, xlsx, csv, zip | Max: 5MB
                        </small>
                    </div>

                    <!-- Image Preview -->
                    <div id="imagePreview" class="mb-4 text-center" style="display:none;">
                        <img id="previewImg" src="" alt="Preview" class="img-thumbnail" style="max-height:250px;">
                    </div>

                    <button type="submit" class="btn btn-primary btn-lg w-100">
                        <i class="fas fa-upload me-1"></i> Upload File
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>
@endsection

@section('scripts')
<script>
    document.getElementById('file').addEventListener('change', function(e) {
        const file = e.target.files[0];
        const preview = document.getElementById('imagePreview');
        const img = document.getElementById('previewImg');

        if (file && file.type.startsWith('image/')) {
            const reader = new FileReader();
            reader.onload = function(e) {
                img.src = e.target.result;
                preview.style.display = 'block';
            };
            reader.readAsDataURL(file);
        } else {
            preview.style.display = 'none';
        }
    });
</script>
@endsection

resources/views/uploads/multiple.blade.php (Multiple File Upload Form)

@extends('layouts.app')

@section('title', 'Upload Multiple Files')

@section('content')
<div class="row justify-content-center">
    <div class="col-lg-7">
        <div class="card">
            <div class="card-header bg-success text-white">
                <h4 class="mb-0"><i class="fas fa-images me-2"></i>Upload Multiple Files</h4>
            </div>
            <div class="card-body p-4">
                <form action="{{ route('uploads.store.multiple') }}" method="POST" enctype="multipart/form-data">
                    @csrf

                    <div class="upload-zone mb-4">
                        <i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
                        <p class="text-muted mb-2">Select up to 10 files at once</p>
                        <input type="file" name="files[]" id="files" class="form-control" multiple required>
                        <small class="text-muted d-block mt-2">
                            Allowed: jpg, png, gif, webp, pdf, doc, docx, xlsx, csv, zip | Max: 5MB each | Max 10 files
                        </small>
                    </div>

                    <div id="fileList" class="mb-3"></div>

                    <button type="submit" class="btn btn-success btn-lg w-100">
                        <i class="fas fa-upload me-1"></i> Upload All Files
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>
@endsection

@section('scripts')
<script>
    document.getElementById('files').addEventListener('change', function(e) {
        const list = document.getElementById('fileList');
        list.innerHTML = '';
        for (const file of e.target.files) {
            const item = document.createElement('div');
            item.className = 'd-flex justify-content-between align-items-center bg-light rounded p-2 mb-2';
            const sizeMB = (file.size / 1024 / 1024).toFixed(2);
            item.innerHTML = '<span><i class="fas fa-file me-2"></i>' + file.name + '</span><span class="badge bg-secondary">' + sizeMB + ' MB</span>';
            list.appendChild(item);
        }
    });
</script>
@endsection

resources/views/uploads/index.blade.php (Gallery / File List)

@extends('layouts.app')

@section('title', 'Uploaded Files')

@section('content')
<div class="d-flex justify-content-between align-items-center mb-4">
    <h2 class="fw-bold">Uploaded Files</h2>
    <div>
        <a href="{{ route('uploads.create') }}" class="btn btn-primary me-2">
            <i class="fas fa-upload me-1"></i> Single Upload
        </a>
        <a href="{{ route('uploads.multiple') }}" class="btn btn-success">
            <i class="fas fa-images me-1"></i> Multiple Upload
        </a>
    </div>
</div>

<div class="row">
    @forelse($files as $file)
        <div class="col-md-6 col-lg-3 mb-4">
            <div class="card h-100">
                @if($file->isImage())
                    <img src="{{ $file->url }}" alt="{{ $file->original_name }}" class="file-thumb">
                @else
                    <div class="d-flex justify-content-center align-items-center" style="height:180px;background:#f8f9fa;border-radius:0.75rem 0.75rem 0 0;">
                        <i class="fas fa-file file-icon"></i>
                    </div>
                @endif
                <div class="card-body">
                    <h6 class="card-title text-truncate" title="{{ $file->original_name }}">
                        {{ $file->original_name }}
                    </h6>
                    <p class="card-text small text-muted mb-1">
                        <span class="badge bg-info">{{ strtoupper($file->extension) }}</span>
                        {{ $file->readable_size }}
                    </p>
                    <p class="card-text small text-muted">{{ $file->created_at->format('M d, Y H:i') }}</p>
                </div>
                <div class="card-footer bg-white border-0 d-flex justify-content-between">
                    <a href="{{ route('uploads.download', $file) }}" class="btn btn-sm btn-outline-primary">
                        <i class="fas fa-download"></i>
                    </a>
                    <form action="{{ route('uploads.destroy', $file) }}" method="POST"
                          onsubmit="return confirm('Delete this file?')">
                        @csrf
                        @method('DELETE')
                        <button class="btn btn-sm btn-outline-danger">
                            <i class="fas fa-trash"></i>
                        </button>
                    </form>
                </div>
            </div>
        </div>
    @empty
        <div class="col-12">
            <div class="alert alert-info text-center py-5">
                <i class="fas fa-inbox fa-3x mb-3 d-block"></i>
                No files uploaded yet. <a href="{{ route('uploads.create') }}">Upload your first file!</a>
            </div>
        </div>
    @endforelse
</div>

<div class="d-flex justify-content-center mt-4">
    {{ $files->links() }}
</div>
@endsection

8 Define Routes

Open routes/web.php and add the following routes:

<?php

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

// File gallery / list
Route::get('/', [FileUploadController::class, 'index'])->name('uploads.index');

// Single file upload
Route::get('/upload', [FileUploadController::class, 'create'])->name('uploads.create');
Route::post('/upload', [FileUploadController::class, 'store'])->name('uploads.store');

// Multiple file upload
Route::get('/upload/multiple', function () {
    return view('uploads.multiple');
})->name('uploads.multiple');
Route::post('/upload/multiple', [FileUploadController::class, 'storeMultiple'])->name('uploads.store.multiple');

// Download and delete
Route::get('/download/{uploadedFile}', [FileUploadController::class, 'download'])->name('uploads.download');
Route::delete('/delete/{uploadedFile}', [FileUploadController::class, 'destroy'])->name('uploads.destroy');
?>

Laravel stores uploaded files in the storage/app/public directory by default. To make these files accessible from the browser, you need to create a symbolic link:

php artisan storage:link

This creates a symlink from public/storagestorage/app/public.

After running this command, any file stored via Storage::disk('public') can be accessed in the browser at:

http://localhost:8000/storage/uploads/your-file-name.jpg


Important Notes:
  • Run php artisan storage:link only once per project
  • On deployment servers, you may need to create the symlink manually if Artisan cannot do it
  • In your .env, make sure FILESYSTEM_DISK=public if you want public to be the default
  • Never store sensitive files on the public disk — use the local disk (private) instead

10 Multiple File Upload

The key differences when handling multiple file uploads in Laravel 12:

1. HTML Form — use array notation in the input name and add multiple attribute:

<input type="file" name="files[]" multiple>

2. Validation — validate the array and each element separately:

$request->validate([
    'files'   => 'required|array|min:1|max:10',  // Array rules
    'files.*' => 'file|max:5120|mimes:jpg,png,pdf', // Per-file rules
]);

3. Processing — loop through the files array:

foreach ($request->file('files') as $file) {
    $path = $file->store('uploads', 'public');

    UploadedFile::create([
        'original_name' => $file->getClientOriginalName(),
        'stored_name'   => basename($path),
        'file_path'     => $path,
        'mime_type'     => $file->getMimeType(),
        'file_size'     => $file->getSize(),
        'extension'     => $file->getClientOriginalExtension(),
        'disk'          => 'public',
    ]);
}


The files.* notation tells Laravel to apply the validation rules to each individual file inside the files array. This ensures that even if one file in a batch fails validation, the appropriate error is shown.

11 Delete Uploaded Files

When deleting files, always remove both the physical file from storage and the database record:

use Illuminate\Support\Facades\Storage;

public function destroy(UploadedFile $uploadedFile)
{
    // Step 1: Delete the physical file from storage
    if (Storage::disk($uploadedFile->disk)->exists($uploadedFile->file_path)) {
        Storage::disk($uploadedFile->disk)->delete($uploadedFile->file_path);
    }

    // Step 2: Delete the database record
    $uploadedFile->delete();

    return redirect()->route('uploads.index')
                     ->with('success', 'File deleted successfully!');
}


Useful Storage Methods Reference:

// Check if file exists
Storage::disk('public')->exists('uploads/file.jpg');

// Delete a single file
Storage::disk('public')->delete('uploads/file.jpg');

// Delete multiple files
Storage::disk('public')->delete(['uploads/file1.jpg', 'uploads/file2.jpg']);

// Get file size (bytes)
Storage::disk('public')->size('uploads/file.jpg');

// Get file last modified time
Storage::disk('public')->lastModified('uploads/file.jpg');

// Get file URL
Storage::disk('public')->url('uploads/file.jpg');

// Get file full path
Storage::disk('public')->path('uploads/file.jpg');

// Copy a file
Storage::disk('public')->copy('uploads/old.jpg', 'uploads/new.jpg');

// Move / rename a file
Storage::disk('public')->move('uploads/old.jpg', 'uploads/renamed.jpg');

12 Folder Structure

13 Run and Test

Start the Laravel development server:

php artisan serve

Test the following scenarios:

1. Single File Upload
- Go to `http://localhost:8000/upload`
- Select a JPG image (under 5MB)
- You should see an image preview before submitting
- Click "Upload File" → Redirected to gallery with success message

2. Multiple File Upload
- Go to `http://localhost:8000/upload/multiple`
- Select 3-5 files at once
- You should see the file list with sizes before submitting
- Click "Upload All Files" → All files appear in the gallery

3. Validation — File Too Large
- Try uploading a file larger than 5MB
- → Error: "File size must not exceed 5MB."

4. Validation — Wrong File Type
- Try uploading an .exe or .php file
- → Error: "Allowed types: jpg, png, gif, webp, pdf, doc, docx, xlsx, csv, zip."

5. Validation — Empty Submission
- Submit the form without selecting a file
- → Error: "Please select a file to upload."

6. File Download
- Click the download icon on any file in the gallery
- → File should download with its original filename

7. File Deletion
- Click the trash icon on any file → Confirm deletion
- → File is removed from gallery and from storage/app/public/uploads

8. Gallery View
- Go to `http://localhost:8000`
- Images show thumbnails, non-images show file icons
- File size, extension badge, and upload date are visible

9. Pagination
- Upload more than 12 files
- → Pagination links should appear below the gallery

14 Conclusion

Congratulations! You have built a complete file upload and validation system in Laravel 12 — with single uploads, multiple uploads, comprehensive validation, storage management, downloads, and deletion.

Here is a summary of what you learned:

  • Setting up a Laravel 12 project and configuring the database for file metadata
  • Creating migrations and Eloquent models with helper methods (URL generation, readable file size, image detection)
  • Building a controller with single upload, multiple upload, download, and delete actions
  • Using Laravel validation rules for file type, size, dimensions, and MIME type
  • Storing files on the public disk using the Storage facade
  • Creating a storage symlink for browser-accessible files
  • Designing responsive Blade views with Bootstrap 5 (upload zones, image preview, gallery grid)
  • Properly deleting files from both storage and database


Next Steps to Extend This Project:

  • Add authentication to restrict uploads to logged-in users
  • Implement image resizing and thumbnail generation using Intervention Image
  • Switch to Amazon S3 or DigitalOcean Spaces for cloud storage — just change the disk driver in .env
  • Add drag-and-drop upload functionality with Dropzone.js or FilePond
  • Implement chunked uploads for very large files
  • Add file categorization and tagging for better organization
  • Build a file manager dashboard with search, filter, and bulk actions


Laravel 12 makes file handling intuitive and secure with its powerful Storage abstraction, built-in validation rules, and clean Eloquent integration — giving you everything you need to handle files like a pro.
Revathi M - PHP and CodeIgniter Developer

Written by Revathi M

PHP Developer & Technical Writer · 10+ years building web applications with CodeIgniter and Laravel

Revathi specializes in PHP backend development, authentication systems, and REST API design. She writes practical, production-tested tutorials at Get Sample Code to help developers build secure applications faster.

Frequently Asked Questions

Laravel uses the max validation rule in kilobytes. For example, max:5120 means 5MB. However, the actual limit also depends on your PHP configuration — check upload_max_filesize and post_max_size in your php.ini file.

Use the dimensions rule: "image|dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000". You can also enforce aspect ratios with dimensions:ratio=16/9.

The mimes rule validates by file extension (e.g., mimes:jpg,png,pdf), while mimetypes validates by actual MIME type (e.g., mimetypes:image/jpeg,application/pdf). Using mimetypes is more secure as it checks the file content, not just the extension.

Install the flysystem-aws-s3-v3 package via Composer, configure your S3 credentials in .env (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET), then change the disk parameter from "public" to "s3" in your store() calls.

The storage:link command creates a symbolic link from public/storage to storage/app/public. Without this, files stored on the public disk cannot be accessed via the browser because the storage directory is outside the web root.