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:
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.pnginstead of/foo.png. That is, creating a route to ourTenantAssetControllerwhich 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.pngtenancy()->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:
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/publicThis is because our root_override is configured like this:
'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:
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'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.
Tenant storage directory
Section titled “Tenant storage directory”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.disksconfig) that have aroot_overridebased on%storage_path%— more details below php artisan tenants:linkcreates symlinks pointing at those disks- cache files and session files when file-based drivers are used
TenantAssetControllerserves assets from there by default- the
DeleteTenantStoragejob deletes it LogChannelBootstrapperconfiguresstorage_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):
$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.
Reading and writing files
Section titled “Reading and writing files”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:
'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:
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'); // nullStorage::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:
localandpublicare intenancy.filesystem.disks, which means they will get separated from other tenants- The suffix for tenant
7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5istenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5, i.e.config('tenancy.filesystem.suffix_base')+$tenant->getTenantKey(). - The tenant storage path is based on the central storage directory and the suffix mentioned above
- The
root_overridesection 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/',], - The disks use new roots, which are used for our
put()andget()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/publictenancy()->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
s3intotenancy.filesystem.disksbut didn’t use it. What’s up with that? - Why is
root_overrideeven 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:
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 tenant storage
Section titled “Deleting tenant storage”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:
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.
Accessing tenant files in the browser
Section titled “Accessing tenant files in the browser”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
Section titled “Cloud disks”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.
TenantAssetController
Section titled “TenantAssetController”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:
// $assetRoot is app/public inside the tenant's storage directoryresponse()->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 closureTenantAssetController::$headers = function (Request $request) {return ['cache-control' => 'public, max-age=3600'];};// ...} -
To serve the assets from a specific disk instead of
app/publicinside the tenant’s storage directory, setTenantAssetController::$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 thatFilesystemTenancyBootstrappercan scope its root). A scoped disk is fine as long as its base disk uses thelocaldriver, 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:
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:
$ php artisan tenants:link INFO The links have been created.$ 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:
$ php artisan tenants:link --remove INFO The links have been removed.This feature works at two points:
- 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 thepublic/folder. - 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/:
Storage::disk('public')->url('foo.png');// http://example.test/public-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/foo.pngThese URLs/symlinks are configured in the url_override section of the tenancy.filesystem config, similar to root_override:
'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.
Symlinking prefixed disks
Section titled “Symlinking prefixed disks”Symlinks work with disks that have a configured prefix. For instance, if you have a disk like this one:
'foo' => [ 'driver' => 'local', 'root' => storage_path('foo'), 'prefix' => 'bar/prefix',],and a URL override like this:
'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:
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.txtIf 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:
'filesystem' => [ 'root_override' => [ 'public' => '%original_storage_path%/app/public/%tenant%/', ],],and the URL override like this:
'filesystem' => [ 'url_override' => [ 'public' => 'storage/%tenant%/', ],],With this config, the disks will work like this:
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.txtWith 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 diskstorage/tenant{id}/for everything else
Cache scoping
Section titled “Cache scoping”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:
'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.
How the cache is scoped
Section titled “How the cache is scoped”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:
'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):
config('cache.stores.file.path');// /Users/samuel/Sites/example/storage/framework/cache/data/custom_subdirconfig('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_subdirconfig('cache.stores.file.lock_path');// /Users/samuel/Sites/example/storage/tenant1/framework/cache/locksIf the configured path isn’t inside the central storage directory, there’s nothing to swap, so the suffix is appended to the end instead:
config('cache.stores.file.path');// /var/cache/myapp
tenancy()->initialize($t1);config('cache.stores.file.path');// /var/cache/myapp/tenant1A store with no configured lock_path stays that way in the tenant context too, with Laravel falling back to using path for the locks.
Session scoping
Section titled “Session scoping”This feature works similarly to cache scoping. To enable it, simply set tenancy.filesystem.scope_sessions to true:
'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.
Storage path suffixing
Section titled “Storage path suffixing”To make the storage_path() helper tenant-aware, the bootstrapper points it at the tenant’s storage directory:
storage_path();// /Users/samuel/Sites/example/storage
tenancy()->initialize(App\Models\Tenant::first());storage_path();// /Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5This 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.
Asset helper logic
Section titled “Asset helper logic”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.
'filesystem' => [ 'asset_helper_override' => true,],<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.
Scoped disks
Section titled “Scoped disks”Tenancy also supports scoped disks.
'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:
'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.