Laravel Magazine

Building a File Upload System with Laravel and Amazon S3

Eric Van Johnson · Tutorials
Building a File Upload System with Laravel and Amazon S3

Storing uploaded files on the local disk works fine until you deploy to more than one server, or your disk fills up, or you need a CDN in front of your assets. Amazon S3 solves all three, and Laravel's filesystem abstraction means you can switch to it with almost no code changes. This tutorial builds a complete upload flow: validation, storage on S3, and secure access to private files.

Step 1: Install the S3 Driver

Laravel uses Flysystem under the hood. The S3 adapter is a separate package:

composer require league/flysystem-aws-s3-v3

Step 2: Configure the Disk

Add your credentials to .env. Never hardcode these:

AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_USE_PATH_STYLE_ENDPOINT=false

The s3 disk is already defined in config/filesystems.php, so there is nothing else to configure. To make S3 the default disk for the whole app, set FILESYSTEM_DISK=s3.

Step 3: Validate the Upload

Always validate uploads with a Form Request. This rejects oversized files and disallowed types before a single byte touches S3:

class StoreDocumentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'document' => [
                'required',
                'file',
                'mimes:pdf,docx,png,jpg',
                'max:10240', // 10 MB, in kilobytes
            ],
        ];
    }
}

Step 4: Store the File

The store() and storeAs() methods on the uploaded file handle everything — they stream the file to the configured disk and return the path:

class DocumentController extends Controller
{
    public function store(StoreDocumentRequest $request)
    {
        // Auto-generated unique filename, stored privately
        $path = $request->file('document')->store('documents', 's3');

        $document = $request->user()->documents()->create([
            'path'          => $path,
            'original_name' => $request->file('document')->getClientOriginalName(),
            'size'          => $request->file('document')->getSize(),
        ]);

        return response()->json($document, 201);
    }
}

Store the returned $path in your database, not the full URL. URLs change; paths do not.

Step 5: Keep Private Files Private

By default, files stored on S3 should not be publicly readable. Set the bucket to block public access and serve files through temporary signed URLs that expire:

public function show(Document $document)
{
    $this->authorize('view', $document);

    // URL valid for 5 minutes, then it stops working
    $url = Storage::disk('s3')->temporaryUrl(
        $document->path,
        now()->addMinutes(5)
    );

    return redirect($url);
}

This is the key security pattern: the file lives in a private bucket, your app authorizes the request, and S3 hands out a short-lived link. Even if someone copies the URL, it stops working in minutes.

Step 6: Clean Up on Delete

When a record is deleted, delete the underlying file too. A model event keeps this automatic:

class Document extends Model
{
    protected static function booted(): void
    {
        static::deleting(function (Document $document) {
            Storage::disk('s3')->delete($document->path);
        });
    }
}

Bonus: Direct-to-S3 Uploads for Large Files

For very large files, routing the upload through your server wastes bandwidth and risks timeouts. Generate a pre-signed upload URL and let the browser send the file straight to S3:

public function signedUpload(Request $request)
{
    $client = Storage::disk('s3')->getClient();
    $command = $client->getCommand('PutObject', [
        'Bucket' => config('filesystems.disks.s3.bucket'),
        'Key'    => 'uploads/' . Str::uuid(),
    ]);

    $url = (string) $client->createPresignedRequest($command, '+10 minutes')->getUri();

    return response()->json(['upload_url' => $url]);
}

The browser then PUTs the file to that URL directly, and your server never handles the bytes. That is the same pattern that powers upload widgets in production apps handling gigabyte-scale media.

You now have a complete, secure upload system: validated input, private storage, expiring access links, automatic cleanup, and a path to direct uploads when you need to scale. The same code works against any S3-compatible service — DigitalOcean Spaces, Cloudflare R2, MinIO — by swapping the endpoint in your config.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.