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. This does the bulk of filesystem scoping under the hood.

assetHelper() optionally (if tenancy.filesystem.asset_helper_tenancy 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(config('tenancy.filesystem.disks')) to remove any disks that have already been created from the FilesystemManager singleton. This ensures that all disks (configured in tenancy.filesystem.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 next section.)

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 cache is scoped per tenant: it gets stored in storage/tenant{id}/framework/cache.

And finally, scopeSessions() works similarly to scopeCache(): it ensures tenant sessions are scoped by placing them in storage/tenant{id}/framework/sessions.

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.

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:

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

The only change I 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

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 storage_path() is suffixed with the suffix mentioned above
  4. The root_override section specifies how these disks should have their roots overridden. The %storage_path% refers to storage_path() after suffixing:
    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 new 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.

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

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

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
response()->file(storage_path("app/public/$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'];
    }
    // ...
    }

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), // `false` by default, but you likely want to make this `true` in production.
// Listeners\CreateTenantStorage::class,
],
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
// Listeners\DeleteTenantStorage::class,
],

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-tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/ A destination
    • Directorypublic-tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/ 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 listener to TenantCreate or php artisan tenants:link, and its url setting will be overridden by the filesystem bootstrapper.

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' => [
'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

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 for the filesystem cache store.

Instead, for the filesystem cache store we have to adjust the entire path to the cache. The filesystem bootstrapper does this for you, though note that you can only use one file cache store.

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

Session scoping

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

Asset helper logic

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

If you enable tenancy.filesystem.asset_helper_tenancy, 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_tenancy' => 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.