Skip to content

FilesystemTenancyBootstrapper

The FilesystemTenancyBootstrapper covers all filesystem-related logic and ensures it’s scoped to the current tenant.

It is by far the most complex bootstrapper in the entire package, but we’ve put a significant effort into simplifying it in version 4 (while still adding a lot of cool new functionality to it — more on that later).

It works like this:

FilesystemTenancyBootstrapper.php
public function bootstrap(Tenant $tenant): void
{
$suffix = $this->suffix($tenant);
$this->storagePath($suffix);
$this->assetHelper($suffix);
$this->forgetDisks();
$this->scopeCache($suffix);
$this->scopeSessions($suffix);
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
$this->diskRoot($disk, $tenant);
$this->diskUrl(
$disk,
str($this->app['config']["tenancy.filesystem.url_override.{$disk}"])
->replace('%tenant%', (string) $tenant->getTenantKey())
->toString(),
);
}
}

Let’s cover the methods used in that bootstrap() call:

suffix() returns the string that should be used as a suffix for things like the storage_path() (we’ll cover where exactly it’s used in the next paragraphs). It’s essentially a string appended to the end of some existing prefixes, so it doesn’t matter much if you think of it as a prefix or a suffix. It’s created as: config('tenancy.filesystem.suffix_base') . $tenant->getTenantKey().

storagePath() updates the storage_path(): instead of pointing to e.g. /Users/samuel/Sites/example/storage, it will point to /Users/samuel/Sites/example/storage/tenant1. The helper will only be suffixed if tenancy.filesystem.suffix_storage_path is enabled. This config option is true by default (covered in Storage path suffixing).

assetHelper() optionally (if tenancy.filesystem.asset_helper_override is enabled) changes how the asset() helper works. It works in two ways:

  • If there’s no existing asset root (the bulk of applications), it changes asset('foo.png') to return /tenancy/assets/foo.png instead of /foo.png. That is, creating a route to our TenantAssetController which can be used to return tenant-specific assets. It’s covered in detail below.
  • If there is an existing asset root (generally the case on Laravel Vapor), it appends the suffix generated above to the existing asset root. This will make the helper return a string like this:
    asset('foo.png');
    // https://(long string).cloudfront.net/(long string)/foo.png
    tenancy()->initialize($t1);
    asset('foo.png');
    // https://(long string).cloudfront.net/(long string)/tenant1/foo.png

forgetDisks() calls Storage::forgetDisk() on all disks listed in tenancy.filesystem.disks to remove them from the FilesystemManager singleton, as well as on all scoped disks based on these tenant-aware disks. This ensures that all of these disks are re-created when they’re used in a new context.

diskRoot() updates the root of each disk configured in tenancy.filesystem.disks:

diskRoot() logic
config('filesystems.disks.local.root');
// /Users/samuel/Sites/example/storage/app/
config('filesystems.disks.public.root');
// /Users/samuel/Sites/example/storage/app/public
tenancy()->initialize($t1);
config('filesystems.disks.local.root');
// /Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/app/
config('filesystems.disks.public.root');
// /Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/app/public

This is because our root_override is configured like this:

config/tenancy.php
'root_override' => [
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],

(For disks that are in tenancy.filesystem.disks but not in tenancy.filesystem.root_override, the behavior is slightly different. This is explained by example in the Reading and writing files section below.)

diskUrl() does the same, but with the url property of the disk configuration (see filesystem.disks to see what I mean) instead of the root:

diskUrl() logic
config('filesystems.disks.public.url');
// http://example.test/storage
tenancy()->initialize($t1);
config('filesystems.disks.public.url');
// http://example.test/public-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
config/tenancy.php
'url_override' => [
'public' => 'public-%tenant%',
],

scopeCache() ensures each file driver-based cache store listed in tenancy.cache.stores is scoped per tenant. It scopes the path the store is configured with (cache.stores.{store}.path). With Laravel’s default file store, that’s storage_path('framework/cache/data'), so the tenant’s cache would be stored in storage/tenant{id}/framework/cache/data by default (see Cache scoping).

And finally, scopeSessions() works similarly to scopeCache(): it ensures tenant sessions are scoped. It scopes the configured session.files path, which defaults to storage_path('framework/sessions'), so tenant sessions would be stored in storage/tenant{id}/framework/sessions by default (see Session scoping).

It’s okay if this section didn’t make much sense just yet — as you read the following sections, you’ll see why things work as described above.

Each tenant gets its own directory inside your storage/ directory: storage/tenant{id}/.

This directory will contain most filesystem-related things specific to a given tenant:

  • files written to tenant-aware disks (i.e. disks included in the tenancy.filesystem.disks config) that have a root_override based on %storage_path% — more details below
  • php artisan tenants:link creates symlinks pointing at those disks
  • cache files and session files when file-based drivers are used
  • TenantAssetController serves assets from there by default
  • the DeleteTenantStorage job deletes it
  • LogChannelBootstrapper configures storage_path()-based log channels to use the tenant storage path

The path to this directory is built like this (this is also explained with examples in the next section):

How the tenant storage path is built (simplified illustration)
$centralStoragePath = storage_path(); // '/project/path/storage/'
$suffixBase = config('tenancy.filesystem.suffix_base'); // 'tenant'
$tenantId = $tenant->getTenantKey();
$centralStoragePath . '/' . $suffixBase . '/' . $currentTenantId;

To get the path to this directory in the tenant context, you can simply use the storage_path() helper assuming storage_path() suffixing is enabled. Alternatively, you can use FilesystemTenancyBootstrapper::getTenantStoragePath($tenant) which works regardless of the storage_path suffixing config or the current context.

Now let’s take a look at how we can actually read and write files — and where they get stored.

In this section, we’ll be using these disks: local, public, s3 — all configured exactly as they are in a fresh Laravel 11 application.

Let’s assume this is our Tenancy config:

config/tenancy.php
'filesystem' => [
'disks' => [
'local',
'public',
's3',
],
'root_override' => [
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
],

The only change we made compared to the default config is uncommenting the s3 disk so that it’s part of the disks affected by the bootstrapper.

Let’s start by creating some files:

php artisan tinker
use Illuminate\Support\Facades\Storage;
Storage::disk('local')->put('foo.txt', "bar\n");
Storage::disk('public')->put('bar.txt', "baz\n");
tenancy()->initialize(App\Models\Tenant::first());
Storage::disk('local')->get('foo.txt'); // null
Storage::disk('public')->get('bar.txt'); // null
Storage::disk('local')->put('foo.txt', "tenant bar\n");
Storage::disk('public')->put('bar.txt', "tenant baz\n");

Now our storage/ directory looks like this:

  • Directoryapp
    • foo.txt bar
    • Directorypublic/
      • bar.txt baz
  • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
    • foo.txt tenant bar
    • Directorypublic/
      • bar.txt tenant baz

Let’s take a look at how this works under the hood by referencing what we learned at the start of this page:

  1. local and public are in tenancy.filesystem.disks, which means they will get separated from other tenants
  2. The suffix for tenant 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5 is tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5, i.e. config('tenancy.filesystem.suffix_base') + $tenant->getTenantKey().
  3. The tenant storage path is based on the central storage directory and the suffix mentioned above
  4. The root_override section specifies how these disks should have their roots overridden. The %storage_path% refers to the tenant’s storage directory:
    config/tenancy.php
    'root_override' => [
    'local' => '%storage_path%/app/',
    'public' => '%storage_path%/app/public/',
    ],
  5. The disks use new roots, which are used for our put() and get() calls. We can verify that the root has changed by simply checking the filesystem config:
    php artisan tinker
    config('filesystems.disks.public.root');
    // /Users/samuel/Sites/example/storage/app/public
    tenancy()->initialize(\App\Models\Tenant::first());
    config('filesystems.disks.public.root');
    // /Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/app/public/

Hopefully the underlying logic of the filesystem bootstrapper should be more clear now.

You may still have two questions:

  • We went out of our way to put s3 into tenancy.filesystem.disks but didn’t use it. What’s up with that?
  • Why is root_override even needed? We’re just suffixing the tenant storage path in both cases.

Let me answer both of those with the final segment of this section: If we define a disk in tenancy.filesystem.disks and don’t make use of root_override, tenancy will simply take the existing root (if any) and suffix it with the suffix we created.

So in the case of S3, our prefix was an empty string. Meaning that after tenancy is initialized, it would become tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5. Which happens to work perfectly for S3! All of our data will be stored like this:

  • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
    • foo.txt tenant file
  • Directorytenant94ce64af-7015-4c91-b26a-3a8d9d925c20
    • bar.txt tenant file
  • baz.txt central file

Tenants will never be able to access the central baz.txt via Laravel’s filesystem logic (e.g. the Storage) facade. And the central context can only access tenant files if it explicitly tries to do so:

Accessing a tenant file from the central context
Storage::get('tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/foo.txt');

This is why tenancy.filesystem.disks and tenancy.filesystem.root_override are separate parts of the config. One configures which disks should be scoped by the bootstrapper, and the other customizes how the root path should be overridden.

Deleting a tenant doesn’t delete its files by default. To delete the tenant’s storage directory when a tenant is deleted, add the DeleteTenantStorage job to the DeletingTenant pipeline:

app/Providers/TenancyServiceProvider.php
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
Jobs\DeleteTenantStorage::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
],

