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:
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 ourTenantAssetController
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:
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
:
This is because our root_override
is configured like this:
(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
:
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:
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:
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:
local
andpublic
are intenancy.filesystem.disks
, which means they will get separated from other tenants- The suffix for tenant
7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
istenant7c27cc0f-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_override
section specifies how these disks should have their roots overridden. The%storage_path%
refers tostorage_path()
after suffixing: - 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:
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
intotenancy.filesystem.disks
but didn’t use it. What’s up with that? - Why is
root_override
even 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:
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:
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):
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:
or by calling the tenants:link
command:
Use --force
if some tenants already have links.
To remove these links, use tenants:link --remove
:
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/
:
These URLs/symlinks are configured in the url_override
section of the tenancy.filesystem
config, similar to root_override
:
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:
and the URL override like this:
With this config, the disks will work like this:
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 diskstorage/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:
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
:
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.
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.