Laravel File Storage and Uploads
The file system is Laravel's "repository management"—whether files are stored on a local disk or in the S3 cloud, the code remains exactly the same.
1. What You'll Learn
- FileSystem abstraction layer: local/public/s3 driver configuration
- The entire file upload process: Validation → Storage → URL Generation → Response
- Store symbolic links: php artisan storage:link
- S3 Cloud Storage Integration and Pre-signed URLs
- File Operations: copy/move/delete/visibility and stream processing
2. A True Story from the World of Operations
(1) Problem: The server disk is full, and all the images are lost
All of ShopMetrics' product images were stored locally on the server in the public/uploads/ directory—500 GB of images filled up the disk, causing the site to crash. To make matters worse, there were no backups after the server hardware failed, and Alice lost all 2,000 of her product images. Bob wanted to migrate to S3, but file paths were hard-coded all over the code, and after three days of work, he still hadn't finished making the changes.
(2) Solutions for the Storage Abstraction Layer
Laravel Storage facade uses a unified API to manage files—whether local, S3, or any other driver, the code remains the same; migrating simply requires changing the .env configuration.
// Same code works for local, S3, or any driver
Storage::disk('public')->put('shops/logo.jpg', $file);
$url = Storage::disk('public')->url('shops/logo.jpg');
// Switch to S3 by changing .env
// FILESYSTEM_DISK=s3
// Everything else stays the same!
(3) Revenue
Bob switched to S3 by changing just two lines .env, with no code modifications. Alice's images on S3 have 11 nines of durability, so disk space running out and hardware failures are no longer an issue.
3. Storage Abstraction Layer
(1) Driver Configuration
// config/filesystems.php
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
],
],
'default' => env('FILESYSTEM_DISK', 'local'),
(2) Driver Comparison
| Drive | Storage Location | Public Network Access | Persistence | Cost |
|---|---|---|---|---|
local |
storage/app/ | ❌ Requires routing | Server | Free |
public |
storage/app/public/ | ✅ Symbolic link | Server | Free |
s3 |
AWS S3 | ✅ URL | 11 nines | Pay-as-you-go |
s3+CDN |
S3 + CloudFront | ✅ CDN | 11 nines | Pay-as-you-go |
(1) ▶ Example: ShopMetrics Storage Configuration
# .env — Development: use public disk
FILESYSTEM_DISK=public
# .env — Production: use S3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=shopmetrics-uploads
# Create symbolic link for public disk
php artisan storage:link
# [OK] Link created: public/storage -> storage/app/public
Output:
# Command executed successfully
4. The Complete File Upload Process
(1) Validation → Storage → URL Generation
// Step 1: Validate
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
// Step 2: Store
$path = $request->file('logo')->store('shops/logos', 'public');
// => "shops/logos/abc123def456.jpg"
// Step 3: Generate URL
$url = Storage::disk('public')->url($path);
// => "http://shopmetrics.test/storage/shops/logos/abc123def456.jpg"
// Step 4: Save path to database
$shop->update(['logo_path' => $path]);
(2) Comparison of Upload Methods
| Method | File Name | Example Path |
|---|---|---|
store('dir', 'disk') |
Random Hash Name | shops/logos/abc123.jpg |
storeAs('dir', name, 'disk') |
Custom Name | shops/logos/alice-store.jpg |
storePublicly('dir', 'disk') |
Random Name + Public | Same store |
storePubliclyAs(...) |
Custom Name + Public | Same as storeAs |
(1) ▶ Example: ShopMetrics Product Image Upload
// app/Http/Controllers/ProductImageController.php
class ProductImageController extends Controller
{
public function store(Request $request, Product $product): JsonResponse
{
$validated = $request->validate([
'image' => 'required|image|mimes:jpeg,png,webp|max:5120',
'is_primary' => 'sometimes|boolean',
]);
$path = $request->file('image')->store(
"products/{$product->id}/images",
's3',
);
$image = $product->images()->create([
'path' => $path,
'is_primary' => $validated['is_primary'] ?? false,
'mime_type' => $request->file('image')->getMimeType(),
'size' => $request->file('image')->getSize(),
]);
return response()->json([
'message' => 'Image uploaded.',
'data' => [
'id' => $image->id,
'url' => Storage::disk('s3')->url($path),
],
], 201);
}
public function destroy(Product $product, Image $image): Response
{
Storage::disk('s3')->delete($image->path);
$image->delete();
return response()->noContent();
}
}
Output:
// Execution Successful
5. Symbolic Links and Public Disks
(1) Create a symbolic link
php artisan storage:link
# Creates: public/storage → storage/app/public
(2) How Symbolic Links Work
public/
├── index.php
├── storage/ → ../../storage/app/public/ (symbolic link)
│ └── shops/logos/abc123.jpg (accessible via web)
storage/
└── app/
└── public/ (actual file location)
└── shops/logos/abc123.jpg
| Path | Purpose | Web Access |
|---|---|---|
storage/app/public/ |
Public File Storage | ✅ Via Symbolic Links |
storage/app/ |
Private File Storage | ❌ Not via the Web |
public/ |
Web Root Directory | ✅ Direct Access |
(1) ▶ Example: Downloading Private Files
// Private file — not accessible via web, must go through controller
Route::get('/reports/{report}/download', [ReportController::class, 'download'])
->middleware('auth');
class ReportController extends Controller
{
public function download(Report $report): StreamedResponse
{
$this->authorize('view', $report);
if (!Storage::disk('local')->exists($report->file_path)) {
abort(404, 'Report file not found.');
}
return Storage::disk('local')->download(
$report->file_path,
"report-{$report->id}.pdf",
);
}
}
Output:
// Execution Successful
6. S3 Cloud Storage and Pre-signed URLs
(1) S3 Integration
composer require league/flysystem-aws-s3-v3:"^3.0"
(2) Pre-signed URL
Pre-signed URLs allow clients to upload and download S3 files directly, without going through a server—saving bandwidth and reducing server load.
// Generate a temporary upload URL (client uploads directly to S3)
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
"products/{$product->id}/images/" . $request->filename,
now()->addMinutes(30),
['ContentType' => $request->mime_type],
);
// Generate a temporary download URL
$downloadUrl = Storage::disk('s3')->temporaryUrl(
$image->path,
now()->addMinutes(15),
);
(3) S3 vs. On-Premises
| Dimension | Public Disk | S3 |
|---|---|---|
| File Storage | Server Disk | AWS S3 |
| Web Access | Symbolic Links | URL/CDN |
| Expansion | Disk-limited | Unlimited |
| Availability | Server Dependency | 99.999999999% |
| CDN Integration | Requires Configuration | CloudFront |
| Pre-signed URL | ❌ | ✅ |
| Cost | Free | Pay-as-you-go |
(1) ▶ Example: ShopMetrics S3 Pre-signed Upload
// app/Http/Controllers/Api/FileUploadController.php
class FileUploadController extends Controller
{
public function presign(Request $request): JsonResponse
{
$validated = $request->validate([
'filename' => 'required|string',
'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp',
'size' => 'required|integer|max:5120',
]);
$path = 'uploads/' . auth()->id() . '/' . Str::uuid() . '/' . $validated['filename'];
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
$path,
now()->addMinutes(30),
['ContentType' => $validated['mime_type']],
);
return response()->json([
'upload_url' => $uploadUrl,
'path' => $path,
'expires_in' => 1800,
]);
}
public function confirm(Request $request): JsonResponse
{
$validated = $request->validate([
'path' => 'required|string',
'attachable_type' => 'required|string',
'attachable_id' => 'required|integer',
]);
$url = Storage::disk('s3')->url($validated['path']);
return response()->json([
'url' => $url,
'path' => $validated['path'],
]);
}
}
Output:
// Execution Successful
7. File Operations
(1) Common Operations
// Read
$content = Storage::disk('s3')->get('shops/logos/abc.jpg');
$exists = Storage::disk('s3')->exists('shops/logos/abc.jpg');
$missing = Storage::disk('s3')->missing('shops/logos/abc.jpg');
// Write
Storage::disk('s3')->put('reports/summary.csv', $csvContent);
Storage::disk('s3')->putFileAs('avatars', $uploadedFile, 'profile.jpg');
// Copy / Move
Storage::disk('s3')->copy('old/path.jpg', 'new/path.jpg');
Storage::disk('s3')->move('temp/file.jpg', 'permanent/file.jpg');
// Delete
Storage::disk('s3')->delete('shops/logos/abc.jpg');
Storage::disk('s3')->delete(['file1.jpg', 'file2.jpg']);
// Visibility
Storage::disk('s3')->setVisibility('file.jpg', 'public');
Storage::disk('s3')->setVisibility('file.jpg', 'private');
$visibility = Storage::disk('s3')->getVisibility('file.jpg');
// Directory
$files = Storage::disk('s3')->files('shops/logos');
$allFiles = Storage::disk('s3')->allFiles('shops');
$directories = Storage::disk('s3')->directories('shops');
Storage::disk('s3')->makeDirectory('shops/new-dir');
Storage::disk('s3')->deleteDirectory('shops/old-dir');
// File metadata
$size = Storage::disk('s3')->size('file.jpg');
$modified = Storage::disk('s3')->lastModified('file.jpg');
$path = Storage::disk('s3')->path('file.jpg');
(1) ▶ Example: ShopMetrics Report Generation and Storage
// app/Services/ReportService.php
class ReportService
{
public function generateOrderReport(Tenant $tenant, string $format = 'csv'): string
{
$orders = Order::where('tenant_id', $tenant->id)
->with(['shop', 'items.product'])
->completed()
->latest()
->get();
$csv = "Order Number,Shop,Customer,Total,Status,Date\n";
foreach ($orders as $order) {
$csv .= implode(',', [
$order->order_number,
$order->shop->name,
$order->user->name,
$order->total,
$order->status,
$order->created_at->format('Y-m-d'),
]) . "\n";
}
$path = "reports/{$tenant->slug}/orders-" . now()->format('Y-m-d-His') . ".csv";
Storage::disk('s3')->put($path, $csv);
return $path;
}
public function getDownloadUrl(string $path, int $expiresMinutes = 15): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresMinutes));
}
public function cleanupOldReports(Tenant $tenant): int
{
$cutoff = now()->subDays(90)->format('Y-m-d');
$files = Storage::disk('s3')->allFiles("reports/{$tenant->slug}");
$deleted = 0;
foreach ($files as $file) {
if (str_contains($file, $cutoff) || Storage::disk('s3')->lastModified($file) < now()->subDays(90)->timestamp) {
Storage::disk('s3')->delete($file);
$deleted++;
}
}
return $deleted;
}
}
Output:
// Execution Successful
8. Comprehensive Example: ShopMetrics File Upload System
// ============================================
// Comprehensive: ShopMetrics File Upload System
// Covers: upload, S3, presigned URLs, cleanup, streaming
// ============================================
// app/Http/Controllers/Api/MediaController.php
class MediaController extends Controller
{
public function upload(Request $request): JsonResponse
{
$validated = $request->validate([
'file' => 'required|file|max:10240',
'collection' => 'required|in:logos,products,reports',
'attachable_type' => 'sometimes|string',
'attachable_id' => 'sometimes|integer',
]);
$file = $request->file('file');
$tenantId = tenant()->id;
$path = $validated['collection'] . "/{$tenantId}/" . Str::uuid() . '.' . $file->extension();
$stored = Storage::disk('s3')->put($path, $file->getContent(), 'public');
if (!$stored) {
return response()->json(['message' => 'Upload failed.'], 500);
}
$media = Media::create([
'tenant_id' => $tenantId,
'path' => $path,
'filename' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'collection' => $validated['collection'],
]);
return response()->json([
'message' => 'File uploaded.',
'data' => [
'id' => $media->id,
'url' => Storage::disk('s3')->url($path),
'filename' => $media->filename,
'size' => $media->size,
],
], 201);
}
public function download(Media $media): StreamedResponse
{
$this->authorize('view', $media);
if ($media->isPublic()) {
return redirect(Storage::disk('s3')->url($media->path));
}
return Storage::disk('s3')->download($media->path, $media->filename);
}
public function temporaryUrl(Media $media): JsonResponse
{
$this->authorize('view', $media);
$url = Storage::disk('s3')->temporaryUrl(
$media->path,
now()->addMinutes(15),
);
return response()->json(['url' => $url, 'expires_in' => 900]);
}
public function destroy(Media $media): Response
{
$this->authorize('delete', $media);
Storage::disk('s3')->delete($media->path);
$media->delete();
return response()->noContent();
}
}
❓ FAQ
storage:link on Windows?php artisan storage:link automatically creates symbolic links on Windows, but administrator privileges are required. If that fails, create it manually: mklink /D public\storage storage\app\public.upload_max_filesize and post_max_size.AWS_URL with your CloudFront domain name. All URLs returned by Storage::url() will automatically be routed through the CDN, accelerating access from around the world.deleting event in the model's boot() to delete associated files; run Artisan commands periodically to clean up orphaned files; use S3 lifecycle policies to automatically expire old files..env's FILESYSTEM_DISK without modifying the code.📖 Summary
- The Storage abstraction layer provides a unified API; to switch drivers, simply modify the .env file.
- Public disks require a
storage:linksymbolic link to be accessible via the web - File upload: Validation → store() → Save path to DB → Generate URL
- S3 is suitable for production environments: high durability, unlimited scalability, and CDN integration
- Pre-signed URLs allow clients to upload directly to S3, saving server bandwidth
- Private files are downloaded via the controller, while public files can be accessed directly via URL
📝 Exercises
-
Basic Exercise (⭐): Configure ShopMetrics to use public disk storage, implement product image uploads (validation, storage, and display), and display the uploaded images on a Blade page.
-
Advanced Exercise (⭐⭐): Switch to S3 storage and implement uploads via pre-signed URLs—the front end retrieves the pre-signed URL and uploads directly to S3, and the back end creates a Media record after verification.
-
Challenge (⭐⭐⭐): Implement S3 file lifecycle management—set a tag (tenant_id) upon upload, write an Artisan command to clean up report files older than 90 days by tenant, and use the S3 bulk delete API to optimize performance.



