Laravel File Storage: The Complete Guide to Disks, Private Files, Direct Uploads and Signed URLs

A complete Laravel file storage guide: disk config, private S3 files, presigned direct uploads, temporary signed URLs, upload validation and orphan cleanup.

Steven Richardson
Steven Richardson
· 28 min read

Every Laravel application grows a file upload, and almost all of them grow it the same way: follow the quickstart, call store('avatars', 'public'), run php artisan storage:link, ship. Six months later the invoices bucket is world-readable, the database holds forty thousand absolute s3.amazonaws.com URLs so the CDN move is now a data migration, a 300 MB video 502s because the bytes go through PHP, and half the objects in the bucket belong to rows that no longer exist. None of that is exotic. It is the default trajectory, because the documentation explains each API correctly and in isolation and nothing joins them into a pipeline. This guide joins them, on Laravel 13, by building one thing: a document library where users upload private PDFs.

Understand what a disk actually is#

Before any code, get the mental model right, because it makes every later decision obvious. Laravel's storage layer is a thin wrapper over Flysystem, and a disk is a named configuration of one Flysystem adapter. local, public, s3, sftp are not four different APIs — they are four rows in config/filesystems.php behind one interface. Every method you call (put, get, delete, exists, size, readStream) resolves through that interface, so application code that says Storage::disk('documents')->put(...) keeps working when documents moves from a local folder to S3 to Cloudflare R2.

The practical rule that falls out of this: the disk name is the only storage detail your application code is ever allowed to know. Not the bucket, not the region, not the URL shape, not whether it's local. The moment a controller contains env('AWS_BUCKET') or a Blade template contains https://my-bucket.s3.eu-west-2.amazonaws.com/, you have leaked the adapter into the application, and the next infrastructure change becomes a find-and-replace across the codebase plus a database migration.

The abstraction is not free, and it's worth knowing where it leaks. Flysystem exposes the intersection of what its adapters can do, so anything provider-specific either has a per-disk escape hatch or isn't there at all. Storage::path() returns an absolute filesystem path on local and a bucket-relative key on s3. Storage::url() returns a working URL on a public local disk and a URL that returns 403 on a private bucket — it does not throw, it just hands you a dead link. temporaryUrl() works on s3 and on local (with 'serve' => true), and throws This driver does not support creating temporary URLs on everything else, including — and this catches nearly everyone — a faked disk in tests.

Writes are the other leak. By default a failed write returns false rather than throwing, which means a disk misconfiguration can silently drop files for a week before anyone notices:

if (! Storage::disk('documents')->put($path, $contents)) {
    // Silence. No exception, no log, no file.
}

Set 'throw' => true on every disk you care about, or 'report' => true if you want the exception logged without changing the return value. I turn throw on everywhere in a new app and have never regretted it; a failed write is not a normal branch, it's an incident.

Configure four disks in config/filesystems.php#

Start by declaring every storage concern your app has as its own disk, even when two of them point at the same bucket. Disks are cheap, and a separate disk is the cheapest possible seam for a future migration. Our document library needs four: local for scratch work, public for genuinely public assets, documents for private user PDFs, and avatars for public images fronted by a CDN. Install the S3 adapter first:

composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies

Then the configuration. Note that the two S3 disks share credentials but not buckets, and that neither of them sets visibility:

// config/filesystems.php
'disks' => [

    'local' => [
        'driver' => 'local',
        'root' => storage_path('app/private'),
        'serve' => true,
        'throw' => true,
    ],

    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
        'throw' => true,
    ],

    'documents' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_DOCUMENTS_BUCKET'),
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        'throw' => true,
        'report' => true,
    ],

    'avatars' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_AVATARS_BUCKET'),
        'url' => env('CDN_URL'),
        'throw' => true,
    ],
],

Three details in there matter more than they look. 'serve' => true on the local disk enables temporaryUrl() for the local driver, which is what lets your development environment behave like production instead of forcing a different code path. 'url' on the avatars disk means Storage::disk('avatars')->url($path) returns a CloudFront or Cloudflare hostname rather than the raw bucket — swapping CDNs later is one environment variable, not a data migration. And the absence of 'visibility' => 'public' on either S3 disk is deliberate: since April 2023, new S3 buckets default to Bucket owner enforced object ownership, which disables ACLs entirely. Any request carrying an ACL header comes back as AccessControlListNotSupported, which means storePublicly(), putFile($path, $file, 'public'), setVisibility() and a disk-level visibility key all fail on a bucket created in the last three years. Make public objects public with a bucket policy or a CloudFront origin access control, not with per-object ACLs.

For an S3-compatible provider, add an endpoint. Cloudflare R2, MinIO and RustFS all need path-style addressing, and getting that wrong produces a 403 or a redirect to a hostname that doesn't exist:

'documents' => [
    'driver' => 's3',
    // ...
    'endpoint' => env('AWS_ENDPOINT'),          // https://<account>.r2.cloudflarestorage.com
    'use_path_style_endpoint' => true,          // bucket in the path, not the subdomain
],

R2 in particular does not implement GetObjectAcl, so any call to getVisibility() or setVisibility() throws UnableToRetrieveMetadata — another reason to design as though visibility doesn't exist. Because each disk reads its own environment variables, you can also scope credentials per disk: a key that can write documents but cannot touch the avatars bucket limits the blast radius of a leak, and the mechanics of storing and rotating those keys are covered in the production secrets management guide.

Decide public or private for each file class#

Do this before writing the upload, because retrofitting privacy onto a public bucket is the single most expensive mistake in this whole area. For each kind of file your application stores, answer four questions: who may read it, may the URL be forwarded to someone else, does access need auditing, and does access need to be revocable before it naturally expires. The answers pick the strategy for you.

File class Who may read Shareable URL? Audit needed Strategy
Marketing images, public avatars Anyone Yes No Public bucket + CDN, Storage::url()
User profile photo in a members area Logged-in users Tolerable No Private bucket + temporaryUrl(), short expiry
Invoices, contracts, medical records One user or team No Yes Private bucket + signed route + policy
Large private video One user No Sometimes Signed route that redirects to a short temporaryUrl()
Anything a regulator asks about Named individuals No Always Signed route + policy + logged access

The row that trips people up is the second one. "It's behind a UUID" is not access control. A UUID prevents guessing; it does not prevent forwarding, browser history, a referrer header, a support ticket screenshot, or an employee pasting a link into a group chat. If the answer to "would it be a problem if this URL leaked" is yes, the object needs to be private and the URL needs to expire — and if the answer to "would it be a problem if the wrong logged-in user opened it" is also yes, it needs a policy check, which means a signed route rather than a bare temporaryUrl().

Write the decision down somewhere in the repo. Six months from now someone will add a new upload, and without a written rule they will default to whatever the nearest existing controller does — which, statistically, is the public disk.

Store the disk and path, never the URL#

Now model the file. This is the section that makes every later migration cheap, and it is three columns and an accessor. The rule: persist the disk name and the relative path, and resolve the URL at read time. Persisting https://old-bucket.s3.amazonaws.com/docs/abc.pdf means the bucket name, the region, the URL scheme and the CDN choice are now rows in your database, and changing any of them is a migration across every table that ever touched a file.

php artisan make:model Attachment -m
// database/migrations/xxxx_create_attachments_table.php
Schema::create('attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->morphs('attachable');
    $table->string('disk', 32);
    $table->string('path');
    $table->string('original_name');
    $table->string('mime', 127);
    $table->unsignedBigInteger('size');
    $table->timestamp('confirmed_at')->nullable();
    $table->timestamps();

    $table->unique(['disk', 'path']);
    $table->index(['confirmed_at', 'created_at']);
});
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Storage;
use Illuminate\Contracts\Filesystem\Filesystem;

class Attachment extends Model
{
    protected $fillable = [
        'user_id', 'disk', 'path', 'original_name', 'mime', 'size',
    ];

    protected function casts(): array
    {
        return [
            'size' => 'integer',
            'confirmed_at' => 'immutable_datetime',
        ];
    }

    public function attachable(): MorphTo
    {
        return $this->morphTo();
    }

    public function storage(): Filesystem
    {
        return Storage::disk($this->disk);
    }

    public function temporaryUrl(int $minutes = 5): string
    {
        return $this->storage()->temporaryUrl(
            $this->path, now()->plus(minutes: $minutes)
        );
    }

    public function isConfirmed(): bool
    {
        return $this->confirmed_at !== null;
    }
}

The disk column is what earns its keep. Moving documents from S3 to R2 becomes: add an r2-documents disk, copy the objects, run Attachment::where('disk', 'documents')->update(['disk' => 'r2-documents']). No URL rewriting, no template changes. Laravel 13's read-through driver makes even that gradual — point a disk at a primary and a fallback, and files are copied to the primary the first time they're read, so you can migrate a bucket without a maintenance window:

'documents' => [
    'driver' => 'read-through',
    'primary' => 'r2-documents',
    'fallback' => 's3-documents',
],

