Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/Bootstrappers/CacheTenancyBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@

/**
* 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
{
Expand Down
5 changes: 4 additions & 1 deletion src/Bootstrappers/DatabaseCacheBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
*
* 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
{
Expand Down
52 changes: 47 additions & 5 deletions src/TenancyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

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;
Expand Down Expand Up @@ -86,14 +88,26 @@ public function register(): void
// 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);

// 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);

// 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);
}
Expand All @@ -102,6 +116,34 @@ public function register(): void
});
}

/**
* 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));
Comment on lines +130 to +143

@coderabbitai coderabbitai Bot Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid materializing every database cache store in globalCache.

This loop resolves $manager->store($name) for every configured database cache store each time globalCache is built. That turns unused database stores into hard dependencies of global_cache() and adds connection/setup cost even when callers only use a different store. Keep the central-connection fix lazy per requested store instead of eagerly instantiating the full cache.stores set.

🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 128-128: Avoid excessively long variable names like $notAValidDatabaseStore. Keep variable name length under 20. (undefined)

(LongVariable)


[error] 139-139: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'makeDatabaseCacheStoresCentral'. (undefined)

(StaticAccess)


[error] 140-140: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'makeDatabaseCacheStoresCentral'. (undefined)

(StaticAccess)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TenancyServiceProvider.php` around lines 127 - 140, The loop eagerly
instantiates every database cache store by calling
$manager->store($name)->getStore(), causing unnecessary connections; instead,
avoid materialization by registering a lazy decorator for each configured
database store using $manager->extend($name, ...), and inside that extender
wrap/return the real store and call setConnection(...) and
setLockConnection(...) on the DatabaseStore using $store['connection'] ??
$centralConnection and $store['lock_connection'] ?? $store['connection'] ??
$centralConnection so the central-connection fallback is applied only when a
store is actually resolved; reference $manager->extend, DatabaseStore,
setConnection, setLockConnection, $store and $centralConnection to locate and
implement the change.

✅ Confirmed as addressed by @lukinovec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this logic should mimic DatabaseCacheBootstrapper's $adjustGlobalCacheManager in a way. That also modifies all "scoped stores" (see the scopedStoreNames method).

With that in mind, is the thing you mention still a problem?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i 'DatabaseCacheBootstrapper.php' --exec cat {}

Length of output: 8917



@lukinovec, you're right — the concern is substantially mitigated. Looking at DatabaseCacheBootstrapper, the $adjustCacheManagerUsing callback it sets does the exact same thing: it iterates all scoped stores and calls $manager->store($storeName)->getStore() eagerly. scopedStoreNames() also resolves cache.stores up-front. So the eager-materialization pattern is already established and accepted in this codebase.

The one remaining (minor) difference is that makeDatabaseCacheStoresCentral touches all configured database stores unconditionally, whereas DatabaseCacheBootstrapper's callback only touches the stores it actually scoped ($originalConnections). When DatabaseCacheBootstrapper::$stores is null (the default), both sets are identical, so in practice there's no real difference. Either way, the concern doesn't introduce a new problem — the trade-off is pre-existing. I'll mark this resolved.


🧬 Code Graph Analysis Results

Relevant snippets

src/TenancyServiceProvider.php

Line 29-30

/** `@internal` */
public static Closure|null $adjustCacheManagerUsing = null;

Line 80-113

$this->app->bind('globalCache', function ($app) {
    // We create a separate CacheManager to be used for "global" cache -- cache that
    // is always central, regardless of the current context.
    //
    // Importantly, we use a regular binding here, not a singleton. Thanks to that,
    // any time we resolve this cache manager, we get a *fresh* instance -- an instance
    // that was not affected by any scoping logic.
    //
    // 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.
    //
    // For that reason, we also adjust the relevant stores on this new CacheManager
    // 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);
    }

    return $manager;
});

Line 123-142

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));
    }
}

🧠 Learnings used
Learnt from: lukinovec
Repo: archtechx/tenancy PR: 1437
File: src/Features/UserImpersonation.php:88-90
Timestamp: 2026-04-13T14:44:48.257Z
Learning: In the `archtechx/tenancy` package, prefer using generic `\Exception` for feature-level errors as established in the codebase. Only introduce dedicated exception classes when they will be reused across multiple throw sites (or provide broader reuse). Do not recommend creating a new dedicated exception class for a single-use throw in this package.

Learnt from: lukinovec
Repo: archtechx/tenancy PR: 1446
File: src/Jobs/DeleteTenantStorage.php:43-44
Timestamp: 2026-04-13T15:14:03.457Z
Learning: When using `File::deleteDirectory($path)` from `archtechx/tenancy`, note its return behavior: it only returns `false` when `$path` is *not* a directory. If the call site is already guarded with `is_dir($path)` (or otherwise guarantees `$path` is a directory), then checking the boolean return value of `File::deleteDirectory($path)` for error handling is unnecessary because, after the directory-existence check passes, the method suppresses internal `rmdir` errors and unconditionally returns `true`. Prefer handling issues via upstream validation/guards (e.g., ensure the path is a directory) rather than relying on the return value in that code path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me the crucial difference here seems to be that DatabaseCacheBootstrapper undoes what it does — only for the stores which it touches — for the global cache manager purposes. Whereas here we loop over every single database-based cache store.

