Laravel File Storage: Local, S3, and Beyond

SS Saurav Sitaula

Handling file uploads the right way. Learn Laravel's filesystem abstraction — store files locally in development, S3 in production, switch with one config change. No more hardcoded paths and manual file management.

The Old Way: Hardcoded Paths and Chaos

My early PHP file handling was embarrassing:

// The horror
$target_dir = "/var/www/html/uploads/";
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);

if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
    echo "File uploaded successfully";
}

Problems:

  • Hardcoded server paths
  • No validation
  • Filename collisions
  • Can’t switch to S3 without rewriting everything
  • Public folder = security nightmare

Laravel’s filesystem abstraction fixed all of this.

The Storage Facade

Laravel abstracts file operations behind a clean API:

use Illuminate\Support\Facades\Storage;

// Store a file
Storage::put('file.txt', 'Contents');

// Get a file
$contents = Storage::get('file.txt');

// Check if file exists
if (Storage::exists('file.txt')) {
    // ...
}

// Delete a file
Storage::delete('file.txt');

// Get file URL
$url = Storage::url('file.txt');

This works the same whether files are stored locally, on S3, or anywhere else.

Configuration: Disks

Storage “disks” are configured in config/filesystems.php:

'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
    ],

    '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_DRIVER', 'local'),

Switch between disks easily:

// Use default disk
Storage::put('file.txt', 'contents');

// Use specific disk
Storage::disk('s3')->put('file.txt', 'contents');
Storage::disk('local')->put('file.txt', 'contents');

Change FILESYSTEM_DRIVER=s3 in .env and your entire app uses S3. No code changes.

Handling File Uploads

Basic Upload

public function store(Request $request)
{
    $request->validate([
        'document' => 'required|file|mimes:pdf,doc,docx|max:10240', // 10MB max
    ]);

    // Store with auto-generated name
    $path = $request->file('document')->store('documents');
    // Returns: "documents/randomstring.pdf"

    // Store with custom name
    $path = $request->file('document')->storeAs(
        'documents',
        'custom-name.pdf'
    );

    // Store on specific disk
    $path = $request->file('document')->store('documents', 's3');

    // Save path to database
    $user->document_path = $path;
    $user->save();

    return redirect()->back()->with('success', 'Document uploaded!');
}

Image Uploads with Validation

public function updateAvatar(Request $request)
{
    $request->validate([
        'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048|dimensions:min_width=100,min_height=100',
    ]);

    $user = auth()->user();

    // Delete old avatar if exists
    if ($user->avatar) {
        Storage::delete($user->avatar);
    }

    // Store new avatar
    $path = $request->file('avatar')->store('avatars', 'public');

    $user->avatar = $path;
    $user->save();

    return redirect()->back()->with('success', 'Avatar updated!');
}

Public vs Private Files

Private Files (Default)

Stored in storage/app/. Not accessible via URL. Good for sensitive documents.

// Store privately
$path = $request->file('contract')->store('contracts');

// Download via controller
public function download($id)
{
    $contract = Contract::findOrFail($id);
    
    // Check authorization
    $this->authorize('view', $contract);
    
    return Storage::download($contract->path);
}

Public Files

Stored in storage/app/public/. Accessible via URL.

First, create the symlink:

php artisan storage:link

This creates a symlink from public/storage to storage/app/public.

// Store publicly
$path = $request->file('avatar')->store('avatars', 'public');

// Get public URL
$url = Storage::url($path);
// Returns: "/storage/avatars/filename.jpg"

// In Blade
<img src="{{ Storage::url($user->avatar) }}" alt="Avatar">

// Or use asset helper
<img src="{{ asset('storage/' . $user->avatar) }}" alt="Avatar">

Working with S3

Install the S3 driver:

composer require league/flysystem-aws-s3-v3

Configure in .env:

FILESYSTEM_DRIVER=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_URL=https://your-bucket.s3.amazonaws.com

Now all Storage:: calls go to S3:

// Stores directly to S3
$path = $request->file('video')->store('videos');

// Get public URL from S3
$url = Storage::url($path);
// Returns: "https://your-bucket.s3.amazonaws.com/videos/filename.mp4"

Temporary URLs for Private S3 Files

// Generate URL that expires in 5 minutes
$url = Storage::temporaryUrl(
    'private-documents/contract.pdf',
    now()->addMinutes(5)
);

User can download for 5 minutes, then the link dies. Perfect for sensitive files.

Common File Operations

Reading Files

// Get contents
$contents = Storage::get('file.txt');

// Get as stream (for large files)
$stream = Storage::readStream('large-video.mp4');