Note there is no url accessor on this model, deliberately. A private attachment has no single URL — it has a policy-checked route and a short-lived signed URL, and which one you want depends on the caller. Exposing one property called url invites a Blade template to render it into a page that gets cached. If you are serialising attachments through an API, put the signed URL on the resource where the expiry is explicit, which fits naturally into the pattern described in the Laravel 13 JSON:API resources guide.

Stream private files through a controller policy#

The most controllable way to serve a private file is to put a controller in front of it, because a controller is the only place you can run an authorisation check against the current user. Every byte passes through PHP, which is the cost, and you get policy enforcement, audit logging and the ability to revoke access instantly, which is the benefit.

namespace App\Http\Controllers;

use App\Models\Attachment;
use Symfony\Component\HttpFoundation\StreamedResponse;

class AttachmentDownloadController extends Controller
{
    public function __invoke(Attachment $attachment): StreamedResponse
    {
        $this->authorize('view', $attachment);

        activity()->performedOn($attachment)->log('attachment.downloaded');

        return $attachment->storage()->download(
            $attachment->path,
            $attachment->original_name,
            ['Content-Type' => $attachment->mime, 'X-Content-Type-Options' => 'nosniff'],
        );
    }
}
// app/Policies/AttachmentPolicy.php
public function view(User $user, Attachment $attachment): bool
{
    return $attachment->isConfirmed()
        && $user->currentTeam->is($attachment->attachable->team);
}

Storage::download() returns a StreamedResponse, so Flysystem reads the object as a stream and PHP does not hold the whole file in memory — a 2 GB file uses a few kilobytes of PHP memory. What it does consume is a PHP worker for the duration of the transfer, which is the real constraint. Ten users each pulling a 500 MB file on a slow connection will occupy ten PHP-FPM workers for minutes. On a server with 20 workers, that is an outage.

The classic fix is to hand the transfer to the web server. Nginx's X-Accel-Redirect (and Apache's X-Sendfile) let PHP do the authorisation, return immediately with a header naming an internal location, and leave the byte-pushing to the web server:

return response(status: 200, headers: [
    'X-Accel-Redirect' => '/internal-documents/'.$attachment->path,
    'Content-Disposition' => 'attachment; filename="'.addslashes($attachment->original_name).'"',
    'Content-Type' => $attachment->mime,
]);
location /internal-documents/ {
    internal;
    alias /var/www/storage/app/private/;
}

That only works when the file is on a filesystem the web server can see, which rules it out for S3 and for most containerised deployments. For S3-backed private files the equivalent trick is the next two steps. Note also that if the caller is a Livewire component rather than a browser hitting a route directly, the streamed file download from a Livewire action pattern applies instead — and has its own size ceiling worth knowing before you rely on it.

Hand the bytes to S3 with temporaryUrl()#

When the file is on S3 and the authorisation rule is simple, skip PHP entirely and let S3 serve the object under a signature you generate. temporaryUrl() asks the AWS SDK to sign a GET request for a specific key with a specific expiry; the browser then talks to S3 directly and your application never sees a byte.

$url = Storage::disk('documents')->temporaryUrl(
    $attachment->path,
    now()->plus(minutes: 5),
    [
        'ResponseContentType' => $attachment->mime,
        'ResponseContentDisposition' => 'attachment; filename="'.$attachment->original_name.'"',
    ],
);

The third argument is worth knowing about: S3 will honour ResponseContentType and ResponseContentDisposition on the response, which lets you force a download and override a stored Content-Type without rewriting the object. That matters for the SVG and HTML cases discussed later.

The trade-off is that a presigned URL is a bearer token in a query string. Anyone holding it can fetch the object until it expires, and there is no revocation — the signature is verified by S3 using your account credentials, and S3 has never heard of your users table. Deleting the user, disabling their account and revoking their session all leave a live presigned URL working until the clock runs out. That is the entire argument for short expiries.

Controller stream Storage::temporaryUrl() Signed route → redirect
Authorisation Full policy None after signing Full policy
Bytes through PHP Yes No No
Revocable early Yes No Yes
Auditable per download Yes No Yes
Works behind your CDN Yes Separate origin Separate origin
Cost at scale PHP workers S3 egress only S3 egress only

Pick expiry by use case, not by habit. An <img> tag in a page the user is already looking at needs about as long as the page takes to render — one to five minutes. A download link in an email needs to survive the recipient getting to their inbox, so hours, and that link should be a signed route rather than a raw presigned URL precisely because you may need to revoke it. An expiry measured in days is a public URL with extra steps.

