skip to content

In Laravel, what do scoped and read-only disks add on top of an existing disk, and when would you create a disk at runtime with Storage::build()?

level: seniorimportance: should knowfreq 28%

answer

  1. wrappers, not new storage
  2. scoped needs disk plus prefix
  3. read-only => true wraps any disk
  4. extra Flysystem packages per wrapper
  5. build(): string root means local

basics

~20 s

A scoped disk prefixes every path of another disk; a read-only disk (read-only => true) rejects writes. Storage::build() creates an uncached disk from a config array or a local root path, for settings known only at runtime.

solid answer

~40 s

A disk with `'driver' => 'scoped'` needs `disk` (a disk name or config array) and `prefix`; Laravel copies the parent config, adds the prefix through Flysystem's `PathPrefixedAdapter`, and can override `visibility` and `throw`, so `Storage::disk('portfolio-9')->put('cover.jpg', ...)` lands at `portfolios/9/cover.jpg` on the parent. Adding `'read-only' => true` to any disk wraps its adapter so writes, deletes and visibility changes fail, and with `throw` off `put()` just returns `false`. Each wrapper needs its Flysystem package: `league/flysystem-path-prefixing` or `league/flysystem-read-only`. `Storage::build()` takes a config array, or a string that becomes the root of a local disk, and returns a disk that is not cached by name; it suits settings known only at runtime, such as a per-designer scoped disk or a customer's SFTP server.

code

php · 19 lines
php
<?php

use App\Models\Designer;
use Illuminate\Support\Facades\Storage;

function portfolioDisk(Designer $designer)
{
    return Storage::build([
        'driver' => 'scoped',
        'disk' => 's3',
        'prefix' => "portfolios/{$designer->id}",
    ]);
}

// Written to portfolios/{id}/cover.jpg in the bucket
portfolioDisk($designer)->put('cover.jpg', $bytes);

// A string builds a local disk rooted at that path
$work = Storage::build(storage_path('app/tmp/export-'.$designer->id));

go deeper

for a junior

Recall that a scoped disk adds a path prefix to another disk and that read-only => true blocks writes.

for a middle

Explain how the scoped driver merges the parent config, which extra packages each wrapper needs, and what put() returns on a read-only disk.

for a senior

Use Storage::build() for runtime disks such as per-tenant scopes or customer SFTP servers, and pair scopes with authorization instead of trusting the prefix.

for a principal

Decide which storage boundaries belong in reviewed configuration and which may be built from data, and how that affects isolation between tenants.

## Wrappers around a disk Both **scoped** and **read-only** disks decorate another disk rather than storing anything themselves. Laravel builds them on Flysystem decorators, adapters that wrap another adapter and change its behaviour: | Feature | How you declare it | Flysystem decorator | Extra package | |---|---|---|---| | Scoped | `'driver' => 'scoped'`, `'disk' => ...`, `'prefix' => ...` | `PathPrefixedAdapter` | `league/flysystem-path-prefixing` | | Read-only | `'read-only' => true` on any disk config | `ReadOnlyFilesystemAdapter` | `league/flysystem-read-only` | ## Scoped disks A design-portfolio site might keep every designer's files under `portfolios/{id}/` in one bucket. A scoped disk makes that prefix automatic: ```php 'portfolio-samples' => [ 'driver' => 'scoped', 'disk' => 's3', 'prefix' => 'portfolios/samples', ], ``` What `createScopedDriver` does: 1. Throws `InvalidArgumentException` if `disk` or `prefix` is missing. 2. Takes the parent's config (by name, or the array you gave). 3. Sets `prefix`, joining it to any prefix the parent already has with the directory separator, so scopes can nest. 4. Copies `visibility` and `throw` from the scoped config if you set them. 5. Builds a fresh disk from the merged config. Some consequences worth knowing: - **It is a separate instance.** A scoped disk named after `s3` gets its own S3 client built from the same config; it does not share the parent's object. - **URLs include the prefix.** `url()` on the scoped disk prepends the prefix, so links point at the real key. - **It is not a sandbox on its own.** Flysystem's path normalizer rejects `..` segments that climb above the root with `PathTraversalDetected`, and the prefix keeps well-formed paths inside the scope; authorization still decides which designer may use which scope. ## Read-only disks Adding `'read-only' => true` to a disk config wraps its adapter before Laravel builds the Flysystem instance. Reads, listings and metadata work; `write`, `writeStream`, `delete`, `deleteDirectory`, `createDirectory`, `setVisibility`, `move` and `copy` throw Flysystem's `UnableTo...` exceptions. Laravel's adapter then applies its usual failure policy: with `'throw' => false` a `put()` returns `false`, with `'throw' => true` the exception reaches your code. Typical uses: - a disk over an archive bucket that code should never modify; - a disk shared with another system that owns the writes; - defence in depth for a reporting job that only needs to read. ## Storage::build() `Storage::build($config)` resolves a disk from configuration you pass at runtime: - **An array** is resolved like a configured disk, with any driver including `scoped` and `read-through`. - **A string** is treated as the root of a `local` driver disk. - **No caching by name.** The result is not stored in the manager's `$disks`, so each call builds a new instance; keep the returned object if you use it repeatedly. When it fits: - a scoped disk per designer or tenant: `Storage::build(['driver' => 'scoped', 'disk' => 's3', 'prefix' => "portfolios/{$designer->id}"])`; - a customer-supplied SFTP or FTP server whose credentials live in the database; - a temporary working directory in an Artisan command, built from a string path. When it does not: disks every request needs belong in `config/filesystems.php`, where they are named, reviewable and resolved once per process. ## Combining wrappers: the scoped read-only trap The scoped driver copies only `prefix`, `visibility` and `throw` from its own entry onto the parent config. A `'read-only' => true` written next to `'driver' => 'scoped'` is therefore **not applied**; the scoped disk stays writable. To get a read-only view of one prefix, put the flag in the parent config instead, either on the named parent disk or in an inline array passed as `disk`: ```php 'samples-readonly' => [ 'driver' => 'scoped', 'prefix' => 'portfolios/samples', 'disk' => [ 'driver' => 's3', // key, secret, region, bucket ... 'read-only' => true, ], ], ``` Because the merged config is built into one Flysystem instance, the read-only decorator and the prefix decorator then both apply. ## Choosing between them - Need the same bucket split into areas? **Scoped disks.** - Need a guarantee that code cannot change files? **Read-only.** - Need a disk whose settings come from data? **`Storage::build()`**, often combined with the scoped driver.

  • What does put() return on a read-only disk?
    The read-only adapter throws `UnableToWriteFile`. Laravel's adapter catches it and, with the default `'throw' => false`, returns `false` (and reports the exception only if `report` is on). With `'throw' => true` the exception propagates, which is usually what you want for a disk that should never be written.
  • Does a scoped disk stop a user from reaching another designer's files?
    It keeps well-formed paths under the prefix, and Flysystem rejects `..` segments that climb above the root with `PathTraversalDetected`. But choosing the prefix is your code's job: if the designer id comes from the request without an authorization check, the scope protects nothing. Treat it as a path convenience backed by policies.
  • Why not call Storage::build() inside a loop for every file?
    `build()` does not cache by name, so each call constructs a new adapter and, for S3, a new client. Build the disk once per unit of work and reuse the returned instance.

saying these in an interview costs you the question

  • A scoped disk shares the same client object as its parent disk
  • read-only is a separate driver you set instead of s3 or local
  • A write to a read-only disk is silently ignored and reported as success
  • Storage::build() caches the disk by name like Storage::disk() does
  • A scoped prefix alone is enough to authorize access to a tenant's files