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. 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.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(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:
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 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:
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 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
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 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:
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
storage_path()is suffixed with the suffix mentioned above - The
root_overridesection specifies how these disks should have their roots overridden. The%storage_path%refers tostorage_path()after suffixing: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 newstorage_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.
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:
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 closureTenantAssetController::$headers = function (Request $request) {return ['cache-control' => 'public, max-age=3600'];}// ...}
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), // `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:
$ 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-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/:
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 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/
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' => [ '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 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:
'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
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
- …
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_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.
'filesystem' => [ 'asset_helper_tenancy' => 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.