Combine a signed route with a temporary URL#

For most private files, the pattern you actually want is the hybrid: a signed route into your application that runs the policy, then redirects to a very short-lived temporaryUrl(). You get authorisation, audit logging and revocability from the route, and S3 still pushes the bytes. This is the option I reach for by default in production.

// routes/web.php
Route::get('/attachments/{attachment}/download', AttachmentRedirectController::class)
    ->name('attachments.download')
    ->middleware(['signed', 'auth']);
public function __invoke(Attachment $attachment): RedirectResponse
{
    $this->authorize('view', $attachment);

    activity()->performedOn($attachment)->log('attachment.downloaded');

    return redirect()->away($attachment->temporaryUrl(minutes: 1));
}

Generate the link with URL::temporarySignedRoute():

$link = URL::temporarySignedRoute(
    'attachments.download',
    now()->plus(hours: 24),
    ['attachment' => $attachment->id],
);

Here is the distinction that the documentation never puts on one page, and that half the Stack Overflow questions on this topic are really about. URL::temporarySignedRoute() signs your route with your APP_KEY; the signed middleware verifies it, and your controller then runs whatever policy, rate limit or audit you like. Storage::temporaryUrl() signs an S3 object with your AWS credentials; S3 verifies it, and no code of yours runs at all. They are not alternatives to each other so much as two different layers, and combining them gives you the properties of both: a 24-hour link a user can act on, backed by a 60-second object URL that is useless to anyone who intercepts it later.

One failure mode to know: signed routes verify the full URL including scheme and host, so behind a load balancer that terminates TLS you will get 403 Invalid Signature on every link unless the app knows the original request was HTTPS. Configure trusted proxies so X-Forwarded-Proto is honoured — setting APP_URL does not fix it, because the signature is checked against the request URL, not the configured one.

Upload straight to S3 with a presigned URL#

Uploads have the mirror-image problem of downloads, and it bites sooner. A file posted to your application passes through upload_max_filesize and post_max_size in PHP, client_max_body_size in Nginx, max_execution_time while it writes, and whatever body limit your load balancer enforces — and every one of those produces a different, unhelpful error. A 413 from Nginx never reaches PHP. PostTooLargeException means PHP rejected it. Silent truncation means someone raised one limit and not the others. Raise all four together or none of them:

; php.ini
upload_max_filesize = 100M
post_max_size = 105M          ; must exceed upload_max_filesize
max_execution_time = 300
memory_limit = 256M
client_max_body_size 105M;

Past roughly 50 MB, stop raising limits and take PHP out of the path. Laravel signs an upload URL for you with temporaryUploadUrl(), which returns both the URL and the headers the client must replay exactly:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

class PresignedUploadController extends Controller
{
    public function __invoke(Request $request): array
    {
        $validated = $request->validate([
            'filename' => ['required', 'string', 'max:255'],
            'content_type' => ['required', Rule::in(['application/pdf'])],
            'size' => ['required', 'integer', 'min:1', 'max:'.(500 * 1024 * 1024)],
        ]);

        $path = sprintf(
            'documents/%d/%s.pdf',
            $request->user()->id,
            Str::uuid7(),
        );

        ['url' => $url, 'headers' => $headers] = Storage::disk('documents')
            ->temporaryUploadUrl($path, now()->plus(minutes: 10));

        return ['path' => $path, 'url' => $url, 'headers' => $headers];
    }
}

Two things are doing security work there. The path is generated server-side from the authenticated user's ID and a UUID — the client never chooses where its object lands, which is what stops ../../other-user/contract.pdf and what stops one user overwriting another's file. And the presign is scoped to exactly that key, so the returned URL cannot be used to write anywhere else in the bucket.

The browser then PUTs the bytes straight to S3. The headers must match what was signed, or S3 returns SignatureDoesNotMatch — this is the single most common failure in the whole flow, and it is usually a client library helpfully adding a Content-Type that wasn't part of the signature:

const { path, url, headers } = await fetch('/uploads/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token },
    body: JSON.stringify({ filename: file.name, content_type: file.type, size: file.size }),
}).then(r => r.json());

await fetch(url, { method: 'PUT', headers, body: file });

await fetch('/uploads/confirm', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token },
    body: JSON.stringify({ path, name: file.name }),
});

None of that works until the bucket allows cross-origin PUTs, and the browser error when it doesn't is deliberately opaque — a CORS failure with no useful detail in the console. Add the rule to the bucket, not to your application:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type", "x-amz-acl"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

For files beyond about 5 GB, or for uploads that must survive a dropped connection, a single presigned PUT is no longer enough and you need S3 multipart. My honest recommendation after doing it the hard way: do not hand-roll resumable multipart. Use a client library that already handles part sizing, retries and resumption, and have your server issue presigned URLs per part. If you are already in a Livewire app, most of this step is done for you — see direct-to-S3 temporary uploads in Livewire 4, which flips the same behaviour on with configuration rather than a custom controller.

Confirm every direct upload on the server#

Never create the database row at presign time, and never trust the client's "I'm done". Between issuing a presigned URL and the object existing, anything can happen — the user closes the tab, the network drops, or the client simply lies. The confirm endpoint is where the upload becomes real, and it is not optional.

namespace App\Http\Controllers;

use App\Models\Attachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;

class ConfirmUploadController extends Controller
{
    public function __invoke(Request $request): Attachment
    {
        $validated = $request->validate([
            'path' => ['required', 'string'],
            'name' => ['required', 'string', 'max:255'],
        ]);

        $expectedPrefix = 'documents/'.$request->user()->id.'/';

        abort_unless(str_starts_with($validated['path'], $expectedPrefix), 403);

        $disk = Storage::disk('documents');

        abort_unless($disk->exists($validated['path']), 422, 'Upload not found.');

        $size = $disk->size($validated['path']);

        if ($size > 500 * 1024 * 1024) {
            $disk->delete($validated['path']);

            throw ValidationException::withMessages(['path' => 'File is too large.']);
        }

        $mime = $this->sniff($disk->readStream($validated['path']));

        if ($mime !== 'application/pdf') {
            $disk->delete($validated['path']);

            throw ValidationException::withMessages(['path' => 'Only PDFs are accepted.']);
        }

        return DB::transaction(fn () => Attachment::create([
            'user_id' => $request->user()->id,
            'disk' => 'documents',
            'path' => $validated['path'],
            'original_name' => $validated['name'],
            'mime' => $mime,
            'size' => $size,
            'confirmed_at' => now(),
        ]));
    }

    private function sniff($stream): string
    {
        $bytes = fread($stream, 4096);
        fclose($stream);

        return (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) ?: 'application/octet-stream';
    }
}

The prefix check is the part people leave out, and it is the whole ballgame. Without it, a user can presign an upload, then call confirm with somebody else's path and get an Attachment row pointing at another customer's contract — a complete authorisation bypass built entirely out of endpoints that each look fine on their own.

The MIME sniff also deserves a note, because it is different from the through-PHP case. On S3, Storage::mimeType() returns the object's stored Content-Type metadata, which the client set when it PUT the object. It is not a measurement, it is an assertion by the uploader. Reading the first few kilobytes through readStream() and running finfo over them is the cheap way to check what the bytes actually are without downloading a 400 MB file.

If any work follows the upload — thumbnailing, virus scanning, text extraction — dispatch it after the transaction commits, not inside it. A job that beats its own transaction to the worker fails with a ModelNotFoundException for a row that plainly exists, which is a confusing afternoon; the mechanics and the after_commit configuration are covered in dispatching jobs inside Laravel transactions. For anything heavier than a thumbnail, give it its own queue so a batch of 200 MB PDFs doesn't starve your transactional jobs, which is the topology argument made in the production queue scaling guide.

Validate uploads against spoofed types and hostile files#

For uploads that do still go through PHP, replace mimes:pdf with the File rule object, and understand what each rule actually checks. extensions: validates the user-supplied extension. mimes: and mimetypes: read the file's contents and guess. File::types() does the same content-based check with a nicer API and size helpers attached:

use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

$request->validate([
    'document' => [
        'required',
        File::types(['pdf'])->min('1kb')->max('50mb'),
        'extensions:pdf',
    ],
    'avatar' => [
        'nullable',
        File::image()
            ->max('5mb')
            ->dimensions(Rule::dimensions()->maxWidth(4000)->maxHeight(4000)),
    ],
]);

File::image() excludes SVG by default, and that default is correct. An SVG is an XML document that may contain <script>, so an SVG served from your own origin is a stored XSS with a file extension. If you must accept them, File::image(allowSvg: true) opts in — and then you are responsible for sanitising the XML, or for serving it from a hostname that shares no cookies with your application. The same applies to any user content you serve inline: a separate hostname (usercontent.example.com, not a subdomain that inherits a .example.com cookie) plus X-Content-Type-Options: nosniff and Content-Disposition: attachment removes an entire class of problem.

