File storage
Laravel has a filesystem abstraction (opens in a new tab) that lets us easily change where files are stored.
When running on Lambda, you will need to use the s3 adapter to store files on AWS S3.
Quick setup with Lift
The easiest way to set up S3 storage is using Serverless Lift (opens in a new tab):
First install the Lift plugin:
serverless plugin install -n serverless-liftThen use the storage construct (opens in a new tab) in serverless.yml:
provider:
# ...
environment:
# environment variable for Laravel
FILESYSTEM_DISK: s3
AWS_BUCKET: ${construct:storage.bucketName}
constructs:
storage:
type: storage
allowAcl: trueThe allowAcl: true configuration (opens in a new tab) is needed because S3 buckets have ACLs disabled by default since April 2023. Many tools and libraries (including PHP's Flysystem, used by Laravel) send ACL headers on S3 operations, which will fail on buckets with ACLs disabled. The allowAcl: true setting lets the bucket accept these headers without errors. Note that this files in the bucket are still completely private, there is no change in the security of the bucket.
To avoid silent failures, we also recommend setting 'throw' => true on your S3 disk in config/filesystems.php:
's3' => [
'driver' => 's3',
// ...
'throw' => true,
],That's it! Lift automatically:
- Creates the S3 bucket
- Grants IAM permissions to your Lambda functions
- Exposes the bucket name via
${construct:storage.bucketName}
The AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN) are set automatically in AWS Lambda, you don't have to define them.
Uploading files
Small files (< 6 MB)
For files under 6 MB, you can upload directly through your Laravel application as usual:
$request->file('document')->store('documents');Large files
AWS Lambda has a 6MB request payload limit. For larger files, you must upload directly to S3 from the browser using presigned URLs.
Since the browser uploads directly to S3 (cross-origin), you need to configure CORS on the bucket and add a lifecycle rule to clean up temporary files. Here is a complete storage construct configuration:
constructs:
storage:
type: storage
lifecycleRules:
# Temporary upload files will be cleaned after 1 day
- prefix: tmp/
expirationInDays: 1
allowAcl: true
# CORS is required for uploading files from the browser via presigned URLs, put the URL of your website here
# See https://github.com/getlift/lift/blob/master/docs/storage.md#cors
cors: ${construct:website.url}If you are not using the website construct, replace ${construct:website.url} with your application's URL, or use '*' during development.
How it works:
- Your frontend requests a presigned upload URL from your backend
- Your backend generates a temporary presigned URL using Laravel's Storage
- The frontend uploads the file directly to S3
- The frontend sends the S3 key back to your backend to save in the database
Backend - Generate presigned URL:
temporaryUploadUrl returns an array with the URL and the headers that must be forwarded to S3 (they contain the request signature):
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
public function presignedUploadUrl(): JsonResponse
{
$key = 'tmp/' . Str::uuid() . '.pdf';
// Generate a presigned PUT URL valid for 15 minutes
$uploadUrl = Storage::temporaryUploadUrl($key, now()->addMinutes(15), [
// Optional: we restrict to PDF files here
'ContentType' => 'application/pdf',
]);
// PSR-7 headers are string[] values and include Host, which browsers forbid
$headers = collect($uploadUrl['headers'])
->except(['Host'])
->map(fn (array $values): string => implode(', ', $values))
->all();
return response()->json([
'url' => $uploadUrl['url'],
'headers' => $headers,
'key' => $key,
]);
}Frontend - Upload to S3:
// 1. Get presigned URL from your backend
const { url, headers, key } = await fetch('/api/presigned-upload-url', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
}).then(r => r.json());
// 2. Upload directly to S3, forwarding the presigned headers
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
...headers,
},
});
// 3. Send the S3 key to your backend (via a form field, API call, etc.)
await fetch('/api/documents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ file_key: key }),
});Backend - Move file to final location:
public function store(Request $request)
{
$validated = $request->validate([
'file_key' => 'required|string',
]);
// Move from temporary location to final location
$finalPath = "documents/{$document->id}.pdf";
Storage::move($validated['file_key'], $finalPath);
// Save the final path in the database
$document->update(['file_path' => $finalPath]);
}Downloading files
For private files, generate temporary presigned URLs:
// Generate a presigned download URL valid for 15 minutes
$url = Storage::temporaryUrl($document->file_path, now()->addMinutes(15));
return response()->json(['download_url' => $url]);The URL can be used directly in the browser or in an <a> tag.
For local development, Laravel's filesystem abstraction lets you use the same code locally and in production. Laravel supports temporary URLs for local files (opens in a new tab) since version 9.
Public files
Some files are meant to be publicly accessible: uploaded photos, generated PDF files, avatars, etc. Laravel traditionally uses a special disk called public (opens in a new tab) for this, but with S3 we can use a simpler convention: store public files under a public/ prefix on the default (S3) disk, and let Lift expose that prefix publicly.
Expose the public/ prefix with Lift
Add publicPath: public (opens in a new tab) to the storage construct. Every file stored under the public/ prefix becomes publicly readable over HTTPS (via a bucket policy), while the rest of the bucket stays completely private:
constructs:
storage:
type: storage
publicPath: public
allowAcl: trueStoring and reading public files
Store public files under the public/ prefix on the default disk. There is no need for a separate public disk, and no need to modify each file's visibility to 'public' (this is useless with publicPath):
// Store a public file
Storage::put('public/avatars/1.png', $fileContents);
// Read its public URL
$url = Storage::url('public/avatars/1.png');In production, set AWS_URL to the public bucket URL exposed by Lift so that Storage::url() returns the correct public URL:
provider:
environment:
FILESYSTEM_DISK: s3
AWS_BUCKET: ${construct:storage.bucketName}
# Base URL used by `Storage::url()` to build public file URLs
AWS_URL: ${construct:storage.publicUrl}Making public files work locally
Locally, the default local disk stores files in storage/app/private, so files written under the public/ prefix end up in storage/app/private/public. To make things work, you can point the storage symlink to that directory in config/filesystems.php:
'links' => [
- public_path('storage') => storage_path('app/public'),
+ public_path('storage') => storage_path('app/private/public'),
],If the public/storage symlink already exists, you need to remove it first so it can be recreated pointing to the new directory:
rm public/storage
php artisan storage:linkDo not run php artisan storage:link in AWS Lambda: it is useless there (files are served from S3), and it will fail because the filesystem is read-only.
Migrating an existing application
If your application already writes to the public disk and you don't want to change all those calls, keep the public disk but turn it into a scoped disk (opens in a new tab) of the default disk, scoped to the public/ prefix. In config/filesystems.php:
'public' => [
'driver' => 'scoped',
'disk' => env('FILESYSTEM_DISK', 'local'),
'prefix' => 'public',
],Scoped disks require the league/flysystem-path-prefixing package:
composer require league/flysystem-path-prefixing "^3.0"With this, calls like Storage::disk('public')->put('avatars/1.png', ...) keep working: they now write to public/avatars/1.png on the default disk, which Lift exposes publicly.