Anything outside the tenant’s storage directory (e.g. non-local disks or local disks with a root outside the tenant’s storage directory) won’t be deleted.

Now that we know how to read and write files in PHP, let’s cover how we can access them in the browser.

Cloud disks like S3 make this very easy: there’s no work needed on your part, just make sure tenant IDs aren’t enumerable so that a given tenant’s files cannot be accessed by a user from another tenant, who might know some hardcoded paths used by your application.

Our package comes with only two routes:

  • /tenancy/assets/{path},
  • /{tenant}/tenancy/assets/{path} (the path identification version of the above).

Both of these point to the TenantAssetController. This is a controller that (after doing some validation) returns:

TenantAssetController.php
// $assetRoot is app/public inside the tenant's storage directory
response()->file("$assetRoot/$path", $headers);

In other words: https://tenant1.example.test/tenancy/assets/foo.txt = storage/tenant{id}/app/public/foo.txt.

This can be really convenient since — much like the cloud disks — it doesn’t require any extra configuration and lets you fetch a tenant’s asset from the browser.

The only downside is that this runs PHP on each asset request that would normally be served directly by your webserver without touching PHP whatsoever.

In practice, this means that you shouldn’t really use this. It’s considered mostly a legacy approach in version 4, but it does have some use cases. Not requiring any extra configuration is good, but using this to fetch 3 logos rendered on each page is not.

If you need to render an image on some special page and don’t want to bother with the approaches outlined in the upcoming sections, it’s okay to use this approach. Just don’t use it for any assets requested on “the average page of your application”.

As for configuration:

  • To customize the used middleware, change tenancy.identification.default_middleware. It’s currently only used by this controller.

  • To add extra headers, e.g. for caching (to stop browsers from spamming a PHP endpoint just to grab an image):

    app/Providers/TenancyServiceProvider.php
    public function boot()
    {
    // This can also be an array instead of a closure
    TenantAssetController::$headers = function (Request $request) {
    return ['cache-control' => 'public, max-age=3600'];
    };
    // ...
    }
  • To serve the assets from a specific disk instead of app/public inside the tenant’s storage directory, set TenantAssetController::$publicDisk:

    app/Providers/TenancyServiceProvider.php
    TenantAssetController::$publicDisk = 'public';

    The disk has to be local and tenant-aware, i.e. included in tenancy.filesystem.disks (so that FilesystemTenancyBootstrapper can scope its root). A scoped disk is fine as long as its base disk uses the local driver, is tenant-aware, and has a name (inline array disks are not supported).

Symlinking tenant directories to the public/ directory

Section titled “Symlinking tenant directories to the public/ directory”

Now we get to the proper solutions to the problem for local disks. This feature was introduced in version 4.

This feature adds support for symlinking local tenant disks into public/. It can be used either using events:

app/Providers/TenancyServiceProvider.php
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
// Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false),
],
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
// Jobs\DeleteTenantStorage::class,
// Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
],

or by calling the tenants:link command:

Terminal window
$ php artisan tenants:link
INFO The links have been created.
Terminal window
$ readlink public/public-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
/Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/app/public/

Use --force if some tenants already have links.

To remove these links, use tenants:link --remove:

Terminal window
$ php artisan tenants:link --remove
INFO The links have been removed.

This feature works at two points:

  1. On tenant creation (or deletion)/when you run php artisan tenants:link — essentially what is described above. Symlinks are created for certain tenant disks in the public/ folder.
  2. On tenant initialization (the diskUrl() method mentioned in the first section of this page).

1) creates symlinks like this:

  • Directorypublic
    • Directorystorage/ created by php artisan storage:link
      • …
    • Directorypublic-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/ A destination
      • …
    • Directorypublic-94ce64af-7015-4c91-b26a-3a8d9d925c20/ B destination
      • …
  • Directorystorage
    • Directoryapp
      • Directorypublic/ central public disk
        • …
    • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
      • Directoryapp
        • Directorypublic/ A source
          • …
    • Directorytenant94ce64af-7015-4c91-b26a-3a8d9d925c20
      • Directoryapp
        • Directorypublic/ B source
          • …

A and B are the symlinks created by our package.

2) makes Storage::url() return URLs to the paths in public/:

php artisan tinker
Storage::disk('public')->url('foo.png');
// http://example.test/public-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/foo.png

These URLs/symlinks are configured in the url_override section of the tenancy.filesystem config, similar to root_override:

config/tenancy.php
'filesystem' => [
'url_override' => [
'public' => 'public-%tenant%',
],
],

Any disk that is configured in this array will have symlinks created via the job in the TenantCreated pipeline or php artisan tenants:link, and its url setting will be overridden by the filesystem bootstrapper.

The symlink points at the disk’s root as the bootstrapper resolves and configures it, so the disk needs to be listed in tenancy.filesystem.disks.

A root_override is not required. A disk without one gets its existing root suffixed (e.g. with /tenant12345, as described in Reading and writing files), and the symlink points there.

Symlinks work with disks that have a configured prefix. For instance, if you have a disk like this one:

config/filesystems.php
'foo' => [
'driver' => 'local',
'root' => storage_path('foo'),
'prefix' => 'bar/prefix',
],

and a URL override like this:

config/tenancy.php
'filesystem' => [
'url_override' => [
'foo' => 'foo-%tenant%',
],
],

and you include the disk in tenancy.filesystem.disks, a symlink will be created at public/foo-6c23be2f-a899-4564-bcc1-729bfc74a82b/bar/prefix, pointing at the disk’s tenant root with the configured prefix appended.

Storage::url() includes the prefix in the generated URL, so the URLs match the symlink:

php artisan tinker
tenancy()->initialize($t1);
Storage::disk('foo')->put('foo.txt', 'bar');
// storage/foo/tenant6c23be2f-a899-4564-bcc1-729bfc74a82b/bar/prefix/foo.txt
Storage::disk('foo')->url('foo.txt');
// http://example.test/foo-6c23be2f-a899-4564-bcc1-729bfc74a82b/bar/prefix/foo.txt

