1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-04 10:24:03 +00:00

Merge remote-tracking branch 'origin/master' into vite-asset-path-fix

This commit is contained in:
Samuel Štancl 2025-08-25 16:08:14 +02:00
commit 6974147042
18 changed files with 695 additions and 52 deletions

View file

@ -8,14 +8,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Check for todo0 - name: Check for priority todos
run: '! grep -r "todo0" --exclude-dir=workflows .' run: '! grep -r "todo[0-9]" --exclude-dir=workflows .'
if: always()
- name: Check for todo1
run: '! grep -r "todo1" --exclude-dir=workflows .'
if: always()
- name: Check for todo2
run: '! grep -r "todo2" --exclude-dir=workflows .'
if: always() if: always()
- name: Check for non-todo skip()s in tests - name: Check for non-todo skip()s in tests
run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"' run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"'

View file

@ -43,3 +43,9 @@ If you need to rebuild the container for any reason (e.g. a change in `Dockerfil
## PHPStan ## PHPStan
Use `composer phpstan` to run our phpstan suite. Use `composer phpstan` to run our phpstan suite.
## PhpStorm
Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run).
If you want to use XDebug, use `composer docker-rebuild-with-xdebug`.

View file

@ -30,4 +30,16 @@ RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini"
# Only used on GHA # Only used on GHA
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Conditionally install and configure Xdebug (last step for faster rebuilds)
ARG XDEBUG_ENABLED=false
RUN if [ "$XDEBUG_ENABLED" = "true" ]; then \
pecl install xdebug && docker-php-ext-enable xdebug && \
echo "xdebug.mode=debug" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
echo "xdebug.start_with_request=yes" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
echo "xdebug.client_host=host.docker.internal" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
echo "xdebug.client_port=9003" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
echo "xdebug.discover_client_host=true" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \
echo "xdebug.log=/var/log/xdebug.log" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini"; \
fi
WORKDIR /var/www/html WORKDIR /var/www/html

View file

@ -63,7 +63,14 @@
"docker-up": "docker compose up -d", "docker-up": "docker compose up -d",
"docker-down": "docker compose down", "docker-down": "docker compose down",
"docker-restart": "docker compose down && docker compose up -d", "docker-restart": "docker compose down && docker compose up -d",
"docker-rebuild": "PHP_VERSION=8.4 docker compose up -d --no-deps --build", "docker-rebuild": [
"Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 docker compose up -d --no-deps --build"
],
"docker-rebuild-with-xdebug": [
"Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 XDEBUG_ENABLED=true docker compose up -d --no-deps --build"
],
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor",
@ -72,10 +79,22 @@
"phpstan": "vendor/bin/phpstan --memory-limit=256M", "phpstan": "vendor/bin/phpstan --memory-limit=256M",
"phpstan-pro": "vendor/bin/phpstan --memory-limit=256M --pro", "phpstan-pro": "vendor/bin/phpstan --memory-limit=256M --pro",
"cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.php", "cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.php",
"test": "./test --no-coverage", "test": [
"test-full": "./test", "Composer\\Config::disableProcessTimeout",
"act": "act -j tests --matrix 'laravel:^11.0'", "./test --no-coverage"
"act-input": "act -j tests --matrix 'laravel:^11.0' --input" ],
"test-full": [
"Composer\\Config::disableProcessTimeout",
"./test"
],
"act": [
"Composer\\Config::disableProcessTimeout",
"act -j tests --matrix 'laravel:^11.0'"
],
"act-input": [
"Composer\\Config::disableProcessTimeout",
"act -j tests --matrix 'laravel:^11.0' --input"
]
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,

View file

@ -2,6 +2,8 @@ services:
test: test:
build: build:
context: . context: .
args:
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@ -18,7 +20,8 @@ services:
dynamodb: dynamodb:
condition: service_healthy condition: service_healthy
volumes: volumes:
- .:/var/www/html:cached - .:${PROJECT_PATH:-$PWD}:cached
working_dir: ${PROJECT_PATH:-$PWD}
environment: environment:
DOCKER: 1 DOCKER: 1
DB_PASSWORD: password DB_PASSWORD: password
@ -30,6 +33,8 @@ services:
TENANCY_TEST_SQLSRV_HOST: mssql TENANCY_TEST_SQLSRV_HOST: mssql
TENANCY_TEST_SQLSRV_USERNAME: sa TENANCY_TEST_SQLSRV_USERNAME: sa
TENANCY_TEST_SQLSRV_PASSWORD: P@ssword TENANCY_TEST_SQLSRV_PASSWORD: P@ssword
extra_hosts:
- "host.docker.internal:host-gateway"
stdin_open: true stdin_open: true
tty: true tty: true
mysql: mysql:

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Exception;
use Illuminate\Cache\CacheManager;
use Illuminate\Cache\DatabaseStore;
use Illuminate\Config\Repository;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\TenancyServiceProvider;
/**
* This bootstrapper allows cache to be stored in tenant databases by switching the database
* connection used by cache stores that use the database driver.
*
* Can be used instead of CacheTenancyBootstrapper.
*
* By default, this bootstrapper scopes ALL cache stores that use the database driver. If you only
* want to scope SOME stores, set the static $stores property to an array of names of the stores
* you want to scope. These stores must use 'database' as their driver.
*
* 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).
*/
class DatabaseCacheBootstrapper implements TenancyBootstrapper
{
/**
* Cache stores to scope.
*
* If null, all cache stores that use the database driver will be scoped.
* If an array, only the specified stores will be scoped. These all must use the database driver.
*/
public static array|null $stores = null;
/**
* Should scoped stores be adjusted on the global cache manager to use the central connection.
*
* You may want to set this to false if you don't use the built-in global cache and instead provide
* a list of stores to scope (static::$stores), with your own global store excluded that you then
* use manually. But in such a scenario you likely wouldn't be using global cache at all which means
* the callbacks for adjusting it wouldn't be executed in the first place.
*/
public static bool $adjustGlobalCacheManager = true;
public function __construct(
protected Repository $config,
protected CacheManager $cache,
protected array $originalConnections = [],
protected array $originalLockConnections = []
) {}
public function bootstrap(Tenant $tenant): void
{
if (! config('database.connections.tenant')) {
throw new Exception('DatabaseCacheBootstrapper must run after DatabaseTenancyBootstrapper.');
}
$stores = $this->scopedStoreNames();
foreach ($stores as $storeName) {
$this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection");
$this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection");
$this->config->set("cache.stores.{$storeName}.connection", 'tenant');
$this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant');
$this->cache->purge($storeName);
}
if (static::$adjustGlobalCacheManager) {
// Preferably we'd try to respect the original value of this static property -- store it in a variable,
// pull it into the closure, and execute it there. But such a naive approach would lead to existing callbacks
// *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that
// (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution.
$originalConnections = array_combine($stores, array_map(fn (string $storeName) => [
'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'),
'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'),
], $stores));
TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) {
foreach ($originalConnections as $storeName => $connections) {
/** @var DatabaseStore $store */
$store = $manager->store($storeName)->getStore();
$store->setConnection(DB::connection($connections['connection']));
$store->setLockConnection(DB::connection($connections['lockConnection']));
}
};
}
}
public function revert(): void
{
foreach ($this->originalConnections as $storeName => $originalConnection) {
$this->config->set("cache.stores.{$storeName}.connection", $originalConnection);
$this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]);
$this->cache->purge($storeName);
}
TenancyServiceProvider::$adjustCacheManagerUsing = null;
}
protected function scopedStoreNames(): array
{
return array_filter(
static::$stores ?? array_keys($this->config->get('cache.stores', [])),
function ($storeName) {
$store = $this->config->get("cache.stores.{$storeName}");
if (! $store) return false;
if (! isset($store['driver'])) return false;
return $store['driver'] === 'database';
}
);
}
}

View file

@ -51,13 +51,24 @@ class Migrate extends MigrateCommand
return 1; return 1;
} }
if ($this->getProcesses() > 1) { $originalTemplateConnection = config('tenancy.database.template_tenant_connection');
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk); if ($database = $this->input->getOption('database')) {
})); config(['tenancy.database.template_tenant_connection' => $database]);
} }
return $this->migrateTenants($this->getTenants()) ? 0 : 1; if ($this->getProcesses() > 1) {
$code = $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk);
}));
} else {
$code = $this->migrateTenants($this->getTenants()) ? 0 : 1;
}
// Reset the template tenant connection to the original one
config(['tenancy.database.template_tenant_connection' => $originalTemplateConnection]);
return $code;
} }
protected function childHandle(mixed ...$args): bool protected function childHandle(mixed ...$args): bool

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Database\Console\Migrations\BaseCommand; use Illuminate\Database\Console\Migrations\BaseCommand;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\LazyCollection; use Illuminate\Support\LazyCollection;
@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI;
class MigrateFresh extends BaseCommand class MigrateFresh extends BaseCommand
{ {
use HasTenantOptions, DealsWithMigrations, ParallelCommand; use HasTenantOptions, DealsWithMigrations, ParallelCommand, ConfirmableTrait;
protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; protected $description = 'Drop all tables and re-run all migrations for tenant(s)';
@ -27,6 +28,7 @@ class MigrateFresh extends BaseCommand
$this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null); $this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null);
$this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.'); $this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Force the command to run when in production.', null);
$this->addProcessesOption(); $this->addProcessesOption();
$this->setName('tenants:migrate-fresh'); $this->setName('tenants:migrate-fresh');
@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand
public function handle(): int public function handle(): int
{ {
if (! $this->confirmToProceed()) {
return 1;
}
$success = true; $success = true;
if ($this->getProcesses() > 1) { if ($this->getProcesses() > 1) {

View file

@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Cache;
class GlobalCache extends Cache class GlobalCache extends Cache
{ {
/** Make sure this works identically to global_cache() */
protected static $cached = false;
protected static function getFacadeAccessor() protected static function getFacadeAccessor()
{ {
return 'globalCache'; return 'globalCache';

View file

@ -17,6 +17,9 @@ abstract class CachedTenantResolver implements TenantResolver
public function __construct(Application $app) public function __construct(Application $app)
{ {
// globalCache should generally not be injected, however in this case
// the class is always created from scratch when calling invalidateCache()
// meaning the global cache stores are also resolved from scratch.
$this->cache = $app->make('globalCache')->store(static::cacheStore()); $this->cache = $app->make('globalCache')->store(static::cacheStore());
} }

View file

@ -23,6 +23,9 @@ class TenancyServiceProvider extends ServiceProvider
public static bool $registerForgetTenantParameterListener = true; public static bool $registerForgetTenantParameterListener = true;
public static bool $migrateFreshOverride = true; public static bool $migrateFreshOverride = true;
/** @internal */
public static Closure|null $adjustCacheManagerUsing = null;
/* Register services. */ /* Register services. */
public function register(): void public function register(): void
{ {
@ -81,7 +84,29 @@ class TenancyServiceProvider extends ServiceProvider
}); });
$this->app->bind('globalCache', function ($app) { $this->app->bind('globalCache', function ($app) {
return new CacheManager($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);
if (static::$adjustCacheManagerUsing !== null) {
(static::$adjustCacheManagerUsing)($manager);
}
return $manager;
}); });
} }

View file

@ -56,7 +56,7 @@ test('tags separate cache properly', function () {
$tenant1 = Tenant::create(); $tenant1 = Tenant::create();
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
cache()->put('foo', 'bar', 1); cache()->put('foo', 'bar');
expect(cache()->get('foo'))->toBe('bar'); expect(cache()->get('foo'))->toBe('bar');
$tenant2 = Tenant::create(); $tenant2 = Tenant::create();
@ -64,7 +64,7 @@ test('tags separate cache properly', function () {
expect(cache('foo'))->not()->toBe('bar'); expect(cache('foo'))->not()->toBe('bar');
cache()->put('foo', 'xyz', 1); cache()->put('foo', 'xyz');
expect(cache()->get('foo'))->toBe('xyz'); expect(cache()->get('foo'))->toBe('xyz');
}); });
@ -72,7 +72,7 @@ test('invoking the cache helper works', function () {
$tenant1 = Tenant::create(); $tenant1 = Tenant::create();
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
cache(['foo' => 'bar'], 1); cache(['foo' => 'bar']);
expect(cache('foo'))->toBe('bar'); expect(cache('foo'))->toBe('bar');
$tenant2 = Tenant::create(); $tenant2 = Tenant::create();
@ -80,7 +80,7 @@ test('invoking the cache helper works', function () {
expect(cache('foo'))->not()->toBe('bar'); expect(cache('foo'))->not()->toBe('bar');
cache(['foo' => 'xyz'], 1); cache(['foo' => 'xyz']);
expect(cache('foo'))->toBe('xyz'); expect(cache('foo'))->toBe('xyz');
}); });
@ -88,7 +88,7 @@ test('cache is persisted', function () {
$tenant1 = Tenant::create(); $tenant1 = Tenant::create();
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
cache(['foo' => 'bar'], 10); cache(['foo' => 'bar']);
expect(cache('foo'))->toBe('bar'); expect(cache('foo'))->toBe('bar');
tenancy()->end(); tenancy()->end();
@ -102,7 +102,7 @@ test('cache is persisted when reidentification is used', function () {
$tenant2 = Tenant::create(); $tenant2 = Tenant::create();
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
cache(['foo' => 'bar'], 10); cache(['foo' => 'bar']);
expect(cache('foo'))->toBe('bar'); expect(cache('foo'))->toBe('bar');
tenancy()->initialize($tenant2); tenancy()->initialize($tenant2);

View file

@ -14,6 +14,8 @@ use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
@ -23,6 +25,8 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withCacheTables;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach($cleanup = function () { beforeEach($cleanup = function () {
Tenant::$extraCustomColumns = []; Tenant::$extraCustomColumns = [];
@ -112,11 +116,19 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
// Only testing update here - presumably if this works, deletes (and other things we test here) // Only testing update here - presumably if this works, deletes (and other things we test here)
// will work as well. The main unique thing about this test is that it makes the change from // will work as well. The main unique thing about this test is that it makes the change from
// *within* the tenant context. // *within* the tenant context.
test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheBootstrapper) { test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheStore, array $bootstrappers) {
config(['tenancy.bootstrappers' => [$cacheBootstrapper]]); config([
'cache.default' => $cacheStore,
'tenancy.bootstrappers' => $bootstrappers,
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
if ($cacheStore === 'database') {
withCacheTables();
withTenantDatabases();
}
$resolver = PathTenantResolver::class; $resolver = PathTenantResolver::class;
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']); $tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']);
@ -150,9 +162,9 @@ test('cache is invalidated when tenant is updated from within the tenant context
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
})->with([ })->with([
// todo@samuel test this with the database cache bootstrapper too? ['redis', [CacheTenancyBootstrapper::class]],
CacheTenancyBootstrapper::class, ['redis', [CacheTagsBootstrapper::class]],
CacheTagsBootstrapper::class, ['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
]); ]);
test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) { test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {

View file

@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Events\MigratingDatabase;
beforeEach(function () { beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () {
expect(Schema::hasTable('users'))->toBeTrue(); expect(Schema::hasTable('users'))->toBeTrue();
}); });
test('migrate command uses the passed database option as the template tenant connection', function () {
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
// Add a custom connection that will be used as the template for the tenant connection
// Identical to the default (mysql), just with different charset and collation
config(['database.connections.custom_connection' => [
"driver" => "mysql",
"url" => "",
"host" => "mysql",
"port" => "3306",
"database" => "main",
"username" => "root",
"password" => "password",
"unix_socket" => "",
"charset" => "latin1", // Different from the default (utf8mb4)
"collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci)
"prefix" => "",
"prefix_indexes" => true,
"strict" => true,
"engine" => null,
"options" => []
]]);
$templateConnectionDuringMigration = null;
$tenantConnectionDuringMigration = null;
Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) {
$templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection');
$tenantConnectionDuringMigration = DB::connection('tenant')->getConfig();
});
// The original tenant template connection config remains default
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
Tenant::create();
// The original template connection is used when the --database option is not passed
pest()->artisan('tenants:migrate');
expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection);
Tenant::create();
// The migrate command temporarily uses the connection passed in the --database option
pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']);
expect($templateConnectionDuringMigration)->toBe('custom_connection');
// The tenant connection during migration actually used custom_connection's config
expect($tenantConnectionDuringMigration['charset'])->toBe('latin1');
expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci');
// The tenant template connection config is restored to the original after migrating
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
});
test('migrate command only throws exceptions if skip-failing is not passed', function() { test('migrate command only throws exceptions if skip-failing is not passed', function() {
Tenant::create(); Tenant::create();
@ -311,6 +366,21 @@ test('migrate fresh command works', function () {
expect(DB::table('users')->exists())->toBeFalse(); expect(DB::table('users')->exists())->toBeFalse();
}); });
test('migrate fresh command respects force option in production', function () {
// Set environment to production
app()->detectEnvironment(fn() => 'production');
Tenant::create();
// Without --force in production, command should prompt for confirmation
pest()->artisan('tenants:migrate-fresh')
->expectsConfirmation('Are you sure you want to run this command?');
// With --force, command should succeed without prompting
pest()->artisan('tenants:migrate-fresh', ['--force' => true])
->assertSuccessful();
});
test('run command with array of tenants works', function () { test('run command with array of tenants works', function () {
$tenantId1 = Tenant::create()->getTenantKey(); $tenantId1 = Tenant::create()->getTenantKey();
$tenantId2 = Tenant::create()->getTenantKey(); $tenantId2 = Tenant::create()->getTenantKey();

View file

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use function Stancl\Tenancy\Tests\withBootstrapping;
use function Stancl\Tenancy\Tests\withCacheTables;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
withBootstrapping();
withCacheTables();
withTenantDatabases(true);
DatabaseCacheBootstrapper::$stores = null;
config([
'cache.stores.database.connection' => 'central', // Explicitly set cache DB connection name in config
'cache.stores.database.lock_connection' => 'central', // Also set lock connection name
'cache.default' => 'database',
'tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
DatabaseCacheBootstrapper::class, // Used instead of CacheTenancyBootstrapper
],
]);
});
afterEach(function () {
DatabaseCacheBootstrapper::$stores = null;
});
test('DatabaseCacheBootstrapper switches the database cache store connections correctly', function () {
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.database.lock_connection'))->toBe('central');
expect(Cache::store()->getConnection()->getName())->toBe('central');
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
tenancy()->initialize(Tenant::create());
expect(config('cache.stores.database.connection'))->toBe('tenant');
expect(config('cache.stores.database.lock_connection'))->toBe('tenant');
expect(Cache::store()->getConnection()->getName())->toBe('tenant');
expect(Cache::lock('foo')->getConnectionName())->toBe('tenant');
tenancy()->end();
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.database.lock_connection'))->toBe('central');
expect(Cache::store()->getConnection()->getName())->toBe('central');
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
});
test('cache is separated correctly when using DatabaseCacheBootstrapper', function() {
// We need the prefix later for lower-level assertions. Let's store it
// once now and reuse this variable rather than re-fetching it to make
// it clear that the scoping does NOT come from a prefix change.
$cachePrefix = config('cache.prefix');
$getCacheUsingDbQuery = fn (string $cacheKey) =>
DB::selectOne("SELECT * FROM `cache` WHERE `key` = '{$cachePrefix}{$cacheKey}'")?->value;
$tenant = Tenant::create();
$tenant2 = Tenant::create();
// Write to cache in central context
cache()->set('foo', 'central');
expect(Cache::get('foo'))->toBe('central');
// The value retrieved by the DB query is formatted like "s:7:"central";".
// We use toContain() because of this formatting instead of just toBe().
expect($getCacheUsingDbQuery('foo'))->toContain('central');
tenancy()->initialize($tenant);
// Central cache doesn't leak to tenant context
expect(Cache::has('foo'))->toBeFalse();
expect($getCacheUsingDbQuery('foo'))->toBeNull();
cache()->set('foo', 'bar');
expect(Cache::get('foo'))->toBe('bar');
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
tenancy()->initialize($tenant2);
// Assert one tenant's cache doesn't leak to another tenant
expect(Cache::has('foo'))->toBeFalse();
expect($getCacheUsingDbQuery('foo'))->toBeNull();
cache()->set('foo', 'xyz');
expect(Cache::get('foo'))->toBe('xyz');
expect($getCacheUsingDbQuery('foo'))->toContain('xyz');
tenancy()->initialize($tenant);
// Assert cache didn't leak to the original tenant
expect(Cache::get('foo'))->toBe('bar');
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
tenancy()->end();
// Assert central 'foo' cache is still the same ('central')
expect(Cache::get('foo'))->toBe('central');
expect($getCacheUsingDbQuery('foo'))->toContain('central');
});
test('DatabaseCacheBootstrapper auto-detects all database driver stores by default', function() {
config([
'cache.stores.database' => [
'driver' => 'database',
'connection' => 'central',
'table' => 'cache',
],
'cache.stores.sessions' => [
'driver' => 'database',
'connection' => 'central',
'table' => 'sessions_cache',
],
'cache.stores.redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'cache.stores.file' => [
'driver' => 'file',
'path' => '/foo/bar',
],
]);
// Here, we're using auto-detection (default behavior)
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.sessions.connection'))->toBe('central');
expect(config('cache.stores.redis.connection'))->toBe('default');
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
tenancy()->initialize(Tenant::create());
// Using auto-detection (default behavior),
// all database driver stores should be configured,
// and stores with non-database drivers are ignored.
expect(config('cache.stores.database.connection'))->toBe('tenant');
expect(config('cache.stores.sessions.connection'))->toBe('tenant');
expect(config('cache.stores.redis.connection'))->toBe('default'); // unchanged
expect(config('cache.stores.file.path'))->toBe('/foo/bar'); // unchanged
tenancy()->end();
// All database stores should be reverted, others unchanged
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.sessions.connection'))->toBe('central');
expect(config('cache.stores.redis.connection'))->toBe('default');
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
});
test('manual $stores configuration takes precedence over auto-detection', function() {
// Configure multiple database stores
config([
'cache.stores.sessions' => [
'driver' => 'database',
'connection' => 'central',
'table' => 'sessions_cache',
],
'cache.stores.redis' => [
'driver' => 'redis',
'connection' => 'default',
],
]);
// Specific store overrides (including non-database stores)
DatabaseCacheBootstrapper::$stores = ['sessions', 'redis']; // Note: excludes 'database'
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.sessions.connection'))->toBe('central');
expect(config('cache.stores.redis.connection'))->toBe('default');
tenancy()->initialize(Tenant::create());
// Manual config takes precedence: only 'sessions' is configured
// - redis filtered out by driver check
// - database store not included in $stores
expect(config('cache.stores.database.connection'))->toBe('central'); // Excluded in manual config
expect(config('cache.stores.sessions.connection'))->toBe('tenant'); // Included and is database driver
expect(config('cache.stores.redis.connection'))->toBe('default'); // Included but filtered out (not database driver)
tenancy()->end();
// Only the manually configured stores' config will be reverted
expect(config('cache.stores.database.connection'))->toBe('central');
expect(config('cache.stores.sessions.connection'))->toBe('central');
expect(config('cache.stores.redis.connection'))->toBe('default');
});

View file

@ -11,6 +11,11 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use function Stancl\Tenancy\Tests\withCacheTables;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () { beforeEach(function () {
config([ config([
@ -20,26 +25,40 @@ beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
withCacheTables();
}); });
test('global cache manager stores data in global cache', function (string $bootstrapper) { test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) {
config(['tenancy.bootstrappers' => [$bootstrapper]]); config([
'cache.default' => $store,
'tenancy.bootstrappers' => $bootstrappers,
]);
if ($store === 'database') withTenantDatabases(true);
expect(cache('foo'))->toBe(null); expect(cache('foo'))->toBe(null);
GlobalCache::put(['foo' => 'bar'], 1); GlobalCache::put('foo', 'bar');
expect(GlobalCache::get('foo'))->toBe('bar'); expect(GlobalCache::get('foo'))->toBe('bar');
$tenant1 = Tenant::create(); $tenant1 = Tenant::create();
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
expect(GlobalCache::get('foo'))->toBe('bar'); expect(GlobalCache::get('foo'))->toBe('bar');
GlobalCache::put(['abc' => 'xyz'], 1); GlobalCache::put('abc', 'xyz');
cache(['def' => 'ghi'], 10); cache(['def' => 'ghi']);
expect(cache('def'))->toBe('ghi'); expect(cache('def'))->toBe('ghi');
// different stores, same underlying connection. the prefix is set ON THE STORE // different stores
expect(cache()->store()->getStore() === GlobalCache::store()->getStore())->toBeFalse(); expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue(); if ($store === 'redis') {
// same underlying connection. the prefix is set ON THE STORE
expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue();
} else {
// different connections
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
}
tenancy()->end(); tenancy()->end();
expect(GlobalCache::get('abc'))->toBe('xyz'); expect(GlobalCache::get('abc'))->toBe('xyz');
@ -51,25 +70,129 @@ test('global cache manager stores data in global cache', function (string $boots
expect(GlobalCache::get('abc'))->toBe('xyz'); expect(GlobalCache::get('abc'))->toBe('xyz');
expect(GlobalCache::get('foo'))->toBe('bar'); expect(GlobalCache::get('foo'))->toBe('bar');
expect(cache('def'))->toBe(null); expect(cache('def'))->toBe(null);
cache(['def' => 'xxx'], 1); cache(['def' => 'xxx']);
expect(cache('def'))->toBe('xxx'); expect(cache('def'))->toBe('xxx');
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
expect(cache('def'))->toBe('ghi'); expect(cache('def'))->toBe('ghi');
})->with([ })->with([
CacheTagsBootstrapper::class, ['redis', [CacheTagsBootstrapper::class]],
CacheTenancyBootstrapper::class, ['redis', [CacheTenancyBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
]); ]);
test('the global_cache helper supports the same syntax as the cache helper', function (string $bootstrapper) { test('global cache facade is not persistent', function () {
config(['tenancy.bootstrappers' => [$bootstrapper]]); $oldId = spl_object_id(GlobalCache::getFacadeRoot());
$_ = new class {};
expect(spl_object_id(GlobalCache::getFacadeRoot()))->not()->toBe($oldId);
});
test('global cache is always central', function (string $store, array $bootstrappers, string $initialCentralCall) {
config([
'cache.default' => $store,
'tenancy.bootstrappers' => $bootstrappers,
]);
if ($store === 'database') {
withTenantDatabases(true);
}
// This tells us which "accessor" for the global cache should be instantiated first, before we go
// into the tenant context. We make sure to not touch the other one here. This tests that whether
// a particular accessor is used "early" makes no difference in the later behavior.
if ($initialCentralCall === 'helper') {
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
global_cache()->put('central-helper', true);
} else if ($initialCentralCall === 'facade') {
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
GlobalCache::put('central-facade', true);
} else if ($initialCentralCall === 'both') {
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
global_cache()->put('central-helper', true);
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
GlobalCache::put('central-facade', true);
}
$tenant = Tenant::create(); $tenant = Tenant::create();
$tenant->enter(); $tenant->enter();
// different stores, same underlying connection. the prefix is set ON THE STORE // Here we use both the helper and the facade to ensure the value is accessible via either one
expect(cache()->store()->getStore() === global_cache()->store()->getStore())->toBeFalse(); if ($initialCentralCall === 'helper') {
expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue(); if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
expect(global_cache('central-helper'))->toBe(true);
expect(GlobalCache::get('central-helper'))->toBe(true);
} else if ($initialCentralCall === 'facade') {
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
expect(global_cache('central-facade'))->toBe(true);
expect(GlobalCache::get('central-facade'))->toBe(true);
} else if ($initialCentralCall === 'both') {
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
expect(global_cache('central-helper'))->toBe(true);
expect(GlobalCache::get('central-helper'))->toBe(true);
expect(global_cache('central-facade'))->toBe(true);
expect(GlobalCache::get('central-facade'))->toBe(true);
}
global_cache()->put('tenant-helper', true);
GlobalCache::put('tenant-facade', true);
tenancy()->end();
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
expect(global_cache('tenant-helper'))->toBe(true);
expect(GlobalCache::get('tenant-helper'))->toBe(true);
expect(global_cache('tenant-facade'))->toBe(true);
expect(GlobalCache::get('tenant-facade'))->toBe(true);
if ($initialCentralCall === 'helper') {
expect(GlobalCache::get('central-helper'))->toBe(true);
} else if ($initialCentralCall === 'facade') {
expect(global_cache('central-facade'))->toBe(true);
} else if ($initialCentralCall === 'both') {
expect(global_cache('central-helper'))->toBe(true);
expect(GlobalCache::get('central-helper'))->toBe(true);
expect(global_cache('central-facade'))->toBe(true);
expect(GlobalCache::get('central-facade'))->toBe(true);
}
})->with([
['redis', [CacheTagsBootstrapper::class]],
['redis', [CacheTenancyBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
])->with([
'helper',
'facade',
'both',
'none',
]);
test('the global_cache helper supports the same syntax as the cache helper', function (string $store, array $bootstrappers) {
config([
'cache.default' => $store,
'tenancy.bootstrappers' => $bootstrappers,
]);
if ($store === 'database') withTenantDatabases(true);
$tenant = Tenant::create();
$tenant->enter();
// different stores
expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
if ($store === 'redis') {
// same underlying connection. the prefix is set ON THE STORE
expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue();
} else {
// different connections
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
}
expect(cache('foo'))->toBe(null); // tenant cache is empty expect(cache('foo'))->toBe(null); // tenant cache is empty
@ -81,6 +204,7 @@ test('the global_cache helper supports the same syntax as the cache helper', fun
expect(cache('foo'))->toBe(null); // tenant cache is not affected expect(cache('foo'))->toBe(null); // tenant cache is not affected
})->with([ })->with([
CacheTagsBootstrapper::class, ['redis', [CacheTagsBootstrapper::class]],
CacheTenancyBootstrapper::class, ['redis', [CacheTenancyBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
]); ]);

View file

@ -2,21 +2,52 @@
namespace Stancl\Tenancy\Tests; namespace Stancl\Tenancy\Tests;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Tests\TestCase; use Stancl\Tenancy\Tests\TestCase;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
uses(TestCase::class)->in(__DIR__); uses(TestCase::class)->in(__DIR__);
function withTenantDatabases() function withBootstrapping()
{ {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
}
function withTenantDatabases(bool $migrate = false)
{
Event::listen(TenantCreated::class, JobPipeline::make($migrate
? [CreateDatabase::class, MigrateDatabase::class]
: [CreateDatabase::class]
)->send(function (TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->toListener()); })->toListener());
} }
function withCacheTables()
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
function pest(): TestCase function pest(): TestCase
{ {
return \Pest\TestSuite::getInstance()->test; return \Pest\TestSuite::getInstance()->test;

View file

@ -24,6 +24,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
@ -38,6 +39,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
ini_set('memory_limit', '1G'); ini_set('memory_limit', '1G');
TenancyServiceProvider::$registerForgetTenantParameterListener = true;
TenancyServiceProvider::$migrateFreshOverride = true;
TenancyServiceProvider::$adjustCacheManagerUsing = null;
Redis::connection('default')->flushdb(); Redis::connection('default')->flushdb();
Redis::connection('cache')->flushdb(); Redis::connection('cache')->flushdb();
Artisan::call('cache:clear memcached'); // flush memcached Artisan::call('cache:clear memcached'); // flush memcached
@ -180,6 +185,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
// to manually register bootstrappers as singletons here. // to manually register bootstrappers as singletons here.
$app->singleton(RedisTenancyBootstrapper::class); $app->singleton(RedisTenancyBootstrapper::class);
$app->singleton(CacheTenancyBootstrapper::class); $app->singleton(CacheTenancyBootstrapper::class);
$app->singleton(DatabaseCacheBootstrapper::class);
$app->singleton(BroadcastingConfigBootstrapper::class); $app->singleton(BroadcastingConfigBootstrapper::class);
$app->singleton(BroadcastChannelPrefixBootstrapper::class); $app->singleton(BroadcastChannelPrefixBootstrapper::class);
$app->singleton(PostgresRLSBootstrapper::class); $app->singleton(PostgresRLSBootstrapper::class);