The other rules that matter are the ones about what you do with the file afterwards:

  • Never store the client's filename. getClientOriginalName() and getClientMimeType() are attacker-controlled strings. Use store() or storeAs() with a generated name, keep the original only as a display label, and escape it on output.
  • Never build a path from user input. Generate the directory from the authenticated user's ID. Laravel normalises paths, but the defence you want is that the user has no say at all.
  • Cap dimensions as well as bytes. A 400 KB PNG can decode to 40,000 × 40,000 pixels and exhaust memory the moment something tries to resize it. Rule::dimensions()->maxWidth(...) runs before your image pipeline does.
  • Cap the archive, not just the zip. If you extract uploaded archives, check the declared uncompressed size and the entry count before extracting, and refuse anything absurd.

When the upload is an image you intend to transform anyway, Laravel 13's image manipulation gives you a re-encode for free — and re-encoding is itself a sanitiser, because it discards everything that isn't pixels:

$path = $request->image('avatar')
    ->cover(400, 400)
    ->toWebp()
    ->store('avatars', 'avatars');

Prune orphaned objects on a schedule#

Every direct-upload architecture leaks objects, by design. The presign succeeds, the PUT succeeds, the confirm never arrives, and the object sits in the bucket forever with no row pointing at it. Nobody notices for a year, and then somebody asks why storage costs four figures a month. Reconcile on a schedule.

The delete-on-model-delete half is easy, and belongs on the model:

protected static function booted(): void
{
    static::deleted(function (Attachment $attachment): void {
        DB::afterCommit(fn () => $attachment->storage()->delete($attachment->path));
    });
}

DB::afterCommit() is the important part. Deleting the object inside a transaction that later rolls back leaves a row pointing at a file you already destroyed, which is strictly worse than an orphan — an orphan wastes money, a missing file breaks a page. If the model uses soft deletes, hook forceDeleted instead, because a soft delete is explicitly a "we might want this back" operation.

The other half is a command that walks the bucket and deletes what nothing owns:

php artisan make:command PruneOrphanedFiles
public function handle(): int
{
    $disk = Storage::disk('documents');
    $cutoff = now()->subHours(24);
    $deleted = 0;

    foreach (array_chunk($disk->allFiles('documents'), 1000) as $chunk) {
        $known = Attachment::query()
            ->where('disk', 'documents')
            ->whereIn('path', $chunk)
            ->pluck('path')
            ->all();

        foreach (array_diff($chunk, $known) as $orphan) {
            if ($disk->lastModified($orphan) > $cutoff->getTimestamp()) {
                continue; // In-flight upload, leave it alone.
            }

            $disk->delete($orphan);
            $deleted++;
        }
    }

    $this->info("Deleted {$deleted} orphaned objects.");

    return self::SUCCESS;
}
// routes/console.php
Schedule::command('files:prune-orphans')->dailyAt('03:30')->onOneServer();

Two warnings. The 24-hour cutoff is not optional — without it the command races in-flight uploads and deletes objects seconds before their confirm request arrives. And allFiles() lists the entire prefix into memory, which is fine at ten thousand objects and a disaster at ten million; past that, page through the S3 API directly or drive the reconciliation from the database side instead. The complementary pattern on the database side is Laravel's Prunable trait, whose pruning() hook is the right place to release files as rows expire. If the scheduler itself runs in a container, the scheduler-in-Docker guide covers why a bare crontab line won't cut it.

Finally, add an S3 lifecycle rule to abort incomplete multipart uploads after seven days. Failed multiparts leave parts that are invisible in the console's object listing and fully billable — it is the quietest line item on any S3 bill. While you are in there, a lifecycle policy that transitions old documents to Infrequent Access pairs well with the off-site backup strategy for the database that points at them.

Test the storage layer with Storage::fake()#

Storage::fake() swaps a disk for a local temporary one, so the through-PHP path tests exactly as you'd hope. Pest, Laravel 13:

use App\Models\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

it('stores an uploaded pdf on the documents disk', function () {
    Storage::fake('documents');

    $this->actingAs($user = User::factory()->create())
        ->post('/documents', ['document' => UploadedFile::fake()->create('report.pdf', 120, 'application/pdf')])
        ->assertCreated();

    $attachment = Attachment::sole();

    expect($attachment->disk)->toBe('documents')
        ->and($attachment->path)->toStartWith("documents/{$user->id}/");

    Storage::disk('documents')->assertExists($attachment->path);
});

