Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 033 / 100

File storage in Laravel: a practical PHP & Laravel guide

One API for local disks and S3.

Expona Admin8 min read

File storage in Laravel: a practical PHP & Laravel guide

Most production applications handle files—avatars, invoices, documents, media. Laravel's storage abstraction makes it painless to swap between local disk and S3 without rewriting your code. We've learned the hard way that getting this right saves engineering time and prevents data loss downstream.

Understanding Laravel's storage abstraction

Laravel's Storage facade provides a unified API for multiple drivers. Whether you're developing locally with the filesystem or running on AWS S3 in production, your code stays the same. The driver configuration lives in config/filesystems.php, and each disk has its own set of credentials and visibility rules.

The key insight is that Storage::disk('s3')->put() and Storage::disk('local')->put() use the same method signature. This abstraction means you can test against local disk and deploy to S3 without touching business logic.

php
// config/filesystems.php
'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
        'url' => env('APP_URL') . '/storage',
        'visibility' => 'private',
    ],
    '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' => false,
        'visibility' => 'private',
    ],
],

Notice that both 'local' and 's3' are configured with 'visibility' => 'private'. This is your first line of defense: files are not publicly readable by default. You explicitly control who can access them.

Storage::disk('s3')->put() and basic operations

The put() method is your workhorse for saving files. You provide a path, the contents, and optional visibility. When you're working with file uploads in a controller, you typically receive a UploadedFile object from Laravel's request validation.

php
// In a controller handling a file upload
public function upload(Request $request)
{
    $request->validate([
        'document' => 'required|file|max:10240|mimes:pdf,doc,docx',
    ]);

    $file = $request->file('document');
    $path = 'documents/' . auth()->id() . '/' . time() . '.pdf';
    
    Storage::disk('s3')->put($path, $file);
    
    return response()->json(['path' => $path]);
}

When you call put() on an S3 disk, Laravel handles the HTTP request to AWS. The file is stored at the path you specify within your configured bucket. The second argument can be a stream, string, or UploadedFile instance.

For deletion and retrieval, the API remains consistent:

php
// Retrieve file contents
$contents = Storage::disk('s3')->get('documents/user-123/invoice.pdf');

// Check if a file exists
if (Storage::disk('s3')->exists('documents/user-123/invoice.pdf')) {
    // Process it
}

// Delete a file
Storage::disk('s3')->delete('documents/user-123/invoice.pdf');

// List files in a directory
$files = Storage::disk('s3')->files('documents/user-123/');

One practical tip: always include some form of user or request identifier in your path structure. This makes it straightforward to clean up files when a user deletes their account or a resource is archived.

Temporary URLs for private files

Private files stored on S3 are not accessible via a direct URL. But sometimes you need to share a file temporarily—a signed invoice, a protected download. Laravel generates temporary signed URLs that expire after a set duration.

php
// Generate a URL valid for 24 hours
$url = Storage::disk('s3')->temporaryUrl(
    'documents/user-123/invoice-2025.pdf',
    now()->addHours(24)
);

// Return the URL to the client (typically in an email or API response)
return response()->json(['download_url' => $url]);

The temporary URL includes a signature and timestamp. AWS verifies the signature on the incoming request; if the URL is tampered with or the timestamp has expired, access is denied. This approach is far better than making files public—you maintain fine-grained control over who can download what and for how long.

In practice, we often generate these URLs at request time and embed them in emails:

php
// In a mailable class
public function toMail($notifiable)
{
    $invoicePath = 'invoices/' . $this->invoice->id . '.pdf';
    $downloadUrl = Storage::disk('s3')->temporaryUrl(
        $invoicePath,
        now()->addDays(30)
    );

    return $this->markdown('emails.invoice-ready')
        ->with('downloadUrl', $downloadUrl)
        ->with('expiresAt', now()->addDays(30)->format('Y-m-d'));
}

The expiration window depends on your use case. For invoices and receipts, 30 days is reasonable. For password reset tokens or API export links, 1 hour might be more appropriate.

Validate type and size on upload

Never assume the uploaded file is what the client claims. Malicious actors will send executables disguised as images, or massive files that consume your storage quota. Laravel's validation rules make this straightforward, but the implementation details matter.

Use the file, mimes, and max rules together:

php
// In a form request
public function rules()
{
    return [
        'avatar' => [
            'required',
            'file',
            'mimes:jpg,jpeg,png,gif',
            'max:2048', // 2 MB
            'dimensions:min_width=100,min_height=100,max_width=5000,max_height=5000',
        ],
        'document' => [
            'required',
            'file',
            'mimes:pdf',
            'max:10240', // 10 MB
        ],
    ];
}

