File Storage and Images
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The Storage facade
wraps Flysystem 3, so the same code reads and writes the local disk, S3 or any other adapter. Bookshelf keeps exports
on the private local disk and book covers in an S3 bucket. The covers are cropped and converted to WebP on upload.
The S3 examples run against SeaweedFS 4.48, an S3-compatible server in a container (MinIO’s image could not be pulled
in this environment). The plain-PHP version, with $_FILES, finfo and GD, is on
File Uploads and Images.
Disks
config/filesystems.php defines disks, and FILESYSTEM_DISK names the default. A new application has three:
-
local, with its root instorage/app/private. It is never served directly, andserve => truelets it hand out temporary URLs. -
public, with its root instorage/app/public, served at/storageoncephp artisan storage:linkcreates thepublic/storagesymlink. -
s3, read from theAWS_*variables. WithAWS_ENDPOINTandAWS_USE_PATH_STYLE_ENDPOINT=trueit works with MinIO, SeaweedFS, Cloudflare R2 and other S3-compatible services. It needscomposer require league/flysystem-aws-s3-v3, which installed AWS SDK 3.399.
Real environment variables win over .env. The first S3 call failed with InvalidAccessKeyId.
config('filesystems.disks.s3.key') showed proxy-injected, because this sandbox exports AWS_ACCESS_KEY_ID for
its own tools, and Laravel’s dotenv loader never overwrites a variable that is already set. The same happens on a
server or CI runner that exports AWS credentials for another purpose. php artisan config:show filesystems.disks.s3
shows the effective values. Here the fix was starting the processes with env -u AWS_ACCESS_KEY_ID -u
AWS_SECRET_ACCESS_KEY.
The local and public disks
<?php
$local = Storage::disk('local');
$local->put('exports/loans-2026-10.csv', "loan,book\n1,Kindred\n");
$local->path('exports/loans-2026-10.csv'); // storage/app/private/exports/loans-2026-10.csv
$local->get('nope.csv'); // null: 'throw' => false is the default
$local->put('../../.env', 'x'); // PathTraversalDetected
$local->temporaryUrl('exports/loans-2026-10.csv', now()->plus(minutes: 5));
Storage::disk('public')->put('covers/kindred.webp', $bytes);
Storage::disk('public')->url('covers/kindred.webp'); // http://localhost:8000/storage/covers/kindred.webp
$scoped = Storage::build(['driver' => 'scoped', 'disk' => 'local', 'prefix' => 'members/2']);
$readOnly = Storage::build(['driver' => 'local', 'root' => storage_path('app/private'), 'read-only' => true, 'throw' => true]);
Source: Laravel — The Local Driver and Temporary URLs
1. local path: storage/app/private/exports/loans-2026-10.csv, size 20
2. missing file with throw=false: NULL
with throw=true: UnableToReadFile: Unable to read file from location: nope.csv.
3. traversal: PathTraversalDetected: Path traversal detected: ../../.env
4. temporaryUrl: http://localhost:8000/storage/exports/loans-2026-10.csv?expires=1791240073&signature=…
5. public url: http://localhost:8000/storage/covers/kindred.webp
6. scoped disk wrote: members/2/reading-list.txt
7. read-only: UnableToWriteFile: Unable to write file at location: x.txt. This is a readonly adapter.
$ curl -s "$TEMPORARY_URL" → loan,book / 1,Kindred (200)
$ curl … the same URL with loans-2026-09 in the path → 403
$ curl localhost:8000/storage/covers/kindred.webp → 403 before storage:link, 200 after
$ curl localhost:8000/storage/app/private/exports/… → 403
Source: Laravel — The Public Disk
-
throw => falsehides failures. A missing file, a full disk or a permission error returnsnullorfalse, and code that does not check the result carries on. Setting'throw' => trueon each disk turns these into exceptions, which the error handler reports.'report' => truereports them without throwing. -
Flysystem normalises paths and refuses anything that would leave the disk’s root. A user-supplied name still should not be used as a path, because two users can choose the same name.
-
The temporary URL is a signed route (
/storage/{path}), served by Laravel from the private root. It is checked like any signed URL (Security: CSRF, Encryption and Hashing). Beforestorage:link, the public file’s URL answered 403 because that same route took the request and found no signature. After the link, the web server served the file itself. -
A scoped disk prefixes every path, so code given
$scopedcannot reach another member’s files.
An upload to S3
The cover endpoint validates the file, crops it to the shelf’s 2:3 ratio, converts it to WebP and stores it under a
random name. composer require intervention/image:^4.0 (4.3.4 here, with GD) enables the Image facade:
<?php
// app/Http/Controllers/BookCoverController.php
namespace App\Http\Controllers;
use App\Models\Book;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Image, Storage};
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
class BookCoverController extends Controller
{
public function store(Request $request, Book $book)
{
$request->validate([
'cover' => ['required', File::image()->types(['jpeg', 'png', 'webp'])->max('2mb')->dimensions(Rule::dimensions()->minWidth(200))],
]);
$path = Image::fromUpload($request->file('cover'))
->cover(400, 600) // crop to the shelf's 2:3 ratio
->toWebp()
->quality(80)
->store('covers', 's3'); // random name, never the client's
$book->update(['cover_path' => $path]);
return response()->json(['path' => $path, 'url' => Storage::disk('s3')->temporaryUrl($path, now()->plus(minutes: 10))], 201);
}
}
// routes/api.php
Route::post('/books/{book}/cover', [BookCoverController::class, 'store'])->middleware(['auth:sanctum', 'can:update,book']);
Source: Laravel — Manipulating Images and Validating Files
Three uploads as a librarian (an 800 × 1000 JPEG, a 100 × 150 PNG, and a PHP script named evil.jpg), then the
stored file:
$ curl -s -w ' %{http_code}\n' -H 'Accept: application/json' -F cover=@cover.jpg localhost:8000/api/books/1/cover
401 # no token
$ curl -s -w ' %{http_code}\n' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" -F cover=@cover.jpg …
{"path":"covers/6TaE7lGjYaqMfxDz73Di85baLuBXYC4qouhvZc2S.webp","url":"http://172.18.0.11:8333/covers/covers/6TaE7lGjYaqMfxDz73Di85baLuBXYC4qouhvZc2S.webp?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=…&X-Amz-Credential=…&X-Amz-Date=…&X-Amz-SignedHeaders=…&X-Amz-Expires=…&X-Amz-Signature=…"} 201
$ … -F cover=@small.png
{"message":"The cover field has invalid image dimensions.","errors":{"cover":["The cover field has invalid image dimensions."]}} 422
$ … -F cover=@evil.jpg
{"message":"The cover field must be a file of type: jpeg, png, webp. (and 2 more errors)","errors":{"cover":["The cover field must be a file of type: jpeg, png, webp.","The cover field must be an image.","The cover field has invalid image dimensions."]}} 422
$ curl -s -o got.webp -w '%{http_code} %{content_type} %{size_download} bytes\n' "$URL"
200 image/webp 516 bytes # RIFF Web/P, VP8, 400x600
$ curl -s -o /dev/null -w '%{http_code}\n' "http://172.18.0.11:8333/covers/$PATH"
403 # the bucket is private
Source: Laravel — Temporary URLs
The 22 KB JPEG became a 516-byte WebP, which is small because the test image is a flat colour. evil.jpg was
rejected on its content, which PHP’s finfo read as text/x-php despite the name. The presigned URL carried
X-Amz-Expires=600, and changing it gave 403, because it is part of the signature.
Converting is also a defence. Re-encoding drops whatever else was in the file (EXIF location data, appended payloads), and the stored name and extension come from the application. Large images use a lot of CPU and memory, so in production the conversion belongs in a queued job, with the original stored first (Queues and Jobs).
The route that skipped model binding
The first version of the cover route lived in routes/web.php, as
->withoutMiddleware('web')->middleware('api'), to get stateless behaviour. Every upload answered 201, but
cover_path never changed. A debug log showed why:
cover debug {"exists":false,"id":null,"updated":false,"dirty_after":[]}
Source: Laravel — Excluding Middleware
withoutMiddleware('web') excludes every middleware in the web group, including SubstituteBindings, and that
exclusion also applied to the copy in the api group added afterwards. Without binding, Book $book was an empty new
model, and update() on a model that does not exist returns false without an error. The route moved to
routes/api.php, where binding works. Model::preventSilentlyDiscardingAttributes() does not catch this, but a test
that checks the database does.
Testing
Storage::fake('s3') replaces the disk with a temporary local one. UploadedFile::fake()->image() creates a real
image with GD:
<?php
// tests/Feature/CoverUploadTest.php
use App\Enums\Role;
use App\Models\{Book, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
Sanctum::actingAs(User::factory()->create(['role' => Role::Librarian]));
$this->book = Book::factory()->create();
});
test('a cover is cropped, converted and stored', function () {
$response = $this->postJson("/api/books/{$this->book->id}/cover", ['cover' => UploadedFile::fake()->image('scan.jpg', 800, 1000)]);
$response->assertCreated();
$path = $response->json('path');
Storage::disk('s3')->assertExists($path);
expect($path)->toEndWith('.webp')->and($this->book->fresh()->cover_path)->toBe($path);
[$width, $height] = getimagesizefromstring(Storage::disk('s3')->get($path));
expect([$width, $height])->toBe([400, 600]);
});
test('a fake file is typed by its name, a real one by its content', function () {
$fake = UploadedFile::fake()->createWithContent('cover.jpg', '<?php echo "pwned";');
$real = new UploadedFile(tap(tempnam(sys_get_temp_dir(), 'up'), fn ($p) => file_put_contents($p, '<?php echo "pwned";')), 'cover.jpg', test: true);
expect($fake->getMimeType())->toBe('image/jpeg')
->and($real->getMimeType())->toBe('text/x-php');
$this->postJson("/api/books/{$this->book->id}/cover", ['cover' => $real])
->assertJsonValidationErrors(['cover' => 'The cover field must be an image.']);
expect(Storage::disk('s3')->allFiles())->toBe([]);
});
Source: Laravel — Filesystem Testing
Both pass. The second test exists because the first attempt used UploadedFile::fake()->createWithContent() for the
PHP script, and only the dimensions rule failed. A fake file reports the MIME type of its extension, so mimes and
image pass on any content named .jpg. A test of content-based validation needs a real UploadedFile. The first
test is also the one that caught the binding problem above, by checking cover_path in the database.
Where older books and tutorials are outdated
-
move_uploaded_file()intopublic/uploads(Nixon ch. 7, Powers ch. 6) puts user files where the web server may execute them. Laravel stores outsidepublic/, under generated names. -
storage/appas the local root is what older applications still have. Current skeletons usestorage/app/private, solocalandpublicno longer overlap. -
Storage::url()for private files was never safe. Local disks now supporttemporaryUrl(), and an application created before that support needs'serve' => trueon itslocaldisk, as the documentation notes. -
mimes:jpeg,pngalone is now usually written asFile::image()->types([...])->max(...), which reads better. -
Intervention Image 2 with
Image::make()is replaced by Laravel 13’s ownImagefacade on Intervention 4.