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

Table Of Content
1 Prerequisites
- PHP 8.2+
- Composer
- MySQL / MariaDB
- Laravel 12 (latest stable)
- Basic understanding of Laravel MVC pattern
2 Introduction
3 Install Laravel 12 Project
3.1 Create Laravel 12 Project
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)
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
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
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
// 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)
<!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">© {{ 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
<?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');
?>
9 Create Storage Symlink
php artisan storage:link
This creates a symlink from public/storage → storage/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
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
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
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
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.
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.
