File Storage Guide
Last Updated: 2026-01-08 Status: Active Audience: Developers
This guide documents file storage patterns and best practices for the Client Portal application.
Table of Contents
Overview
Laravel's filesystem abstraction provides a unified API for:
- Local file storage
- Cloud storage (S3, GCS, etc.)
- FTP/SFTP servers
Storage Disks
| Disk | Use Case |
|---|---|
local | Private files (invoices, reports) |
public | Publicly accessible files (avatars, logos) |
s3 | Production cloud storage |
Storage Configuration
Filesystem Configuration
// config/filesystems.php
return [
'default' => env('FILESYSTEM_DISK', 'local'),
'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',
'throw' => false,
],
'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'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
// Custom disk for project files
'project_files' => [
'driver' => 'local',
'root' => storage_path('app/projects'),
'visibility' => 'private',
],
],
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
Environment Variables
# Local development
FILESYSTEM_DISK=local
# Production with S3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=client-portal-files
Create Symbolic Link
php artisan storage:link
File Uploads
Form Request Validation
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadProjectFileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('uploadFile', $this->route('project'));
}
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:' . config('client_portal.files.max_size_kb', 10240), // 10MB
'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png',
],
'description' => ['nullable', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'file.max' => 'The file size cannot exceed ' .
(config('client_portal.files.max_size_kb', 10240) / 1024) . 'MB.',
];
}
}
Controller File Upload
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UploadProjectFileRequest;
use App\Models\Project;
use App\Models\ProjectFile;
use Illuminate\Support\Facades\Storage;
class ProjectFileController extends Controller
{
public function store(UploadProjectFileRequest $request, Project $project)
{
$file = $request->file('file');
// Generate unique filename
$filename = $this->generateFilename($file);
// Store the file
$path = $file->storeAs(
"projects/{$project->id}",
$filename,
'local'
);
// Create database record
$projectFile = $project->files()->create([
'filename' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'description' => $request->validated('description'),
'uploaded_by' => auth()->id(),
]);
return redirect()
->back()
->with('success', 'File uploaded successfully.');
}
private function generateFilename($file): string
{
$extension = $file->getClientOriginalExtension();
return uniqid() . '_' . time() . '.' . $extension;
}
}
Service Class for Uploads
<?php
namespace App\Services;
use App\Models\Project;
use App\Models\ProjectFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileUploadService
{
public function __construct(
private string $disk = 'local'
) {}
/**
* Upload a file for a project.
*/
public function uploadProjectFile(
Project $project,
UploadedFile $file,
?string $description = null
): ProjectFile {
$path = $this->storeFile($file, "projects/{$project->id}");
return $project->files()->create([
'filename' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'description' => $description,
'uploaded_by' => auth()->id(),
]);
}
/**
* Store a file with unique name.
*/
public function storeFile(UploadedFile $file, string $directory): string
{
$filename = $this->generateUniqueFilename($file);
return $file->storeAs($directory, $filename, $this->disk);
}
/**
* Generate a unique filename.
*/
private function generateUniqueFilename(UploadedFile $file): string
{
$extension = $file->getClientOriginalExtension();
$basename = Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
return $basename . '_' . Str::random(8) . '.' . $extension;
}
/**
* Delete a file.
*/
public function deleteFile(ProjectFile $projectFile): bool
{
if (Storage::disk($this->disk)->exists($projectFile->path)) {
Storage::disk($this->disk)->delete($projectFile->path);
}
return $projectFile->delete();
}
}
Multiple File Upload
public function rules(): array
{
return [
'files' => ['required', 'array', 'max:10'],
'files.*' => [
'file',
'max:10240',
'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png',
],
];
}
public function store(Request $request, Project $project, FileUploadService $service)
{
$uploadedFiles = collect();
foreach ($request->file('files') as $file) {
$uploadedFiles->push(
$service->uploadProjectFile($project, $file)
);
}
return redirect()
->back()
->with('success', $uploadedFiles->count() . ' files uploaded successfully.');
}
File Management
Basic Storage Operations
use Illuminate\Support\Facades\Storage;
// Check if file exists
Storage::exists('projects/1/document.pdf');
Storage::disk('s3')->exists('invoices/invoice-123.pdf');
// Get file contents
$contents = Storage::get('projects/1/document.pdf');
// Get file URL (public disk only)
$url = Storage::url('avatars/user-1.jpg');
// Get temporary URL (S3)
$url = Storage::temporaryUrl('invoices/invoice-123.pdf', now()->addMinutes(5));
// Get file metadata
$size = Storage::size('document.pdf');
$lastModified = Storage::lastModified('document.pdf');
$mimeType = Storage::mimeType('document.pdf');
// Copy and move
Storage::copy('old/file.pdf', 'new/file.pdf');
Storage::move('old/file.pdf', 'new/file.pdf');
// Delete files
Storage::delete('file.pdf');
Storage::delete(['file1.pdf', 'file2.pdf']);
// Delete directory
Storage::deleteDirectory('projects/123');
Directory Operations
// List files in directory
$files = Storage::files('projects/1');
$allFiles = Storage::allFiles('projects'); // Including subdirectories
// List directories
$directories = Storage::directories('projects');
$allDirectories = Storage::allDirectories('projects');
// Create directory
Storage::makeDirectory('projects/new-project');
File Model Pattern
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class ProjectFile extends Model
{
protected $fillable = [
'project_id',
'filename',
'path',
'mime_type',
'size',
'description',
'uploaded_by',
];
protected $casts = [
'size' => 'integer',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
/**
* Get human-readable file size.
*/
public function getFormattedSizeAttribute(): string
{
$bytes = $this->size;
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
}
return $bytes . ' bytes';
}
/**
* Get the file extension.
*/
public function getExtensionAttribute(): string
{
return pathinfo($this->filename, PATHINFO_EXTENSION);
}
/**
* Check if file is an image.
*/
public function isImage(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
/**
* Check if file is a PDF.
*/
public function isPdf(): bool
{
return $this->mime_type === 'application/pdf';
}
/**
* Get file contents.
*/
public function getContents(): ?string
{
if (Storage::exists($this->path)) {
return Storage::get($this->path);
}
return null;
}
/**
* Delete file from storage when model is deleted.
*/
protected static function booted(): void
{
static::deleting(function (ProjectFile $file) {
Storage::delete($file->path);
});
}
}
Secure Downloads
Authenticated File Downloads
<?php
namespace App\Http\Controllers;
use App\Models\ProjectFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FileDownloadController extends Controller
{
public function download(ProjectFile $file): StreamedResponse
{
// Authorization check
Gate::authorize('download', $file);
// Check file exists
if (!Storage::exists($file->path)) {
abort(404, 'File not found.');
}
return Storage::download(
$file->path,
$file->filename,
['Content-Type' => $file->mime_type]
);
}
public function preview(ProjectFile $file): StreamedResponse
{
Gate::authorize('view', $file);
if (!Storage::exists($file->path)) {
abort(404, 'File not found.');
}
// Inline display (for PDFs, images)
return Storage::response(
$file->path,
$file->filename,
[
'Content-Type' => $file->mime_type,
'Content-Disposition' => 'inline; filename="' . $file->filename . '"',
]
);
}
}
Signed URLs for Temporary Access
<?php
namespace App\Http\Controllers;
use App\Models\ProjectFile;
use Illuminate\Support\Facades\URL;
class FileController extends Controller
{
/**
* Generate a signed download URL.
*/
public function getDownloadLink(ProjectFile $file): array
{
$this->authorize('download', $file);
$url = URL::signedRoute(
'files.download.signed',
['file' => $file->id],
now()->addMinutes(30)
);
return ['url' => $url, 'expires_at' => now()->addMinutes(30)];
}
}
// Route definition
Route::get('/files/{file}/download', [FileDownloadController::class, 'downloadSigned'])
->name('files.download.signed')
->middleware('signed');
S3 Temporary URLs
public function getTemporaryUrl(ProjectFile $file): string
{
$this->authorize('download', $file);
return Storage::disk('s3')->temporaryUrl(
$file->path,
now()->addMinutes(15),
[
'ResponseContentDisposition' => 'attachment; filename="' . $file->filename . '"',
]
);
}
Image Handling
Image Upload with Intervention
composer require intervention/image
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
class ImageService
{
/**
* Upload and process an avatar image.
*/
public function uploadAvatar(UploadedFile $file, int $userId): string
{
// Create image instance
$image = Image::read($file);
// Resize to square, maintaining aspect ratio
$image->cover(200, 200);
// Generate filename
$filename = "avatars/{$userId}_" . time() . '.jpg';
// Save to storage
Storage::disk('public')->put(
$filename,
$image->toJpeg(quality: 85)
);
return $filename;
}
/**
* Upload image with multiple sizes.
*/
public function uploadWithThumbnails(UploadedFile $file, string $directory): array
{
$image = Image::read($file);
$basename = pathinfo($file->hashName(), PATHINFO_FILENAME);
$paths = [];
// Original (max 1920px wide)
$original = clone $image;
$original->scaleDown(width: 1920);
$paths['original'] = "{$directory}/{$basename}_original.jpg";
Storage::disk('public')->put($paths['original'], $original->toJpeg(85));
// Medium (800px)
$medium = clone $image;
$medium->scaleDown(width: 800);
$paths['medium'] = "{$directory}/{$basename}_medium.jpg";
Storage::disk('public')->put($paths['medium'], $medium->toJpeg(85));
// Thumbnail (200px)
$thumb = clone $image;
$thumb->cover(200, 200);
$paths['thumbnail'] = "{$directory}/{$basename}_thumb.jpg";
Storage::disk('public')->put($paths['thumbnail'], $thumb->toJpeg(85));
return $paths;
}
}
Image Validation
public function rules(): array
{
return [
'avatar' => [
'required',
'image',
'mimes:jpeg,jpg,png,webp',
'max:2048', // 2MB
'dimensions:min_width=100,min_height=100,max_width=4000,max_height=4000',
],
];
}
Image Display in Blade
{{-- Using Storage URL --}}
<img src="{{ Storage::url($user->avatar_path) }}" alt="{{ $user->name }}">
{{-- With fallback --}}
<img src="{{ $user->avatar_path
? Storage::url($user->avatar_path)
: asset('images/default-avatar.png') }}"
alt="{{ $user->name }}">
{{-- In model with accessor --}}
{{-- User.php --}}
public function getAvatarUrlAttribute(): string
{
if ($this->avatar_path && Storage::disk('public')->exists($this->avatar_path)) {
return Storage::url($this->avatar_path);
}
return asset('images/default-avatar.png');
}
{{-- In Blade --}}
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
Testing
Fake Storage
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_file_upload_stores_file(): void
{
Storage::fake('local');
$file = UploadedFile::fake()->create('document.pdf', 1024);
$project = Project::factory()->create();
$this->actingAs($admin)
->post(route('admin.projects.files.store', $project), [
'file' => $file,
]);
Storage::disk('local')->assertExists("projects/{$project->id}/" . $file->hashName());
}
public function test_file_deletion_removes_from_storage(): void
{
Storage::fake('local');
// Create a file
Storage::disk('local')->put('projects/1/test.pdf', 'contents');
$projectFile = ProjectFile::factory()->create([
'path' => 'projects/1/test.pdf',
]);
Storage::disk('local')->assertExists('projects/1/test.pdf');
$this->actingAs($admin)
->delete(route('admin.projects.files.destroy', $projectFile));
Storage::disk('local')->assertMissing('projects/1/test.pdf');
}
Testing Image Uploads
public function test_avatar_upload_creates_resized_image(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg', 1000, 1000);
$this->actingAs($user)
->post(route('profile.avatar'), ['avatar' => $file]);
$user->refresh();
Storage::disk('public')->assertExists($user->avatar_path);
}
Testing File Downloads
public function test_authorized_user_can_download_file(): void
{
Storage::fake('local');
Storage::disk('local')->put('projects/1/document.pdf', 'PDF content');
$projectFile = ProjectFile::factory()->create([
'path' => 'projects/1/document.pdf',
'filename' => 'document.pdf',
]);
$response = $this->actingAs($authorizedUser)
->get(route('files.download', $projectFile));
$response->assertOk();
$response->assertDownload('document.pdf');
}
public function test_unauthorized_user_cannot_download_file(): void
{
$projectFile = ProjectFile::factory()->create();
$response = $this->actingAs($unauthorizedUser)
->get(route('files.download', $projectFile));
$response->assertForbidden();
}
Best Practices
Do
- Use Storage facade, not raw file functions
- Validate file types and sizes
- Generate unique filenames
- Store paths in database, not full URLs
- Use private storage for sensitive files
- Delete files when models are deleted
- Use queued jobs for large file processing
Don't
- Store files in the web root
- Trust user-provided filenames
- Skip file type validation
- Store absolute paths in database
- Forget to create storage symlink
- Process large files synchronously
Security Considerations
// Always validate file types
$file->getMimeType(); // Get actual MIME type
$file->getClientMimeType(); // User-provided (can be spoofed)
// Sanitize filenames
$safeName = Str::slug(pathinfo($filename, PATHINFO_FILENAME));
// Store outside web root
Storage::disk('local')->put('private/document.pdf', $contents);
// Authenticate all downloads
Route::get('/files/{file}/download', [FileController::class, 'download'])
->middleware('auth');