A user uploads a 500MB MP4. The default Livewire upload path lands the whole file on your application server first, in livewire-tmp/. Your Octane worker holds the bytes, the request blocks for the duration, and on Forge or Vapor you hit the wall — memory limit, request timeout, worker starvation, or all three at once.
The fix is one environment variable, one CORS rule, and one Artisan command. Livewire then issues a presigned PUT URL from S3 and the browser uploads directly to the bucket. Your PHP process never touches the file.
Below is the full end-to-end setup — config, CORS, lifecycle cleanup, and the component you'll actually paste into your app.
How Livewire's default upload flow burns your server#
By default Livewire uploads use the framework's default filesystem disk (usually local). The flow is: the browser POSTs the file to a signed upload route, Laravel writes it under storage/app/livewire-tmp/, and your component sees a TemporaryUploadedFile pointing at that path. Every byte funnels through PHP.
That works fine for a 200KB avatar. It does not work for a 500MB video. PHP holds the request open for the entire upload, your upload_max_filesize / post_max_size need to be raised, your Nginx client_max_body_size has to match, and on Octane the whole worker is parked until the upload completes. If you've ever debugged this on Vapor, you've also discovered that API Gateway caps request bodies at 6MB — you cannot tune your way around it. The same constraints come up in the Laravel Vapor vs Forge in 2026 comparison — file uploads are the single biggest reason teams pick Forge over Vapor.
Direct-to-S3 mode flips the script. Livewire still issues the signed URL, but it's an S3 presigned PUT URL. The browser uploads to S3 directly. Your application server only ever sees two small JSON requests: one to get the signed URL, one to acknowledge the finished upload. Bytes never enter the PHP process.
Install the S3 flysystem driver and configure the s3 disk#
Laravel ships with the s3 disk wired up in config/filesystems.php, but the Flysystem AWS adapter isn't part of the default install. Pull it in with Composer first.
composer require league/flysystem-aws-s3-v3:"^3.0"
Now confirm the s3 disk block in config/filesystems.php looks right. Don't add a new disk — use the one Laravel already defines.
// config/filesystems.php
'disks' => [
// ...
'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' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
Fill in the matching .env values. The IAM user (or access key) attached to AWS_ACCESS_KEY_ID needs s3:PutObject and s3:PutObjectAcl on the bucket — that's it for temporary uploads. If you also want to use temporaryUrl() for image previews, add s3:GetObject.
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=eu-west-1
AWS_BUCKET=my-app-uploads
AWS_USE_PATH_STYLE_ENDPOINT=false
Configure CORS on the bucket to allow PUT from your origin#
This is the step that quietly kills more direct-to-S3 setups than any other. Livewire's JS gets a presigned URL and the browser fires a PUT request — but the browser also fires a preflight OPTIONS request first. If S3 doesn't return CORS headers that allow PUT from your origin, the preflight fails, the browser blocks the PUT, and you get a 403 with no useful message in the Laravel log because Laravel never saw it.
Apply this CORS rule to the bucket — AWS console, Permissions tab, CORS configuration. Replace the AllowedOrigins entries with your actual hostnames.
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
"AllowedOrigins": [
"https://app.example.com",
"https://staging.example.com",
"http://localhost:8000"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
A few traps to dodge here: AllowedOrigins does not accept wildcards mid-string (https://*.example.com is rejected), the protocol must match exactly, and the port matters — http://localhost:8000 is a different origin from http://localhost. If your dev environment runs through npm run dev on a different port than your Herd app, list both.
Point livewire.temporary_file_upload.disk at S3#
The simplest path is one env variable. Livewire reads LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK and uses whatever disk you name there for temporary file storage.
LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3
For full control over rules, middleware, and the directory name, publish the config file and edit it directly.
php artisan livewire:config
That writes config/livewire.php. The relevant block:
// config/livewire.php
'temporary_file_upload' => [
'disk' => 's3', // null|'local'|'s3' — wins over the env var
'rules' => 'file|max:512000', // 500MB global max
'directory' => 'livewire-tmp', // leave this — the cleanup command targets this prefix
'middleware' => 'throttle:5,1', // per-user rate limit
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'webp',
],
'max_upload_time' => 5, // minutes
],
The 'rules' setting is a global validation rule applied to every temporary upload before the component-level rules run. Set it generously here and use per-property #[Validate] attributes for the tight rules — that pattern slots neatly into the property-level approach covered in Livewire 4 Form Objects with #[Validate].
Add the 24-hour lifecycle rule on livewire-tmp/#
Livewire never deletes its own S3 temporary files. Without a lifecycle rule the livewire-tmp/ prefix accumulates abandoned uploads forever — every user who started an upload and bailed leaves bytes behind, and you pay storage on them indefinitely.
Livewire ships an Artisan command that installs the rule for you. Run it once per environment that uses an S3 bucket.
php artisan livewire:configure-s3-upload-cleanup
The command reads your s3 disk config, talks to the bucket, and adds a lifecycle rule that expires anything under livewire-tmp/ after 1 day. If you'd rather wire it manually (or you're on R2 or MinIO where the command can fail), here's the AWS CLI equivalent:
aws s3api put-bucket-lifecycle-configuration \
--bucket my-app-uploads \
--lifecycle-configuration '{
"Rules": [
{
"ID": "expire-livewire-tmp",
"Status": "Enabled",
"Filter": { "Prefix": "livewire-tmp/" },
"Expiration": { "Days": 1 }
}
]
}'
Confirm it stuck with aws s3api get-bucket-lifecycle-configuration --bucket my-app-uploads. R2 and MinIO both honour the same API but you may need to hand-roll the rule because their CLIs sometimes reject the Filter element format AWS's command produces.
Move the file to its final disk inside the component#
The component code looks exactly the same as a local-disk upload — the change is entirely in the config. Here's a video upload component that validates the MIME type, shows a progress bar, and stores the finished file under videos/ on the same S3 disk.
<?php
// app/Livewire/UploadVideo.php
namespace App\Livewire;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class UploadVideo extends Component
{
use WithFileUploads;
#[Validate('required|mimetypes:video/mp4,video/quicktime|max:512000')]
public ?TemporaryUploadedFile $video = null;
public ?string $storedPath = null;
public function save(): void
{
$this->validate();
// The file is already on S3 under livewire-tmp/.
// storeAs() issues an S3 CopyObject — no bytes leave AWS.
$this->storedPath = $this->video->storeAs(
path: 'videos',
name: $this->video->hashName(),
options: 's3',
);
$this->reset('video');
}
public function render()
{
return view('livewire.upload-video');
}
}
Two things worth pointing out. First, storeAs() from livewire-tmp/ to videos/ is an S3-to-S3 CopyObject when both source and destination are the same disk — the bytes never round-trip through PHP. Second, $this->reset('video') clears the property; the original temporary file is left in livewire-tmp/ and gets swept up by the lifecycle rule.
The Blade side wires up wire:model, a preview, and an Alpine-driven progress bar. The progress bar pattern uses $wire and Alpine state, the same approach I cover in Livewire 4 and Alpine.js — Share State with $wire and @entangle.
{{-- resources/views/livewire/upload-video.blade.php --}}
<form wire:submit="save"
x-data="{ uploading: false, progress: 0 }"
x-on:livewire-upload-start="uploading = true"
x-on:livewire-upload-finish="uploading = false; progress = 0"
x-on:livewire-upload-cancel="uploading = false; progress = 0"
x-on:livewire-upload-error="uploading = false; progress = 0"
x-on:livewire-upload-progress="progress = $event.detail.progress">
<input type="file" wire:model="video" accept="video/mp4,video/quicktime">
<div x-show="uploading" class="mt-3">
<progress max="100" x-bind:value="progress" class="w-full"></progress>
<span x-text="`${progress}%`"></span>
</div>
@error('video') <span class="error">{{ $message }}</span> @enderror
<button type="submit"
x-bind:disabled="uploading"
class="not-data-loading:opacity-100 data-loading:opacity-50">
Upload video
</button>
</form>
Test the upload in the network tab to confirm it bypasses PHP#
Don't trust the config. Open DevTools, pick a file, and look at the network panel. You should see exactly three requests in order:
- A POST to
/livewire/update(or/livewire/upload-file) — small, returns a JSON payload containing a presignedhttps://my-app-uploads.s3.eu-west-1.amazonaws.com/livewire-tmp/<hash>?X-Amz-...URL. - A PUT to s3.amazonaws.com — this is the actual upload. The Initiator should be Livewire's JS, the Request URL should point at S3, and the response should be a 200 with an empty body and an
ETagheader. - A POST back to
/livewire/updateconfirming the file is set on the property.
If you instead see a PUT to your own domain, the direct-to-S3 path is not active — re-check the env variable and run php artisan config:clear. If the PUT to S3 returns 403, your bucket policy is blocking the access key. If you see the preflight OPTIONS in the network tab returning 403 or "CORS error" in the console, the CORS rule isn't applied — go back to the CORS step.
Gotchas and edge cases#
A few hazards I've hit on real projects, in roughly descending order of how often they bite people.
temporaryUrl() previews and S3. When temporary_file_upload.disk is s3, $file->temporaryUrl() returns a presigned S3 GET URL. That's only generated for MIME types in the preview_mimes config list. If your image preview is <img src="">-broken, your file is probably a MIME type Livewire doesn't class as previewable.
Multiple file uploads on S3. Livewire's docs note that WithFileUploads plus multiple works with S3, but each file is uploaded independently — there's no S3 multipart upload semantics here. For very large files (over 1GB) you may still hit S3's single-PUT limit of 5GB, but more practically you'll hit the browser's max upload time long before that. If you genuinely need >1GB uploads, look at a dedicated multipart uploader (Uppy with AWS S3 multipart) instead of Livewire's wire:model.
R2 and MinIO compatibility. Cloudflare R2 and MinIO both implement the S3 PUT API and Livewire works against them, but two things differ. The livewire:configure-s3-upload-cleanup command can fail on R2 with a "Filter element can only be used in Lifecycle V2" error — apply the lifecycle rule via R2's dashboard instead. For MinIO, set AWS_USE_PATH_STYLE_ENDPOINT=true and AWS_ENDPOINT=http://minio:9000 or wherever your MinIO lives, and make sure the bucket exists before the first upload — Livewire won't create it.
Scoped disk for hybrid setups. If you need S3 storage but want PHP to handle the upload — common when you're behind a CSP that won't allow S3 origins, or your app is firewalled and S3 access is server-side only — set up a scoped disk. The driver is scoped, the underlying disk is s3, and Livewire serves the upload route through Laravel.
// config/filesystems.php
'temporary' => [
'driver' => 'scoped',
'disk' => 's3',
'prefix' => 'temporary',
],
Then point Livewire at it: LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=temporary. The browser uploads to your Laravel app, your app streams to S3, and the file ends up in s3://bucket/temporary/livewire-tmp/.... You lose the bypass-PHP benefit but keep S3 storage and centralized lifecycle rules.
Octane and Vapor. On Octane the bypass-PHP behaviour is dramatic — your workers stay free and the box can serve real traffic during a 500MB upload. The full deployment pattern for that setup is covered in Laravel Octane + FrankenPHP — Production Deployment Without Surprises. On Vapor, direct-to-S3 is effectively the only viable path because Lambda has a 6MB request body limit; any non-trivial upload must bypass the Lambda function entirely.
Validation rules that need to read the file. Rules like dimensions:max_width=1920 or image need to read the file's bytes. When the file lives on S3, Laravel has to download it to evaluate those rules, which makes validation slow and undoes most of the direct-to-S3 benefit. Stick to MIME type and size rules at upload time (mimetypes:video/mp4, max:512000) and run heavier validation in a queued job after the save() action.
Wrapping up#
Flip the env variable, apply the CORS rule, run the cleanup command, and verify the PUT request in your network tab — that's the whole job. Once the bytes stop flowing through PHP, your Forge boxes stop OOM'ing on big uploads and your Octane workers stay free for real requests.
A natural follow-on is wiring validation cleanly: move the file rules into a form object so the upload component stays thin, as covered in the Livewire 4 Form Objects guide. If you're still on Livewire 3 and the directive names look unfamiliar, start with the practical Livewire 3 to Livewire 4 migration guide before changing anything in production.
FAQ#
How do I upload files directly from Livewire to S3?
Set LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3 in your .env, make sure the s3 disk is configured in config/filesystems.php with valid AWS credentials, and apply a CORS rule on the bucket that allows PUT from your app's origin. Livewire will then issue a presigned S3 URL for every upload and the browser PUTs straight to the bucket — your application server never sees the bytes.
Why is my Livewire S3 upload returning 403 Forbidden?
The two usual culprits are CORS and IAM permissions. If the browser sees a CORS error before the 403, your bucket CORS rule is missing PUT for your origin — fix the AllowedMethods and AllowedOrigins entries on the bucket. If the request reaches S3 and S3 itself returns 403, the IAM user behind your access key lacks s3:PutObject on the bucket or on the livewire-tmp/ prefix. Add an IAM policy granting PutObject and PutObjectAcl on arn:aws:s3:::your-bucket/* and retry.
How long are Livewire temporary files kept?
By default, indefinitely — Livewire does not clean up its own temporary files on S3. The framework expects you to install a 24-hour S3 lifecycle rule on the livewire-tmp/ prefix, which you can do automatically by running php artisan livewire:configure-s3-upload-cleanup once per environment. After that rule is in place, S3 expires anything under livewire-tmp/ 24 hours after creation, regardless of whether the upload was completed.
What CORS rules do I need for Livewire direct-to-S3 uploads?
The bucket needs CORS configuration that allows PUT, POST, GET, and HEAD methods from your application origins (and any dev/staging origins). AllowedHeaders should be ["*"] so the presigned URL headers can be sent, and ExposeHeaders should include ETag so the browser can read S3's response. Wildcards inside an origin string (https://*.example.com) are not supported — list each subdomain explicitly, and remember that protocol and port are part of the origin match.
Can I use Livewire file uploads with a non-AWS S3-compatible bucket (R2, MinIO)?
Yes. Both Cloudflare R2 and MinIO implement the S3 PUT API and Livewire treats them the same as AWS S3 once the disk is configured. For R2, set AWS_ENDPOINT to your R2 account endpoint and the livewire:configure-s3-upload-cleanup command may fail with a lifecycle V2 error — install the 24-hour expiry rule via the R2 dashboard instead. For MinIO, set AWS_USE_PATH_STYLE_ENDPOINT=true and AWS_ENDPOINT to your MinIO URL, and make sure the bucket exists before the first upload.
How do I clean up the livewire-tmp folder on S3?
Run php artisan livewire:configure-s3-upload-cleanup once. It uses your configured S3 disk to install a lifecycle rule that expires objects under livewire-tmp/ after 1 day. If the command errors out (most commonly on non-AWS providers), apply the rule manually with the AWS CLI's put-bucket-lifecycle-configuration command using a Prefix filter of livewire-tmp/ and an Expiration of 1 day. Once the rule is in place S3 handles cleanup automatically — you don't need a scheduled Laravel task.