If the prefix contains slashes, the resulting symlink will be nested inside multiple directories. In the example above, the prefix is bar/prefix, so the ‘prefix’ symlink will be nested inside a bar directory, which gets created automatically along with the symlinks. If the prefix were bar/baz/prefix, both bar and baz directories would be created.

Pointing the public disk’s root at a subfolder within public/

Section titled “Pointing the public disk’s root at a subfolder within public/”

This is a simplified version of the above.

The upside is that you don’t need to create any new symlinks, we’ll simply be using subdirectories within the symlink created by php artisan storage:link. The downside is that your tenant data will be more scattered and won’t all be contained within storage/tenant{id}/.

To use this approach, set the root override like this:

config/tenancy.php
'filesystem' => [
'root_override' => [
'public' => '%original_storage_path%/app/public/%tenant%/',
],
],

and the URL override like this:

config/tenancy.php
'filesystem' => [
'url_override' => [
'public' => 'storage/%tenant%/',
],
],

With this config, the disks will work like this:

php artisan tinker
Storage::disk('local')->put('foo.txt', "central foo\n");
Storage::disk('public')->put('bar.txt', "central bar\n");
Storage::disk('public')->url('bar.txt');
// http://example.test/storage/bar.txt
tenancy()->initialize(App\Models\Tenant::first());
Storage::disk('local')->put('foo.txt', "tenant foo\n");
Storage::disk('public')->put('bar.txt', "tenant bar\n");
Storage::disk('public')->url('bar.txt');
// http://example.test/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/bar.txt

With the following file structure:

  • Directorypublic
    • Directorystorage/ symlink destination
      • …
  • Directorystorage
    • Directoryapp
      • foo.txt central foo
      • Directorypublic symlink source
        • bar.txt central bar
        • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
          • bar.txt tenant bar
  • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
    • app foo.txt tenant foo

This approach is simpler than the previous section since there are no tenant symlinks to manage, but the file structure is more complex and tenant data is scattered across:

  • storage/app/public/tenant{id}/ for the public disk
  • storage/tenant{id}/ for everything else

Normally, to scope tenant cache you’d use the CacheTenancyBootstrapper. However, that bootstrapper works by applying a prefix to cache stores — which isn’t supported by file driver-based cache stores.

Instead, for these cache stores we have to adjust the entire path to the cache. The filesystem bootstrapper does this for you.

To use this feature, make sure your cache store is included in tenancy.cache.stores and tenancy.filesystem.scope_cache is enabled. If you use the default configuration and simply set your CACHE_STORE to file, the filesystem bootstrapper will automatically scope this cache:

config/tenancy.php
'cache' => [
'stores' => [
env('CACHE_STORE'),
],
],
'filesystem' => [
'scope_cache' => true,
],

With this configuration and the FilesystemTenancyBootstrapper enabled, cache files will be stored like this:

  • Directorystorage
    • Directoryapp/
      • …
    • Directoryframework
      • Directorycache/ central cache
        • …
    • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
      • Directoryapp/
        • …
      • Directoryframework
        • Directorycache/ tenant cache
          • …

Every store listed in tenancy.cache.stores that uses the file driver gets scoped this way.

The bootstrapper scopes the store’s configured paths (cache.stores.<store>.path, and cache.stores.<store>.lock_path if it’s set) by replacing the central storage path at the start of each path with the tenant’s storage path.

Say your file store is configured like this, with a separate lock directory:

config/cache.php
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data/custom_subdir'),
'lock_path' => storage_path('framework/cache/locks'),
],

Both paths get scoped (independently of each other):

php artisan tinker
config('cache.stores.file.path');
// /Users/samuel/Sites/example/storage/framework/cache/data/custom_subdir
config('cache.stores.file.lock_path');
// /Users/samuel/Sites/example/storage/framework/cache/locks
tenancy()->initialize($t1);
config('cache.stores.file.path');
// /Users/samuel/Sites/example/storage/tenant1/framework/cache/data/custom_subdir
config('cache.stores.file.lock_path');
// /Users/samuel/Sites/example/storage/tenant1/framework/cache/locks

If the configured path isn’t inside the central storage directory, there’s nothing to swap, so the suffix is appended to the end instead:

php artisan tinker
config('cache.stores.file.path');
// /var/cache/myapp
tenancy()->initialize($t1);
config('cache.stores.file.path');
// /var/cache/myapp/tenant1

A store with no configured lock_path stays that way in the tenant context too, with Laravel falling back to using path for the locks.

This feature works similarly to cache scoping. To enable it, simply set tenancy.filesystem.scope_sessions to true:

config/tenancy.php
'filesystem' => [
'scope_sessions' => true,
],

This will ensure sessions are stored like this:

  • Directorystorage
    • Directoryapp/
      • …
    • Directoryframework
      • Directorysessions/ central sessions
        • …
    • Directorytenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
      • Directoryapp/
        • …
      • Directoryframework
        • Directorysessions/ tenant sessions
          • …

The bootstrapper scopes the configured session.files path just like it scopes cache.stores.<store>.path for cache stores. See How the cache is scoped.

To make the storage_path() helper tenant-aware, the bootstrapper points it at the tenant’s storage directory:

php artisan tinker
storage_path();
// /Users/samuel/Sites/example/storage
tenancy()->initialize(App\Models\Tenant::first());
storage_path();
// /Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5

This is controlled by the tenancy.filesystem.suffix_storage_path config, which is enabled by default.

With the suffixing disabled, storage_path() returns the central storage path in the tenant context too. Nothing else changes since Tenancy’s code resolves the tenant’s storage directory independently of the helper.

We recommend keeping the suffixing enabled unless you have a specific reason not to. The config option exists primarily for edge cases where a suffixed storage_path() could break something.

This section will cover the assetHelper() method mentioned at the beginning of this page.

If you enable tenancy.filesystem.asset_helper_override, the asset() helper will be changed to return paths to the TenantAssetController mentioned above.

This can be helpful if you’re used to using asset() to generate links to frontend assets you reference from your templates.

config/tenancy.php
'filesystem' => [
'asset_helper_override' => true,
],
page.blade.php
<img src="{{ asset('logo.png') }}">
<!-- Produces: -->
<img src="/tenancy/assets/logo.png">

With this feature enabled, you may still want to generate global asset() links. To do that, you can use the global_asset() helper which functions exactly as asset() with the override disabled.

You should also enable the Vite bundler feature so that Vite uses the global_asset() helper instead of the overridden asset() helper.

If you do not want to override the asset() helper but would still like to use a convenient helper for generating routes to the TenantAssetController, you may use the tenant_asset() function. It behaves identically to the overridden asset() helper with one exception.

The exception is that when an ASSET_URL is set — which is the case on Laravel Vapor — the asset() override makes it such that the helper returns: $originalAssetRoot / $tenantSuffix / $path, with $tenantSuffix referring to the suffix mentioned at the start of this page.

The tenant_asset() helper always returns a path to the TenantAssetController route.

Tenancy also supports scoped disks.

config/filesystems.php
'foo' => [
'driver' => 'scoped',
'disk' => 'public', // the base disk
'prefix' => 'foo',
],

Scoped disks inherit the root and url from their base disk’s config. Configuring root and url on the scoped disk itself has no effect — it’ll use the base disk’s root and url anyway.

The filesystem bootstrapper needs to configure the base disk to make the scoped disk tenant-aware, so the base disk has to be added to tenancy.filesystem.disks:

config/tenancy.php
'filesystem' => [
'disks' => [
'local',
'public', // including this makes the 'foo' disk tenant-aware
'foo', // including this does nothing
],
],

When using root_override and url_override, make sure to only include the base disk. In some circumstances the scoped “child” disk may be ignored, but in others it may throw an exception. Only proper local disks have roots that can be overridden by root_override and only proper local disks can have symlinks created using url_override.