it('deletes the object when the attachment is deleted', function () {
    Storage::fake('documents');

    $attachment = Attachment::factory()->create(['disk' => 'documents']);
    Storage::disk('documents')->put($attachment->path, 'pdf bytes');

    $attachment->delete();

    Storage::disk('documents')->assertMissing($attachment->path);
});

The presigned path is where people get stuck, because a faked disk throws This driver does not support creating temporary URLs — and rightly so, since there is no S3 to sign against. Don't fight it. Split the behaviour in two and test each half for what it is actually responsible for: the presign endpoint is responsible for scoping, and the confirm endpoint is responsible for authorisation and verification.

it('scopes the presigned path to the authenticated user', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->postJson('/uploads/presign', [
            'filename' => 'contract.pdf',
            'content_type' => 'application/pdf',
            'size' => 2048,
        ])->assertOk();

    expect($response->json('path'))->toStartWith("documents/{$user->id}/")
        ->and($response->json('path'))->toEndWith('.pdf');
});

it('refuses to confirm an object belonging to another user', function () {
    Storage::fake('documents');

    $victim = User::factory()->create();
    $attacker = User::factory()->create();

    Storage::disk('documents')->put($path = "documents/{$victim->id}/secret.pdf", '%PDF-1.7');

    $this->actingAs($attacker)
        ->postJson('/uploads/confirm', ['path' => $path, 'name' => 'secret.pdf'])
        ->assertForbidden();

    expect(Attachment::count())->toBe(0);
});

That second test is the one I would not ship without. It is the difference between a direct-upload flow and a data breach, and it costs eight lines. For the browser half of the flow — the PUT, the progress bar, the CORS handshake — a real browser test is the only thing that proves it, and Pest 4's Playwright browser testing will drive it against a real bucket in a staging environment.

Ship it and avoid the mistakes that cost the most#

Roll it out in the order this guide built it — disks, decision table, model, serving, uploading, validation, pruning, tests — and check your existing code against the list below before you do, because most of these are already in your codebase somewhere. Each one is written as the symptom you'll recognise rather than the rule you'll forget.

  • Absolute URLs in the database. Symptom: changing CDN requires a migration. Fix: disk + path columns, resolve at read time.
  • storage:link on an ephemeral container. Symptom: works locally, 404s in production, or the symlink vanishes on redeploy. The public local disk assumes a persistent writable filesystem, which containers and Lambda do not have — everything user-uploaded belongs on S3. See the Kubernetes deployment guide and the Vapor vs Forge comparison for what each platform expects.
  • AccessControlListNotSupported. Symptom: storePublicly() fails on a new bucket. Fix: stop using ACLs; use a bucket policy.
  • Storage::url() on a private disk. Symptom: an image that 403s instead of an error you can catch. It returns a URL happily; it just doesn't work.
  • Unbounded temporaryUrl() expiry. Symptom: a week-old link in someone's browser history still opens the file.
  • Trusting getClientMimeType(). Symptom: shell.php.jpg in your uploads directory. Content-based validation only.
  • CORS missing on the bucket. Symptom: direct upload fails with an empty console error and no server log.
  • SignatureDoesNotMatch on PUT. Symptom: the presign worked, the upload 403s. Fix: replay the returned headers exactly, nothing added.
  • No confirm step. Symptom: none, until someone reads the other tenant's files.
  • No prune job. Symptom: a storage bill that only goes up.

Next, pick the cluster piece that matches where you are. If your uploads live in Livewire components, direct-to-S3 temporary uploads removes most of the custom controller work in this guide. If post-upload processing is your bottleneck, the queue scaling guide covers the topology. And if the files you are storing are generated rather than uploaded, generating and storing images with the Laravel AI SDK uses the same disk abstraction from the other end.

FAQ#

What is the difference between the public and local disk in Laravel?

Both use the local driver; the difference is where they point and who can reach them. The local disk writes to storage/app/private, which sits outside the web root and is unreachable by URL, so those files can only be served by PHP. The public disk writes to storage/app/public and expects a symlink at public/storage created by php artisan storage:link, which makes the files directly web-accessible with no authorisation at all. Use public only for files you would be happy to see indexed by a search engine.

How do I serve private files securely in Laravel?

Three ways, in increasing order of offload. Stream through a controller with a policy check using Storage::download() — full control, but every byte goes through a PHP worker. Generate a short Storage::temporaryUrl() so S3 serves the object under a signature — no PHP involvement, but no authorisation and no revocation either. Or combine them: a URL::temporarySignedRoute() into a controller that runs the policy and then redirects to a sixty-second temporaryUrl(). The hybrid is the right default for anything genuinely private.