The mimes rule checks the file's MIME type. The max rule limits file size in kilobytes. The dimensions rule (for images) ensures the image is within acceptable width and height bounds.

However, MIME type detection is not foolproof. A determined attacker can rename an executable to have a .jpg extension and forge the MIME type header. For critical uploads, consider adding a server-side inspection library:

php
// Install: composer require fideloper/proxy

use fideloper\Proxy\TrustProxies;
use Symfony\Component\HttpFoundation\File\File;

// In your upload controller
public function upload(Request $request)
{
    $request->validate([
        'file' => 'required|file|mimes:pdf|max:10240',
    ]);

    $file = $request->file('file');
    
    // Use finfo to double-check MIME type
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mimeType = finfo_file($finfo, $file->getRealPath());
    finfo_close($finfo);
    
    if ($mimeType !== 'application/pdf') {
        return response()->json(['error' => 'Invalid file'], 422);
    }

    // Safe to store
    Storage::disk('s3')->putFileAs(
        'uploads/user-' . auth()->id(),
        $file,
        'document-' . time() . '.pdf'
    );
}

Laravel also provides putFileAs(), which is a convenience method that handles the file stream automatically and lets you specify a custom filename.

Never trust the client file name

The filename sent by the client in the Content-Disposition header or form field is user input. Treat it like any other form field: validate, sanitize, or ignore it entirely.

If you store the original filename, an attacker could potentially craft a path traversal attack (e.g., ../../../etc/passwd) or create confusion by uploading files with identical names to overwrite existing files. The safest approach is to generate your own filename server-side:

php
// BAD: trusting the client filename
$path = 'uploads/' . $request->file('document')->getClientOriginalName();
Storage::disk('s3')->put($path, $request->file('document'));

// GOOD: generate a unique filename
$extension = $request->file('document')->extension();
$filename = 'document-' . auth()->id() . '-' . time() . '.' . $extension;
$path = 'uploads/user-' . auth()->id() . '/' . $filename;
Storage::disk('s3')->put($path, $request->file('document'));

// BETTER: use a UUID
$filename = \Illuminate\Support\Str::uuid() . '.' . $request->file('document')->extension();
$path = 'uploads/user-' . auth()->id() . '/' . $filename;
Storage::disk('s3')->put($path, $request->file('document'));

If you need to preserve the original filename for user-facing download links or database records, store it separately in a database column and use your generated server-side filename as the actual storage key. When serving the file back to the user, you can use the download() response helper with the original name:

php
// In a controller
$document = Document::find($id);

return Storage::disk('s3')->download(
    $document->storage_path,  // e.g. 'uploads/user-123/abc-123-def.pdf'
    $document->original_name  // e.g. 'Invoice-2025-01.pdf'
);

This approach gives users a readable filename when they download, while your system maintains control over storage organization and prevents naming collisions.

Common mistakes

Storing credentials in code: Never hardcode AWS keys in your application. Use environment variables and Laravel's env() helper. Your .env file should be in .gitignore.

Making files public without intention: If you set 'visibility' => 'public' on a disk that contains sensitive data, every file becomes world-readable. Be explicit and deliberate about which disks or paths are public.

Forgetting to handle cleanup: When a user deletes their account or a resource is archived, orphaned files remain on S3 and incur storage charges. Use Laravel's model events or a scheduled job to delete associated files.

php
// In a model
protected static function booted()
{
    static::deleting(function ($document) {
        Storage::disk('s3')->delete($document->storage_path);
    });
}

Not validating MIME types: Rely on both Laravel's validation rules and server-side inspection for files that require strict validation.

Storing files in the web root: If you use the 'local' disk and configure it to serve from public/storage, remember that everyone on the internet can download those files. Use the Storage::disk('local') with the private root for confidential data, and serve them through authenticated controller actions.

Key takeaways

  • Laravel's Storage facade abstracts local and S3 disks behind a single API—write code once, configure multiple environments.
  • Use temporary signed URLs to share private files securely with expiration boundaries.
  • Always validate file type and size at upload time using Laravel's validation rules plus server-side inspection for critical files.
  • Generate server-side filenames; never trust the client's uploaded filename.
  • Set 'visibility' => 'private' by default on disks containing user data or sensitive information.
  • Clean up orphaned files when resources are deleted to avoid unexpected storage costs.
  • Store credentials in environment variables, never in code or version control.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles