From 3cc79023779eb27dee7b5faf0e505ae3bbc5bf75 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 8 Aug 2026 13:04:37 +0200 Subject: [PATCH 01/70] Make DeleteTenantStorage test assert the expected behavior The job should delete the tenant storage regardless of the suffix_storage_path config. Now, the job depends on that config, so currently, this test fails. Also remove the "FS bootstrapper disabled" assertions. The job clearly depends on the bootstrapper being enabled, so I don't think these assertions matter in the end. --- .../FilesystemTenancyBootstrapperTest.php | 64 ++++++------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 785ffbc3..add296fd 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -185,64 +185,38 @@ test('create and delete storage symlinks jobs work', function() { $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); }); -test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function() { +test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function(bool $suffixStoragePath) { Event::listen(DeletingTenant::class, JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false)->toListener() ); + config([ + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], + // suffix_storage_path only affects the storage_path() helper. + // The disks are scoped to the tenant's storage directory either way, + // so the tenant files end up there. + 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath, + // This is the default tenancy config -- set it here explicitly for clarity + 'tenancy.filesystem.suffix_base' => 'tenant', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + ]); + $centralStoragePath = storage_path(); - tenancy()->initialize(Tenant::create()); + $tenant = Tenant::create(); + $tenantStoragePath = $centralStoragePath . "/tenant{$tenant->getTenantKey()}"; - // FilesystemTenancyBootstrapper not enabled, - // tenant and central storage path is the same, - // the storage deletion will be skipped. - $tenantStoragePath = storage_path(); - expect($tenantStoragePath)->toBe($centralStoragePath); - expect(File::isDirectory($centralStoragePath))->toBeTrue(); - tenant()->delete(); + tenancy()->initialize($tenant); - expect(File::isDirectory($centralStoragePath))->toBeTrue(); + Storage::disk('public')->put('foo.txt', 'tenant file'); + expect(file_get_contents($tenantStoragePath . '/app/public/foo.txt'))->toBe('tenant file'); - config([ - 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], - 'tenancy.filesystem.suffix_storage_path' => false, - ]); - - tenancy()->initialize(Tenant::create()); - - $tenantStoragePath = storage_path(); - - // FilesystemTenancyBootstrapper enabled, - // but tenant and central storage path is still the same - // because suffix_storage_path is false. - // The storage deletion will be skipped. - expect($tenantStoragePath)->toBe($centralStoragePath); - expect(File::isDirectory($centralStoragePath))->toBeTrue(); - tenant()->delete(); - - expect(File::isDirectory($centralStoragePath))->toBeTrue(); - - config([ - 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], - 'tenancy.filesystem.suffix_storage_path' => true, - ]); - - tenancy()->initialize(Tenant::create()); - $tenantStoragePath = storage_path(); - - // FilesystemTenancyBootstrapper enabled, - // suffix_storage_path enabled, so the two paths are distinct. - // Tenant storage will be deleted. - expect($tenantStoragePath)->not()->toBe($centralStoragePath); - expect(File::isDirectory($tenantStoragePath))->toBeTrue(); - - tenant()->delete(); + $tenant->delete(); expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($centralStoragePath))->toBeTrue(); -}); +})->with([true, false]); test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { config([ From 0bd58528d28c20d4e42e98ba156d79661fae7ac3 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 8 Aug 2026 13:07:38 +0200 Subject: [PATCH 02/70] Make DeleteTenantStorage delete the tenant storage directory regardless of suffix_storage_path The job depended on storage_path(), which is only suffixed when suffix_storage_path is enabled, so with it disabled the tenant's files were left behind. It now uses the bootstrapper's own suffix logic via a new getBoundTenantStoragePath() method.. --- .../FilesystemTenancyBootstrapper.php | 13 ++++++++++ src/Jobs/DeleteTenantStorage.php | 26 +++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 1574ea4b..a00c93c3 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -327,4 +327,17 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper { return app(static::class)->originalStoragePath; } + + /** + * Get the storage path of the passed tenant (independent of the current context). + * + * This is the directory the bootstrapper scopes disks, cache and sessions to, + * regardless of suffix_storage_path (that config option only affects the storage_path() helper). + */ + public static function getBoundTenantStoragePath(Tenant $tenant): string + { + $bootstrapper = app(static::class); + + return $bootstrapper->tenantStoragePath($bootstrapper->suffix($tenant)); + } } diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index 36a0d326..5dab4dcd 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -10,8 +10,20 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\File; +use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +/** + * Delete the tenant's storage directory. + * + * Requires FilesystemTenancyBootstrapper to be enabled, since the deleted directory + * is the one the bootstrapper scopes disks, cache and sessions to. + * + * The job does not depend on the tenancy.filesystem.suffix_storage_path config, + * since it doesn't use the storage_path() helper. + * + * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper + */ class DeleteTenantStorage implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -22,19 +34,7 @@ class DeleteTenantStorage implements ShouldQueue public function handle(): void { - if (config('tenancy.filesystem.suffix_storage_path') === false) { - // Skip storage deletion if path suffixing is disabled - return; - } - - $centralStoragePath = tenancy()->central(fn () => storage_path()); - $tenantStoragePath = tenancy()->run($this->tenant, fn () => storage_path()); - - if ($tenantStoragePath === $centralStoragePath) { - // Check again to ensure the tenant storage path is distinct from the central storage path - // to avoid any accidental central storage path deletion - return; - } + $tenantStoragePath = FilesystemTenancyBootstrapper::getBoundTenantStoragePath($this->tenant); if (is_dir($tenantStoragePath)) { File::deleteDirectory($tenantStoragePath); From 179114bfb72e82aba922b603b4c26b374345ca17 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 8 Aug 2026 14:19:21 +0200 Subject: [PATCH 03/70] Make storage symlink test assert the expected behavior The symlinks should point to the tenant's disk root regardless of the suffix_storage_path config and of which root_override placeholders are used. Currently, the storage_path() helper is used for generating the symlink path, so the two new datasets fail. --- tests/ActionTest.php | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 93db0eb3..a411ccfe 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -12,19 +12,22 @@ use Stancl\Tenancy\Actions\CreateStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); -test('create storage symlinks action works', function() { +test('create storage symlinks action works', function (string $rootOverride, bool $suffixStoragePath) { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, ], 'tenancy.filesystem.suffix_base' => 'tenant-', - 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + // The disk root is suffixed regardless of the suffix_storage_path config + 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath, + 'tenancy.filesystem.root_override.public' => $rootOverride, 'tenancy.filesystem.url_override.public' => 'public-%tenant%' ]); @@ -38,13 +41,19 @@ test('create storage symlinks action works', function() { expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse(); expect(file_exists($publicPath))->toBeFalse(); + Storage::disk('public')->put('foo.txt', 'tenant file'); + (new CreateStorageSymlinksAction)($tenant); - // The symlink exists and is valid - expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); - expect(file_exists($publicPath))->toBeTrue(); - $this->assertEquals(storage_path("app/public/"), readlink($publicPath)); -}); + // The symlink exists and points to the directory the tenant's disk writes to + expect(is_link($publicPath))->toBeTrue(); + expect(readlink($publicPath))->toBe(config('filesystems.disks.public.root')); + expect(file_get_contents($publicPath . '/foo.txt'))->toBe('tenant file'); +})->with([ + 'default root_override' => ['%storage_path%/app/public/', true], + 'suffix_storage_path disabled' => ['%storage_path%/app/public/', false], + 'custom root_override' => ['%original_storage_path%/app/public/%tenant%/', true], +]); test('remove storage symlinks action works', function() { config([ From 98d86478e756a722e70b805e6597ea28e73bcc54 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 8 Aug 2026 14:22:29 +0200 Subject: [PATCH 04/70] Make storage symlinks point to the tenant's disk root instead of storage_path() possibleTenantSymlinks() resolved the root_override template on its own, using storage_path() for %storage_path% and leaving %original_storage_path% and %tenant% unreplaced. Let the FS bootstrapper resolve the placeholders tenant instead, so the symlinks point where the disks actually write. --- src/Concerns/DealsWithTenantSymlinks.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 114eadb5..479db163 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -7,15 +7,21 @@ namespace Stancl\Tenancy\Concerns; use Exception; use Stancl\Tenancy\Contracts\Tenant; +/** + * Requires FilesystemTenancyBootstrapper to be enabled, since the tenant symlinks + * point to the disk roots scoped by the bootstrapper. + * + * @see \Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper + */ trait DealsWithTenantSymlinks { /** - * Get all possible tenant symlinks, existing or not (array of ['public path' => 'storage path']). + * Get all possible tenant symlinks, existing or not (array of ['public path' => 'disk root']). * * Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config. * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks. - * The same storage path can be symlinked to multiple public paths, which is why the public path - * is the Collection key. + * The same disk root can be symlinked to multiple public paths, which is why the public path + * is the array key. * * @return array */ @@ -26,7 +32,7 @@ trait DealsWithTenantSymlinks $rootOverrides = config('tenancy.filesystem.root_override'); $tenantKey = $tenant->getTenantKey(); - $tenantStoragePath = tenancy()->run($tenant, fn () => storage_path()); + $tenantDisks = tenancy()->run($tenant, fn () => config('filesystems.disks')); /** @var array $symlinks */ $symlinks = []; @@ -45,9 +51,8 @@ trait DealsWithTenantSymlinks } $publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath); - $storagePath = str_replace('%storage_path%', $tenantStoragePath, $rootOverrides[$disk]); - $symlinks[public_path($publicPath)] = $storagePath; + $symlinks[public_path($publicPath)] = $tenantDisks[$disk]['root']; } return $symlinks; From a9a727451e453837d21137e7d10b8ad978c446dd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 17 Aug 2026 16:48:59 +0200 Subject: [PATCH 05/70] Assert that TenantAssetController does not depend on storage_path() suffixing (regression test) --- tests/TenantAssetTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index ef1cb41f..4e6e550b 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -65,6 +65,29 @@ test('asset can be accessed using the url returned by the tenant asset helper', expect($content)->toBe('bar'); }); +test('tenant assets are served even when the suffix_storage_path config is set to false', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + 'tenancy.filesystem.suffix_storage_path' => false, + ]); + + // With suffix_storage_path disabled, storage_path() stays central in tenant context + $centralStoragePath = storage_path(); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $filename = 'testfile' . Str::random(8); + Storage::disk('public')->put($filename, 'bar'); + + $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + + // The asset is served from the tenant's storage directory, not from the central storage path + $response->assertSuccessful(); + expect($response->getFile()->getPathname()) + ->toBe("$centralStoragePath/tenant{$tenant->id}/app/public/$filename"); +}); + test('asset helper returns a link to tenant asset controller when asset url is null', function () { config(['app.asset_url' => null]); config(['tenancy.filesystem.asset_helper_override' => true]); From 11fec0345d0baea5bea75f1ab423fcb23e4b9fbd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 17 Aug 2026 16:52:05 +0200 Subject: [PATCH 06/70] Make TenantAssetController not depend on suffixed storage_path() The only thing the controller now depends on is that FilesystemTenancyBootstrapper needs to be enabled (so basically, the same dependency as before, but before this, there was the extra "suffix_storage_path === true" dependency -- not literally, storage_path() just had to be suffixed in tenant context, otherwise, the controller would read from the central storage in tenant context). --- src/Controllers/TenantAssetController.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 243135ed..bd27e7be 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -9,6 +9,7 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; +use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; @@ -51,12 +52,27 @@ class TenantAssetController implements HasMiddleware ? (static::$headers)($request) : static::$headers; - return response()->file(storage_path("app/public/$path"), $headers); + return response()->file($this->assetRoot() . "/$path", $headers); } catch (Throwable) { abort(404); } } + /** + * Assets are served from app/public inside the tenant's storage directory. + * + * The directory is resolved using the FilesystemTenancyBootstrapper (rather than storage_path(), + * so that it's tenant-scoped regardless of the suffix_storage_path config). + */ + protected function assetRoot(): string + { + if ($tenant = tenant()) { + return FilesystemTenancyBootstrapper::getBoundTenantStoragePath($tenant) . '/app/public'; + } + + return storage_path('app/public'); + } + /** * Prevent path traversal attacks. This is generally a non-issue on modern * webservers but it's still worth handling on the application level as well. @@ -67,9 +83,9 @@ class TenantAssetController implements HasMiddleware { $this->abortIf($path === null, 'Empty path'); - $allowedRoot = realpath(storage_path('app/public')); + $allowedRoot = realpath($this->assetRoot()); - // `storage_path('app/public')` doesn't exist, so it cannot contain files + // The asset root doesn't exist, so it cannot contain files $this->abortIf($allowedRoot === false, "Storage root doesn't exist"); $attemptedPath = realpath("{$allowedRoot}/{$path}"); From b771fd13a983b5793bb5af952ff98991106c1c68 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 14:19:40 +0200 Subject: [PATCH 07/70] Make the disk used for serving tenant assets configurable TenantAssetController::$publicDisk is null by default, which keeps serving the assets from app/public inside the tenant's storage directory. Setting it to a disk name serves the assets from that disk's root instead. A disk with no root path throws instead of resolving to an empty path. realpath('') returns the current working directory, so the controller would end up treating the whole app directory as the allowed root. --- src/Controllers/TenantAssetController.php | 25 ++++++++++++-- tests/TenantAssetTest.php | 42 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index bd27e7be..1863c975 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -29,6 +29,13 @@ class TenantAssetController implements HasMiddleware */ public static array $middleware = []; + /** + * Disk the assets are served from. + * + * When null, the assets are served from app/public inside the tenant's storage directory. + */ + public static string|null $publicDisk = null; + public static function middleware() { return array_map( @@ -59,13 +66,25 @@ class TenantAssetController implements HasMiddleware } /** - * Assets are served from app/public inside the tenant's storage directory. + * Directory the assets are served from -- the root of the $publicDisk, + * or app/public inside the tenant's storage directory when no disk is configured. * - * The directory is resolved using the FilesystemTenancyBootstrapper (rather than storage_path(), - * so that it's tenant-scoped regardless of the suffix_storage_path config). + * The storage directory is resolved using the FilesystemTenancyBootstrapper (rather than + * storage_path(), so that it's tenant-scoped regardless of the suffix_storage_path config). */ protected function assetRoot(): string { + if (static::$publicDisk) { + $diskRoot = config('filesystems.disks.' . static::$publicDisk . '.root'); + + if (! is_string($diskRoot)) { + // A disk with no root path would let the controller serve any file in the app + throw new Exception('Disk [' . static::$publicDisk . '] has no root path configured.'); + } + + return rtrim($diskRoot, '/'); + } + if ($tenant = tenant()) { return FilesystemTenancyBootstrapper::getBoundTenantStoragePath($tenant) . '/app/public'; } diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 4e6e550b..7f1b9ab0 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -30,6 +30,7 @@ beforeEach(function () { TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenantAssetController::$headers = []; + TenantAssetController::$publicDisk = null; /** @var CloneRoutesAsTenant $cloneAction */ $cloneAction = app(CloneRoutesAsTenant::class); @@ -88,6 +89,47 @@ test('tenant assets are served even when the suffix_storage_path config is set t ->toBe("$centralStoragePath/tenant{$tenant->id}/app/public/$filename"); }); +test('the disk used for serving tenant assets is configurable', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // This is tenancy's default override for the local disk's root, set it here for clarity + 'tenancy.filesystem.root_override.local' => '%storage_path%/app/', + ]); + + // The local disk's root is overridden to '%storage_path%/app/' (so it does not use 'app/public') + TenantAssetController::$publicDisk = 'local'; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $filename = 'testfile' . Str::random(8); + Storage::disk('local')->put($filename, 'bar'); + $path = Storage::disk('local')->path($filename); + + $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + + // The asset is served from the disk's root instead of 'app/public' + $response->assertSuccessful(); + expect($response->getFile()->getPathname())->toBe($path); +}); + +test('tenant asset controller throws when the configured disk has no root', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + 'filesystems.disks.rootless' => ['driver' => 's3'], + ]); + + TenantAssetController::$publicDisk = 'rootless'; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $this->withoutExceptionHandling(); + pest()->expectExceptionMessage('Disk [rootless] has no root path configured.'); + + pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); +}); + test('asset helper returns a link to tenant asset controller when asset url is null', function () { config(['app.asset_url' => null]); config(['tenancy.filesystem.asset_helper_override' => true]); From 1fb9acec75cea9bbc4458f21e2038417aa42f71e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 14:20:17 +0200 Subject: [PATCH 08/70] Restore the central storage directory check in DeleteTenantStorage The check compares the tenant's storage path with the bootstrapper's central storage path, so unlike the original one, it doesn't depend on storage_path(). With the current path resolution, the two can only be the same if suffix_base and the tenant's key are both empty, so this is just a safety net for weird configurations. --- src/Jobs/DeleteTenantStorage.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index 5dab4dcd..825eaef2 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -35,6 +35,12 @@ class DeleteTenantStorage implements ShouldQueue public function handle(): void { $tenantStoragePath = FilesystemTenancyBootstrapper::getBoundTenantStoragePath($this->tenant); + $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); + + if (realpath($tenantStoragePath) === realpath($centralStoragePath)) { + // Never delete the central storage directory -- that would delete the files of all tenants + return; + } if (is_dir($tenantStoragePath)) { File::deleteDirectory($tenantStoragePath); From c1e09fb25d82720834f02ffe84e5d84f3fb29d03 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 14:21:05 +0200 Subject: [PATCH 09/70] Assert that DeleteTenantStorage does not delete the central storage directory when the FS bootstrapper is disabled With the bootstrapper disabled, the job resolves the path to a tenant directory that was never created, so nothing gets deleted. --- .../FilesystemTenancyBootstrapperTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index add296fd..1135e84d 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -218,6 +218,17 @@ test('tenant storage gets deleted during tenant deletion when the DeletingTenant expect(File::isDirectory($centralStoragePath))->toBeTrue(); })->with([true, false]); +test('DeleteTenantStorage does not delete the central storage directory when the filesystem bootstrapper is disabled', function () { + config(['tenancy.bootstrappers' => []]); + + $centralStoragePath = storage_path(); + $tenant = Tenant::create(); + + (new DeleteTenantStorage($tenant))->handle(); + + expect(File::isDirectory($centralStoragePath))->toBeTrue(); +}); + test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { config([ 'tenancy.bootstrappers' => [ From a9cdeb220b489152953ac36e1d1b10155565a731 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 14:54:22 +0200 Subject: [PATCH 10/70] Fix the suffix_storage_path comment in the config Disabling the config doesn't break local disk tenancy -- it only affects the storage_path() helper. Disks, cache and sessions are scoped either way, so the tradeoff is that files accessed using storage_path() are shared by all tenants. --- assets/config.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/config.php b/assets/config.php index b2001091..33d2e109 100644 --- a/assets/config.php +++ b/assets/config.php @@ -378,7 +378,9 @@ return [ /** * Should storage_path() be suffixed. * - * Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3. + * Note: This only affects the storage_path() helper. Disks, cache and sessions are + * scoped to the tenant's storage directory either way. With this disabled, files + * accessed using storage_path() are shared by all tenants. * * For the vast majority of applications, this feature should be enabled. But in some * edge cases, it can cause issues (like using Passport with Vapor - see #196), so From ff3a2a3e2c508accf343892e30ce88ee025bfdd6 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 17:13:29 +0200 Subject: [PATCH 11/70] Assert that the tenant asset controller only serves files inside the configured disk's root (regression test) Currently this fails because the controller checks that the requested file is inside the asset root using a plain string prefix, so with the root set to '%storage_path%/app/media/', a request for '../media-originals/photo.jpg' is served from the sibling 'app/media-originals' directory. --- tests/TenantAssetTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 7f1b9ab0..3bd501de 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -298,6 +298,34 @@ test('tenant asset controller returns a 404 when accessing a nonexistent file', ]); }); +test('tenant asset controller throws an exception when accessing a file in a directory whose name starts with the name of the asset root', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // Disk used for serving the assets -- its root is 'app/media' in the tenant's storage directory + 'filesystems.disks.media' => ['driver' => 'local', 'root' => storage_path('app/media')], + 'tenancy.filesystem.disks' => array_merge(config('tenancy.filesystem.disks'), ['media']), + 'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/', + ]); + + TenantAssetController::$publicDisk = 'media'; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + Storage::disk('media')->put('photo.jpg', 'public file'); + + // A directory next to the asset root, e.g. one holding files that shouldn't be served + mkdir($privateDirectory = storage_path('app/media-originals'), recursive: true); + file_put_contents($privateDirectory . '/photo.jpg', 'private file'); + + $this->withoutExceptionHandling(); + pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404 + + pest()->get(tenant_asset('../media-originals/photo.jpg'), [ + 'X-Tenant' => $tenant->id, + ]); +}); + test('test asset controller returns a 404 when accessing a file outside the storage root', function () { config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); From 4e1fb850bc2cff45e61736ab3b503d0bb97c1d62 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Aug 2026 17:14:42 +0200 Subject: [PATCH 12/70] Require a directory boundary when checking that an asset is inside the asset root The resolved path was compared to the asset root using a plain string prefix, so a directory whose name just starts with the asset root's name passed the check. This didn't matter while the asset root was hardcoded to app/public, but $publicDisk lets it be any disk root. --- src/Controllers/TenantAssetController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 1863c975..48da0b01 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -112,8 +112,10 @@ class TenantAssetController implements HasMiddleware // User is attempting to access a nonexistent file $this->abortIf($attemptedPath === false, 'Accessing a nonexistent file'); - // User is attempting to access a file outside the $allowedRoot folder - $this->abortIf(! str($attemptedPath)->startsWith($allowedRoot), 'Accessing a file outside the storage root'); + // User is attempting to access a file outside the $allowedRoot folder. + // The trailing separator is needed so that sibling directories that + // start with the same name (e.g. app/public-private) don't pass. + $this->abortIf(! str($attemptedPath)->startsWith(rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR), 'Accessing a file outside the storage root'); } /** @return void|never */ From b6e6aa634c078bc3f28210d0cf64115454778c10 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 10:27:20 +0200 Subject: [PATCH 13/70] Assert that CreateStorageSymlinksAction cannot create symlinks for disks that are not in tenancy.filesystem.disks, i.e. aren't tenant-aware (regression test) --- tests/ActionTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index a411ccfe..90a7b807 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -55,6 +55,30 @@ test('create storage symlinks action works', function (string $rootOverride, boo 'custom root_override' => ['%original_storage_path%/app/public/%tenant%/', true], ]); +test('create storage symlinks action fails for disks that are not tenant-aware', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + // The public disk has both overrides configured, but it is not in + // tenancy.filesystem.disks, so the bootstrapper never scopes its root. + // Symlinking it would point every tenant's public path to the central disk root. + 'tenancy.filesystem.disks' => ['local'], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + + (new CreateStorageSymlinksAction)($tenant); + + expect(fn () => (new CreateStorageSymlinksAction)($tenant)) + ->toThrow(Exception::class, 'Disk public is not tenant-aware.'); + + expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeFalse(); +}); + test('remove storage symlinks action works', function() { config([ 'tenancy.bootstrappers' => [ From 0eb2cf18a4e46e0586280e8e0af4d674fef51166 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 10:35:07 +0200 Subject: [PATCH 14/70] Throw an exception in possibleTenantSymlinks if the disk is not tenant-aware When a disk in url_override and root_override is absent from tenancy.filesystem.disks, FilesystemTenancyBootstrapper leaves its root unchanged, possibleTenantSymlinks allows creating a symlink for that unscoped disk (= a disk with a central root), which can expose shared files. Fixed by throwing an exception in possibleTenantSymlinks saying that the disk should be tenant-aware (= included in the tenancy.filesystem.disks config). --- src/Concerns/DealsWithTenantSymlinks.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 479db163..6ab4efc0 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -50,6 +50,12 @@ trait DealsWithTenantSymlinks throw new Exception("Disk $disk is not a local disk. Only local disks can be symlinked."); } + if (! in_array($disk, config('tenancy.filesystem.disks'), true)) { + // The bootstrapper only scopes disks listed in tenancy.filesystem.disks. Without that, + // the disk root stays central, and the symlink of every tenant would point to it. + throw new Exception("Disk $disk is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific."); + } + $publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath); $symlinks[public_path($publicPath)] = $tenantDisks[$disk]['root']; From 2bd590ef0e5b762dd04c1871470fc48ee2b7f488 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 10:46:08 +0200 Subject: [PATCH 15/70] Correct the regression test --- tests/ActionTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 90a7b807..5390afd9 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -71,8 +71,6 @@ test('create storage symlinks action fails for disks that are not tenant-aware', /** @var Tenant $tenant */ $tenant = Tenant::create(); - (new CreateStorageSymlinksAction)($tenant); - expect(fn () => (new CreateStorageSymlinksAction)($tenant)) ->toThrow(Exception::class, 'Disk public is not tenant-aware.'); From 79484cac09b90962c89c6061bc5ea2e7f83a4e91 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 10:53:33 +0200 Subject: [PATCH 16/70] Make the suffix_storage_path config comment clearer The comment said that disks, cache and sessions are scoped ot the tenant's storage dir either way, but that's only true if the disks are included in tenancy.filesystem.disks, and for cache and sessions, scope_cache and scope_sessions have to be enabled. This might be obvious, but it'll be better to make this completely clear from the comment. --- assets/config.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/config.php b/assets/config.php index 33d2e109..0403cfd1 100644 --- a/assets/config.php +++ b/assets/config.php @@ -378,7 +378,8 @@ return [ /** * Should storage_path() be suffixed. * - * Note: This only affects the storage_path() helper. Disks, cache and sessions are + * Note: This only affects the storage_path() helper. Disks listed in the 'disks' config + * above, and cache and sessions if 'scope_cache' and 'scope_sessions' are enabled, are * scoped to the tenant's storage directory either way. With this disabled, files * accessed using storage_path() are shared by all tenants. * From aad435da44fbe321b14681fc7c8e55cbddb3245b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 15:10:07 +0200 Subject: [PATCH 17/70] Assert that symlinks work without a root_override and that disks with a null url_override are skipped (regression test) --- tests/ActionTest.php | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 5390afd9..545c679e 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -19,7 +19,7 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); -test('create storage symlinks action works', function (string $rootOverride, bool $suffixStoragePath) { +test('create storage symlinks action works', function (string|null $rootOverride, bool $suffixStoragePath) { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -53,6 +53,8 @@ test('create storage symlinks action works', function (string $rootOverride, boo 'default root_override' => ['%storage_path%/app/public/', true], 'suffix_storage_path disabled' => ['%storage_path%/app/public/', false], 'custom root_override' => ['%original_storage_path%/app/public/%tenant%/', true], + // Without a root_override, the bootstrapper suffixes the disk's central root + 'no root_override' => [null, true], ]); test('create storage symlinks action fails for disks that are not tenant-aware', function () { @@ -77,6 +79,28 @@ test('create storage symlinks action fails for disks that are not tenant-aware', expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeFalse(); }); +test('create storage symlinks action skips disks with a null url_override', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.url_override' => [ + 'public' => 'public-%tenant%', + // A null override means the disk's URL is not overridden, same as in the FS bootstrapper + 'local' => null, + ], + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + + (new CreateStorageSymlinksAction)($tenant); + + // The local disk is skipped, so the public disk still gets its symlink + expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeTrue(); +}); + test('remove storage symlinks action works', function() { config([ 'tenancy.bootstrappers' => [ From ae77c49985d2ce40119f951ede5f10105a81dd92 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 15:15:14 +0200 Subject: [PATCH 18/70] Stop requiring a root_override in possibleTenantSymlinks The symlink target used to be built from the root_override template, so a disk without an entry there had nothing to resolve. The symlink target is now the disk's tenant-context root, which the bootstrapper sets either way -- with a root_override it expands the template, without one, it appends the suffix to the disk's own root. Disks that only have a url_override now get a working symlink instead of being skipped while their URL was still overridden. Skipping disks with a null url_override is now explicit. The root_override check used to cover that by accident, and without it str_replace() gets null and throws a TypeError. --- assets/config.php | 2 +- src/Concerns/DealsWithTenantSymlinks.php | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/assets/config.php b/assets/config.php index 0403cfd1..96a0aeca 100644 --- a/assets/config.php +++ b/assets/config.php @@ -357,7 +357,7 @@ return [ * Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory. */ 'url_override' => [ - // Note that the local disk you add must exist in the tenancy.filesystem.root_override config + // Note that the local disk you add must exist in the tenancy.filesystem.disks config 'public' => 'public-%tenant%', ], diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 6ab4efc0..fcb9e432 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -18,7 +18,9 @@ trait DealsWithTenantSymlinks /** * Get all possible tenant symlinks, existing or not (array of ['public path' => 'disk root']). * - * Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config. + * Tenants can have a symlink for each local disk that is listed + * in both tenancy.filesystem.disks and tenancy.filesystem.url_override. + * * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks. * The same disk root can be symlinked to multiple public paths, which is why the public path * is the array key. @@ -29,7 +31,6 @@ trait DealsWithTenantSymlinks { $disks = config('filesystems.disks'); $urlOverrides = config('tenancy.filesystem.url_override'); - $rootOverrides = config('tenancy.filesystem.root_override'); $tenantKey = $tenant->getTenantKey(); $tenantDisks = tenancy()->run($tenant, fn () => config('filesystems.disks')); @@ -38,11 +39,12 @@ trait DealsWithTenantSymlinks $symlinks = []; foreach ($urlOverrides as $disk => $publicPath) { - if (! isset($disks[$disk])) { + if (! $publicPath) { + // The disk's URL is not overridden, same as in FilesystemTenancyBootstrapper::diskUrl() continue; } - if (! isset($rootOverrides[$disk])) { + if (! isset($disks[$disk])) { continue; } @@ -51,8 +53,8 @@ trait DealsWithTenantSymlinks } if (! in_array($disk, config('tenancy.filesystem.disks'), true)) { - // The bootstrapper only scopes disks listed in tenancy.filesystem.disks. Without that, - // the disk root stays central, and the symlink of every tenant would point to it. + // The bootstrapper only scopes disks listed in tenancy.filesystem.disks. + // Without that, the root stays central and the symlink of every tenant would point to it. throw new Exception("Disk $disk is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific."); } From 223b49a3959ddd08a464b27bbe7f893eead74fa0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 19 Aug 2026 15:22:33 +0200 Subject: [PATCH 19/70] Correct FSBootstrapper and DeleteTenantStorage docblocks getBoundTenantStoragePath() and DeleteTenantStorage both claimed the tenant storage directory is where disks, cache and sessions are scoped to. That's only true when root_override points there and scope_cache/scope_sessions are enabled -- a root_override using %original_storage_path% puts the disk root outside it entirely. --- src/Bootstrappers/FilesystemTenancyBootstrapper.php | 4 ++-- src/Jobs/DeleteTenantStorage.php | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index a00c93c3..f2688e51 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -331,8 +331,8 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper /** * Get the storage path of the passed tenant (independent of the current context). * - * This is the directory the bootstrapper scopes disks, cache and sessions to, - * regardless of suffix_storage_path (that config option only affects the storage_path() helper). + * The returned path doesn't depend on suffix_storage_path -- that config option + * only controls whether storage_path() uses it. */ public static function getBoundTenantStoragePath(Tenant $tenant): string { diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index 825eaef2..0e8290e6 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -16,13 +16,16 @@ use Stancl\Tenancy\Contracts\Tenant; /** * Delete the tenant's storage directory. * - * Requires FilesystemTenancyBootstrapper to be enabled, since the deleted directory - * is the one the bootstrapper scopes disks, cache and sessions to. + * Requires FilesystemTenancyBootstrapper to be enabled, since the tenant storage path + * is resolved from it. * - * The job does not depend on the tenancy.filesystem.suffix_storage_path config, + * Files outside that directory (e.g. disks with an %original_storage_path%-based + * root_override) are not deleted. + * + * Note that this job is not affected by the tenancy.filesystem.suffix_storage_path config * since it doesn't use the storage_path() helper. * - * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper + * @see FilesystemTenancyBootstrapper */ class DeleteTenantStorage implements ShouldQueue { From 6b27bc5f36c25cece994a5928bdae78a8f6b5e6d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 11:48:37 +0200 Subject: [PATCH 20/70] Assert that disks with an empty url_override are skipped by both the FS bootstrapper and possibleTenantSymlinks (regression test) Update the existing "create storage symlinks action skips disks with a null url_override" test so that it covers disks with NO url_override (unset/null and empty string). The test fails with the empty string override at the moment. --- tests/ActionTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 545c679e..f7f5cdda 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -79,7 +79,7 @@ test('create storage symlinks action fails for disks that are not tenant-aware', expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeFalse(); }); -test('create storage symlinks action skips disks with a null url_override', function () { +test('create storage symlinks action skips disks with no url_override', function (string|null $localUrlOverride) { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -87,8 +87,7 @@ test('create storage symlinks action skips disks with a null url_override', func 'tenancy.filesystem.suffix_base' => 'tenant-', 'tenancy.filesystem.url_override' => [ 'public' => 'public-%tenant%', - // A null override means the disk's URL is not overridden, same as in the FS bootstrapper - 'local' => null, + 'local' => $localUrlOverride, ], ]); @@ -99,7 +98,16 @@ test('create storage symlinks action skips disks with a null url_override', func // The local disk is skipped, so the public disk still gets its symlink expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeTrue(); -}); + + // The bootstrapper skips the same disk, so its URL is not overridden either + $centralUrl = config('filesystems.disks.local.url'); + tenancy()->initialize($tenant); + + expect(config('filesystems.disks.local.url'))->toBe($centralUrl); +})->with([ + 'null url_override' => [null], + 'empty url_override' => [''], +]); test('remove storage symlinks action works', function() { config([ From 7ecb34f3fe7abfb17ef5aab39a96183b0ac1a519 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 11:51:03 +0200 Subject: [PATCH 21/70] Skip disks with *empty* url_override in diskUrl() Previously, we only skipped disks with `null` override. But an override with an empty string is also incorrect, and simply checking if $this->app['config']["tenancy.filesystem.url_override.{$disk}"]) is falsy instead of strictly null takes care of that. --- src/Bootstrappers/FilesystemTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index f2688e51..7235674b 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -176,7 +176,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper { $diskConfig = $this->app['config']["filesystems.disks.{$disk}"]; - if ($diskConfig['driver'] !== 'local' || $this->app['config']["tenancy.filesystem.url_override.{$disk}"] === null) { + if ($diskConfig['driver'] !== 'local' || ! $this->app['config']["tenancy.filesystem.url_override.{$disk}"]) { return; } From 15144def3c83bafc8c0397f2a0e1f8685c448eab Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 11:58:31 +0200 Subject: [PATCH 22/70] Update tenancy.filesystem config docblocks Briefly document the root_override placeholders, make the links point to v4 docs instead of the v3 ones. Also in the url_override comments, mention that local disks must have a valid root in order for the override to work correctly. --- assets/config.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/assets/config.php b/assets/config.php index 96a0aeca..d37d1d6b 100644 --- a/assets/config.php +++ b/assets/config.php @@ -321,7 +321,7 @@ return [ /** * Filesystem tenancy config. Used by FilesystemTenancyBootstrapper. - * https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper. + * https://v4.tenancyforlaravel.com/bootstrappers/filesystem. */ 'filesystem' => [ /** @@ -337,10 +337,16 @@ return [ /** * Use this for local disks. * - * See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper + * The override can use these placeholders: + * - %storage_path% -- the tenant's storage directory. Note that this is resolved + * by the bootstrapper, so it doesn't depend on the 'suffix_storage_path' config below. + * - %original_storage_path% -- the central storage directory. + * - %tenant% -- the tenant's key. + * + * See https://v4.tenancyforlaravel.com/bootstrappers/filesystem */ 'root_override' => [ - // Disks whose roots should be overridden after storage_path() is suffixed. + // Disks whose roots should be overridden in tenant context. 'local' => '%storage_path%/app/', 'public' => '%storage_path%/app/public/', ], @@ -357,7 +363,8 @@ return [ * Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory. */ 'url_override' => [ - // Note that the local disk you add must exist in the tenancy.filesystem.disks config + // Note that the local disk you add must exist in the tenancy.filesystem.disks config, + // and it must have a non-falsy root (i.e., not null or empty string). 'public' => 'public-%tenant%', ], From 1a693d8e36b02d98cac6a9a79b6580efad486408 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 15:32:12 +0200 Subject: [PATCH 23/70] Improve comments Correct misleading ones, add ones that were missing (e.g. the TenantAssetController's docblock, the FSBootstrapper dependency should be mentioned there) --- .../FilesystemTenancyBootstrapper.php | 4 ++-- src/Controllers/TenantAssetController.php | 20 +++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 7235674b..05d480e4 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -331,8 +331,8 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper /** * Get the storage path of the passed tenant (independent of the current context). * - * The returned path doesn't depend on suffix_storage_path -- that config option - * only controls whether storage_path() uses it. + * Note that the returned path doesn't depend on suffix_storage_path. + * That config option only affects the storage_path() helper. */ public static function getBoundTenantStoragePath(Tenant $tenant): string { diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 48da0b01..7926c1ce 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -13,6 +13,13 @@ use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; +/** + * Requires FilesystemTenancyBootstrapper to be enabled, since the assets are served from + * the tenant's storage directory (or from the root of $publicDisk), and neither is + * tenant-specific unless the bootstrapper scopes it. + * + * @see FilesystemTenancyBootstrapper + */ class TenantAssetController implements HasMiddleware { /** @@ -33,6 +40,10 @@ class TenantAssetController implements HasMiddleware * Disk the assets are served from. * * When null, the assets are served from app/public inside the tenant's storage directory. + * + * The disk has to be local and have a root configured, since the assets are read from the filesystem. + * It should also be listed in tenancy.filesystem.disks. FilesystemTenancyBootstrapper only scopes + * the roots of disks listed there, so otherwise every tenant is served the same central directory. */ public static string|null $publicDisk = null; @@ -66,11 +77,12 @@ class TenantAssetController implements HasMiddleware } /** - * Directory the assets are served from -- the root of the $publicDisk, - * or app/public inside the tenant's storage directory when no disk is configured. + * Directory the assets are served from -- the root of the $publicDisk, or app/public + * inside the tenant's storage directory when no disk is configured. With no current + * tenant (e.g. on a universal route), the central storage directory is used. * - * The storage directory is resolved using the FilesystemTenancyBootstrapper (rather than - * storage_path(), so that it's tenant-scoped regardless of the suffix_storage_path config). + * The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper (rather + * than storage_path(), so that it's tenant-scoped regardless of the suffix_storage_path config). */ protected function assetRoot(): string { From 4826667ac1e4f27d4b07ae8d75d522cb45952ffc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 15:34:02 +0200 Subject: [PATCH 24/70] Add test that covers how tenant_asset() works when called in central context Added to cover the `return storage_path('app/public')` line in TenantAssetController::assetRoot --- tests/TenantAssetTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 3bd501de..bedc4d00 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -31,6 +31,7 @@ beforeEach(function () { TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenantAssetController::$headers = []; TenantAssetController::$publicDisk = null; + InitializeTenancyByRequestData::$onFail = null; /** @var CloneRoutesAsTenant $cloneAction */ $cloneAction = app(CloneRoutesAsTenant::class); @@ -130,6 +131,21 @@ test('tenant asset controller throws when the configured disk has no root', func pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); }); +test('tenant assets are served from the central storage path in central context', function () { + config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); + + // Mimic the universal route setup (let the request through even though no tenant is identified) + InitializeTenancyByRequestData::$onFail = fn ($e, $request, $next) => $next($request); + + $filename = 'testfile' . Str::random(8); + Storage::disk('public')->put($filename, 'bar'); + + $response = pest()->get(tenant_asset($filename)); + + $response->assertSuccessful(); + expect($response->getFile()->getPathname())->toBe(storage_path("app/public/$filename")); +}); + test('asset helper returns a link to tenant asset controller when asset url is null', function () { config(['app.asset_url' => null]); config(['tenancy.filesystem.asset_helper_override' => true]); From 5e54e3e7d930d8f399692c77dcde05da68fae19f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 16:00:54 +0200 Subject: [PATCH 25/70] Reword DeleteTenantStorage docblock The docblock said that the FSBootstrapper was required for this job to work at all, but that's not fully true since the job just uses the FSBootstrapper's public static methods to get the storage paths. --- src/Jobs/DeleteTenantStorage.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index 0e8290e6..d38ab485 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -16,8 +16,10 @@ use Stancl\Tenancy\Contracts\Tenant; /** * Delete the tenant's storage directory. * - * Requires FilesystemTenancyBootstrapper to be enabled, since the tenant storage path - * is resolved from it. + * The directory is the one FilesystemTenancyBootstrapper scopes the tenant's disks, cache + * and sessions to. The path is derived the same way the bootstrapper derives it, so the + * bootstrapper doesn't *have* to be enabled (though if it isn't, nothing was written there and + * there's nothing to delete). * * Files outside that directory (e.g. disks with an %original_storage_path%-based * root_override) are not deleted. From 9403d8c8fda68edace5bd2d62233a19825a507a8 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 16:44:08 +0200 Subject: [PATCH 26/70] Add afterEach cleanup to TenantAssetTest --- tests/TenantAssetTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index bedc4d00..a29bef97 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -41,6 +41,12 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); +afterEach(function () { + TenantAssetController::$headers = []; + TenantAssetController::$publicDisk = null; + InitializeTenancyByRequestData::$onFail = null; +}); + test('asset can be accessed using the url returned by the tenant asset helper', function () { config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); @@ -229,8 +235,6 @@ test('TenantAssetController headers are configurable', function () { $response->assertSuccessful(); $response->assertHeader('X-Foo', 'Bar'); - - TenantAssetController::$headers = []; // reset static property }); test('global asset helper returns the same url regardless of tenancy initialization', function () { From 848736448c98e4cabf23685346ed44e45dc5dcbe Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 20 Aug 2026 16:44:19 +0200 Subject: [PATCH 27/70] Clarify TenantAssetController docblock --- src/Controllers/TenantAssetController.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 7926c1ce..7b80cc2b 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -14,9 +14,11 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; /** - * Requires FilesystemTenancyBootstrapper to be enabled, since the assets are served from - * the tenant's storage directory (or from the root of $publicDisk), and neither is - * tenant-specific unless the bootstrapper scopes it. + * Requires FilesystemTenancyBootstrapper to be enabled, since the assets are served from the + * tenant's storage directory, which isn't tenant-specific unless the bootstrapper scopes it. + * + * With a $publicDisk configured, the assets are served from the disk's root instead, so the + * bootstrapper is only needed if that root should be tenant-specific. * * @see FilesystemTenancyBootstrapper */ From efa8c0368091d0bc5e86aeeee9282689f6ecbb13 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 21 Aug 2026 14:06:24 +0200 Subject: [PATCH 28/70] Assert that the tenant asset root is read from the resolved disk (regression tests) Test that tenant assets can be served from scoped disks, and that tenant asset roots respect the disk's configured prefix. Currently, the tests fail because TenantAssetController grabs the root from the config instead of resolving it via Storage::disk(...)->path(''). --- tests/TenantAssetTest.php | 78 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index a29bef97..cb789a40 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -120,23 +120,93 @@ test('the disk used for serving tenant assets is configurable', function () { expect($response->getFile()->getPathname())->toBe($path); }); -test('tenant asset controller throws when the configured disk has no root', function () { +test('tenant asset controller throws when the configured disk is not local', function () { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, - 'filesystems.disks.rootless' => ['driver' => 's3'], + // Add a disk that uses the s3 driver (= non-local disk). + // Use dummy credentials so that the s3 disk can be resolved without throwing an AWS exception. + 'filesystems.disks.remote' => [ + 'driver' => 's3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + ], ]); - TenantAssetController::$publicDisk = 'rootless'; + TenantAssetController::$publicDisk = 'remote'; $tenant = Tenant::create(); tenancy()->initialize($tenant); $this->withoutExceptionHandling(); - pest()->expectExceptionMessage('Disk [rootless] has no root path configured.'); + pest()->expectExceptionMessage('Disk [remote] is not a local disk.'); pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); }); +test('tenant assets are served from the resolved root of a scoped disk', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // A scoped disk has no configured root -- it inherits the root of its parent disk + // and appends its prefix to it, both of which happens when the disk is resolved. + 'filesystems.disks.scoped_disk' => [ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'scoped_disk_prefix', + ], + // Default tenancy config, set it here for clarity + 'tenancy.filesystem.disks' => ['local', 'public'], + ]); + + TenantAssetController::$publicDisk = 'scoped_disk'; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $filename = 'testfile' . Str::random(8); + Storage::disk('scoped_disk')->put($filename, 'bar'); + $path = Storage::disk('scoped_disk')->path($filename); + + // The parent disk is tenant-aware, so the scoped disk's root is inside the tenant's storage directory + expect($path)->toBe(storage_path("app/public/scoped_disk_prefix/$filename")); + + $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + + $response->assertSuccessful(); + expect($response->getFile()->getPathname())->toBe($path); +}); + +test('tenant assets are served from the resolved root of a disk with a configured prefix', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // A prefix is part of the disk's root path, so it has to be included in the asset root + 'filesystems.disks.prefixed' => [ + 'driver' => 'local', + 'root' => storage_path('app/media'), + 'prefix' => 'foo-prefix', + ], + 'tenancy.filesystem.disks' => ['prefixed'], + 'tenancy.filesystem.root_override.prefixed' => '%storage_path%/app/media/', + ]); + + TenantAssetController::$publicDisk = 'prefixed'; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + $filename = 'testfile' . Str::random(8); + Storage::disk('prefixed')->put($filename, 'bar'); + $path = Storage::disk('prefixed')->path($filename); + + expect($path)->toBe(storage_path("app/media/foo-prefix/$filename")); + + $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + + $response->assertSuccessful(); + expect($response->getFile()->getPathname())->toBe($path); +}); + test('tenant assets are served from the central storage path in central context', function () { config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); From e70057e3badd3604752d76d6dcc360e802c4200e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 21 Aug 2026 14:16:40 +0200 Subject: [PATCH 29/70] Read the tenant asset root from the resolved disk instead of the disk config Also, instead of throwing the "no root path configured" exception, just throw an exception if the disk is not local (i.e. is not instanceof LocalFilesystemAdapter). A local disk HAS to have a string root, otherwise, Laravel throws an exception while instantiating that disk. --- src/Controllers/TenantAssetController.php | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 7b80cc2b..5c74b790 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -9,6 +9,8 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; +use Illuminate\Filesystem\LocalFilesystemAdapter; +use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; @@ -43,9 +45,13 @@ class TenantAssetController implements HasMiddleware * * When null, the assets are served from app/public inside the tenant's storage directory. * - * The disk has to be local and have a root configured, since the assets are read from the filesystem. - * It should also be listed in tenancy.filesystem.disks. FilesystemTenancyBootstrapper only scopes - * the roots of disks listed there, so otherwise every tenant is served the same central directory. + * The disk has to be local, since the assets are read from the filesystem. Disks using the + * 'scoped' driver are supported as long as their parent disk uses the 'local' driver. + * + * It should also be listed in tenancy.filesystem.disks -- for scoped disks, it's the parent + * disk that has to be listed there (since a scoped disk inherits the parent's root). + * FilesystemTenancyBootstrapper only scopes the roots of disks listed there, so otherwise + * every tenant is served the same (central) directory. */ public static string|null $publicDisk = null; @@ -89,14 +95,17 @@ class TenantAssetController implements HasMiddleware protected function assetRoot(): string { if (static::$publicDisk) { - $diskRoot = config('filesystems.disks.' . static::$publicDisk . '.root'); + $disk = Storage::disk(static::$publicDisk); - if (! is_string($diskRoot)) { - // A disk with no root path would let the controller serve any file in the app - throw new Exception('Disk [' . static::$publicDisk . '] has no root path configured.'); + if (! $disk instanceof LocalFilesystemAdapter) { + // The root has to be read from the resolved disk rather than from the disk's config, + // since the config root isn't the full root path of every local disk. Disks using the + // 'scoped' driver have no root in their config -- they inherit their parent disk's root -- + // and a 'prefix' is part of the root path as well. + throw new Exception('Disk [' . static::$publicDisk . '] is not a local disk. Only local disks can be used for serving assets.'); } - return rtrim($diskRoot, '/'); + return rtrim($disk->path(''), DIRECTORY_SEPARATOR); } if ($tenant = tenant()) { From 802022a9269bd34a62fd7e055ca5737871760877 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 21 Aug 2026 16:07:01 +0200 Subject: [PATCH 30/70] Exercise the valid asset path before testing traversal Request photo.jpg and assert success before asserting rejection of ../media-originals/photo.jpg (addresses https://github.com/archtechx/tenancy/pull/1479#pullrequestreview-4984403598) --- tests/TenantAssetTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index cb789a40..9ffa1f05 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -404,6 +404,10 @@ test('tenant asset controller throws an exception when accessing a file in a dir Storage::disk('media')->put('photo.jpg', 'public file'); + pest()->get(tenant_asset('photo.jpg'), [ + 'X-Tenant' => $tenant->id, + ])->assertSuccessful(); + // A directory next to the asset root, e.g. one holding files that shouldn't be served mkdir($privateDirectory = storage_path('app/media-originals'), recursive: true); file_put_contents($privateDirectory . '/photo.jpg', 'private file'); From ae98ac3bd4b89d45c2f539f40de8566adbcec575 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 14:07:31 +0000 Subject: [PATCH 31/70] Fix code style (php-cs-fixer) --- src/Controllers/TenantAssetController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 5c74b790..c8196c5f 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -6,10 +6,10 @@ namespace Stancl\Tenancy\Controllers; use Closure; use Exception; +use Illuminate\Filesystem\LocalFilesystemAdapter; use Illuminate\Http\Request; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; -use Illuminate\Filesystem\LocalFilesystemAdapter; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Symfony\Component\HttpFoundation\BinaryFileResponse; From ae88836c0b043282fc0027b656da5a398e94454d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 24 Aug 2026 16:03:09 +0200 Subject: [PATCH 32/70] Clarify TenantAssetController's docblock --- src/Controllers/TenantAssetController.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index c8196c5f..46cd4143 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -16,11 +16,16 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; /** - * Requires FilesystemTenancyBootstrapper to be enabled, since the assets are served from the - * tenant's storage directory, which isn't tenant-specific unless the bootstrapper scopes it. + * Serves files from app/public inside the tenant's storage directory, or from the root + * of the $publicDisk when one is configured. * - * With a $publicDisk configured, the assets are served from the disk's root instead, so the - * bootstrapper is only needed if that root should be tenant-specific. + * Requires FilesystemTenancyBootstrapper to be enabled since it points the public disk's + * root at the tenant's storage directory. Without it, the disk keeps writing to the central + * storage/app/public, so the tenant's directory is never populated and requests 404 + * (the path itself is tenant-specific either way, via getBoundTenantStoragePath()). + * + * With a $publicDisk configured, the assets come from that disk's root instead. The + * bootstrapper is needed to make that root tenant-specific (see $publicDisk). * * @see FilesystemTenancyBootstrapper */ From 2ef1ea94ed2a4cf0245ef60dc20807f748e5fc83 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 25 Aug 2026 13:20:50 +0200 Subject: [PATCH 33/70] Make LogChannelBootstrapper not depend on suffixed storage_path() Since the tenant storage path can now be grabbed using FilesystemTenancyBootstrapper::getBoundTenantStoragePath(), the log bootstrapper doesn't need to depend on the FSBootstrapper being enabled and storage_path() being suffixed. Instead of adding a regression test, just delete FilesystemTenancyBootstrapper from the config settings in the log bootstrapper tests (and in tests that did use storage_path() in tenant context assertions, use explicitly "hardcoded" paths instead). --- src/Bootstrappers/LogChannelBootstrapper.php | 22 ++--- .../LogChannelBootstrapperTest.php | 85 +++++-------------- 2 files changed, 30 insertions(+), 77 deletions(-) diff --git a/src/Bootstrappers/LogChannelBootstrapper.php b/src/Bootstrappers/LogChannelBootstrapper.php index 293028e3..af30733a 100644 --- a/src/Bootstrappers/LogChannelBootstrapper.php +++ b/src/Bootstrappers/LogChannelBootstrapper.php @@ -21,11 +21,8 @@ use Stancl\Tenancy\Contracts\Tenant; * Laravel's 'single' and 'daily' channels by default. To customize it, * see the property's docblock. * - * For the storage path channels to be scoped correctly: - * - this bootstrapper must run *after* FilesystemTenancyBootstrapper, - * since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant - * - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path') - * must be true), since the storage path suffix is what separates filesystem-based logs + * Note that since the tenant's storage path is resolved using FilesystemTenancyBootstrapper::getBoundTenantStoragePath(), + * which is a public static method, FilesystemTenancyBootstrapper does not have to be enabled. * * For logging channels that are not filesystem-based, see the $channelOverrides logic. * @@ -40,14 +37,10 @@ class LogChannelBootstrapper implements TenancyBootstrapper /** * Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily'). * - * Channels included here will be configured to use tenant-specific storage paths - * created using storage_path() in the tenant context. Overrides in the $channelOverrides - * property take precedence over $storagePathChannels when a channel is included in both. + * Channels included here will be configured to use tenant-specific storage paths. * - * Requires FilesystemTenancyBootstrapper to run before this bootstrapper, - * and storage path suffixing to be enabled. - * - * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper + * Overrides in the $channelOverrides property take precedence over + * $storagePathChannels when a channel is included in both. */ public static array $storagePathChannels = ['single', 'daily']; @@ -158,11 +151,12 @@ class LogChannelBootstrapper implements TenancyBootstrapper // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log". $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); + $tenantStoragePath = FilesystemTenancyBootstrapper::getBoundTenantStoragePath($tenant); // The tenant log will inherit the segment that follows the storage path from the central channel path config. // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), - // the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log). - $this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); + // the '/logs/foo.log' segment will be appended to the tenant storage path (so the log will be located at storage/tenant123/logs/foo.log). + $this->config->set("logging.channels.{$channel}.path", $tenantStoragePath . Str::after($originalChannelPath, $centralStoragePath)); } } } diff --git a/tests/Bootstrappers/LogChannelBootstrapperTest.php b/tests/Bootstrappers/LogChannelBootstrapperTest.php index c37e7b54..009a0d66 100644 --- a/tests/Bootstrappers/LogChannelBootstrapperTest.php +++ b/tests/Bootstrappers/LogChannelBootstrapperTest.php @@ -9,7 +9,6 @@ use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper; -use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Illuminate\Support\Facades\Log; afterEach($cleanup = function () { @@ -42,15 +41,6 @@ beforeEach(function () use ($cleanup) { }); test('storage path channels get tenant-specific paths by default', function () { - // Note that for LogChannelBootstrapper to change the paths correctly by default, - // the bootstrapper MUST run after FilesystemTenancyBootstrapper. - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], - ]); - $centralStoragePath = storage_path(); $tenant = Tenant::create(); @@ -76,10 +66,6 @@ test('storage path channels get tenant-specific paths by default', function () { test('all channels included in a stack get processed correctly', function () { config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], 'logging.channels.stack' => [ 'driver' => 'stack', 'channels' => ['single', 'daily'], @@ -176,30 +162,24 @@ test('channel config keys remain unchanged if the specified tenant override attr }); test('channel overrides take precedence over the default storage path channel updating logic', function () { + $centralStoragePath = storage_path(); $tenant = Tenant::create(['id' => 'tenant1']); LogChannelBootstrapper::$storagePathChannels = ['single']; LogChannelBootstrapper::$channelOverrides = [ - 'single' => function (Tenant $tenant, array $channel) { - return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); + 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) { + return array_merge($channel, ['path' => "{$centralStoragePath}/logs/override-{$tenant->id}.log"]); }, ]; tenancy()->initialize($tenant); // Should use channel override, not the storage path updating behavior - expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); + expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-tenant1.log"); }); test('channels are forgotten and re-resolved during bootstrap and revert', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], - ]); - $logManager = app('log'); $originalChannel = $logManager->channel('single'); $originalSinglePath = config('logging.channels.single.path'); @@ -229,14 +209,8 @@ test('channels are forgotten and re-resolved during bootstrap and revert', funct // Test real usage test('logs are written to tenant-specific files and do not leak between contexts', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], - ]); - - $centralLogPath = storage_path('logs/laravel.log'); + $centralStoragePath = storage_path(); + $centralLogPath = "{$centralStoragePath}/logs/laravel.log"; Log::channel('single')->info('central'); @@ -244,15 +218,11 @@ test('logs are written to tenant-specific files and do not leak between contexts [$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])]; - tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) { + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralStoragePath) { Log::channel('single')->info($tenant->id); - $tenantLogPath = storage_path('logs/laravel.log'); - // The log gets saved to the tenant's storage directory (default behavior) - expect($tenantLogPath) - ->not()->toBe($centralLogPath) - ->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log"); + $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log"; expect(file_get_contents($tenantLogPath)) ->toContain($tenant->id) @@ -268,14 +238,14 @@ test('logs are written to tenant-specific files and do not leak between contexts // Tenant log messages didn't leak to logs of other tenants tenancy()->initialize($tenant1); - expect(file_get_contents(storage_path('logs/laravel.log'))) + expect(file_get_contents("{$centralStoragePath}/tenant{$tenant1->id}/logs/laravel.log")) ->toContain('tenant1') ->not()->toContain('central') ->not()->toContain('tenant2'); tenancy()->initialize($tenant2); - expect(file_get_contents(storage_path('logs/laravel.log'))) + expect(file_get_contents("{$centralStoragePath}/tenant{$tenant2->id}/logs/laravel.log")) ->toContain('tenant2') ->not()->toContain('central') ->not()->toContain('tenant1'); @@ -285,9 +255,8 @@ test('logs are written to tenant-specific files and do not leak between contexts $tenant = Tenant::create(['id' => 'override-tenant']); LogChannelBootstrapper::$channelOverrides = [ - 'single' => function (Tenant $tenant, array $channel) { - // The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log - return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]); + 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) { + return array_merge($channel, ['path' => "{$centralStoragePath}/tenant{$tenant->id}/logs/custom-{$tenant->id}.log"]); }, ]; @@ -296,21 +265,18 @@ test('logs are written to tenant-specific files and do not leak between contexts Log::channel('single')->info('tenant-override'); - expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override'); + expect(file_get_contents("{$centralStoragePath}/tenantoverride-tenant/logs/custom-override-tenant.log"))->toContain('tenant-override'); }); test('stack logs are written to all configured channels with tenant-specific paths', function () { config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], 'logging.channels.stack' => [ 'driver' => 'stack', 'channels' => ['single', 'daily'], ], ]); + $centralStoragePath = storage_path(); $tenant = Tenant::create(['id' => 'stack-tenant']); $today = now()->format('Y-m-d'); @@ -327,8 +293,8 @@ test('stack logs are written to all configured channels with tenant-specific pat // Tenant context stack log tenancy()->initialize($tenant); Log::channel('stack')->info('tenant'); - $tenantSingleLogPath = storage_path('logs/laravel.log'); - $tenantDailyLogPath = storage_path("logs/laravel-{$today}.log"); + $tenantSingleLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log"; + $tenantDailyLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel-{$today}.log"; expect(file_get_contents($tenantSingleLogPath))->toContain('tenant'); expect(file_get_contents($tenantDailyLogPath))->toContain('tenant'); @@ -351,18 +317,15 @@ test('stack logs are written to all configured channels with tenant-specific pat test('stack channels that include any configured channel are re-resolved', function () { config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], 'logging.channels.custom_stack' => [ 'driver' => 'stack', 'channels' => ['single'], ], ]); + $centralStoragePath = storage_path(); $tenant = Tenant::create(['id' => 'stack-tenant']); - $centralLogPath = storage_path('logs/laravel.log'); + $centralLogPath = "{$centralStoragePath}/logs/laravel.log"; $logManager = app('log'); @@ -387,7 +350,7 @@ test('stack channels that include any configured channel are re-resolved', funct ->toContain('central log message') ->not()->toContain('tenant log message'); - $tenantLogPath = storage_path('logs/laravel.log'); + $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log"; expect(file_exists($tenantLogPath))->toBeTrue(); expect(file_get_contents($tenantLogPath)) ->toContain('tenant log message'); @@ -454,10 +417,6 @@ test('slack channel uses correct webhook urls', function () { test('tenant logs inherit the path from the central log path config', function () { config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - LogChannelBootstrapper::class, - ], 'logging.channels.stack' => [ 'driver' => 'stack', 'channels' => ['single', 'daily'], @@ -466,6 +425,7 @@ test('tenant logs inherit the path from the central log path config', function ( 'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'), ]); + $centralStoragePath = storage_path(); $tenant = Tenant::create(); $today = now()->format('Y-m-d'); @@ -476,18 +436,17 @@ test('tenant logs inherit the path from the central log path config', function ( tenancy()->initialize($tenant); - // Tenant log is located at storage/tenantX/logs/custom-name.log Log::channel('stack')->info($tenant->id); // The filename from the central config is preserved in tenant context expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log'); expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log'); - expect(file_get_contents(storage_path('logs/single/custom-name.log'))) + expect(file_get_contents("{$centralStoragePath}/tenant{$tenant->id}/logs/single/custom-name.log")) ->toContain($tenant->id) ->not()->toContain('central'); - expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log"))) + expect(file_get_contents("{$centralStoragePath}/tenant{$tenant->id}/logs/daily/custom-name-{$today}.log")) ->toContain($tenant->id) ->not()->toContain('central'); }); From bb12443fa680d4e75ccd3658790251bb8160664f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 25 Aug 2026 13:36:10 +0200 Subject: [PATCH 34/70] Update src/Jobs/DeleteTenantStorage.php Co-authored-by: Samuel Stancl --- src/Jobs/DeleteTenantStorage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index d38ab485..5dc0e9ed 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -43,7 +43,7 @@ class DeleteTenantStorage implements ShouldQueue $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); if (realpath($tenantStoragePath) === realpath($centralStoragePath)) { - // Never delete the central storage directory -- that would delete the files of all tenants + // Never delete the central storage directory return; } From 41f7e2e03484a8ca5aa9259fba1580487030baee Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Thu, 27 Aug 2026 18:20:35 -0700 Subject: [PATCH 35/70] improve comments --- assets/config.php | 26 ++++++++++------------- src/Controllers/TenantAssetController.php | 18 ++++++---------- src/Jobs/DeleteTenantStorage.php | 15 +++++-------- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/assets/config.php b/assets/config.php index d37d1d6b..11ac739d 100644 --- a/assets/config.php +++ b/assets/config.php @@ -321,11 +321,12 @@ return [ /** * Filesystem tenancy config. Used by FilesystemTenancyBootstrapper. - * https://v4.tenancyforlaravel.com/bootstrappers/filesystem. + * https://v4.tenancyforlaravel.com/bootstrappers/filesystem */ 'filesystem' => [ /** - * Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id. + * Each disk listed in the 'disks' array will have its root + * suffixed by the suffix_base, followed by the tenant_id. */ 'suffix_base' => 'tenant', 'disks' => [ @@ -337,11 +338,13 @@ return [ /** * Use this for local disks. * - * The override can use these placeholders: - * - %storage_path% -- the tenant's storage directory. Note that this is resolved - * by the bootstrapper, so it doesn't depend on the 'suffix_storage_path' config below. + * Customizes how the disk's root is scoped, instead of the default behavior + * which simply appends the tenant suffix to the original root. + * + * The overrides can reference the following placeholders: + * - %storage_path% -- the tenant's storage directory * - %original_storage_path% -- the central storage directory. - * - %tenant% -- the tenant's key. + * - %tenant% -- the tenant key. * * See https://v4.tenancyforlaravel.com/bootstrappers/filesystem */ @@ -364,7 +367,7 @@ return [ */ 'url_override' => [ // Note that the local disk you add must exist in the tenancy.filesystem.disks config, - // and it must have a non-falsy root (i.e., not null or empty string). + // and it must have a non-falsy root (not null nor an empty string). 'public' => 'public-%tenant%', ], @@ -385,14 +388,7 @@ return [ /** * Should storage_path() be suffixed. * - * Note: This only affects the storage_path() helper. Disks listed in the 'disks' config - * above, and cache and sessions if 'scope_cache' and 'scope_sessions' are enabled, are - * scoped to the tenant's storage directory either way. With this disabled, files - * accessed using storage_path() are shared by all tenants. - * - * For the vast majority of applications, this feature should be enabled. But in some - * edge cases, it can cause issues (like using Passport with Vapor - see #196), so - * you may want to disable this if you are experiencing these edge case issues. + * Only affects the storage_path() helper, other features use the tenant storage directory regardless. */ 'suffix_storage_path' => true, diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 46cd4143..8e21ab45 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -17,15 +17,11 @@ use Throwable; /** * Serves files from app/public inside the tenant's storage directory, or from the root - * of the $publicDisk when one is configured. + * of the $publicDisk when the property is set. * - * Requires FilesystemTenancyBootstrapper to be enabled since it points the public disk's - * root at the tenant's storage directory. Without it, the disk keeps writing to the central - * storage/app/public, so the tenant's directory is never populated and requests 404 - * (the path itself is tenant-specific either way, via getBoundTenantStoragePath()). - * - * With a $publicDisk configured, the assets come from that disk's root instead. The - * bootstrapper is needed to make that root tenant-specific (see $publicDisk). + * Requires FilesystemTenancyBootstrapper to be enabled, so that writes to the default + * public disk end up in the app/public within the *tenant's* storage, or so that the + * public disk set in the static property is similarly scoped. * * @see FilesystemTenancyBootstrapper */ @@ -55,8 +51,8 @@ class TenantAssetController implements HasMiddleware * * It should also be listed in tenancy.filesystem.disks -- for scoped disks, it's the parent * disk that has to be listed there (since a scoped disk inherits the parent's root). - * FilesystemTenancyBootstrapper only scopes the roots of disks listed there, so otherwise - * every tenant is served the same (central) directory. + * FilesystemTenancyBootstrapper only scopes the roots of disks listed there, so + * without that every tenant would be served the same (central) directory. */ public static string|null $publicDisk = null; @@ -142,7 +138,7 @@ class TenantAssetController implements HasMiddleware // User is attempting to access a file outside the $allowedRoot folder. // The trailing separator is needed so that sibling directories that - // start with the same name (e.g. app/public-private) don't pass. + // start with the same name (e.g. app/public-private) aren't accepted. $this->abortIf(! str($attemptedPath)->startsWith(rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR), 'Accessing a file outside the storage root'); } diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index 5dc0e9ed..d8161be1 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -16,16 +16,11 @@ use Stancl\Tenancy\Contracts\Tenant; /** * Delete the tenant's storage directory. * - * The directory is the one FilesystemTenancyBootstrapper scopes the tenant's disks, cache - * and sessions to. The path is derived the same way the bootstrapper derives it, so the - * bootstrapper doesn't *have* to be enabled (though if it isn't, nothing was written there and - * there's nothing to delete). - * - * Files outside that directory (e.g. disks with an %original_storage_path%-based - * root_override) are not deleted. - * - * Note that this job is not affected by the tenancy.filesystem.suffix_storage_path config - * since it doesn't use the storage_path() helper. + * The directory is used by the FilesystemTenancyBootstrapper for: + * - scoped storage_path() when suffix_storage_path is enabled + * - scoped cache when enabled + * - scoped sessions when enabled + * - scoped disks when enabled * * @see FilesystemTenancyBootstrapper */ From 8e671fe5bef82690eadc2336253e74d2420ec2bd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 31 Aug 2026 16:28:12 +0200 Subject: [PATCH 36/70] Use "placed within" instead of "appended to" in log bootstrapper comment In response to https://github.com/archtechx/tenancy/pull/1479#discussion_r3876808350 --- src/Bootstrappers/LogChannelBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogChannelBootstrapper.php b/src/Bootstrappers/LogChannelBootstrapper.php index af30733a..36ed32f9 100644 --- a/src/Bootstrappers/LogChannelBootstrapper.php +++ b/src/Bootstrappers/LogChannelBootstrapper.php @@ -155,7 +155,7 @@ class LogChannelBootstrapper implements TenancyBootstrapper // The tenant log will inherit the segment that follows the storage path from the central channel path config. // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), - // the '/logs/foo.log' segment will be appended to the tenant storage path (so the log will be located at storage/tenant123/logs/foo.log). + // the '/logs/foo.log' segment will be placed within the tenant storage path (so the log will be located at storage/tenant123/logs/foo.log). $this->config->set("logging.channels.{$channel}.path", $tenantStoragePath . Str::after($originalChannelPath, $centralStoragePath)); } } From 99994dca855c20f42f8a02574e31106069522f68 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 31 Aug 2026 16:52:08 +0200 Subject: [PATCH 37/70] Delete redundant comment --- src/Concerns/DealsWithTenantSymlinks.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index fcb9e432..578bda01 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -40,7 +40,6 @@ trait DealsWithTenantSymlinks foreach ($urlOverrides as $disk => $publicPath) { if (! $publicPath) { - // The disk's URL is not overridden, same as in FilesystemTenancyBootstrapper::diskUrl() continue; } From 041b023a95172c1cb169f2a160570964a21c0488 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 2 Sep 2026 14:35:44 +0200 Subject: [PATCH 38/70] Assert that TenantAssetController cannot serve assets from a disk that isn't tenant-aware (regression test) --- tests/TenantAssetTest.php | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 9ffa1f05..7741a6da 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -145,6 +145,55 @@ test('tenant asset controller throws when the configured disk is not local', fun pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); }); +test('tenant asset controller throws when the disk used for serving assets is not tenant-aware', function (string $publicDisk, string $expectedMessage) { + $centralStoragePath = storage_path(); + + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + 'filesystems.disks.media' => [ + 'driver' => 'local', + 'root' => storage_path('app/media'), + ], + 'filesystems.disks.scoped_media' => [ + 'driver' => 'scoped', + 'disk' => 'media', + 'prefix' => 'assets', + ], + 'filesystems.disks.inline_scoped_media' => [ + 'driver' => 'scoped', + // Laravel allows configuring the parent disk inline, but such a disk + // has no name, so it cannot be listed in tenancy.filesystem.disks (i.e. made tenant-aware) + 'disk' => [ + 'driver' => 'local', + 'root' => storage_path('app/media'), + ], + 'prefix' => 'assets', + ], + // 'media' isn't tenant-aware (i.e. not included in tenancy.filesystem.disks) + 'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/', + ]); + + TenantAssetController::$publicDisk = $publicDisk; + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + Storage::disk($publicDisk)->put($filename = 'testfile' . Str::random(8), 'bar'); + + // The disk's root stays central + expect(Storage::disk($publicDisk)->path($filename))->toStartWith("$centralStoragePath/app/media/"); + + $this->withoutExceptionHandling(); + + pest()->expectExceptionMessage($expectedMessage); + + pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); +})->with([ + 'disk' => ['media', 'Disk [media] is not tenant-aware.'], + 'scoped disk' => ['scoped_media', 'Disk [media] is not tenant-aware.'], + 'scoped disk with an inline parent disk' => ['inline_scoped_media', 'Disk [inline_scoped_media] has its parent disk configured inline.'], +]); + test('tenant assets are served from the resolved root of a scoped disk', function () { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, From e0e696f83ead968d3e75ec261b3f0dc9a296ed29 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 2 Sep 2026 14:42:23 +0200 Subject: [PATCH 39/70] Throw an exception in TenantAssetController if the disk is not tenant-aware Instead of just saying that the publicDisk *should* be listed in tenancy.filesystem.disks, enforce that -- if the disk isn't tenant-aware, throw an exception. Also update comments accordingly. E.g. since scoped disks don't have to have a single "parent disk" (the parent can also be a scoped disk and have another parent, and so on), use "base disk". --- src/Controllers/TenantAssetController.php | 45 ++++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 8e21ab45..8ba466bd 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -47,10 +47,10 @@ class TenantAssetController implements HasMiddleware * When null, the assets are served from app/public inside the tenant's storage directory. * * The disk has to be local, since the assets are read from the filesystem. Disks using the - * 'scoped' driver are supported as long as their parent disk uses the 'local' driver. + * 'scoped' driver are supported as long as the disk they're based on uses the 'local' driver. * - * It should also be listed in tenancy.filesystem.disks -- for scoped disks, it's the parent - * disk that has to be listed there (since a scoped disk inherits the parent's root). + * The disk also has to be listed in tenancy.filesystem.disks -- for scoped disks, it's the + * disk they're based on that has to be listed there (since a scoped disk inherits its root). * FilesystemTenancyBootstrapper only scopes the roots of disks listed there, so * without that every tenant would be served the same (central) directory. */ @@ -87,8 +87,8 @@ class TenantAssetController implements HasMiddleware /** * Directory the assets are served from -- the root of the $publicDisk, or app/public - * inside the tenant's storage directory when no disk is configured. With no current - * tenant (e.g. on a universal route), the central storage directory is used. + * inside the tenant's storage directory when no disk is configured. With no disk and + * no current tenant (e.g. on a universal route), the central app/public is used. * * The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper (rather * than storage_path(), so that it's tenant-scoped regardless of the suffix_storage_path config). @@ -99,13 +99,20 @@ class TenantAssetController implements HasMiddleware $disk = Storage::disk(static::$publicDisk); if (! $disk instanceof LocalFilesystemAdapter) { - // The root has to be read from the resolved disk rather than from the disk's config, - // since the config root isn't the full root path of every local disk. Disks using the - // 'scoped' driver have no root in their config -- they inherit their parent disk's root -- - // and a 'prefix' is part of the root path as well. throw new Exception('Disk [' . static::$publicDisk . '] is not a local disk. Only local disks can be used for serving assets.'); } + $baseDiskName = $this->baseDiskName(static::$publicDisk); + + if (! in_array($baseDiskName, config('tenancy.filesystem.disks'), true)) { + // FilesystemTenancyBootstrapper only scopes the roots of disks listed in tenancy.filesystem.disks. + // Without that, the root stays central and every tenant would be served the same directory. + throw new Exception("Disk [$baseDiskName] is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific."); + } + + // The root is read from the resolved disk rather than from the disk's config, since the + // config root isn't the full root path of every local disk. Disks using the 'scoped' driver + // have no root in their config, and a 'prefix' is part of the root path as well. return rtrim($disk->path(''), DIRECTORY_SEPARATOR); } @@ -116,6 +123,26 @@ class TenantAssetController implements HasMiddleware return storage_path('app/public'); } + /** + * Name of the disk whose root the passed disk uses. + * + * Disks using the 'scoped' driver have no root of their own -- they inherit the root of their parent disk, + * which can be scoped as well, so the final/base parent is what has to be tenant-aware. + */ + protected function baseDiskName(string $disk): string + { + while (config("filesystems.disks.$disk.driver") === 'scoped') { + if (! is_string($parent = config("filesystems.disks.$disk.disk"))) { + // Laravel allows configuring the parent inline as an array, in which case it has no name + throw new Exception("Disk [$disk] has its parent disk configured inline. Use a named parent disk listed in tenancy.filesystem.disks."); + } + + $disk = $parent; + } + + return $disk; + } + /** * Prevent path traversal attacks. This is generally a non-issue on modern * webservers but it's still worth handling on the application level as well. From ae91281d3a814dd621dee31337ba3c2cdcfbae0e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 14:53:59 +0200 Subject: [PATCH 40/70] Assert that nested scoped disks are scoped properly (regression test) The test fails with the nested disk dataset because we resolve a disk first, then initialize tenancy, and because the nested scoped disks aren't forgotten, so the disk config changes that the FS bootstrapper applies aren't reflected on the already-resolved disk instance. --- .../FilesystemTenancyBootstrapperTest.php | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 1135e84d..8365ca4f 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -250,7 +250,7 @@ test('the framework/cache directory is created when storage_path is scoped', fun } })->with([true, false]); -test('scoped disks are scoped per tenant', function () { +test('scoped disks are scoped per tenant', function (bool $nested) { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -260,30 +260,44 @@ test('scoped disks are scoped per tenant', function () { 'disk' => 'public', 'prefix' => 'scoped_disk_prefix', ], + 'filesystems.disks.nested_disk' => [ + 'driver' => 'scoped', + 'disk' => 'scoped_disk', + 'prefix' => 'nested_disk_prefix', + ], ]); + $disk = $nested ? 'nested_disk' : 'scoped_disk'; + $prefix = $nested ? 'scoped_disk_prefix/nested_disk_prefix' : 'scoped_disk_prefix'; + $tenant = Tenant::create(); - Storage::disk('scoped_disk')->put('foo.txt', 'central'); - expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); - expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central'); + Storage::disk($disk)->put('foo.txt', 'central'); + + config(['filesystem.disks.public.prefix' => 'scoped_disk_prefix']); + + expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); + expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('central'); tenancy()->initialize($tenant); - expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null); - Storage::disk('scoped_disk')->put('foo.txt', 'tenant'); - expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); - expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant'); + expect(Storage::disk($disk)->get('foo.txt'))->toBe(null); + Storage::disk($disk)->put('foo.txt', 'tenant'); + expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('tenant'); + expect(Storage::disk($disk)->get('foo.txt'))->toBe('tenant'); tenancy()->end(); - expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); - Storage::disk('scoped_disk')->put('foo.txt', 'central2'); - expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2'); + expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); + Storage::disk($disk)->put('foo.txt', 'central2'); + expect(Storage::disk($disk)->get('foo.txt'))->toBe('central2'); - expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2'); - expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); -}); + expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('central2'); + expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/{$prefix}/foo.txt"))->toBe('tenant'); +})->with([ + 'scoped disk' => false, + 'nested scoped disk' => true, +]); test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { $fooPath = storage_path('framework/cache/foo_file'); From 3936ab9effd59c7727ed71d17fecb9e634ec8d5b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 14:59:22 +0200 Subject: [PATCH 41/70] Test that non-local scoped disks get scoped per tenant --- .../FilesystemTenancyBootstrapperTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 8365ca4f..508ed42c 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -299,6 +299,33 @@ test('scoped disks are scoped per tenant', function (bool $nested) { 'nested scoped disk' => true, ]); +test('scoped disks based on a non-local disk are scoped per tenant', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.scoped_s3' => [ + 'driver' => 'scoped', + 'disk' => 's3', + 'prefix' => 'scoped_s3_prefix', + ], + 'tenancy.filesystem.disks' => ['s3'], // As long as the base disk (s3) is listed here, the scoped disk will be scoped + ]); + + expect(Storage::disk('s3')->path('foo.txt'))->toBe('foo.txt'); + expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt'); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + expect(Storage::disk('s3')->path('foo.txt'))->toBe("tenant{$tenant->id}/foo.txt"); + expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe("tenant{$tenant->id}/scoped_s3_prefix/foo.txt"); + + tenancy()->end(); + + expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt'); +}); + test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { $fooPath = storage_path('framework/cache/foo_file'); $barPath = storage_path('framework/cache/bar_file'); From 2cd514c781bfc5ab68ae44b009d2c444b5176fcd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 15:45:00 +0200 Subject: [PATCH 42/70] Test that listing scoped disks in tenancy.filesystem.disks is harmless (regression test) FilesystemTenancyBootstrapper should only change config of the base/parent disks -- scoped disks should be ignored. --- .../FilesystemTenancyBootstrapperTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 508ed42c..1aa422be 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -326,6 +326,36 @@ test('scoped disks based on a non-local disk are scoped per tenant', function () expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt'); }); +test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.foo' => [ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'foo', + ], + // Only the scoped disk's parent/base disk ('public') has to be listed here. + // Listing 'foo' too is redundant, and it shouldn't change anything. + 'tenancy.filesystem.disks' => ['local', 'public', 'foo'], + ]); + + $tenant = Tenant::create(); + tenancy()->initialize($tenant); + + // The 'foo' disk's parent disk ('public') is tenant-aware, so its root is scoped the same way. + // It doesn't matter that 'foo' itself is tenant-aware. + expect(Storage::disk('foo')->path('testing.txt'))->toBe(storage_path('app/public/foo/testing.txt')); + + // Scoped disks have no root or url of their own, so the bootstrapper leaves their config alone + expect(config('filesystems.disks.foo'))->toBe([ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'foo', + ]); +}); + test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { $fooPath = storage_path('framework/cache/foo_file'); $barPath = storage_path('framework/cache/bar_file'); From e20085588b34c9f58661640fb1ccfe15149c3358 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 16:03:15 +0200 Subject: [PATCH 43/70] Assert that TenantAssetController throws for scoped disks with a non-local parent --- tests/TenantAssetTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 7741a6da..e1c0b630 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -120,7 +120,7 @@ test('the disk used for serving tenant assets is configurable', function () { expect($response->getFile()->getPathname())->toBe($path); }); -test('tenant asset controller throws when the configured disk is not local', function () { +test('tenant asset controller throws when the configured disk is not local', function (string $publicDisk) { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, // Add a disk that uses the s3 driver (= non-local disk). @@ -132,18 +132,26 @@ test('tenant asset controller throws when the configured disk is not local', fun 'secret' => 'secret', 'bucket' => 'bucket', ], + 'filesystems.disks.scoped_remote' => [ + 'driver' => 'scoped', + 'disk' => 'remote', + 'prefix' => 'assets', + ], ]); - TenantAssetController::$publicDisk = 'remote'; + TenantAssetController::$publicDisk = $publicDisk; $tenant = Tenant::create(); tenancy()->initialize($tenant); $this->withoutExceptionHandling(); - pest()->expectExceptionMessage('Disk [remote] is not a local disk.'); + pest()->expectExceptionMessage("Disk [$publicDisk] is not a local disk."); pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); -}); +})->with([ + 'disk' => 'remote', + 'scoped disk' => 'scoped_remote', +]); test('tenant asset controller throws when the disk used for serving assets is not tenant-aware', function (string $publicDisk, string $expectedMessage) { $centralStoragePath = storage_path(); From 9cda4cb3e40b9845a728f97701aca07de4662386 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 16:16:27 +0200 Subject: [PATCH 44/70] Forget scoped disk's parent no matter how nested it is This includes moving the TenantAssetController baseDiskName() method to FSBootstrapper and making it public static, since the same logic is used in two places now. Also cover the edge case where a scoped disk A has a scoped disk B as its parent, and B has A as its parent -- in that case, the method would be stuck in an infinite loop (also added separate test for this, commenting out the $visited-related code in baseDiskName will make the test fail). Also updated the assetRoot's unnamed disk exception message. --- .../FilesystemTenancyBootstrapper.php | 34 +++++++++++++++++-- src/Controllers/TenantAssetController.php | 26 +++----------- .../FilesystemTenancyBootstrapperTest.php | 25 ++++++++++++++ tests/TenantAssetTest.php | 2 +- 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 05d480e4..57f98114 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -128,9 +128,9 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $scopedDisks = []; foreach ($this->app['config']['filesystems.disks'] as $name => $disk) { - if (isset($disk['driver'], $disk['disk']) + if (isset($disk['driver']) && $disk['driver'] === 'scoped' - && in_array($disk['disk'], $tenantDisks, true)) { + && in_array(static::baseDiskName($name), $tenantDisks, true)) { $scopedDisks[] = $name; } } @@ -340,4 +340,34 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper return $bootstrapper->tenantStoragePath($bootstrapper->suffix($tenant)); } + + /** + * Name of the disk whose root the passed disk uses. + * + * Disks using the 'scoped' driver have no root or url of their own -- they inherit these from their parent disk, + * which can be scoped as well, so only the final/base parent has to be tenant-aware. + * + * Returns null if the chain doesn't end with a named disk, i.e. when a parent disk is + * configured inline or when the disks reference each other. + */ + public static function baseDiskName(string $disk): string|null + { + // Keep track of visited disks to avoid infinite loops in case of disks referencing each other + $visited = []; + + while (config("filesystems.disks.$disk.driver") === 'scoped') { + if (in_array($disk, $visited, true)) { + return null; + } + + $visited[] = $disk; + + if (! is_string($disk = config("filesystems.disks.$disk.disk"))) { + // Laravel allows configuring the parent disk inline as an array, and such a disk has no name + return null; + } + } + + return $disk; + } } diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 8ba466bd..20ff6f99 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -102,7 +102,11 @@ class TenantAssetController implements HasMiddleware throw new Exception('Disk [' . static::$publicDisk . '] is not a local disk. Only local disks can be used for serving assets.'); } - $baseDiskName = $this->baseDiskName(static::$publicDisk); + $baseDiskName = FilesystemTenancyBootstrapper::baseDiskName(static::$publicDisk); + + if ($baseDiskName === null) { + throw new Exception('Disk [' . static::$publicDisk . '] has an unnamed parent disk. Use a named parent disk listed in tenancy.filesystem.disks.'); + } if (! in_array($baseDiskName, config('tenancy.filesystem.disks'), true)) { // FilesystemTenancyBootstrapper only scopes the roots of disks listed in tenancy.filesystem.disks. @@ -123,26 +127,6 @@ class TenantAssetController implements HasMiddleware return storage_path('app/public'); } - /** - * Name of the disk whose root the passed disk uses. - * - * Disks using the 'scoped' driver have no root of their own -- they inherit the root of their parent disk, - * which can be scoped as well, so the final/base parent is what has to be tenant-aware. - */ - protected function baseDiskName(string $disk): string - { - while (config("filesystems.disks.$disk.driver") === 'scoped') { - if (! is_string($parent = config("filesystems.disks.$disk.disk"))) { - // Laravel allows configuring the parent inline as an array, in which case it has no name - throw new Exception("Disk [$disk] has its parent disk configured inline. Use a named parent disk listed in tenancy.filesystem.disks."); - } - - $disk = $parent; - } - - return $disk; - } - /** * Prevent path traversal attacks. This is generally a non-issue on modern * webservers but it's still worth handling on the application level as well. diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 1aa422be..cf4e56ae 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -356,6 +356,31 @@ test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk ]); }); +test('scoped disks referencing each other do not make bootstrapper hang', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.foo' => [ + 'driver' => 'scoped', + 'disk' => 'bar', + 'prefix' => 'foo', + ], + 'filesystems.disks.bar' => [ + 'driver' => 'scoped', + 'disk' => 'foo', + 'prefix' => 'bar', + ], + ]); + + expect(FilesystemTenancyBootstrapper::baseDiskName('foo'))->toBeNull(); + expect(FilesystemTenancyBootstrapper::baseDiskName('bar'))->toBeNull(); + + tenancy()->initialize(Tenant::create()); + + expect(tenant())->not()->toBeNull(); +}); + test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { $fooPath = storage_path('framework/cache/foo_file'); $barPath = storage_path('framework/cache/bar_file'); diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index e1c0b630..ee3ef911 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -199,7 +199,7 @@ test('tenant asset controller throws when the disk used for serving assets is no })->with([ 'disk' => ['media', 'Disk [media] is not tenant-aware.'], 'scoped disk' => ['scoped_media', 'Disk [media] is not tenant-aware.'], - 'scoped disk with an inline parent disk' => ['inline_scoped_media', 'Disk [inline_scoped_media] has its parent disk configured inline.'], + 'scoped disk with an inline parent disk' => ['inline_scoped_media', 'Disk [inline_scoped_media] has an unnamed parent disk.'], ]); test('tenant assets are served from the resolved root of a scoped disk', function () { From 394a9fa562c7c9868ebfe7d5f2e309a07b69b56d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Sep 2026 16:24:17 +0200 Subject: [PATCH 45/70] Explicitly skip scoped disks in diskRoot() If diskRoot() somehow ended up receiving a scoped disk (e.g. in case the scoped disk was listed in tenancy.filesystem.disks), its root would get configured, and it'd be completely redundant. It wouldn't break anything since scoped disk's configured root is ignored -- its parent's root is always used. Even though not adding this skipping code would essentially do no harm, it prevents the method from doing redundant work and defines the behavior a bit more clearly. diskUrl() is similar in that regard, but that method already has a strict "disk driver has to be 'local'" -- scoped disks never made it through so nothing to change there. --- src/Bootstrappers/FilesystemTenancyBootstrapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 57f98114..16721e27 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -140,6 +140,11 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper protected function diskRoot(string $disk, Tenant|false $tenant): void { + if ($this->app['config']["filesystems.disks.$disk.driver"] === 'scoped') { + // Skip scoped disks since they have no root to override + return; + } + if ($tenant === false) { $this->app['config']["filesystems.disks.$disk.root"] = $this->originalDisks[$disk]['root']; From efc0877f102be57020b65117cb431e5f8116e54e Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Sun, 6 Sep 2026 22:09:02 -0700 Subject: [PATCH 46/70] minor polish --- src/Bootstrappers/FilesystemTenancyBootstrapper.php | 8 +++++--- src/Bootstrappers/LogChannelBootstrapper.php | 4 ++-- src/Controllers/TenantAssetController.php | 2 +- src/Jobs/DeleteTenantStorage.php | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 16721e27..413210ac 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -339,7 +339,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper * Note that the returned path doesn't depend on suffix_storage_path. * That config option only affects the storage_path() helper. */ - public static function getBoundTenantStoragePath(Tenant $tenant): string + public static function getTenantStoragePath(Tenant $tenant): string { $bootstrapper = app(static::class); @@ -349,7 +349,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper /** * Name of the disk whose root the passed disk uses. * - * Disks using the 'scoped' driver have no root or url of their own -- they inherit these from their parent disk, + * Disks using the 'scoped' driver have no root or url of their own -- they inherit those from their parent disk, * which can be scoped as well, so only the final/base parent has to be tenant-aware. * * Returns null if the chain doesn't end with a named disk, i.e. when a parent disk is @@ -362,12 +362,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper while (config("filesystems.disks.$disk.driver") === 'scoped') { if (in_array($disk, $visited, true)) { + // The disk and its parents reference each other, invalid return null; } $visited[] = $disk; + $disk = config("filesystems.disks.$disk.disk"); - if (! is_string($disk = config("filesystems.disks.$disk.disk"))) { + if (! is_string($disk)) { // Laravel allows configuring the parent disk inline as an array, and such a disk has no name return null; } diff --git a/src/Bootstrappers/LogChannelBootstrapper.php b/src/Bootstrappers/LogChannelBootstrapper.php index 36ed32f9..0d4ddfd0 100644 --- a/src/Bootstrappers/LogChannelBootstrapper.php +++ b/src/Bootstrappers/LogChannelBootstrapper.php @@ -151,12 +151,12 @@ class LogChannelBootstrapper implements TenancyBootstrapper // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log". $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); - $tenantStoragePath = FilesystemTenancyBootstrapper::getBoundTenantStoragePath($tenant); + $tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($tenant); // The tenant log will inherit the segment that follows the storage path from the central channel path config. // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), // the '/logs/foo.log' segment will be placed within the tenant storage path (so the log will be located at storage/tenant123/logs/foo.log). - $this->config->set("logging.channels.{$channel}.path", $tenantStoragePath . Str::after($originalChannelPath, $centralStoragePath)); + $this->config->set("logging.channels.{$channel}.path", $tenantStoragePath . Str::after($originalChannelPath, rtrim($centralStoragePath, '/\\'))); } } } diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 20ff6f99..33799b29 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -121,7 +121,7 @@ class TenantAssetController implements HasMiddleware } if ($tenant = tenant()) { - return FilesystemTenancyBootstrapper::getBoundTenantStoragePath($tenant) . '/app/public'; + return FilesystemTenancyBootstrapper::getTenantStoragePath($tenant) . '/app/public'; } return storage_path('app/public'); diff --git a/src/Jobs/DeleteTenantStorage.php b/src/Jobs/DeleteTenantStorage.php index d8161be1..7245c4d0 100644 --- a/src/Jobs/DeleteTenantStorage.php +++ b/src/Jobs/DeleteTenantStorage.php @@ -34,7 +34,7 @@ class DeleteTenantStorage implements ShouldQueue public function handle(): void { - $tenantStoragePath = FilesystemTenancyBootstrapper::getBoundTenantStoragePath($this->tenant); + $tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($this->tenant); $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); if (realpath($tenantStoragePath) === realpath($centralStoragePath)) { From e7c0193931bcf61f324cb1ad6e7f47ce9bb06f6e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 7 Sep 2026 10:42:18 +0200 Subject: [PATCH 47/70] Add comment above $attemptedPath At first glance, it could look weird that $attemptedPath uses "/" but the check in abortIf below uses DIRECTORY_SEPARATOR. Add comment that explains this. --- src/Controllers/TenantAssetController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 33799b29..1c79ee35 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -142,6 +142,7 @@ class TenantAssetController implements HasMiddleware // The asset root doesn't exist, so it cannot contain files $this->abortIf($allowedRoot === false, "Storage root doesn't exist"); + // realpath() ensures the directory exists and converts / to \ on Windows $attemptedPath = realpath("{$allowedRoot}/{$path}"); // User is attempting to access a nonexistent file From dde9be6f58718ea77855403af849422a36a10f87 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 7 Sep 2026 11:04:29 +0200 Subject: [PATCH 48/70] Throw an exception if a scoped disk is listed in tenant-aware disks without its base disk In FSBootstrapper::forgetDisks(): - `tenancy.filesystem.disks => ['scoped']` throws - `tenancy.filesystem.disks => ['scoped', 'parent']` does NOT throw - `tenancy.filesystem.disks => ['scoped_with_scoped_parent', 'scoped_parent']` (invalid config where a scoped disk's base disk doesn't actually exist because the scoped disks just reference themselves) throws --- .../FilesystemTenancyBootstrapper.php | 13 +++-- .../FilesystemTenancyBootstrapperTest.php | 49 ++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 413210ac..eb9138c9 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -128,10 +128,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper $scopedDisks = []; foreach ($this->app['config']['filesystems.disks'] as $name => $disk) { - if (isset($disk['driver']) - && $disk['driver'] === 'scoped' - && in_array(static::baseDiskName($name), $tenantDisks, true)) { + if (($disk['driver'] ?? null) !== 'scoped') { + continue; + } + + $baseDisk = static::baseDiskName($name); + + if (in_array($baseDisk, $tenantDisks, true)) { $scopedDisks[] = $name; + } elseif (in_array($name, $tenantDisks, true)) { + throw new Exception("A disk using the 'scoped' driver cannot be tenant-aware. List its base disk in tenancy.filesystem.disks instead."); } } @@ -142,6 +148,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper { if ($this->app['config']["filesystems.disks.$disk.driver"] === 'scoped') { // Skip scoped disks since they have no root to override + // (reachable when a scoped disk is listed in tenancy.filesystem.disks alongside its base disk). return; } diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index cf4e56ae..b7f91907 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -326,7 +326,53 @@ test('scoped disks based on a non-local disk are scoped per tenant', function () expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt'); }); -test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk', function () { +test('adding a scoped disk to tenancy.filesystem.disks throws an exception if its base disk is not listed', function (string $disk) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.foo' => [ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'foo', + ], + 'filesystems.disks.bar' => [ + 'driver' => 'scoped', + 'disk' => 'foo', + 'prefix' => 'bar', + ], + // Disks referencing each other (neither has a base disk) + 'filesystems.disks.abc' => [ + 'driver' => 'scoped', + 'disk' => 'def', + 'prefix' => 'abc', + ], + 'filesystems.disks.def' => [ + 'driver' => 'scoped', + 'disk' => 'abc', + 'prefix' => 'def', + ], + 'tenancy.filesystem.disks' => [$disk], + ]); + + expect(fn () => tenancy()->initialize(Tenant::create())) + ->toThrow(Exception::class, "List its base disk in tenancy.filesystem.disks instead"); + + // Parent of 'abc' is 'def', whose parent is 'abc' -- there's no base disk for these, so these are + // still invalid and the exception will still be thrown. + if ($disk !== 'abc') { + config(['tenancy.filesystem.disks' => ['public', $disk]]); + + expect(fn () => tenancy()->initialize(Tenant::create())) + ->not()->toThrow(Exception::class, "List its base disk in tenancy.filesystem.disks instead"); + } +})->with([ + 'scoped disk' => 'foo', + 'nested scoped disk' => 'bar', + 'scoped disk with no base disk' => 'abc', +]); + +test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk when its base disk is listed too', function () { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -345,7 +391,6 @@ test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk tenancy()->initialize($tenant); // The 'foo' disk's parent disk ('public') is tenant-aware, so its root is scoped the same way. - // It doesn't matter that 'foo' itself is tenant-aware. expect(Storage::disk('foo')->path('testing.txt'))->toBe(storage_path('app/public/foo/testing.txt')); // Scoped disks have no root or url of their own, so the bootstrapper leaves their config alone From 197ca18566161d251d0144202f7e994fab397f30 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 7 Sep 2026 12:32:24 +0200 Subject: [PATCH 49/70] Simplify baseDiskName() Refrain from dealing with the impossible "self-referencing" scoped disk case. Instead of that, test the inline parent behavior. Also update the exception message in forgetDisks() so that it's a bit less vague. --- .../FilesystemTenancyBootstrapper.php | 24 +++------ .../FilesystemTenancyBootstrapperTest.php | 53 +++++-------------- 2 files changed, 19 insertions(+), 58 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index eb9138c9..0a4f9992 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -132,12 +132,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper continue; } - $baseDisk = static::baseDiskName($name); - - if (in_array($baseDisk, $tenantDisks, true)) { + if (in_array(static::baseDiskName($name), $tenantDisks, true)) { $scopedDisks[] = $name; } elseif (in_array($name, $tenantDisks, true)) { - throw new Exception("A disk using the 'scoped' driver cannot be tenant-aware. List its base disk in tenancy.filesystem.disks instead."); + throw new Exception("Disk [$name] uses the 'scoped' driver, so it has no root to make tenant-aware. List its base disk in tenancy.filesystem.disks instead."); } } @@ -359,27 +357,17 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper * Disks using the 'scoped' driver have no root or url of their own -- they inherit those from their parent disk, * which can be scoped as well, so only the final/base parent has to be tenant-aware. * - * Returns null if the chain doesn't end with a named disk, i.e. when a parent disk is - * configured inline or when the disks reference each other. + * Returns null if the base disk has no name, i.e. when the disk is configured inline as an array. */ public static function baseDiskName(string $disk): string|null { - // Keep track of visited disks to avoid infinite loops in case of disks referencing each other - $visited = []; - while (config("filesystems.disks.$disk.driver") === 'scoped') { - if (in_array($disk, $visited, true)) { - // The disk and its parents reference each other, invalid - return null; - } - - $visited[] = $disk; - $disk = config("filesystems.disks.$disk.disk"); - - if (! is_string($disk)) { + if (! is_string($parent = config("filesystems.disks.$disk.disk"))) { // Laravel allows configuring the parent disk inline as an array, and such a disk has no name return null; } + + $disk = $parent; } return $disk; diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index b7f91907..26307674 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -341,35 +341,33 @@ test('adding a scoped disk to tenancy.filesystem.disks throws an exception if it 'disk' => 'foo', 'prefix' => 'bar', ], - // Disks referencing each other (neither has a base disk) - 'filesystems.disks.abc' => [ + // Scoped disk with an inline parent + 'filesystems.disks.inline_parent' => [ 'driver' => 'scoped', - 'disk' => 'def', - 'prefix' => 'abc', - ], - 'filesystems.disks.def' => [ - 'driver' => 'scoped', - 'disk' => 'abc', - 'prefix' => 'def', + 'disk' => [ + 'driver' => 'local', + 'root' => storage_path('app/inline'), + ], + 'prefix' => 'inline_parent', ], 'tenancy.filesystem.disks' => [$disk], ]); expect(fn () => tenancy()->initialize(Tenant::create())) - ->toThrow(Exception::class, "List its base disk in tenancy.filesystem.disks instead"); + ->toThrow(Exception::class, "Disk [$disk] uses the 'scoped' driver, so it has no root to make tenant-aware."); - // Parent of 'abc' is 'def', whose parent is 'abc' -- there's no base disk for these, so these are - // still invalid and the exception will still be thrown. - if ($disk !== 'abc') { + // 'inline_parent' has no base disk name to list, so there's no way to make it tenant-aware + // and the exception is thrown regardless of what's listed. + if ($disk !== 'inline_parent') { config(['tenancy.filesystem.disks' => ['public', $disk]]); expect(fn () => tenancy()->initialize(Tenant::create())) - ->not()->toThrow(Exception::class, "List its base disk in tenancy.filesystem.disks instead"); + ->not()->toThrow(Exception::class, "Disk [$disk] uses the 'scoped' driver, so it has no root to make tenant-aware."); } })->with([ 'scoped disk' => 'foo', 'nested scoped disk' => 'bar', - 'scoped disk with no base disk' => 'abc', + 'scoped disk with an inline base disk' => 'inline_parent', ]); test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk when its base disk is listed too', function () { @@ -401,31 +399,6 @@ test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk ]); }); -test('scoped disks referencing each other do not make bootstrapper hang', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'filesystems.disks.foo' => [ - 'driver' => 'scoped', - 'disk' => 'bar', - 'prefix' => 'foo', - ], - 'filesystems.disks.bar' => [ - 'driver' => 'scoped', - 'disk' => 'foo', - 'prefix' => 'bar', - ], - ]); - - expect(FilesystemTenancyBootstrapper::baseDiskName('foo'))->toBeNull(); - expect(FilesystemTenancyBootstrapper::baseDiskName('bar'))->toBeNull(); - - tenancy()->initialize(Tenant::create()); - - expect(tenant())->not()->toBeNull(); -}); - test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { $fooPath = storage_path('framework/cache/foo_file'); $barPath = storage_path('framework/cache/bar_file'); From 45f6bc6b96577fa1cc4e56147abf5b786def369f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 7 Sep 2026 13:07:02 +0200 Subject: [PATCH 50/70] Fix getBoundTenantStoragePath -> getTenantStoragePath rename leftover --- src/Bootstrappers/LogChannelBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogChannelBootstrapper.php b/src/Bootstrappers/LogChannelBootstrapper.php index 0d4ddfd0..d25d9f5f 100644 --- a/src/Bootstrappers/LogChannelBootstrapper.php +++ b/src/Bootstrappers/LogChannelBootstrapper.php @@ -21,7 +21,7 @@ use Stancl\Tenancy\Contracts\Tenant; * Laravel's 'single' and 'daily' channels by default. To customize it, * see the property's docblock. * - * Note that since the tenant's storage path is resolved using FilesystemTenancyBootstrapper::getBoundTenantStoragePath(), + * Note that since the tenant's storage path is resolved using FilesystemTenancyBootstrapper::getTenantStoragePath(), * which is a public static method, FilesystemTenancyBootstrapper does not have to be enabled. * * For logging channels that are not filesystem-based, see the $channelOverrides logic. From 496e0c36bd9ceb9a13826ee806ccd3c589ba4053 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 11:54:58 +0200 Subject: [PATCH 51/70] Improve TenantAssetController comments --- src/Controllers/TenantAssetController.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 1c79ee35..16e8a433 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -20,7 +20,7 @@ use Throwable; * of the $publicDisk when the property is set. * * Requires FilesystemTenancyBootstrapper to be enabled, so that writes to the default - * public disk end up in the app/public within the *tenant's* storage, or so that the + * public disk end up in the app/public directory within the *tenant's* storage, or so that the * public disk set in the static property is similarly scoped. * * @see FilesystemTenancyBootstrapper @@ -51,8 +51,6 @@ class TenantAssetController implements HasMiddleware * * The disk also has to be listed in tenancy.filesystem.disks -- for scoped disks, it's the * disk they're based on that has to be listed there (since a scoped disk inherits its root). - * FilesystemTenancyBootstrapper only scopes the roots of disks listed there, so - * without that every tenant would be served the same (central) directory. */ public static string|null $publicDisk = null; @@ -87,11 +85,10 @@ class TenantAssetController implements HasMiddleware /** * Directory the assets are served from -- the root of the $publicDisk, or app/public - * inside the tenant's storage directory when no disk is configured. With no disk and - * no current tenant (e.g. on a universal route), the central app/public is used. + * inside the tenant's storage directory when no disk is configured. When no disk is + * configured and there's no current tenant, the central app/public is used. * - * The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper (rather - * than storage_path(), so that it's tenant-scoped regardless of the suffix_storage_path config). + * The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper::getTenantStoragePath(). */ protected function assetRoot(): string { @@ -114,9 +111,10 @@ class TenantAssetController implements HasMiddleware throw new Exception("Disk [$baseDiskName] is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific."); } - // The root is read from the resolved disk rather than from the disk's config, since the - // config root isn't the full root path of every local disk. Disks using the 'scoped' driver - // have no root in their config, and a 'prefix' is part of the root path as well. + // The full path is read from the resolved disk rather than from the disk's configured 'root', + // since the 'root' doesn't have to be the full path of every local disk: + // - disks using the 'scoped' driver have no 'root' in their config -- they inherit it from the parent disk + // - a disk's configured 'prefix' is a part of the full path as well. return rtrim($disk->path(''), DIRECTORY_SEPARATOR); } From 45100453c56c3bf17cec688b1e45cceb940c7221 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 14:55:24 +0200 Subject: [PATCH 52/70] Refactor scoped disks test Remove redundant config(['filesystem.disks.public.prefix' => 'scoped_disk_prefix']); line, try making the test less dense. --- .../FilesystemTenancyBootstrapperTest.php | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 26307674..583de83d 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -268,32 +268,27 @@ test('scoped disks are scoped per tenant', function (bool $nested) { ]); $disk = $nested ? 'nested_disk' : 'scoped_disk'; - $prefix = $nested ? 'scoped_disk_prefix/nested_disk_prefix' : 'scoped_disk_prefix'; + $path = 'app/public/scoped_disk_prefix/' . ($nested ? 'nested_disk_prefix/' : '') . 'foo.txt'; $tenant = Tenant::create(); + $centralFile = storage_path($path); + $tenantFile = storage_path("tenant{$tenant->id}/$path"); Storage::disk($disk)->put('foo.txt', 'central'); - - config(['filesystem.disks.public.prefix' => 'scoped_disk_prefix']); - - expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); - expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('central'); + expect(file_get_contents($centralFile))->toBe('central'); tenancy()->initialize($tenant); - expect(Storage::disk($disk)->get('foo.txt'))->toBe(null); + expect(Storage::disk($disk)->get('foo.txt'))->toBeNull(); + Storage::disk($disk)->put('foo.txt', 'tenant'); - expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('tenant'); - expect(Storage::disk($disk)->get('foo.txt'))->toBe('tenant'); + expect(file_get_contents($tenantFile))->toBe('tenant'); tenancy()->end(); expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); - Storage::disk($disk)->put('foo.txt', 'central2'); - expect(Storage::disk($disk)->get('foo.txt'))->toBe('central2'); - - expect(file_get_contents(storage_path() . "/app/public/{$prefix}/foo.txt"))->toBe('central2'); - expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/{$prefix}/foo.txt"))->toBe('tenant'); + expect(file_get_contents($centralFile))->toBe('central'); + expect(file_get_contents($tenantFile))->toBe('tenant'); })->with([ 'scoped disk' => false, 'nested scoped disk' => true, From e6d785a8d1f0b76d26b801a674b37bc9d5529a38 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 15:00:19 +0200 Subject: [PATCH 53/70] Refactor DeleteTenantStorage tests Use the *original* 'tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage' test and remove what's not necessary anymore. Also make it clear that enabling FS bootstrapper is not required for the deletion to work -- the tenant directory just has to exist. Delete the nonsensical 'DeleteTenantStorage does not delete the central storage directory when the filesystem bootstrapper is disabled' test. That one was there to test that the central dir never gets deleted, but it was wrong. Added 'DeleteTenantStorage never deletes the central storage directory' which actually makes the job's realpath() comparison check pass and the job just returns. --- .../FilesystemTenancyBootstrapperTest.php | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 583de83d..8b53b6a8 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -185,7 +185,7 @@ test('create and delete storage symlinks jobs work', function() { $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); }); -test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function(bool $suffixStoragePath) { +test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function (bool $bootstrapperEnabled) { Event::listen(DeletingTenant::class, JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) { return $event->tenant; @@ -193,40 +193,47 @@ test('tenant storage gets deleted during tenant deletion when the DeletingTenant ); config([ - 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], - // suffix_storage_path only affects the storage_path() helper. - // The disks are scoped to the tenant's storage directory either way, - // so the tenant files end up there. - 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath, - // This is the default tenancy config -- set it here explicitly for clarity - 'tenancy.filesystem.suffix_base' => 'tenant', - 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.bootstrappers' => $bootstrapperEnabled ? [FilesystemTenancyBootstrapper::class] : [], ]); $centralStoragePath = storage_path(); + $tenantStoragePath = fn (Tenant $tenant) => $centralStoragePath . "/tenant{$tenant->getTenantKey()}"; + $tenant = Tenant::create(); - $tenantStoragePath = $centralStoragePath . "/tenant{$tenant->getTenantKey()}"; - tenancy()->initialize($tenant); + File::ensureDirectoryExists($tenantStoragePath($tenant)); - Storage::disk('public')->put('foo.txt', 'tenant file'); - expect(file_get_contents($tenantStoragePath . '/app/public/foo.txt'))->toBe('tenant file'); + expect(File::isDirectory($centralStoragePath))->toBeTrue(); + expect(File::isDirectory($tenantStoragePath($tenant)))->toBeTrue(); $tenant->delete(); - expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($centralStoragePath))->toBeTrue(); -})->with([true, false]); + expect(File::isDirectory($tenantStoragePath($tenant)))->toBeFalse(); +})->with([ + 'filesystem bootstrapper enabled' => true, + 'filesystem bootstrapper disabled' => false, +]); -test('DeleteTenantStorage does not delete the central storage directory when the filesystem bootstrapper is disabled', function () { - config(['tenancy.bootstrappers' => []]); - - $centralStoragePath = storage_path(); +test('DeleteTenantStorage never deletes the central storage directory', function () { $tenant = Tenant::create(); + $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); + $tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($tenant); + + File::ensureDirectoryExists($centralStoragePath . '/app'); + + // Make the tenant storage path a symlink to the central storage directory + File::deleteDirectory($tenantStoragePath); + symlink($centralStoragePath, $tenantStoragePath); + + expect(realpath($tenantStoragePath))->toBe(realpath($centralStoragePath)); + (new DeleteTenantStorage($tenant))->handle(); expect(File::isDirectory($centralStoragePath))->toBeTrue(); + expect(File::isDirectory($centralStoragePath . '/app'))->toBeTrue(); + expect(is_link($tenantStoragePath))->toBeTrue(); }); test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { From e3ba44563023c6c69f8c3b0b168749d363bb5920 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 16:22:15 +0200 Subject: [PATCH 54/70] Refactor scoped disk exception throwing tests, remove redundant test "adding a scoped disk to tenancy.filesystem.disks throws an exception if its base disk is not listed" doesn't use a dataset and deal with inline baes disks anymore. Added a separate test for scoped disks with inline base ("adding a scoped disk with an inline base disk to tenancy.filesystem.disks throws an exception"). Removed the "adding a scoped disk to tenancy.filesystem.disks has no effect on the disk when its base disk is listed too" test, it was mostly redundant. --- .../FilesystemTenancyBootstrapperTest.php | 75 +++++++------------ 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 8b53b6a8..4ebf1995 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -328,7 +328,7 @@ test('scoped disks based on a non-local disk are scoped per tenant', function () expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt'); }); -test('adding a scoped disk to tenancy.filesystem.disks throws an exception if its base disk is not listed', function (string $disk) { +test('adding a scoped disk to tenancy.filesystem.disks throws an exception if its base disk is not listed', function () { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -343,62 +343,41 @@ test('adding a scoped disk to tenancy.filesystem.disks throws an exception if it 'disk' => 'foo', 'prefix' => 'bar', ], - // Scoped disk with an inline parent - 'filesystems.disks.inline_parent' => [ + ]); + + $initializeTenancy = fn () => tenancy()->initialize(Tenant::create()); + + config(['tenancy.filesystem.disks' => ['foo']]); + expect($initializeTenancy)->toThrow(Exception::class, "Disk [foo] uses the 'scoped' driver, so it has no root to make tenant-aware."); + + config(['tenancy.filesystem.disks' => ['bar']]); + expect($initializeTenancy)->toThrow(Exception::class, "Disk [bar] uses the 'scoped' driver, so it has no root to make tenant-aware."); + + config(['tenancy.filesystem.disks' => ['public', 'foo', 'bar']]); + expect($initializeTenancy)->not()->toThrow(Throwable::class); +}); + +test('adding a scoped disk with an inline base disk to tenancy.filesystem.disks throws an exception', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.inline_base' => [ 'driver' => 'scoped', 'disk' => [ 'driver' => 'local', 'root' => storage_path('app/inline'), ], - 'prefix' => 'inline_parent', + 'prefix' => 'inline_base', ], - 'tenancy.filesystem.disks' => [$disk], ]); - expect(fn () => tenancy()->initialize(Tenant::create())) - ->toThrow(Exception::class, "Disk [$disk] uses the 'scoped' driver, so it has no root to make tenant-aware."); + $initializeTenancy = fn () => tenancy()->initialize(Tenant::create()); - // 'inline_parent' has no base disk name to list, so there's no way to make it tenant-aware - // and the exception is thrown regardless of what's listed. - if ($disk !== 'inline_parent') { - config(['tenancy.filesystem.disks' => ['public', $disk]]); - - expect(fn () => tenancy()->initialize(Tenant::create())) - ->not()->toThrow(Exception::class, "Disk [$disk] uses the 'scoped' driver, so it has no root to make tenant-aware."); - } -})->with([ - 'scoped disk' => 'foo', - 'nested scoped disk' => 'bar', - 'scoped disk with an inline base disk' => 'inline_parent', -]); - -test('adding a scoped disk to tenancy.filesystem.disks has no effect on the disk when its base disk is listed too', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'filesystems.disks.foo' => [ - 'driver' => 'scoped', - 'disk' => 'public', - 'prefix' => 'foo', - ], - // Only the scoped disk's parent/base disk ('public') has to be listed here. - // Listing 'foo' too is redundant, and it shouldn't change anything. - 'tenancy.filesystem.disks' => ['local', 'public', 'foo'], - ]); - - $tenant = Tenant::create(); - tenancy()->initialize($tenant); - - // The 'foo' disk's parent disk ('public') is tenant-aware, so its root is scoped the same way. - expect(Storage::disk('foo')->path('testing.txt'))->toBe(storage_path('app/public/foo/testing.txt')); - - // Scoped disks have no root or url of their own, so the bootstrapper leaves their config alone - expect(config('filesystems.disks.foo'))->toBe([ - 'driver' => 'scoped', - 'disk' => 'public', - 'prefix' => 'foo', - ]); + // The base disk is inline, so it has no name. + // There's no way to make the scoped disk tenant-aware. + config(['tenancy.filesystem.disks' => ['inline_base']]); + expect($initializeTenancy)->toThrow(Exception::class, "Disk [inline_base] uses the 'scoped' driver, so it has no root to make tenant-aware."); }); test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { From 4af473ede59bdd2ebb326d94d9cb5c5bd24f16ef Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 16:58:42 +0200 Subject: [PATCH 55/70] Move falsy url_override assertions to more appropriate places --- tests/ActionTest.php | 39 ++++--------------- .../FilesystemTenancyBootstrapperTest.php | 20 ++++++++++ 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index f7f5cdda..b2076475 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -28,7 +28,11 @@ test('create storage symlinks action works', function (string|null $rootOverride // The disk root is suffixed regardless of the suffix_storage_path config 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath, 'tenancy.filesystem.root_override.public' => $rootOverride, - 'tenancy.filesystem.url_override.public' => 'public-%tenant%' + 'tenancy.filesystem.url_override' => [ + 'public' => 'public-%tenant%', + // Disks with a falsy url_override are skipped + 'local' => '', + ], ]); /** @var Tenant $tenant */ @@ -49,6 +53,9 @@ test('create storage symlinks action works', function (string|null $rootOverride expect(is_link($publicPath))->toBeTrue(); expect(readlink($publicPath))->toBe(config('filesystems.disks.public.root')); expect(file_get_contents($publicPath . '/foo.txt'))->toBe('tenant file'); + + // The local disk is skipped because its url_override is '' -- no symlink is created at public_path('') + expect(is_link(public_path('')))->toBeFalse(); })->with([ 'default root_override' => ['%storage_path%/app/public/', true], 'suffix_storage_path disabled' => ['%storage_path%/app/public/', false], @@ -79,36 +86,6 @@ test('create storage symlinks action fails for disks that are not tenant-aware', expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeFalse(); }); -test('create storage symlinks action skips disks with no url_override', function (string|null $localUrlOverride) { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], - 'tenancy.filesystem.suffix_base' => 'tenant-', - 'tenancy.filesystem.url_override' => [ - 'public' => 'public-%tenant%', - 'local' => $localUrlOverride, - ], - ]); - - /** @var Tenant $tenant */ - $tenant = Tenant::create(); - - (new CreateStorageSymlinksAction)($tenant); - - // The local disk is skipped, so the public disk still gets its symlink - expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeTrue(); - - // The bootstrapper skips the same disk, so its URL is not overridden either - $centralUrl = config('filesystems.disks.local.url'); - tenancy()->initialize($tenant); - - expect(config('filesystems.disks.local.url'))->toBe($centralUrl); -})->with([ - 'null url_override' => [null], - 'empty url_override' => [''], -]); - test('remove storage symlinks action works', function() { config([ 'tenancy.bootstrappers' => [ diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 4ebf1995..4766d50d 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -146,6 +146,26 @@ test('links to storage disks with a configured root are suffixed if not overridd expect(storage_path())->toEqual($expectedStoragePath); }); +test('disks with a falsy url_override do not get their url overridden', function ($urlOverride) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.url_override.public' => $urlOverride, + ]); + + $tenant = Tenant::create(); + + $centralUrl = config('filesystems.disks.public.url'); + + tenancy()->initialize($tenant); + + expect(config('filesystems.disks.public.url'))->toBe($centralUrl); +})->with([ + 'empty string' => [''], + 'null' => [null], +]); + test('create and delete storage symlinks jobs work', function() { Event::listen( TenantCreated::class, From 1abeb852ded4da5a710f52bede9265b505b9609e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 17:54:20 +0200 Subject: [PATCH 56/70] Merge the new "throws an exception when accessing a file in a directory whose name starts with the name of the asset root" test with the pre-existing one "test asset controller returns a 404 when accessing a file outside the storage root" tested very similar things to the new test (which had some redundant config anyway). Merged these tests into one -- "tenant asset controller only serves files inside the asset root" --- tests/TenantAssetTest.php | 56 +++++++++------------------------------ 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index ee3ef911..35261b57 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -445,56 +445,26 @@ test('tenant asset controller returns a 404 when accessing a nonexistent file', ]); }); -test('tenant asset controller throws an exception when accessing a file in a directory whose name starts with the name of the asset root', function () { - config([ - 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, - // Disk used for serving the assets -- its root is 'app/media' in the tenant's storage directory - 'filesystems.disks.media' => ['driver' => 'local', 'root' => storage_path('app/media')], - 'tenancy.filesystem.disks' => array_merge(config('tenancy.filesystem.disks'), ['media']), - 'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/', - ]); - - TenantAssetController::$publicDisk = 'media'; - - $tenant = Tenant::create(); - tenancy()->initialize($tenant); - - Storage::disk('media')->put('photo.jpg', 'public file'); - - pest()->get(tenant_asset('photo.jpg'), [ - 'X-Tenant' => $tenant->id, - ])->assertSuccessful(); - - // A directory next to the asset root, e.g. one holding files that shouldn't be served - mkdir($privateDirectory = storage_path('app/media-originals'), recursive: true); - file_put_contents($privateDirectory . '/photo.jpg', 'private file'); - - $this->withoutExceptionHandling(); - pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404 - - pest()->get(tenant_asset('../media-originals/photo.jpg'), [ - 'X-Tenant' => $tenant->id, - ]); -}); - -test('test asset controller returns a 404 when accessing a file outside the storage root', function () { +test('tenant asset controller only serves files inside the asset root', function () { config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); $tenant = Tenant::create(); - tenancy()->initialize($tenant); - $storageRoot = storage_path("app/public"); + Storage::disk('public')->put('photo.jpg', 'public file'); - if (! is_dir($storageRoot)) { - mkdir(storage_path("app/public"), recursive: true); - file_put_contents(storage_path('app/foo.txt'), 'bar'); - } + pest()->get(tenant_asset('photo.jpg'), ['X-Tenant' => $tenant->id])->assertSuccessful(); + + // Files outside the asset root, e.g. ones that shouldn't be served. + // The directory with the second file starts with the name of the asset root. + file_put_contents(storage_path('app/photo.jpg'), 'private file'); + mkdir($siblingDirectory = storage_path('app/public-originals'), recursive: true); + file_put_contents($siblingDirectory . '/photo.jpg', 'private file'); $this->withoutExceptionHandling(); - pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404 - pest()->get(tenant_asset('../foo.txt'), [ - 'X-Tenant' => $tenant->id, - ]); + foreach (['../photo.jpg', '../public-originals/photo.jpg'] as $path) { + expect(fn () => pest()->get(tenant_asset($path), ['X-Tenant' => $tenant->id])) + ->toThrow(Exception::class, 'Accessing a file outside the storage root'); // outside tests this is a 404 + } }); From fe85fe45bd6ffe63d86be17c20e0bb7712c45e3a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 18:11:41 +0200 Subject: [PATCH 57/70] Merge 'not a local disk'/'not tenant-aware'/'unnamed parent' throwing tests into a single test --- tests/TenantAssetTest.php | 54 ++++++++++++--------------------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 35261b57..a6f1f647 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -120,7 +120,7 @@ test('the disk used for serving tenant assets is configurable', function () { expect($response->getFile()->getPathname())->toBe($path); }); -test('tenant asset controller throws when the configured disk is not local', function (string $publicDisk) { +test('tenant asset controller throws when the configured disk is not local or not tenant-aware', function () { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, // Add a disk that uses the s3 driver (= non-local disk). @@ -137,27 +137,7 @@ test('tenant asset controller throws when the configured disk is not local', fun 'disk' => 'remote', 'prefix' => 'assets', ], - ]); - - TenantAssetController::$publicDisk = $publicDisk; - - $tenant = Tenant::create(); - tenancy()->initialize($tenant); - - $this->withoutExceptionHandling(); - pest()->expectExceptionMessage("Disk [$publicDisk] is not a local disk."); - - pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]); -})->with([ - 'disk' => 'remote', - 'scoped disk' => 'scoped_remote', -]); - -test('tenant asset controller throws when the disk used for serving assets is not tenant-aware', function (string $publicDisk, string $expectedMessage) { - $centralStoragePath = storage_path(); - - config([ - 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // 'media' isn't tenant-aware (i.e. not included in tenancy.filesystem.disks) 'filesystems.disks.media' => [ 'driver' => 'local', 'root' => storage_path('app/media'), @@ -177,30 +157,28 @@ test('tenant asset controller throws when the disk used for serving assets is no ], 'prefix' => 'assets', ], - // 'media' isn't tenant-aware (i.e. not included in tenancy.filesystem.disks) - 'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/', ]); - TenantAssetController::$publicDisk = $publicDisk; - $tenant = Tenant::create(); tenancy()->initialize($tenant); - Storage::disk($publicDisk)->put($filename = 'testfile' . Str::random(8), 'bar'); - - // The disk's root stays central - expect(Storage::disk($publicDisk)->path($filename))->toStartWith("$centralStoragePath/app/media/"); - $this->withoutExceptionHandling(); - pest()->expectExceptionMessage($expectedMessage); + $expectedExceptions = [ + 'remote' => 'Disk [remote] is not a local disk.', + 'scoped_remote' => 'Disk [scoped_remote] is not a local disk.', + 'media' => 'Disk [media] is not tenant-aware.', + 'scoped_media' => 'Disk [media] is not tenant-aware.', + 'inline_scoped_media' => 'Disk [inline_scoped_media] has an unnamed parent disk.', + ]; - pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); -})->with([ - 'disk' => ['media', 'Disk [media] is not tenant-aware.'], - 'scoped disk' => ['scoped_media', 'Disk [media] is not tenant-aware.'], - 'scoped disk with an inline parent disk' => ['inline_scoped_media', 'Disk [inline_scoped_media] has an unnamed parent disk.'], -]); + foreach ($expectedExceptions as $publicDisk => $exceptionMessage) { + TenantAssetController::$publicDisk = $publicDisk; + + expect(fn () => pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id])) + ->toThrow(Exception::class, $exceptionMessage); + } +}); test('tenant assets are served from the resolved root of a scoped disk', function () { config([ From 823e5c34d6263de6c63172b2e0007c4b3a61fcaf Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 18:18:54 +0200 Subject: [PATCH 58/70] Merge the 'served from the resolved root' tests --- tests/TenantAssetTest.php | 56 +++++++++++++-------------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index a6f1f647..b74b4a90 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -180,7 +180,7 @@ test('tenant asset controller throws when the configured disk is not local or no } }); -test('tenant assets are served from the resolved root of a scoped disk', function () { +test('tenant assets are served from the resolved root of the configured disk', function () { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, // A scoped disk has no configured root -- it inherits the root of its parent disk @@ -190,56 +190,36 @@ test('tenant assets are served from the resolved root of a scoped disk', functio 'disk' => 'public', 'prefix' => 'scoped_disk_prefix', ], - // Default tenancy config, set it here for clarity - 'tenancy.filesystem.disks' => ['local', 'public'], - ]); - - TenantAssetController::$publicDisk = 'scoped_disk'; - - $tenant = Tenant::create(); - tenancy()->initialize($tenant); - - $filename = 'testfile' . Str::random(8); - Storage::disk('scoped_disk')->put($filename, 'bar'); - $path = Storage::disk('scoped_disk')->path($filename); - - // The parent disk is tenant-aware, so the scoped disk's root is inside the tenant's storage directory - expect($path)->toBe(storage_path("app/public/scoped_disk_prefix/$filename")); - - $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); - - $response->assertSuccessful(); - expect($response->getFile()->getPathname())->toBe($path); -}); - -test('tenant assets are served from the resolved root of a disk with a configured prefix', function () { - config([ - 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, - // A prefix is part of the disk's root path, so it has to be included in the asset root + // A prefix is part of the disk's full path, so it has to be included in the asset root 'filesystems.disks.prefixed' => [ 'driver' => 'local', 'root' => storage_path('app/media'), - 'prefix' => 'foo-prefix', + 'prefix' => 'foo_prefix', ], - 'tenancy.filesystem.disks' => ['prefixed'], + 'tenancy.filesystem.disks' => ['local', 'public', 'prefixed'], 'tenancy.filesystem.root_override.prefixed' => '%storage_path%/app/media/', ]); - TenantAssetController::$publicDisk = 'prefixed'; - $tenant = Tenant::create(); tenancy()->initialize($tenant); - $filename = 'testfile' . Str::random(8); - Storage::disk('prefixed')->put($filename, 'bar'); - $path = Storage::disk('prefixed')->path($filename); + foreach ([ + 'scoped_disk' => 'app/public/scoped_disk_prefix', + 'prefixed' => 'app/media/foo_prefix', + ] as $publicDisk => $expectedRoot) { + TenantAssetController::$publicDisk = $publicDisk; - expect($path)->toBe(storage_path("app/media/foo-prefix/$filename")); + $filename = 'testfile' . Str::random(8); + Storage::disk($publicDisk)->put($filename, 'bar'); + $path = Storage::disk($publicDisk)->path($filename); - $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + expect($path)->toBe(storage_path("{$expectedRoot}/$filename")); - $response->assertSuccessful(); - expect($response->getFile()->getPathname())->toBe($path); + $response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]); + + $response->assertSuccessful(); + expect($response->getFile()->getPathname())->toBe($path); + } }); test('tenant assets are served from the central storage path in central context', function () { From 9bfc9c4dca358b7df79caa76d5c5369716491a94 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 8 Sep 2026 18:27:38 +0200 Subject: [PATCH 59/70] Delete the 'tenant assets are served from the central storage path in central context' test On one hand, this test covered the TenantAssetController's fallback. On the other hand, using tenant asset routes in central context is not a valid use case (also, the fallback isn't exactly a new thing) --- tests/TenantAssetTest.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index b74b4a90..603db9b3 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -31,7 +31,6 @@ beforeEach(function () { TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenantAssetController::$headers = []; TenantAssetController::$publicDisk = null; - InitializeTenancyByRequestData::$onFail = null; /** @var CloneRoutesAsTenant $cloneAction */ $cloneAction = app(CloneRoutesAsTenant::class); @@ -44,7 +43,6 @@ beforeEach(function () { afterEach(function () { TenantAssetController::$headers = []; TenantAssetController::$publicDisk = null; - InitializeTenancyByRequestData::$onFail = null; }); test('asset can be accessed using the url returned by the tenant asset helper', function () { @@ -222,21 +220,6 @@ test('tenant assets are served from the resolved root of the configured disk', f } }); -test('tenant assets are served from the central storage path in central context', function () { - config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); - - // Mimic the universal route setup (let the request through even though no tenant is identified) - InitializeTenancyByRequestData::$onFail = fn ($e, $request, $next) => $next($request); - - $filename = 'testfile' . Str::random(8); - Storage::disk('public')->put($filename, 'bar'); - - $response = pest()->get(tenant_asset($filename)); - - $response->assertSuccessful(); - expect($response->getFile()->getPathname())->toBe(storage_path("app/public/$filename")); -}); - test('asset helper returns a link to tenant asset controller when asset url is null', function () { config(['app.asset_url' => null]); config(['tenancy.filesystem.asset_helper_override' => true]); From d783879f9860f9bb8885fdd07b40e4244eb28ef2 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 8 Sep 2026 19:43:42 -0700 Subject: [PATCH 60/70] add assertion to ensure the two paths are the same --- tests/TenantAssetTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 603db9b3..ccd8ae1b 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -83,6 +83,8 @@ test('tenant assets are served even when the suffix_storage_path config is set t $tenant = Tenant::create(); tenancy()->initialize($tenant); + expect(storage_path())->toBe($centralStoragePath); + $filename = 'testfile' . Str::random(8); Storage::disk('public')->put($filename, 'bar'); From 96734641280a589c7ad83cb57815daee5af229fd Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 8 Sep 2026 20:16:57 -0700 Subject: [PATCH 61/70] minor test cleanup --- .../FilesystemTenancyBootstrapperTest.php | 71 ++++++++----------- tests/TenantAssetTest.php | 5 +- 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 4766d50d..1066f872 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -277,7 +277,7 @@ test('the framework/cache directory is created when storage_path is scoped', fun } })->with([true, false]); -test('scoped disks are scoped per tenant', function (bool $nested) { +test('scoped disks are scoped per tenant', function () { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -294,32 +294,30 @@ test('scoped disks are scoped per tenant', function (bool $nested) { ], ]); - $disk = $nested ? 'nested_disk' : 'scoped_disk'; - $path = 'app/public/scoped_disk_prefix/' . ($nested ? 'nested_disk_prefix/' : '') . 'foo.txt'; + foreach (['scoped_disk' => '', 'nested_disk' => '/nested_disk_prefix'] as $disk => $nested_prefix) { + $path = "app/public/scoped_disk_prefix{$nested_prefix}/foo.txt"; - $tenant = Tenant::create(); - $centralFile = storage_path($path); - $tenantFile = storage_path("tenant{$tenant->id}/$path"); + $tenant = Tenant::create(); + $centralFile = storage_path($path); + $tenantFile = storage_path("tenant{$tenant->id}/$path"); - Storage::disk($disk)->put('foo.txt', 'central'); - expect(file_get_contents($centralFile))->toBe('central'); + Storage::disk($disk)->put('foo.txt', 'central'); + expect(file_get_contents($centralFile))->toBe('central'); - tenancy()->initialize($tenant); + tenancy()->initialize($tenant); - expect(Storage::disk($disk)->get('foo.txt'))->toBeNull(); + expect(Storage::disk($disk)->get('foo.txt'))->toBeNull(); - Storage::disk($disk)->put('foo.txt', 'tenant'); - expect(file_get_contents($tenantFile))->toBe('tenant'); + Storage::disk($disk)->put('foo.txt', 'tenant'); + expect(file_get_contents($tenantFile))->toBe('tenant'); - tenancy()->end(); + tenancy()->end(); - expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); - expect(file_get_contents($centralFile))->toBe('central'); - expect(file_get_contents($tenantFile))->toBe('tenant'); -})->with([ - 'scoped disk' => false, - 'nested scoped disk' => true, -]); + expect(Storage::disk($disk)->get('foo.txt'))->toBe('central'); + expect(file_get_contents($centralFile))->toBe('central'); + expect(file_get_contents($tenantFile))->toBe('tenant'); + } +}); test('scoped disks based on a non-local disk are scoped per tenant', function () { config([ @@ -363,25 +361,7 @@ test('adding a scoped disk to tenancy.filesystem.disks throws an exception if it 'disk' => 'foo', 'prefix' => 'bar', ], - ]); - - $initializeTenancy = fn () => tenancy()->initialize(Tenant::create()); - - config(['tenancy.filesystem.disks' => ['foo']]); - expect($initializeTenancy)->toThrow(Exception::class, "Disk [foo] uses the 'scoped' driver, so it has no root to make tenant-aware."); - - config(['tenancy.filesystem.disks' => ['bar']]); - expect($initializeTenancy)->toThrow(Exception::class, "Disk [bar] uses the 'scoped' driver, so it has no root to make tenant-aware."); - - config(['tenancy.filesystem.disks' => ['public', 'foo', 'bar']]); - expect($initializeTenancy)->not()->toThrow(Throwable::class); -}); - -test('adding a scoped disk with an inline base disk to tenancy.filesystem.disks throws an exception', function () { - config([ - 'tenancy.bootstrappers' => [ - FilesystemTenancyBootstrapper::class, - ], + // There's no way for a scoped disk with an array parent to have the parent listed in tenancy.filesystem.disks 'filesystems.disks.inline_base' => [ 'driver' => 'scoped', 'disk' => [ @@ -394,10 +374,19 @@ test('adding a scoped disk with an inline base disk to tenancy.filesystem.disks $initializeTenancy = fn () => tenancy()->initialize(Tenant::create()); - // The base disk is inline, so it has no name. - // There's no way to make the scoped disk tenant-aware. + config(['tenancy.filesystem.disks' => ['foo']]); + expect($initializeTenancy)->toThrow(Exception::class, "Disk [foo] uses the 'scoped' driver, so it has no root to make tenant-aware."); + + config(['tenancy.filesystem.disks' => ['bar']]); + expect($initializeTenancy)->toThrow(Exception::class, "Disk [bar] uses the 'scoped' driver, so it has no root to make tenant-aware."); + config(['tenancy.filesystem.disks' => ['inline_base']]); expect($initializeTenancy)->toThrow(Exception::class, "Disk [inline_base] uses the 'scoped' driver, so it has no root to make tenant-aware."); + + config(['tenancy.filesystem.disks' => ['public', 'foo', 'bar']]); + expect($initializeTenancy)->not()->toThrow(Throwable::class); + + // No way to make the 'inline_base' disk work }); test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index ccd8ae1b..ed7feaa8 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -398,9 +398,10 @@ test('tenant asset controller only serves files inside the asset root', function pest()->get(tenant_asset('photo.jpg'), ['X-Tenant' => $tenant->id])->assertSuccessful(); - // Files outside the asset root, e.g. ones that shouldn't be served. - // The directory with the second file starts with the name of the asset root. + // Files outside the asset root file_put_contents(storage_path('app/photo.jpg'), 'private file'); + + // This path starts with the asset root but is a sibling dir, not a child. Regression assertion mkdir($siblingDirectory = storage_path('app/public-originals'), recursive: true); file_put_contents($siblingDirectory . '/photo.jpg', 'private file'); From b2b8d50edba3148dc25504dd414bba48b6ca8676 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 9 Sep 2026 12:18:04 +0200 Subject: [PATCH 62/70] Add symlink support for prefixed disks Disk prefixes are no longer ignored by tenants:link. possibleTenantSymlinks() now appends them to both the public path and the disk root. CreateStorageSymlinksAction now creates parent directories for the symlinks in the public/ directory (e.g. for a disk with 'abc/def' prefix, the 'abc/def' subdirectory will be created inside public/). RemoveStorageSymlinksAction removes the directories that CreateStorageSymlinksAction creates for the symlinks. --- src/Actions/CreateStorageSymlinksAction.php | 6 + src/Actions/RemoveStorageSymlinksAction.php | 14 ++- src/Concerns/DealsWithTenantSymlinks.php | 10 +- tests/ActionTest.php | 116 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php index 2de9e915..94917c10 100644 --- a/src/Actions/CreateStorageSymlinksAction.php +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -47,6 +47,12 @@ class CreateStorageSymlinksAction mkdir($storagePath, 0777, true); } + // The public path of a prefixed disk includes the prefix, + // and its parent directories may not exist yet. + if (! is_dir($publicParent = dirname($publicPath))) { + mkdir($publicParent, 0777, true); + } + if ($relativeLink) { app()->make('files')->relativeLink($storagePath, $publicPath); } else { diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index 7b7b04d5..78b27e74 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -32,12 +32,24 @@ class RemoveStorageSymlinksAction protected function removeLink(string $publicPath, Tenant $tenant): void { + $files = app()->make('files'); + if ($this->symlinkExists($publicPath)) { event(new RemovingStorageSymlink($tenant)); - app()->make('files')->delete($publicPath); + $files->delete($publicPath); event(new StorageSymlinkRemoved($tenant)); } + + // Remove the directories CreateStorageSymlinksAction created for the symlink + // until a non-empty one is reached. + $directory = dirname($publicPath); + + while ($directory !== public_path() && $files->isEmptyDirectory($directory)) { + $files->deleteDirectory($directory); + + $directory = dirname($directory); + } } } diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 578bda01..3bb267c8 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -58,8 +58,16 @@ trait DealsWithTenantSymlinks } $publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath); + $diskRoot = $tenantDisks[$disk]['root']; - $symlinks[public_path($publicPath)] = $tenantDisks[$disk]['root']; + if ($prefix = trim($disks[$disk]['prefix'] ?? '', '/\\')) { + $diskRoot = rtrim($diskRoot, '/\\') . DIRECTORY_SEPARATOR . $prefix; + + // Storage::url() appends the disk's prefix to the url, so the prefix has to be in the public path as well + $publicPath .= DIRECTORY_SEPARATOR . $prefix; + } + + $symlinks[public_path($publicPath)] = $diskRoot; } return $symlinks; diff --git a/tests/ActionTest.php b/tests/ActionTest.php index b2076475..4b364603 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -149,3 +149,119 @@ test('removing tenant symlinks works even if the symlinks are invalid', function expect(is_link($publicPath))->toBeFalse(); }); + +test('disks with a prefix are symlinked correctly', function (string $prefix) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => $prefix, + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + // possibleTenantSymlinks() trims the prefix, do the same here for the assertions to be accurate + $prefix = trim($prefix, '/'); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + (new CreateStorageSymlinksAction)($tenant); + + expect(Storage::disk('public')->url('foo.txt'))->toBe("http://localhost/public-{$tenantKey}/{$prefix}/foo.txt"); + expect(readlink(public_path("public-{$tenantKey}/{$prefix}")))->toBe(storage_path("app/public/{$prefix}")); + expect(file_get_contents(public_path("public-{$tenantKey}/{$prefix}/foo.txt")))->toBe('tenant file'); +})->with(['abc', 'abc/def', '/abc/def/']); + +test('symlinks of prefixed disks only expose the prefixed directory', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + // The disk cannot reach this file, neither should the symlink + File::put(storage_path('app/public/sibling.txt'), 'file next to the prefixed directory'); + + (new CreateStorageSymlinksAction)($tenant); + + expect(file_exists(public_path("public-{$tenantKey}/sibling.txt")))->toBeFalse(); + expect(file_get_contents(public_path("public-{$tenantKey}/abc/foo.txt")))->toBe('tenant file'); +}); + +test('removing a prefixed disk symlink removes the directories created for it', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc/def', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + (new CreateStorageSymlinksAction)($tenant); + + $symlink = public_path("public-{$tenantKey}/abc/def"); + $diskRoot = readlink($symlink); + + (new RemoveStorageSymlinksAction)($tenant); + + // The symlink and every directory created for it are deleted + expect(is_link($symlink))->toBeFalse(); + expect(file_exists(public_path("public-{$tenantKey}")))->toBeFalse(); + // public_path() itself is not deleted + expect(is_dir(public_path()))->toBeTrue(); + // The directory that the symlink points to is untouched + expect(file_get_contents($diskRoot . '/foo.txt'))->toBe('tenant file'); +}); + +test('non-empty directories are not removed with the symlink', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc/def', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + (new CreateStorageSymlinksAction)($tenant); + + File::put(public_path("public-{$tenantKey}/abc/actual-file.txt"), 'foo'); + + (new RemoveStorageSymlinksAction)($tenant); + + expect(is_link(public_path("public-{$tenantKey}/abc/def")))->toBeFalse(); + expect(file_get_contents(public_path("public-{$tenantKey}/abc/actual-file.txt")))->toBe('foo'); +}); From 1e1e2efb8c77558f1f2d23203fcb45db6e59944a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 10 Sep 2026 12:09:07 +0200 Subject: [PATCH 63/70] Refactor remove symlinks action, add static::$removeNestedDirectories In removeLink(), return early if the symlink doesn't exist. The nested directory deletion is now controlled by the $removeNestedDirectories static property. It's disabled by default. public_path() and dirname($publicPath) are now normalized using realpath() before the nested dir deletion. The delete loop now checks if the directory-to-be-deleted is *inside* the public root instead of checking if it's not equal to to the public root. --- src/Actions/RemoveStorageSymlinksAction.php | 34 ++++++++++++++++----- tests/ActionTest.php | 10 ++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index 78b27e74..14964eae 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -15,6 +15,15 @@ class RemoveStorageSymlinksAction { use DealsWithTenantSymlinks; + /** + * Should the directories created for nested symlinks be removed along with the symlink. + * + * Before enabling this, make sure you understand the removeLink() method and its implications. + * + * @see CreateStorageSymlinksAction + */ + public static bool $removeNestedDirectories = false; + /** * @param Tenant|Collection|LazyCollection $tenants */ @@ -32,21 +41,32 @@ class RemoveStorageSymlinksAction protected function removeLink(string $publicPath, Tenant $tenant): void { + if (! $this->symlinkExists($publicPath)) { + return; + } + $files = app()->make('files'); - if ($this->symlinkExists($publicPath)) { - event(new RemovingStorageSymlink($tenant)); + event(new RemovingStorageSymlink($tenant)); - $files->delete($publicPath); + $files->delete($publicPath); - event(new StorageSymlinkRemoved($tenant)); + event(new StorageSymlinkRemoved($tenant)); + + if (! static::$removeNestedDirectories) { + return; + } + + $publicRoot = realpath(public_path()); + $directory = realpath(dirname($publicPath)); + + if ($publicRoot === false || $directory === false) { + return; } // Remove the directories CreateStorageSymlinksAction created for the symlink // until a non-empty one is reached. - $directory = dirname($publicPath); - - while ($directory !== public_path() && $files->isEmptyDirectory($directory)) { + while (str_starts_with($directory, $publicRoot . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { $files->deleteDirectory($directory); $directory = dirname($directory); diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 4b364603..8d5a8ac1 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -17,6 +17,12 @@ use Illuminate\Support\Facades\Storage; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + RemoveStorageSymlinksAction::$removeNestedDirectories = false; +}); + +afterEach(function () { + RemoveStorageSymlinksAction::$removeNestedDirectories = false; }); test('create storage symlinks action works', function (string|null $rootOverride, bool $suffixStoragePath) { @@ -207,6 +213,8 @@ test('symlinks of prefixed disks only expose the prefixed directory', function ( }); test('removing a prefixed disk symlink removes the directories created for it', function () { + RemoveStorageSymlinksAction::$removeNestedDirectories = true; + config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -241,6 +249,8 @@ test('removing a prefixed disk symlink removes the directories created for it', }); test('non-empty directories are not removed with the symlink', function () { + RemoveStorageSymlinksAction::$removeNestedDirectories = true; + config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, From 9e890e9a67732b0efcdcfbc4f67dcb2f76cd8801 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 10 Sep 2026 12:32:14 +0200 Subject: [PATCH 64/70] Improve symlink comments Make it clear that both diskRoot and publicPath get the same prefix appended. In the possibleTenantSymlinks() docblock, correct the array example (the values are not just 'disk root' anymore -- if the disk has a prefix, it will be appended to the configured root). --- src/Concerns/DealsWithTenantSymlinks.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 3bb267c8..a53c2e51 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -16,7 +16,7 @@ use Stancl\Tenancy\Contracts\Tenant; trait DealsWithTenantSymlinks { /** - * Get all possible tenant symlinks, existing or not (array of ['public path' => 'disk root']). + * Get all possible tenant symlinks, existing or not (array of ['public path' => 'disk root with the disk's prefix appended']). * * Tenants can have a symlink for each local disk that is listed * in both tenancy.filesystem.disks and tenancy.filesystem.url_override. @@ -61,9 +61,11 @@ trait DealsWithTenantSymlinks $diskRoot = $tenantDisks[$disk]['root']; if ($prefix = trim($disks[$disk]['prefix'] ?? '', '/\\')) { + // Append the disk's prefix to the disk root $diskRoot = rtrim($diskRoot, '/\\') . DIRECTORY_SEPARATOR . $prefix; - // Storage::url() appends the disk's prefix to the url, so the prefix has to be in the public path as well + // Append the same prefix to the public path. + // Storage::url() appends the disk's prefix to the url, so the prefix has to be in the public path as well. $publicPath .= DIRECTORY_SEPARATOR . $prefix; } From 9ec732cfce239f661bcf080530f6cff5dbc19c97 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 11 Sep 2026 07:05:10 +0200 Subject: [PATCH 65/70] rtrim directory and public root in remove symlinks action realpath() already returns paths without the trailing separator -- rtrim is used just so that the code is more self=documenting. --- src/Actions/RemoveStorageSymlinksAction.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index 14964eae..6f254d76 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -66,7 +66,7 @@ class RemoveStorageSymlinksAction // Remove the directories CreateStorageSymlinksAction created for the symlink // until a non-empty one is reached. - while (str_starts_with($directory, $publicRoot . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { + while (str_starts_with(rtrim($directory, '/\\'), rtrim($publicRoot, '/\\') . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { $files->deleteDirectory($directory); $directory = dirname($directory); From 42df25ac392d8919c15c3a9312d05073a8609a92 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 11 Sep 2026 07:05:31 +0200 Subject: [PATCH 66/70] Update removeNestedDirectories docblock --- src/Actions/RemoveStorageSymlinksAction.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index 6f254d76..ce808ab4 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -18,7 +18,8 @@ class RemoveStorageSymlinksAction /** * Should the directories created for nested symlinks be removed along with the symlink. * - * Before enabling this, make sure you understand the removeLink() method and its implications. + * Before enabling this, make sure you understand the removeLink() method + * and the high stakes of recursively removing parent directories (even if the logic should be sound). * * @see CreateStorageSymlinksAction */ From 1a3ab476b074fe769a25b2a89f1468bf21471cfa Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 14 Sep 2026 12:57:37 +0200 Subject: [PATCH 67/70] In the asset controller, throw if tenancy isn't initialized Instead of falling back to returning storage_path('app/public') in assetRoot(), throw an exception *at the start of the method* -- tenant assets shouldn't be served in central context. --- src/Controllers/TenantAssetController.php | 13 ++++++------- tests/TenantAssetTest.php | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/Controllers/TenantAssetController.php b/src/Controllers/TenantAssetController.php index 16e8a433..af222218 100644 --- a/src/Controllers/TenantAssetController.php +++ b/src/Controllers/TenantAssetController.php @@ -85,13 +85,16 @@ class TenantAssetController implements HasMiddleware /** * Directory the assets are served from -- the root of the $publicDisk, or app/public - * inside the tenant's storage directory when no disk is configured. When no disk is - * configured and there's no current tenant, the central app/public is used. + * inside the tenant's storage directory when no disk is configured. * * The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper::getTenantStoragePath(). */ protected function assetRoot(): string { + if (! tenant()) { + throw new Exception('Tenant assets can only be served in tenant context.'); + } + if (static::$publicDisk) { $disk = Storage::disk(static::$publicDisk); @@ -118,11 +121,7 @@ class TenantAssetController implements HasMiddleware return rtrim($disk->path(''), DIRECTORY_SEPARATOR); } - if ($tenant = tenant()) { - return FilesystemTenancyBootstrapper::getTenantStoragePath($tenant) . '/app/public'; - } - - return storage_path('app/public'); + return FilesystemTenancyBootstrapper::getTenantStoragePath(tenant()) . '/app/public'; } /** diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index ed7feaa8..a4e169b7 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -17,6 +17,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Controllers\TenantAssetController; +use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; @@ -180,6 +181,23 @@ test('tenant asset controller throws when the configured disk is not local or no } }); +test('tenant assets cannot be served in central context', function () { + config([ + 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, + // Make the asset route skip tenant identification + 'tenancy.default_route_mode' => RouteMode::UNIVERSAL, + ]); + + $this->withoutExceptionHandling(); + + foreach ([null, 'local'] as $publicDisk) { + TenantAssetController::$publicDisk = $publicDisk; + + expect(fn () => pest()->get(tenant_asset('foo.txt'))) + ->toThrow(Exception::class, 'Tenant assets can only be served in tenant context.'); + } +}); + test('tenant assets are served from the resolved root of the configured disk', function () { config([ 'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class, From 388f253964ad5fc27ab34f8309f9b7fbfa898882 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 14 Sep 2026 16:39:33 +0200 Subject: [PATCH 68/70] Assert symlink jobs dispatch events Also cover RemoveStorageSymlinksAction not doing anything if there's no existing symlink --- tests/ActionTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 8d5a8ac1..a885819d 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -4,6 +4,10 @@ declare(strict_types=1); use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Events\TenancyEnded; +use Stancl\Tenancy\Events\StorageSymlinkCreated; +use Stancl\Tenancy\Events\StorageSymlinkRemoved; +use Stancl\Tenancy\Events\CreatingStorageSymlink; +use Stancl\Tenancy\Events\RemovingStorageSymlink; use Stancl\Tenancy\Database\Models\Tenant; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; @@ -53,8 +57,13 @@ test('create storage symlinks action works', function (string|null $rootOverride Storage::disk('public')->put('foo.txt', 'tenant file'); + Event::fake([CreatingStorageSymlink::class, StorageSymlinkCreated::class]); + (new CreateStorageSymlinksAction)($tenant); + Event::assertDispatched(CreatingStorageSymlink::class, fn (CreatingStorageSymlink $event) => $event->tenant->is($tenant)); + Event::assertDispatched(StorageSymlinkCreated::class, fn (StorageSymlinkCreated $event) => $event->tenant->is($tenant)); + // The symlink exists and points to the directory the tenant's disk writes to expect(is_link($publicPath))->toBeTrue(); expect(readlink($publicPath))->toBe(config('filesystems.disks.public.root')); @@ -114,11 +123,25 @@ test('remove storage symlinks action works', function() { expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); expect(file_exists($publicPath))->toBeTrue(); + Event::fake([RemovingStorageSymlink::class, StorageSymlinkRemoved::class]); + (new RemoveStorageSymlinksAction)($tenant); + Event::assertDispatched(RemovingStorageSymlink::class, fn (RemovingStorageSymlink $event) => $event->tenant->is($tenant)); + Event::assertDispatched(StorageSymlinkRemoved::class, fn (StorageSymlinkRemoved $event) => $event->tenant->is($tenant)); + // The symlink doesn't exist expect(is_link($publicPath))->toBeFalse(); expect(file_exists($publicPath))->toBeFalse(); + + // Flush + Event::fake([RemovingStorageSymlink::class, StorageSymlinkRemoved::class]); + + // Nothing happens when there are no symlinks + (new RemoveStorageSymlinksAction)($tenant); + + Event::assertNotDispatched(RemovingStorageSymlink::class); + Event::assertNotDispatched(StorageSymlinkRemoved::class); }); test('removing tenant symlinks works even if the symlinks are invalid', function() { From 35d96b5b6f075e0ea24633378c938b82a101bfff Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 15 Sep 2026 11:09:40 +0200 Subject: [PATCH 69/70] Use rmdir instead of deleteDirectory rmdir refuses to delete non-empty directories, so it's the better alternative to deleteDirectory here. --- src/Actions/RemoveStorageSymlinksAction.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index ce808ab4..d04faf5c 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -68,7 +68,10 @@ class RemoveStorageSymlinksAction // Remove the directories CreateStorageSymlinksAction created for the symlink // until a non-empty one is reached. while (str_starts_with(rtrim($directory, '/\\'), rtrim($publicRoot, '/\\') . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { - $files->deleteDirectory($directory); + if (! @rmdir($directory)) { + // Stop the loop if the directory couldn't be removed + break; + } $directory = dirname($directory); } From 1ef7710da4307514ec931d32b25536183f51b6eb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 18 Sep 2026 10:58:01 +0200 Subject: [PATCH 70/70] remove directory rtrim The rtrim is redundant, it doesn't communicate anything useful. --- src/Actions/RemoveStorageSymlinksAction.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index d04faf5c..477a5c26 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -67,7 +67,7 @@ class RemoveStorageSymlinksAction // Remove the directories CreateStorageSymlinksAction created for the symlink // until a non-empty one is reached. - while (str_starts_with(rtrim($directory, '/\\'), rtrim($publicRoot, '/\\') . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { + while (str_starts_with($directory, rtrim($publicRoot, '/\\') . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) { if (! @rmdir($directory)) { // Stop the loop if the directory couldn't be removed break;