How do I upload directly to S3 from the browser in Laravel?

Generate a presigned upload URL server-side with Storage::disk('documents')->temporaryUploadUrl($path, now()->plus(minutes: 10)), where $path is built from the authenticated user's ID and a UUID rather than anything the client sent. Return the URL and the headers it hands back, and have the browser PUT the file with exactly those headers. Add a CORS rule to the bucket allowing PUT from your origin, then call a confirm endpoint that verifies the object exists, checks its real size and type, and only then writes the database row.

How long should a Laravel temporary signed URL last?

Match the expiry to the action, not to convenience. An image rendered into a page the user is currently viewing needs one to five minutes. A presigned upload URL needs as long as the upload plausibly takes — ten minutes for large files. A download link sent by email needs hours or a day, and should be a signed route rather than a raw presigned URL so you can still revoke it. Anything longer than a day is a public URL with extra steps, because a presigned S3 URL cannot be revoked once issued.

How do I validate file uploads safely in Laravel?

Use the File rule object rather than the mimes: string rule, because it reads the file's contents to determine the type instead of trusting the client: File::types(['pdf'])->min('1kb')->max('50mb'). Pair it with extensions:pdf so the user-assigned extension has to agree with the sniffed type, add Rule::dimensions() caps for images, and never store getClientOriginalName() as a path component. For direct uploads that bypass PHP entirely, do the same check server-side in the confirm step by reading the first few kilobytes through readStream() and running finfo over them.

Should I store the file URL or the file path in the database?

Store the path, plus a column naming the disk. A URL bakes the bucket, region, scheme and CDN into your data, so changing any of them becomes a migration across every table that references a file. A disk and path pair means the same row works when the file moves from local to S3 to R2, and switching CDNs is a change to the disk's url option. Resolve the URL at read time, and for private files do not expose a single url property at all — the correct URL depends on whether the caller can pass a policy check.

How do I clean up orphaned files after a failed upload?

Two mechanisms, because there are two kinds of orphan. For files whose row is deleted, hook the model's deleted event and delete the object inside DB::afterCommit() so a rolled-back transaction doesn't destroy a file the row still points at. For objects that were uploaded but never confirmed, schedule a reconciliation command that lists the bucket, subtracts the paths present in the database, and deletes what is left — with a cutoff of at least 24 hours so it never races an upload still in flight. Add an S3 lifecycle rule to abort incomplete multipart uploads too, since those are billable and invisible in the object listing.

Does storage:link work on Laravel Vapor or a read-only container?

No, and it shouldn't. storage:link creates a symlink from public/storage to storage/app/public on the local filesystem, which assumes that filesystem is both writable and persistent. On Vapor the filesystem is ephemeral and read-only apart from /tmp, and on Kubernetes or any container built from an immutable image the symlink either can't be created or disappears on the next deploy. The answer on both platforms is the same: user-uploaded files belong on S3 via a cloud disk, and the public local disk is for local development only.

Why do I get "This driver does not support creating temporary URLs"?

The disk you called temporaryUrl() on uses a driver that can't sign URLs. Only s3 and local support it, and the local driver needs 'serve' => true in its disk configuration for apps created before that option landed. The other very common trigger is calling it in a test after Storage::fake(), because the fake is a local disk with no signing capability — in that case, don't try to fake the signature. Test that your code generated a correctly scoped URL, and test the endpoint that consumes it against the fake disk.

How do I fix a SignatureDoesNotMatch error on a presigned S3 upload?

The request the browser sent differs from the request that was signed. Almost always this is headers: temporaryUploadUrl() returns a headers array that must be replayed exactly, and an HTTP client that helpfully adds its own Content-Type or an extra x-amz-* header will break the signature. Check that you are using the same HTTP verb the URL was signed for (PUT, not POST), that the key in the URL is untouched, and that the clock on the signing server is accurate. A 307 redirect on the preflight usually means the region in your configuration doesn't match the bucket's.

How do I use Cloudflare R2 or MinIO with Laravel's S3 disk?

Keep the s3 driver and add two options: an endpoint pointing at the provider, and 'use_path_style_endpoint' => true, which puts the bucket in the URL path instead of the subdomain. Without path-style addressing the SDK builds a hostname that doesn't resolve, which surfaces as a 403 or a redirect. Be aware that R2 does not implement GetObjectAcl, so getVisibility() and setVisibility() throw UnableToRetrieveMetadata — design as though visibility does not exist and control access with bucket settings and signed URLs instead.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.