// Return as response
return Storage::response('file.txt');
return Storage::download('file.txt', 'custom-name.txt');

Writing Files

// Put contents
Storage::put('file.txt', 'Contents');

// Put from stream
Storage::putStream('large-file.zip', $stream);

// Append to file
Storage::append('log.txt', 'New line');

// Prepend to file
Storage::prepend('log.txt', 'First line');

Copying and Moving

Storage::copy('old/file.txt', 'new/file.txt');
Storage::move('old/file.txt', 'new/file.txt');

File Information

// Size in bytes
$size = Storage::size('file.txt');

// Last modified timestamp
$time = Storage::lastModified('file.txt');

// Get MIME type
$mime = Storage::mimeType('file.txt');

Directories

// Get all files in directory
$files = Storage::files('documents');

// Get all files recursively
$files = Storage::allFiles('documents');

// Get all directories
$directories = Storage::directories('uploads');

// Create directory
Storage::makeDirectory('new-folder');

// Delete directory
Storage::deleteDirectory('old-folder');

File Visibility

Control who can access files:

// Store with specific visibility
Storage::put('file.txt', 'contents', 'public');
Storage::put('private.txt', 'contents', 'private');

// Change visibility
Storage::setVisibility('file.txt', 'public');

// Get visibility
$visibility = Storage::getVisibility('file.txt');

On S3, this sets the ACL. On local disk, it sets file permissions.

Image Manipulation

Laravel doesn’t include image manipulation, but Intervention Image works great:

composer require intervention/image
use Intervention\Image\Facades\Image;

public function uploadAvatar(Request $request)
{
    $file = $request->file('avatar');
    
    // Resize and save
    $image = Image::make($file)
        ->fit(200, 200)
        ->encode('jpg', 80);  // 80% quality
    
    $path = 'avatars/' . Str::random(40) . '.jpg';
    
    Storage::put($path, $image);
    
    // Create thumbnail
    $thumb = Image::make($file)
        ->fit(50, 50)
        ->encode('jpg', 70);
    
    $thumbPath = 'avatars/thumbs/' . Str::random(40) . '.jpg';
    Storage::put($thumbPath, $thumb);
    
    return [
        'avatar' => $path,
        'thumbnail' => $thumbPath,
    ];
}

Organizing Uploads

Structure matters as your app grows:

storage/app/
├── public/
│   ├── avatars/
│   │   ├── 2019/
│   │   │   ├── 05/
│   │   │   │   └── user-123-abc123.jpg
│   │   │   └── 06/
│   │   └── thumbs/
│   ├── products/
│   └── posts/
├── documents/
│   ├── contracts/
│   └── invoices/
└── temp/
// Organize by date
$path = $request->file('photo')->store(
    'photos/' . date('Y/m'),
    'public'
);
// Result: "photos/2019/05/randomstring.jpg"

// Organize by user
$path = $request->file('document')->store(
    'documents/user-' . auth()->id()
);

Cleaning Up Old Files

// In a scheduled command
public function handle()
{
    // Delete temp files older than 24 hours
    $files = Storage::files('temp');
    
    foreach ($files as $file) {
        if (Storage::lastModified($file) < now()->subDay()->timestamp) {
            Storage::delete($file);
        }
    }
}

Testing File Uploads

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

public function test_avatar_can_be_uploaded()
{
    Storage::fake('public');
    
    $file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
    
    $response = $this->actingAs($this->user)
        ->post('/profile/avatar', ['avatar' => $file]);
    
    $response->assertStatus(200);
    
    // Assert file was stored
    Storage::disk('public')->assertExists('avatars/' . $file->hashName());
}

Storage::fake() creates an in-memory filesystem. No actual files written.

What I Wish I’d Known Earlier

  1. Run storage:link early. Forget once, debug for hours wondering why images don’t show.

  2. Use the public disk for user-facing files. Don’t fight the conventions.

  3. Store paths, not URLs. Save avatars/abc123.jpg, not https://bucket.s3.amazonaws.com/avatars/abc123.jpg. Generate URLs dynamically.

  4. Validate strictly. File type, size, dimensions. Users will upload anything.

  5. Delete old files when updating. Otherwise you accumulate orphaned files forever.

The Journey Continues

File storage was now clean and portable. Development used local disk, production used S3, and the code was identical.

But my app was starting to slow down. Database queries were taking too long. Time to learn about caching.


P.S. — The first time I deployed to production and switched from local storage to S3 by changing ONE environment variable — with zero code changes — I realized the power of good abstraction. Laravel’s filesystem is a perfect example of hiding complexity behind a simple, consistent interface.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism