The Filesystem

A disk is a named place to put files, and Storage gives local, public and S3 the same methods.

SyntaxStorage::disk(string|UnitEnum|null $name = null): Illuminate\Contracts\Filesystem\Filesystem

A disk is a named place to put files. Disks are declared in config/filesystems.php. Each one names a driver — local, s3, ftp, sftp or scoped — plus the settings that driver needs. The Storage facade then offers the same methods whatever the driver is, so code that writes a file does not know where the bytes land.

A fresh Laravel 13 application ships three disks. local is rooted at storage/app/private and is private. public is rooted at storage/app/public and carries 'visibility' => 'public' plus a url derived from APP_URL. s3 reads its bucket and credentials from the environment. FILESYSTEM_DISK decides which disk Storage uses when you do not name one.

Naming a disk, or not

Storage::put($path, $bytes) and Storage::disk(config('filesystems.default'))->put($path, $bytes) are the same call. The short form reads the default from config; the long form is what it resolves to. Name the disk in application code, because the default changes between environments.

What trips people up

Paths are always relative to the disk root. Storage::disk('local')->put('invoices/1.pdf', $pdf) writes storage/app/private/invoices/1.pdf, and a leading slash does not escape the root. Storage::path() returns an absolute filesystem path only on the local driver; on s3 it returns the object's key inside the bucket, which is not a path you can open. Reach for url() or temporaryUrl() instead.

Example

Example · php
<?php
// config/filesystems.php

return [

    // Which disk Storage uses when no disk is named. Nothing more.
    'default' => env('FILESYSTEM_DISK', 'local'),

    'disks' => [

        // Private. Nothing under this root is reachable over HTTP unless a
        // route reads it, which is why uploads belong here by default.
        // 'serve' => true registers a route named storage.local that will
        // only hand out a file for a correctly signed URL.
        'local' => [
            'driver' => 'local',
            'root' => storage_path('app/private'),
            'serve' => true,
            'throw' => false,
            'report' => false,
        ],

        // Public. `php artisan storage:link` points public/storage at this
        // root so the web server can send the bytes without starting PHP.
        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
            'visibility' => 'public',
            'throw' => false,
            'report' => false,
        ],

        // A disk of your own: same driver, different root. Code written
        // against Storage::disk('invoices') never mentions a path, so the
        // root can move without touching the code. 'throw' => true makes a
        // failed read raise instead of returning null.
        'invoices' => [
            'driver' => 'local',
            'root' => storage_path('app/invoices'),
            'throw' => true,
        ],

    ],

    // What `php artisan storage:link` creates. Link => target.
    'links' => [
        public_path('storage') => storage_path('app/public'),
    ],

];

When to use it

  • Keeping generated PDF invoices out of the web root so no URL can reach them.
  • Pointing the same upload code at the local disk in development and a bucket in production.
  • Giving an import command a disk for an NFS mount without adding it to config/filesystems.php.
  • Listing every file a tenant has stored under its own prefix.

More examples

Reading config/filesystems.php as plain data

config() is not available in the sandbox, but the file it reads is an ordinary array. Running Arr::get against it is the quickest way to see which key a disk setting actually lives under before you debug a wrong root in a real application.

Example · php
<?php

declare(strict_types=1);

use Illuminate\Support\Arr;

// config/filesystems.php returns an array, so a disk's settings resolve with
// the same dot notation config() uses underneath. Nothing here needs a disk.
$filesystems = [
    'default' => 'local',
    'disks' => [
        'local' => ['driver' => 'local', 'root' => '/app/storage/app/private'],
        'public' => ['driver' => 'local', 'root' => '/app/storage/app/public', 'visibility' => 'public'],
        's3' => ['driver' => 's3', 'bucket' => 'invoices-eu'],
    ],
];

$default = Arr::get($filesystems, 'default');

echo $default, PHP_EOL;
echo Arr::get($filesystems, "disks.{$default}.root"), PHP_EOL;
echo Arr::get($filesystems, 'disks.public.visibility'), PHP_EOL;

// A local disk with no visibility key is private, which is why the default
// disk is a safe place for an upload and the public disk is not.
echo Arr::get($filesystems, 'disks.local.visibility', 'private'), PHP_EOL;
echo implode(',', array_keys(Arr::get($filesystems, 'disks'))), PHP_EOL;

// Output:
// local
// /app/storage/app/private
// public
// private
// local,public,s3

The same service against any disk

Reach for this when a class writes files and you want to swap where they go without editing it. The contract is Illuminate\Contracts\Filesystem\Filesystem, so the same object works whether the bound disk is local or a bucket.

Example · php
<?php
// app/Services/InvoiceArchive.php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Contracts\Filesystem\Filesystem;

final class InvoiceArchive
{
    // Type-hinting the contract rather than the facade is what makes this
    // class disk-agnostic. The caller decides which disk arrives.
    public function __construct(private readonly Filesystem $disk)
    {
    }

    public function store(string $reference, string $pdf): string
    {
        $path = "invoices/{$reference}.pdf";

        // put() returns bool. It does NOT return the path.
        $this->disk->put($path, $pdf);

        return $path;
    }

    public function read(string $path): ?string
    {
        // Returns null when the file is missing, because the shipped disks
        // all have 'throw' => false.
        return $this->disk->get($path);
    }

    /** @return array<int, string> */
    public function listFor(int $year): array
    {
        // Paths are relative to the disk root, and come back relative too.
        return $this->disk->files("invoices/{$year}");
    }
}

// app/Providers/AppServiceProvider.php
// Bind the disk once, in one place:
//
// $this->app->when(InvoiceArchive::class)
//     ->needs(Filesystem::class)
//     ->give(fn () => Storage::disk('invoices'));

A disk that is not in the config file

Use an on-demand disk for a path that exists for the length of one command, such as a nightly import mount. It keeps a throwaway root out of config, and the returned object has the identical method set as a configured disk.

Example · php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Storage;

// Storage::build() creates a disk on the spot from a config array. Handy for
// a one-off import directory you do not want in config/filesystems.php.
$incoming = Storage::build([
    'driver' => 'local',
    'root' => '/mnt/nfs/incoming',
    'throw' => true,
]);

foreach ($incoming->files() as $path) {
    // Copy into a real disk, then remove the source.
    Storage::disk('invoices')->put($path, $incoming->get($path));
    $incoming->delete($path);
}

// A string argument is shorthand for the local driver at that root:
//   Storage::build('/mnt/nfs/incoming')
// is the same as
//   Storage::build(['driver' => 'local', 'root' => '/mnt/nfs/incoming']).

Discussion

  • Be the first to comment on this lesson.