Skip to Content
🎉 Bref 3.0 is released. Read more →

File storage

Laravel has a filesystem abstraction  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 :

First install the Flysystem S3 adapter, which Laravel’s s3 disk requires (without it, any file operation fails with Class "League\Flysystem\AwsS3V3\PortableVisibilityConverter" not found):

composer require league/flysystem-aws-s3-v3

Then install the Lift plugin:

npm install --save-dev serverless-lift

Enable it in the plugins section of serverless.yml (in the file generated for Laravel, uncomment the - serverless-lift line), then use the storage construct in serverless.yml:

serverless.yml
provider: # ... environment: # environment variable for Laravel FILESYSTEM_DISK: s3 AWS_BUCKET: ${construct:storage.bucketName} constructs: storage: type: storage allowAcl: true

The allowAcl: true configuration 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 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:

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 (< 4 MB)

For files under 4 MB, you can upload directly through your Laravel application as usual:

$request->file('document')->store('documents');

Large files

AWS Lambda has a 6 MB request payload limit, which leaves about 4 MB for the file itself because of base64 encoding. 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:

serverless.yml
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.

The bref/laravel-bridge package provides everything else (update it to the latest version):

  1. A route (/signed-upload-url) that generates presigned upload URLs.
  2. A JavaScript helper that uploads the file directly to S3, with progress.
  3. The UploadedToS3 validation rule to validate the uploaded file.

Authorize uploads: the route is protected by the uploadFiles gate. Define it in a service provider (for example AppServiceProvider):

app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate; // Allow all authenticated users to upload files Gate::define('uploadFiles', fn (User $user) => true);

Frontend: the package ships a JavaScript helper (bref-upload.js), it has no dependency and works with any framework. Either import it from the Composer package, so that it always matches the installed version of bref/laravel-bridge, by adding an alias in vite.config.js:

vite.config.js
import path from 'path'; export default defineConfig({ // ... resolve: { alias: { 'bref-upload': path.resolve('vendor/bref/laravel-bridge/resources/js/bref-upload.js'), }, }, });

TypeScript applications also need the mapping in tsconfig.json, without the .js extension so that the type declarations (bref-upload.d.ts) are found:

tsconfig.json
{ "compilerOptions": { "paths": { "bref-upload": ["./vendor/bref/laravel-bridge/resources/js/bref-upload"] } } }

Or copy it into your application with php artisan vendor:publish --tag=bref-upload (it is copied to resources/js/bref-upload.js along with its type declarations, that copy will not change when the package is updated) and import it with import { upload } from './bref-upload'; instead.

import { upload } from 'bref-upload'; // 1. Get a presigned URL from the backend and upload the file directly to S3 const { key } = await upload(file, { progress: (ratio) => console.log(`${Math.round(ratio * 100)}%`), }); // 2. 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 }), });

With Inertia: upload the file as soon as it is selected, keep the returned key in the form, and submit the form as usual. The server validation error on the key shows up like any other field error. Here is a Vue example (the React version is the same, with useForm from @inertiajs/react):

resources/js/Pages/Documents/Create.vue
<script setup> import { ref } from 'vue'; import { useForm } from '@inertiajs/vue3'; import { upload } from 'bref-upload'; const form = useForm({ title: '', file_key: null, file_name: null, }); const uploading = ref(false); const progress = ref(0); const uploadError = ref(null); async function onFileSelected(event) { const file = event.target.files[0]; if (!file) return; uploading.value = true; uploadError.value = null; try { // Uploads directly to S3, the Laravel application only receives the key const { key } = await upload(file, { progress: (ratio) => (progress.value = Math.round(ratio * 100)), }); form.file_key = key; form.file_name = file.name; } catch (error) { uploadError.value = 'The upload failed, please try again.'; } finally { uploading.value = false; } } </script> <template> <form @submit.prevent="form.post('/documents')"> <input v-model="form.title" type="text" /> <input type="file" accept=".pdf" :disabled="uploading" @change="onFileSelected" /> <span v-if="uploading">Uploading… {{ progress }}%</span> <span v-else-if="form.file_name">{{ form.file_name }}</span> <span v-if="uploadError || form.errors.file_key">{{ uploadError || form.errors.file_key }}</span> <!-- Block the submission while the file is uploading --> <button type="submit" :disabled="uploading || form.processing">Save</button> </form> </template>

Backend: validate the key and copy the file to its final location:

use Bref\LaravelBridge\Upload\UploadedToS3; use Illuminate\Support\Facades\Storage; public function store(Request $request) { $validated = $request->validate([ // Checks that the file was uploaded by the current user, exists, and matches the extension and size // (pass `extensions: null` to accept any file type) 'file_key' => ['required', new UploadedToS3(extensions: ['pdf'], maxSize: 10 * 1024 * 1024)], ]); // Copy from the temporary location to the final location $finalPath = "documents/{$document->id}.pdf"; Storage::copy($validated['file_key'], $finalPath); // Save the final path in the database $document->update(['file_path' => $finalPath]); }

There is no need to delete the temporary file: the lifecycle rule configured above removes everything under tmp/ after one day.

upload() resolves with { uuid, key, bucket, url, headers, extension } and rejects with an UploadError whose status property contains the HTTP status code. It accepts the following options:

OptionDefaultDescription
url/signed-upload-urlThe route returning presigned URLs
contentTypefile.typeThe MIME type of the file
progressCallback receiving the upload progress, from 0 to 1
headers{}Extra headers for the request to the backend route (e.g. Authorization)
csrfTokenThe CSRF token, sent as X-CSRF-TOKEN. By default the XSRF-TOKEN cookie set by Laravel is sent as X-XSRF-TOKEN (like axios and Inertia do), or else the <meta name="csrf-token"> tag is used
signalAn AbortSignal to cancel the upload
httpClientAn axios-compatible client (e.g. axios) to use instead of fetch, like Vapor.store(). The client then handles CSRF and authentication

Files are uploaded under tmp/{user id}/ (the prefix cleaned up by the lifecycle rule above), so that a user cannot reference another user’s upload. To allow uploads from guests (for example a public form), remove the auth middleware in the configuration below and accept a nullable user in the gate (fn (?User $user) => true): guest uploads are stored under tmp/ directly.

The feature can be configured in the uploads section of config/bref.php (php artisan vendor:publish --tag=bref-config):

config/bref.php
'uploads' => [ // Route that returns presigned upload URLs. Set to null to disable the feature. 'route' => '/signed-upload-url', // To allow uploads from guests, remove `auth` and accept a nullable user in the `uploadFiles` gate 'middleware' => ['web', 'auth'], // Disk used for uploads (null = default disk). Must be an S3 disk. 'disk' => null, // Prefix of temporary uploads. Configure an S3 lifecycle rule to expire this prefix. 'prefix' => 'tmp', // Validity of presigned URLs, in minutes 'expires' => 5, // Maximum size in bytes, checked by the validation rule (null = no limit) 'max_size' => 50 * 1024 * 1024, // 50 MB ],

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  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 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 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:

serverless.yml
constructs: storage: type: storage publicPath: public allowAcl: true

Storing 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:

serverless.yml
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:link

Do 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  of the default disk, scoped to the public/ prefix. In config/filesystems.php:

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.

Last updated on