In the vast majority of apps there are only going to be one or two database-based cache stores so this isn't a huge issue performance-wise, but the approach does feel a bit crude and if possible I'd prefer something cleaner.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stancl, the core issue is that the current loop materializes every database-driver store, including those that already carry an explicit connection value in config and therefore can never be affected by DatabaseTenancyBootstrapper switching the default connection. The only stores that actually need correction are those whose connection (or lock_connection) config is null, because null means "use the current default DB connection" — which is the value that gets flipped to tenant.

A targeted version that skips already-correct stores:

protected function makeDatabaseCacheStoresCentral(CacheManager $manager): void
{
    $centralConnection = $this->app['config']['tenancy.database.central_connection'];

    foreach ($this->app['config']['cache.stores'] ?? [] as $name => $store) {
        if (! is_array($store) || ($store['driver'] ?? null) !== 'database') {
            continue;
        }

        $configuredConnection     = $store['connection']      ?? null;
        $configuredLockConnection = $store['lock_connection'] ?? null;

        // If both the connection and lock_connection are explicitly configured,
        // they can never default to the current DB connection, so no fix is needed.
        if ($configuredConnection !== null && $configuredLockConnection !== null) {
            continue;
        }

        /** `@var` DatabaseStore $databaseStore */
        $databaseStore = $manager->store($name)->getStore();

        if ($configuredConnection === null) {
            $databaseStore->setConnection(DB::connection($centralConnection));
        }

        if ($configuredLockConnection === null) {
            $databaseStore->setLockConnection(
                DB::connection($configuredConnection ?? $centralConnection)
            );
        }
    }
}

Key changes from the current version:

  • Stores with both connection and lock_connection explicitly set are skipped entirely — no eager materialization.
  • setConnection / setLockConnection are only called when the relevant config key is null (the case that actually causes the wrong connection to be used).
  • In most apps the default cache store has connection: null, so typically only that one store is materialized.

This mirrors the spirit of DatabaseCacheBootstrapper — touch only what actually needs fixing — without requiring knowledge of which stores were previously scoped.


🧠 Learnings used
Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Applies to config/tenancy.php : Configure central config in `config/tenancy.php` to control tenant/domain model classes, database connection settings, enabled bootstrappers and features, identification middleware and resolvers, and cache and storage prefixes

Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Use `TenancyServiceProvider` (not the stub) to register services, commands, and bootstrappers

Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Use `DatabaseTenancyBootstrapper` to switch database connections for tenants

Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Use `tenancy()->central($callback)` to explicitly run code in the central context

Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Use `CacheTenancyBootstrapper` to isolate cache by tenant

Learnt from: CR
Repo: archtechx/tenancy PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-13T21:58:36.731Z
Learning: Use the `Tenancy` main orchestrator class to manage tenant context and lifecycle in Tenancy for Laravel

Learnt from: lukinovec
Repo: archtechx/tenancy PR: 1437
File: src/Features/UserImpersonation.php:88-90
Timestamp: 2026-04-13T14:44:48.257Z
Learning: In the `archtechx/tenancy` package, prefer using generic `\Exception` for feature-level errors as established in the codebase. Only introduce dedicated exception classes when they will be reused across multiple throw sites (or provide broader reuse). Do not recommend creating a new dedicated exception class for a single-use throw in this package.

Learnt from: lukinovec
Repo: archtechx/tenancy PR: 1446
File: src/Jobs/DeleteTenantStorage.php:43-44
Timestamp: 2026-04-13T15:14:03.457Z
Learning: When using `File::deleteDirectory($path)` from `archtechx/tenancy`, note its return behavior: it only returns `false` when `$path` is *not* a directory. If the call site is already guarded with `is_dir($path)` (or otherwise guarantees `$path` is a directory), then checking the boolean return value of `File::deleteDirectory($path)` for error handling is unnecessary because, after the directory-existence check passes, the method suppresses internal `rmdir` errors and unconditionally returns `true`. Prefer handling issues via upstream validation/guards (e.g., ensure the path is a directory) rather than relying on the return value in that code path.

}
}

/* Bootstrap services. */
public function boot(): void
{
Expand Down
1 change: 1 addition & 0 deletions tests/CachedTenantResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
['redis', [CacheTenancyBootstrapper::class]],
['redis', [CacheTagsBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]);

test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {
Expand Down
1 change: 1 addition & 0 deletions tests/GlobalCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
['redis', [CacheTagsBootstrapper::class]],
['redis', [CacheTenancyBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
])->with([
'helper',
'facade',
Expand Down
Loading