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 in storage/app/private. It is never served directly, and serve => true lets it hand out temporary URLs.

  • public, with its root in storage/app/public, served at /storage once php artisan storage:link creates the public/storage symlink.

  • s3, read from the AWS_* variables. With AWS_ENDPOINT and AWS_USE_PATH_STYLE_ENDPOINT=true it works with MinIO, SeaweedFS, Cloudflare R2 and other S3-compatible services. It needs composer 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]);
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
  • throw => false hides failures. A missing file, a full disk or a permission error returns null or false, and code that does not check the result carries on. Setting 'throw' => true on each disk turns these into exceptions, which the error handler reports. 'report' => true reports 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). Before storage: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 $scoped cannot 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']);

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

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":[]}

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([]);
});

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() into public/uploads (Nixon ch. 7, Powers ch. 6) puts user files where the web server may execute them. Laravel stores outside public/, under generated names.

  • storage/app as the local root is what older applications still have. Current skeletons use storage/app/private, so local and public no longer overlap.

  • Storage::url() for private files was never safe. Local disks now support temporaryUrl(), and an application created before that support needs 'serve' => true on its local disk, as the documentation notes.

  • mimes:jpeg,png alone is now usually written as File::image()->types([...])->max(...), which reads better.

  • Intervention Image 2 with Image::make() is replaced by Laravel 13’s own Image facade on Intervention 4.