From 0cf7043b733848b6d139de967df676e30247323e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 1 Jun 2026 13:36:18 +0200 Subject: [PATCH 1/8] Add datasets to globalCache/invalidation tests as regression --- tests/CachedTenantResolverTest.php | 1 + tests/GlobalCacheTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 920c95a1..fc6cfb79 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -165,6 +165,7 @@ test('cache is invalidated when tenant is updated from within the tenant context ['redis', [CacheTenancyBootstrapper::class]], ['redis', [CacheTagsBootstrapper::class]], ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]], ]); test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) { diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 016ad2a4..4cda8b74 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -165,6 +165,7 @@ test('global cache is always central', function (string $store, array $bootstrap ['redis', [CacheTagsBootstrapper::class]], ['redis', [CacheTenancyBootstrapper::class]], ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]], + ['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]], ])->with([ 'helper', 'facade', From 5e65c67ea0daf98f57f2a6a7b0e1937bbc397a56 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 1 Jun 2026 13:47:52 +0200 Subject: [PATCH 2/8] Make globalCache always use the central connection --- src/TenancyServiceProvider.php | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index afd20fb6..aeaa0855 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,9 +6,11 @@ namespace Stancl\Tenancy; use Closure; use Illuminate\Cache\CacheManager; +use Illuminate\Cache\DatabaseStore; use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -94,6 +96,15 @@ class TenancyServiceProvider extends ServiceProvider // using the callback below. It is set by DatabaseCacheBootstrapper. $manager = new CacheManager($app); + // Make globalCache use either the configured non-null connection, + // or fall back to the central connection. + $this->makeDatabaseCacheStoresCentral($manager); + + // If a bootstrapper (like DatabaseCacheBootstrapper) makes the + // cache connection tenant explicitly, the makeDatabaseCacheStoresCentral() + // call ends up setting the tenant connection rather than the central one, + // and the $adjustCacheManagerUsing callback is needed to + // make globalCache use the central connection. if (static::$adjustCacheManagerUsing !== null) { (static::$adjustCacheManagerUsing)($manager); } @@ -102,6 +113,34 @@ class TenancyServiceProvider extends ServiceProvider }); } + /** + * Ensure globalCache uses the central connection for database cache stores. + * + * A freshly built CacheManager creates database stores using the current default connection, which + * DatabaseTenancyBootstrapper switches to the tenant connection. Since global cache should always be + * central, reset those stores back to their configured connection, falling back to the central one. + */ + protected function makeDatabaseCacheStoresCentral(CacheManager $manager): void + { + $centralConnection = $this->app['config']['tenancy.database.central_connection']; + + foreach ($this->app['config']['cache.stores'] ?? [] as $name => $store) { + $notAValidDatabaseStore = ! is_array($store) || ($store['driver'] ?? null) !== 'database'; + + if ($notAValidDatabaseStore) { + continue; + } + + /** @var DatabaseStore $databaseStore */ + $databaseStore = $manager->store($name)->getStore(); + + // If $store['connection'] is null, it defaults to the default DB connection (which may be tenant). + // Fall back to the central connection to keep the global cache central. + $databaseStore->setConnection(DB::connection($store['connection'] ?? $centralConnection)); + $databaseStore->setLockConnection(DB::connection($store['lock_connection'] ?? $store['connection'] ?? $centralConnection)); + } + } + /* Bootstrap services. */ public function boot(): void { From 20036ef1ff051d938de84f206e1c1ef03a08c8eb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 1 Jun 2026 15:38:27 +0200 Subject: [PATCH 3/8] Clean up $adjustCacheManagerUsing in before and afterEach --- tests/CachedTenantResolverTest.php | 2 ++ tests/GlobalCacheTest.php | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index fc6cfb79..26c4f875 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -24,12 +24,14 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; +use Stancl\Tenancy\TenancyServiceProvider; use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\withCacheTables; use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach($cleanup = function () { Tenant::$extraCustomColumns = []; + TenancyServiceProvider::$adjustCacheManagerUsing = null; }); afterEach($cleanup); diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 4cda8b74..2bf0e3c5 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -13,11 +13,14 @@ use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\TenancyServiceProvider; use function Stancl\Tenancy\Tests\withCacheTables; use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { + TenancyServiceProvider::$adjustCacheManagerUsing = null; + config([ 'cache.default' => 'redis', 'tenancy.cache.stores' => ['redis'], @@ -29,6 +32,10 @@ beforeEach(function () { withCacheTables(); }); +afterEach(function () { + TenancyServiceProvider::$adjustCacheManagerUsing = null; +}); + test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) { config([ 'cache.default' => $store, From 566f50440ae385b7cc75dde0bbc40361f315e92a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 5 Jun 2026 07:18:42 +0200 Subject: [PATCH 4/8] Improve comments --- .../DatabaseCacheBootstrapper.php | 5 +++- src/TenancyServiceProvider.php | 27 ++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php index 0e41849f..0ec29266 100644 --- a/src/Bootstrappers/DatabaseCacheBootstrapper.php +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -25,7 +25,10 @@ use Stancl\Tenancy\TenancyServiceProvider; * * Notably, this bootstrapper sets TenancyServiceProvider::$adjustCacheManagerUsing to a callback * that ensures all affected stores still use the central connection when accessed via global cache - * (typicaly the GlobalCache facade or global_cache() helper). + * (typically the GlobalCache facade or global_cache() helper), even though this bootstrapper explicitly + * sets the connection to tenant for all scoped cache stores. TenancyServiceProvider::makeDatabaseCacheStoresCentral() + * cannot fix globalCache on its own because it reads 'tenant' from config (set by this bootstrapper), not null, + * so the callback is still needed to correct the connection to central for globalCache. */ class DatabaseCacheBootstrapper implements TenancyBootstrapper { diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index aeaa0855..d0316ed9 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -88,23 +88,26 @@ class TenancyServiceProvider extends ServiceProvider // This works great for cache stores that are *directly* scoped, like Redis or // any other tagged or prefixed stores, but it doesn't work for the database driver. // - // When we use the DatabaseTenancyBootstrapper, it changes the default connection, - // and therefore the connection of the database store that will be created when - // this new CacheManager is instantiated again. + // When DatabaseTenancyBootstrapper is used, it changes the default DB connection + // to 'tenant'. A freshly created CacheManager would therefore instantiate database + // stores with the tenant connection. // - // For that reason, we also adjust the relevant stores on this new CacheManager - // using the callback below. It is set by DatabaseCacheBootstrapper. + // For that reason, we adjust the relevant stores on this new CacheManager + // using the makeDatabaseCacheStoresCentral() method and the $adjustCacheManagerUsing callback below + // (set by DatabaseCacheBootstrapper). $manager = new CacheManager($app); - // Make globalCache use either the configured non-null connection, - // or fall back to the central connection. + // When DatabaseTenancyBootstrapper is used, database stores whose 'connection' + // config is null fall back to the default DB connection ('tenant'). Reset each + // such store to its explicitly configured connection, or fall back to central. $this->makeDatabaseCacheStoresCentral($manager); - // If a bootstrapper (like DatabaseCacheBootstrapper) makes the - // cache connection tenant explicitly, the makeDatabaseCacheStoresCentral() - // call ends up setting the tenant connection rather than the central one, - // and the $adjustCacheManagerUsing callback is needed to - // make globalCache use the central connection. + // DatabaseCacheBootstrapper explicitly writes 'tenant' into each store's 'connection' + // config. makeDatabaseCacheStoresCentral() above would then read 'tenant' as the + // configured value (not null) and use it directly, so the central connection fallback + // wouldn't be used. + // + // This callback is used to correct those connections back to central for globalCache. if (static::$adjustCacheManagerUsing !== null) { (static::$adjustCacheManagerUsing)($manager); } From 5e708bbaec11174a46148413381a859f010707ee Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Sat, 6 Jun 2026 15:03:10 -0700 Subject: [PATCH 5/8] remove seemingly unnecessary static prop resets from tests --- tests/CachedTenantResolverTest.php | 2 -- tests/GlobalCacheTest.php | 7 ------- 2 files changed, 9 deletions(-) diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 26c4f875..fc6cfb79 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -24,14 +24,12 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; -use Stancl\Tenancy\TenancyServiceProvider; use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\withCacheTables; use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach($cleanup = function () { Tenant::$extraCustomColumns = []; - TenancyServiceProvider::$adjustCacheManagerUsing = null; }); afterEach($cleanup); diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 2bf0e3c5..4cda8b74 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -13,14 +13,11 @@ use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\TenancyServiceProvider; use function Stancl\Tenancy\Tests\withCacheTables; use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { - TenancyServiceProvider::$adjustCacheManagerUsing = null; - config([ 'cache.default' => 'redis', 'tenancy.cache.stores' => ['redis'], @@ -32,10 +29,6 @@ beforeEach(function () { withCacheTables(); }); -afterEach(function () { - TenancyServiceProvider::$adjustCacheManagerUsing = null; -}); - test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) { config([ 'cache.default' => $store, From 602a151104092803245e42359ffb77d488c9ed2e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 14:18:30 +0200 Subject: [PATCH 6/8] Expand CacheTenancyBootstrapper docblock Mention that it's not intended to be used with database cache stores (it *can* be used with db cache stores, there's just a little unexpected behavior). --- src/Bootstrappers/CacheTenancyBootstrapper.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 97bd7d24..d13ab9b5 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -16,6 +16,15 @@ use Stancl\Tenancy\Contracts\Tenant; /** * Makes cache tenant-aware by applying a prefix. + * + * Using this bootstrapper together with DatabaseTenancyBootstrapper + * with a database cache store results in double scoping. The store is scoped by + * DB connection (entries go into the tenant's database) *and* by the prefix. This is + * harmless for most use cases, but can produce unexpected behavior. + * + * If you're using a database cache store, use DatabaseCacheBootstrapper instead of this one. + * + * @see Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper */ class CacheTenancyBootstrapper implements TenancyBootstrapper { From f65b05549f568bd8ebb2aeb8f01bce2a9dea7fad Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 12 Jun 2026 16:21:50 +0200 Subject: [PATCH 7/8] Use extend() to override database cache store creation for globalCache Instead of looping through all database cache stores and setting their connection to central, use extend() to create the database stores lazily, using central connection --- .../DatabaseCacheBootstrapper.php | 2 +- src/TenancyServiceProvider.php | 47 +++++-------------- 2 files changed, 13 insertions(+), 36 deletions(-) diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php index 0ec29266..391ab866 100644 --- a/src/Bootstrappers/DatabaseCacheBootstrapper.php +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -26,7 +26,7 @@ use Stancl\Tenancy\TenancyServiceProvider; * Notably, this bootstrapper sets TenancyServiceProvider::$adjustCacheManagerUsing to a callback * that ensures all affected stores still use the central connection when accessed via global cache * (typically the GlobalCache facade or global_cache() helper), even though this bootstrapper explicitly - * sets the connection to tenant for all scoped cache stores. TenancyServiceProvider::makeDatabaseCacheStoresCentral() + * sets the connection to tenant for all scoped cache stores. Extending database store on the global cache manager * cannot fix globalCache on its own because it reads 'tenant' from config (set by this bootstrapper), not null, * so the callback is still needed to correct the connection to central for globalCache. */ diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index d0316ed9..621075ae 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,11 +6,9 @@ namespace Stancl\Tenancy; use Closure; use Illuminate\Cache\CacheManager; -use Illuminate\Cache\DatabaseStore; use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -92,18 +90,25 @@ class TenancyServiceProvider extends ServiceProvider // to 'tenant'. A freshly created CacheManager would therefore instantiate database // stores with the tenant connection. // - // For that reason, we adjust the relevant stores on this new CacheManager - // using the makeDatabaseCacheStoresCentral() method and the $adjustCacheManagerUsing callback below - // (set by DatabaseCacheBootstrapper). + // For that reason, we override the 'database' driver creator on this manager so that + // database stores are built with the central connection, and we run the + // $adjustCacheManagerUsing callback below (set by DatabaseCacheBootstrapper). $manager = new CacheManager($app); // When DatabaseTenancyBootstrapper is used, database stores whose 'connection' // config is null fall back to the default DB connection ('tenant'). Reset each // such store to its explicitly configured connection, or fall back to central. - $this->makeDatabaseCacheStoresCentral($manager); + $centralConnection = $app['config']['tenancy.database.central_connection']; + + $manager->extend('database', function ($app, array $config) use ($centralConnection) { + $config['connection'] ??= $centralConnection; + + /** @var CacheManager $this */ + return $this->createDatabaseDriver($config); + }); // DatabaseCacheBootstrapper explicitly writes 'tenant' into each store's 'connection' - // config. makeDatabaseCacheStoresCentral() above would then read 'tenant' as the + // config. The database store extend above would then read 'tenant' as the // configured value (not null) and use it directly, so the central connection fallback // wouldn't be used. // @@ -116,34 +121,6 @@ class TenancyServiceProvider extends ServiceProvider }); } - /** - * Ensure globalCache uses the central connection for database cache stores. - * - * A freshly built CacheManager creates database stores using the current default connection, which - * DatabaseTenancyBootstrapper switches to the tenant connection. Since global cache should always be - * central, reset those stores back to their configured connection, falling back to the central one. - */ - protected function makeDatabaseCacheStoresCentral(CacheManager $manager): void - { - $centralConnection = $this->app['config']['tenancy.database.central_connection']; - - foreach ($this->app['config']['cache.stores'] ?? [] as $name => $store) { - $notAValidDatabaseStore = ! is_array($store) || ($store['driver'] ?? null) !== 'database'; - - if ($notAValidDatabaseStore) { - continue; - } - - /** @var DatabaseStore $databaseStore */ - $databaseStore = $manager->store($name)->getStore(); - - // If $store['connection'] is null, it defaults to the default DB connection (which may be tenant). - // Fall back to the central connection to keep the global cache central. - $databaseStore->setConnection(DB::connection($store['connection'] ?? $centralConnection)); - $databaseStore->setLockConnection(DB::connection($store['lock_connection'] ?? $store['connection'] ?? $centralConnection)); - } - } - /* Bootstrap services. */ public function boot(): void { From ff8f73ec085a7d2e1727860b8ed6f459793b79ce Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 13 Jun 2026 07:25:16 +0200 Subject: [PATCH 8/8] Ignore PHPStan error PHPStan complains that createDatabaseDriver is a protected method on CacheManager. Since PHPStan doesn't handle $this in the extend()'s closure well, I'm adding an ignore. --- src/TenancyServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 621075ae..c1c3e1fb 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -104,7 +104,7 @@ class TenancyServiceProvider extends ServiceProvider $config['connection'] ??= $centralConnection; /** @var CacheManager $this */ - return $this->createDatabaseDriver($config); + return $this->createDatabaseDriver($config); // @phpstan-ignore method.protected }); // DatabaseCacheBootstrapper explicitly writes 'tenant' into each store's 'connection'