Cloud Storage
An S3 disk is one package and four environment variables, because a container's own disk is scratch space.
Storage::disk('s3')->put(string $path, $contents, mixed $options = []): boolThe s3 disk is already in config/filesystems.php. It needs one package, composer require league/flysystem-aws-s3-v3 "^3.0", and four environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION and AWS_BUCKET. Set FILESYSTEM_DISK=s3 and every unqualified Storage call goes to the bucket.
Credentials you do not write down
The driver assembles a credentials array only when key and secret are both non-empty. Leave them blank and the AWS SDK falls back to its own provider chain: an instance profile, an ECS task role, or a projected service-account token on Kubernetes. On a hosted cluster, blank is the correct configuration rather than a broken one.
S3-compatible services
AWS_ENDPOINT points the client at MinIO, R2 or Spaces, and those usually also need AWS_USE_PATH_STYLE_ENDPOINT=true. AWS_URL replaces the host in url(), which is how a CDN gets in front of the bucket, and the disk's temporary_url key does the same for presigned URLs.
The container's disk is not storage
A write to the local disk lands inside the running container. The next deploy replaces that container and the file is gone. With two replicas it is worse: the upload handled by one pod gives a 404 on the other, intermittently, which reads like a caching bug. Anything a user must see twice belongs on a shared disk. storage/framework and storage/logs can stay local, because they are per-process scratch.
Example
<?php
// config/filesystems.php -- the s3 disk, as shipped in Laravel 13.
return [
'default' => env('FILESYSTEM_DISK', 'local'),
// Which disk Storage::cloud() returns. Defaults to 's3' when absent.
'cloud' => env('FILESYSTEM_CLOUD', 's3'),
'disks' => [
's3' => [
'driver' => 's3',
// Leave both blank on a host that supplies a role. The driver
// builds a 'credentials' array only when key AND secret are
// non-empty, so blank means "let the AWS SDK find them".
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
// Set this to a CDN host and url() returns CDN addresses.
// Leave it null and the SDK builds the bucket's own URL.
'url' => env('AWS_URL'),
// MinIO, R2, Spaces. Path style is usually required with them.
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
];
// .env on a server with static keys
// FILESYSTEM_DISK=s3
// AWS_ACCESS_KEY_ID=AKIA...
// AWS_SECRET_ACCESS_KEY=...
// AWS_DEFAULT_REGION=eu-west-1
// AWS_BUCKET=app-uploads-eu
// AWS_URL=https://cdn.example.com
// .env in a Kubernetes pod using a service-account role. The two key lines
// are present and empty on purpose: the SDK reads the projected token
// instead, and no secret exists in the manifest to leak.
// FILESYSTEM_DISK=s3
// AWS_ACCESS_KEY_ID=
// AWS_SECRET_ACCESS_KEY=
// AWS_DEFAULT_REGION=eu-west-1
// AWS_BUCKET=app-uploads-euWhen to use it
- Moving uploads off a single server before scaling the application to two replicas.
- Running against MinIO in development and real S3 in production with no code change.
- Giving pods bucket access through a service-account role instead of baked-in keys.
- Serving private reports through presigned URLs so large downloads never occupy a PHP worker.
More examples
Reading the disk config before you trust it
Use this to settle the most common S3 confusion: whether missing credentials are a misconfiguration. They are not, on a role-based host. It also shows which of the two URL shapes you should expect from url(), depending on whether AWS_URL is set.
<?php
declare(strict_types=1);
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
// The whole difference between a local disk and a bucket is this array.
// Reading it as data makes the shape obvious before any AWS call happens.
$s3 = [
'driver' => 's3',
'bucket' => 'app-uploads-eu',
'region' => 'eu-west-1',
'key' => '',
'secret' => '',
'url' => 'https://cdn.example.com',
'use_path_style_endpoint' => false,
];
// An empty key is not a mistake. The driver only builds a credentials array
// when key and secret are both non-empty, so blank means "use the host role".
echo (! empty($s3['key']) && ! empty($s3['secret'])) ? 'static keys' : 'host role', PHP_EOL;
// With 'url' set, url() is that host plus the object key.
echo Str::finish(Arr::get($s3, 'url'), '/').'invoices/2026-03.pdf', PHP_EOL;
// Without it, the SDK builds the bucket's own virtual-hosted address.
echo sprintf('https://%s.s3.%s.amazonaws.com/%s', $s3['bucket'], $s3['region'], 'invoices/2026-03.pdf'), PHP_EOL;
// Output:
// host role
// https://cdn.example.com/invoices/2026-03.pdf
// https://app-uploads-eu.s3.eu-west-1.amazonaws.com/invoices/2026-03.pdfPrivate objects, served without passing through PHP
This is the shape to use for any large private file on a bucket. The controller stays cheap because it never reads the object, and the link expires on its own, so there is nothing to revoke if it ends up in someone's browser history.
<?php
// app/Http/Controllers/ReportController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Report;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
final class ReportController extends Controller
{
public function download(Report $report): RedirectResponse
{
// Gate::authorize(), not $this->authorize(): the Laravel 13 base
// controller ships without the AuthorizesRequests trait.
Gate::authorize('view', $report);
// Mark the object private at write time, not at read time.
// Storage::disk('s3')->put($path, $bytes, 'private');
//
// Then hand out a presigned URL. The client fetches the bytes from
// S3; this request only issues the signature and redirects, so a
// two-gigabyte report does not occupy a PHP worker.
return redirect(
Storage::disk('s3')->temporaryUrl($report->path, now()->addMinutes(5))
);
}
}
// The s3 driver always reports providesTemporaryUrls() as true, so no guard
// is needed here. A local disk does not, unless it has 'serve' => true --
// which is why code that works in production can throw in development:
// RuntimeException: This driver does not support creating temporary URLs.Moving an existing application onto a bucket
Run this with --dry-run before a migration from a single server to a cluster. The order matters: copy first, verify, then change FILESYSTEM_DISK, because the switch does not move anything and both disks accept the same relative paths.
<?php
// app/Console/Commands/MigrateUploadsToS3.php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
final class MigrateUploadsToS3 extends Command
{
protected $signature = 'uploads:migrate {--dry-run}';
public function handle(): int
{
$from = Storage::disk('local');
$to = Storage::disk('s3');
// Paths are relative to the disk root on both sides, which is the
// reason this copy needs no rewriting of database columns: the same
// string identifies the file on either disk.
foreach ($from->allFiles('uploads') as $path) {
if ($to->exists($path)) {
continue;
}
$this->line($path);
if ($this->option('dry-run')) {
continue;
}
// readStream keeps memory flat for large files; put() accepts a
// resource as well as a string.
$stream = $from->readStream($path);
$to->writeStream($path, $stream);
}
// Only after this has run clean should FILESYSTEM_DISK change. Flip
// it first and every old path becomes a miss.
return self::SUCCESS;
}
}
Discussion