From 7089efb2eeee369301a3dbc4384017298542bf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 18 Aug 2025 15:04:42 +0200 Subject: [PATCH 01/31] resolve minor todos --- assets/TenancyServiceProvider.stub.php | 15 +++++++++++++++ assets/config.php | 15 +++++++++++++++ docker-compose.yml | 2 +- .../PreventAccessFromUnwantedDomains.php | 2 -- src/Tenancy.php | 10 +++++++--- src/helpers.php | 7 ++++++- tests/ActionTest.php | 2 -- 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 84787c0d..1a01e9a8 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -21,6 +21,21 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper; +/** + * Tenancy for Laravel. + * + * Documentation: https://tenancyforlaravel.com + * + * We can sustainably develop Tenancy for Laravel thanks to our sponsors. + * Big thanks to everyone listed here: https://github.com/sponsors/stancl + * + * You can also support us, and save time, by purchasing these products: + * Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com + * Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate + * Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book + * + * All of these products can also be accessed at https://portal.archte.ch + */ class TenancyServiceProvider extends ServiceProvider { // By default, no namespace is used to support the callable array syntax. diff --git a/assets/config.php b/assets/config.php index ba503aad..06bceccb 100644 --- a/assets/config.php +++ b/assets/config.php @@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\UniqueIdentifierGenerators; +/** + * Tenancy for Laravel. + * + * Documentation: https://tenancyforlaravel.com + * + * We can sustainably develop Tenancy for Laravel thanks to our sponsors. + * Big thanks to everyone listed here: https://github.com/sponsors/stancl + * + * You can also support us, and save time, by purchasing these products: + * Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com + * Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate + * Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book + * + * All of these products can also be accessed at https://portal.archte.ch + */ return [ /** * Configuration for the models used by Tenancy. diff --git a/docker-compose.yml b/docker-compose.yml index 2d7a6e9f..34bd1cc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,7 @@ services: image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - - SA_PASSWORD=P@ssword # todo reuse env from above + - SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' interval: 10s diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 91ebff05..e3fea4ff 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Enums\RouteMode; /** - * todo@name come up with a better name. - * * Prevents accessing central domains in the tenant context/tenant domains in the central context. * The access isn't prevented if the request is trying to access a route flagged as 'universal', * or if this middleware should be skipped. diff --git a/src/Tenancy.php b/src/Tenancy.php index 66173cba..8e0ded99 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -24,7 +24,11 @@ class Tenancy */ public Tenant|null $tenant = null; - // todo@docblock + /** + * Custom callback for providing a list of bootstrappers to use. + * When this is null, config('tenancy.bootstrappers') is used. + * @var ?Closure(): list + */ public ?Closure $getBootstrappersUsing = null; /** Is tenancy fully initialized? */ @@ -131,12 +135,12 @@ class Tenancy /** @return TenancyBootstrapper[] */ public function getBootstrappers(): array { - // If no callback for getting bootstrappers is set, we just return all of them. + // If no callback for getting bootstrappers is set, we return the ones in config. $resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) { return config('tenancy.bootstrappers'); }; - // Here We instantiate the bootstrappers and return them. + // Here we instantiate the bootstrappers and return them. return array_map('app', $resolve($this->tenant)); } diff --git a/src/helpers.php b/src/helpers.php index c8f5c9b3..0b812e65 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -36,7 +36,12 @@ if (! function_exists('tenant')) { } if (! function_exists('tenant_asset')) { - // todo@docblock + /** + * Generate a URL to an asset in tenant storage. + * + * If app.asset_url is set, this helper suffixes that URL before appending the asset path. + * If it is not set, the stancl.tenancy.asset route is used. + */ function tenant_asset(string|null $asset): string { if ($assetUrl = config('app.asset_url')) { diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 63b6b377..93db0eb3 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -18,8 +18,6 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); -// todo@move move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests" - test('create storage symlinks action works', function() { config([ 'tenancy.bootstrappers' => [ From 6b0066c5ef35916ca7af2493ad1894adae4c6209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 24 Aug 2025 23:54:34 +0200 Subject: [PATCH 02/31] Pending tenants refactor (BC break) - [BC BREAK] Make pullPendingFromPool() $firstOrCreate arg default to false (pullPending() is now a direct alias for pullPendingFromPool() with default $firstOrCreate=true) - Resolve race conditions in pullPendingFromPool() - Make createPending() set pending_since regardless of exceptions - Make pullPending() accept $attributes - Fire PullingPendingTenant from within a DB transaction - Clarify --count arg description for CreatePendingTenants command - Add docblock to PullingPendingTenant with a notice --- src/Commands/CreatePendingTenants.php | 2 +- src/Database/Concerns/HasPending.php | 63 +++++++++++++++++---------- src/Events/PullingPendingTenant.php | 5 +++ 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index c37b8bd7..11bdae63 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class CreatePendingTenants extends Command { - protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}'; + protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}'; protected $description = 'Create pending tenants.'; diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index ffb35f0c..34a66544 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\CreatingPendingTenant; use Stancl\Tenancy\Events\PendingTenantCreated; use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PullingPendingTenant; -// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending - /** * @property ?Carbon $pending_since * @@ -50,46 +49,62 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = static::create($attributes); - - event(new CreatingPendingTenant($tenant)); - - // Update the pending_since value only after the tenant is created so it's - // Not marked as pending until finishing running the migrations, seeders, etc. - $tenant->update([ - 'pending_since' => now()->timestamp, - ]); + try { + $tenant = static::create($attributes); + event(new CreatingPendingTenant($tenant)); + } finally { + // Update the pending_since value only after the tenant is created so it's + // not marked as pending until after migrations, seeders, etc are run. + $tenant->update([ + 'pending_since' => now()->timestamp, + ]); + } event(new PendingTenantCreated($tenant)); return $tenant; } - /** Pull a pending tenant. */ - public static function pullPending(): Model&Tenant + /** + * Pull a pending tenant from the pool or create a new one if the pool is empty. + * + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPending(array $attributes = []): Model&Tenant { /** @var Model&Tenant $pendingTenant */ - $pendingTenant = static::pullPendingFromPool(true); + $pendingTenant = static::pullPendingFromPool(true, $attributes); return $pendingTenant; } - /** Try to pull a tenant from the pool of pending tenants. */ - public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant + /** + * Try to pull a tenant from the pool of pending tenants. + * + * @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned. + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant { - /** @var (Model&Tenant)|null $tenant */ - $tenant = static::onlyPending()->first(); + $tenant = DB::transaction(function () use ($attributes): ?Tenant { + /** @var (Model&Tenant)|null $tenant */ + $tenant = static::onlyPending()->first(); + + if ($tenant !== null) { + event(new PullingPendingTenant($tenant)); + $tenant->update(array_merge($attributes, [ + 'pending_since' => null, + ])); + } + + return $tenant; + }); if ($tenant === null) { return $firstOrCreate ? static::create($attributes) : null; } - event(new PullingPendingTenant($tenant)); - - $tenant->update(array_merge($attributes, [ - 'pending_since' => null, - ])); - + // Only triggered if a tenant that was pulled from the pool is returned event(new PendingTenantPulled($tenant)); return $tenant; diff --git a/src/Events/PullingPendingTenant.php b/src/Events/PullingPendingTenant.php index f823bb17..26d0433d 100644 --- a/src/Events/PullingPendingTenant.php +++ b/src/Events/PullingPendingTenant.php @@ -4,4 +4,9 @@ declare(strict_types=1); namespace Stancl\Tenancy\Events; +/** + * Importantly, listeners for this event should not switch tenancy context. + * + * This event is fired from within a database transaction. + */ class PullingPendingTenant extends Contracts\TenantEvent {} From 99d854ed8e87affed8f527791ab923d1e8e31f9a Mon Sep 17 00:00:00 2001 From: Farishrf Date: Mon, 25 Aug 2025 18:27:59 +0300 Subject: [PATCH 03/31] [4.x] Fix ViteBundler not affecting Vite static calls (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ViteBundler not affecting Vite static calls Replace custom Vite class override with Vite::createAssetPathsUsing() to ensure ViteBundler works for both container and static usage when asset_helper_override is enabled. Fixes #1388 * Remove redundant logic from tests * Simplify test further * Re-add file creation logic --------- Co-authored-by: Samuel Štancl --- src/Features/ViteBundler.php | 6 ++-- src/Overrides/Vite.php | 22 -------------- tests/Features/ViteBundlerTest.php | 48 +++++++++++++++++++----------- 3 files changed, 35 insertions(+), 41 deletions(-) delete mode 100644 src/Overrides/Vite.php diff --git a/src/Features/ViteBundler.php b/src/Features/ViteBundler.php index 96f379b7..987187c7 100644 --- a/src/Features/ViteBundler.php +++ b/src/Features/ViteBundler.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Vite; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Overrides\Vite; use Stancl\Tenancy\Tenancy; class ViteBundler implements Feature @@ -21,6 +21,8 @@ class ViteBundler implements Feature public function bootstrap(Tenancy $tenancy): void { - $this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class); + Vite::createAssetPathsUsing(function ($path, $secure = null) { + return global_asset($path); + }); } } diff --git a/src/Overrides/Vite.php b/src/Overrides/Vite.php deleted file mode 100644 index 66bc9268..00000000 --- a/src/Overrides/Vite.php +++ /dev/null @@ -1,22 +0,0 @@ -toBeInstanceOf(Vite::class); - expect($vite)->not()->toBeInstanceOf(StanclVite::class); +use function Stancl\Tenancy\Tests\withBootstrapping; +beforeEach(function () { config([ - 'tenancy.features' => [ViteBundler::class], + 'tenancy.filesystem.asset_helper_override' => true, + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], ]); - $tenant = Tenant::create(); - - tenancy()->initialize($tenant); - - app()->forgetInstance(Vite::class); - - $vite = app(Vite::class); - - expect($vite)->toBeInstanceOf(StanclVite::class); + File::ensureDirectoryExists(dirname($manifestPath = public_path('build/manifest.json'))); + File::put($manifestPath, json_encode([ + 'foo' => [ + 'file' => 'assets/foo-AbC123.js', + 'src' => 'js/foo.js', + ], + ])); +}); + +test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { + config(['tenancy.features' => [ViteBundler::class]]); + + withBootstrapping(); + + tenancy()->initialize(Tenant::create()); + + // Not what we want + expect(asset('foo'))->toBe(route('stancl.tenancy.asset', ['path' => 'foo'])); + + $viteAssetUrl = app(Vite::class)->asset('foo'); + $expectedGlobalUrl = global_asset('build/assets/foo-AbC123.js'); + + expect($viteAssetUrl)->toBe($expectedGlobalUrl); + expect($viteAssetUrl)->toBe('http://localhost/build/assets/foo-AbC123.js'); }); From a4309fdbc7e91de2c72e9c65a858e303676bb342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 25 Aug 2025 17:43:45 +0200 Subject: [PATCH 04/31] Remove TestCase::randomString() --- tests/EarlyIdentificationTest.php | 3 ++- tests/TenantAssetTest.php | 5 +++-- tests/TenantDatabaseManagerTest.php | 6 +++--- tests/TestCase.php | 5 ----- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index a95bac0b..e6c08d26 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Events\TenancyInitialized; use Illuminate\Support\Facades\Route as RouteFacade; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; @@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']); }); - $tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]); + $tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]); // Migrate users and comments tables on tenant connection pest()->artisan('tenants:migrate', [ diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 5c223fe2..ef1cb41f 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; @@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper', $tenant = Tenant::create(); tenancy()->initialize($tenant); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(8); Storage::disk('public')->put($filename, 'bar'); $path = storage_path("app/public/$filename"); @@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () { tenancy()->initialize($tenant); $tenant->createDomain('foo.localhost'); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(10); Storage::disk('public')->put($filename, 'bar'); $this->withoutExceptionHandling(); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index c41ea35a..051312da 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -43,7 +43,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager "tenancy.database.managers.$driver" => $databaseManager, ]); - $name = 'db' . pest()->randomString(); + $name = 'db' . Str::random(10); $manager = app($databaseManager); @@ -70,7 +70,7 @@ test('dbs can be created when another driver is used for the central db', functi return $event->tenant; })->toListener()); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); $mysqlmanager = app(MySQLDatabaseManager::class); $mysqlmanager->setConnection('mysql'); @@ -86,7 +86,7 @@ test('dbs can be created when another driver is used for the central db', functi $postgresManager = app(PostgreSQLDatabaseManager::class); $postgresManager->setConnection('pgsql'); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); expect($postgresManager->databaseExists($database))->toBeFalse(); Tenant::create([ diff --git a/tests/TestCase.php b/tests/TestCase.php index d4f2657b..bdd43fd4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -236,11 +236,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); } - public function randomString(int $length = 10) - { - return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length); - } - public function assertArrayIsSubset($subset, $array, string $message = ''): void { parent::assertTrue(array_intersect($subset, $array) == $subset, $message); From 24797278cd62eab55ee5ea62a05d4389733a4b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 25 Aug 2025 17:50:09 +0200 Subject: [PATCH 05/31] phpstan fix --- src/Tenancy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tenancy.php b/src/Tenancy.php index 8e0ded99..86eb6d8d 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -136,7 +136,7 @@ class Tenancy public function getBootstrappers(): array { // If no callback for getting bootstrappers is set, we return the ones in config. - $resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) { + $resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) { return config('tenancy.bootstrappers'); }; From 33e4a8e4e2bd206e2255e84af0ec3fe0caeee565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 31 Aug 2025 16:57:52 +0200 Subject: [PATCH 06/31] Remove and recategorize todos --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 6 +++--- src/Database/DatabaseConfig.php | 2 +- .../PermissionControlledPostgreSQLSchemaManager.php | 2 +- src/Middleware/PreventAccessFromUnwantedDomains.php | 4 +++- tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php | 2 -- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 8cc8127b..33ff7b29 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.'); } - // Better debugging, but breaks cached lookup in prod - if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149 + // Better debugging, but breaks cached lookup, so we disable this in prod + if (app()->environment('local') || app()->environment('testing')) { $database = $tenant->database()->getName(); - if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection? + if (! $tenant->database()->manager()->databaseExists($database)) { // todo@dbRefactor does this call correctly use the host connection? throw new TenantDatabaseDoesNotExistException($database); } } diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 9a876d2d..7dbbc577 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -222,7 +222,7 @@ class DatabaseConfig } /** - * todo@name come up with a better name + * todo@dbRefactor come up with a better name * Get database manager class from the given connection config's driver. * * @throws DatabaseManagerNotRegisteredException diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 933740ed..eca2ef87 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -30,7 +30,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); // Grant permissions to any existing tables. This is used with RLS - // todo@samuel refactor this along with the todo in TenantDatabaseManager + // todo@dbRefactor refactor this along with the todo in TenantDatabaseManager // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` // but maybe moving it inside $createUser is wrong because some central user may migrate new tables // while the RLS user should STILL get access to those tables diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index e3fea4ff..cdfa3b2c 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -66,9 +66,11 @@ class PreventAccessFromUnwantedDomains return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } - // todo@samuel technically not an identification middleware but probably ok to keep this here public function requestHasTenant(Request $request): bool { + // This middleware is special in that it's not an identification middleware + // but still uses some logic from UsableWithEarlyIdentification, so we just + // need to implement this method here. It doesn't matter what it returns. return false; } } diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index d6b6a231..857e0eac 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() { test('storage_path helper does not change if suffix_storage_path is off', function() { $originalStoragePath = storage_path(); - // todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362 - config([ 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], 'tenancy.filesystem.suffix_storage_path' => false, From 4578c9ed7d87d2392331728e4a8d1f4e9292b6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 31 Aug 2025 23:14:07 +0200 Subject: [PATCH 07/31] Features refactor Features are now *always* bootstrapped, even if Tenancy is not resolved from the container. Previous implementations include https://github.com/tenancy-for-laravel/v4/pull/19 https://github.com/archtechx/tenancy/pull/1021 Bug originally reported here https://github.com/archtechx/tenancy/issues/949 This implementation is much simpler, we do not distinguish between features that should be "always bootstrapped" and features that should only be bootstrapped after Tenancy is resolved. All features should work without issues if they're bootstrapped when TSP::boot() is called. We also add a Tenancy::bootstrapFeatures() method that can be used to bootstrap any features dynamically added at runtime that weren't bootstrapped in TSP::boot(). The function keeps track of which features were already bootstrapped so it doesn't bootstrap them again. The only potentialy risky thing in this implementation is that we're now resolving Tenancy in TSP::boot() (previously Tenancy was not being resolved) but that shouldn't be causing any issues. --- src/Contracts/Feature.php | 4 +- .../SQLiteDatabaseManager.php | 2 + src/Features/CrossDomainRedirect.php | 3 +- src/Features/DisallowSqliteAttach.php | 7 +++- src/Features/TelescopeTags.php | 3 +- src/Features/TenantConfig.php | 3 +- src/Features/UserImpersonation.php | 4 +- src/Features/ViteBundler.php | 13 ++---- src/Tenancy.php | 41 ++++++++++++++++++- src/TenancyServiceProvider.php | 14 +++---- tests/Features/NoAttachTest.php | 2 +- tests/Features/RedirectTest.php | 2 + tests/Features/TenantConfigTest.php | 24 +++++------ tests/Features/ViteBundlerTest.php | 1 + tests/TenantUserImpersonationTest.php | 2 + 15 files changed, 80 insertions(+), 45 deletions(-) diff --git a/src/Contracts/Feature.php b/src/Contracts/Feature.php index 74289981..25363cf5 100644 --- a/src/Contracts/Feature.php +++ b/src/Contracts/Feature.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; -use Stancl\Tenancy\Tenancy; - /** Additional features, like Telescope tags and tenant redirects. */ interface Feature { - public function bootstrap(Tenancy $tenancy): void; + public function bootstrap(): void; } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 64b96fc1..818539c2 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -94,6 +94,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return false; } + // todo@sqlite we can just respect Laravel config for WAL now if (static::$WAL) { $pdo = new PDO('sqlite:' . $path); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); @@ -123,6 +124,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager } try { + // todo@sqlite we should also remove any other files for the DB e.g. WAL return unlink($this->getPath($name)); } catch (Throwable) { return false; diff --git a/src/Features/CrossDomainRedirect.php b/src/Features/CrossDomainRedirect.php index a48be6ea..57786274 100644 --- a/src/Features/CrossDomainRedirect.php +++ b/src/Features/CrossDomainRedirect.php @@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features; use Illuminate\Http\RedirectResponse; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class CrossDomainRedirect implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { RedirectResponse::macro('domain', function (string $domain) { /** @var RedirectResponse $this */ diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index f428a051..16dd47f0 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -10,14 +10,13 @@ use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; use PDO; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class DisallowSqliteAttach implements Feature { protected static bool|null $loadExtensionSupported = null; public static string|false|null $extensionPath = null; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { // Handle any already resolved connections foreach (DB::getConnections() as $connection) { @@ -40,16 +39,20 @@ class DisallowSqliteAttach implements Feature protected function loadExtension(PDO $pdo): bool { if (static::$loadExtensionSupported === null) { + // todo@sqlite refactor to local static static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); } if (static::$loadExtensionSupported === false) { return false; } + if (static::$extensionPath === false) { return false; } + // todo@sqlite we may want to check for 64 bit + $suffix = match (PHP_OS_FAMILY) { 'Linux' => 'so', 'Windows' => 'dll', diff --git a/src/Features/TelescopeTags.php b/src/Features/TelescopeTags.php index 0a580d23..225049df 100644 --- a/src/Features/TelescopeTags.php +++ b/src/Features/TelescopeTags.php @@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features; use Laravel\Telescope\IncomingEntry; use Laravel\Telescope\Telescope; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class TelescopeTags implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { if (! class_exists(Telescope::class)) { return; diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 5bc84060..10283da3 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -12,7 +12,6 @@ use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\TenancyBootstrapped; -use Stancl\Tenancy\Tenancy; class TenantConfig implements Feature { @@ -27,7 +26,7 @@ class TenantConfig implements Feature protected Repository $config, ) {} - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { /** @var Tenant $tenant */ diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 3db563a4..ac478d07 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -17,9 +17,9 @@ class UserImpersonation implements Feature /** The lifespan of impersonation tokens (in seconds). */ public static int $ttl = 60; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { - $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { + Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { return UserImpersonation::modelClass()::create([ Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), 'user_id' => $userId, diff --git a/src/Features/ViteBundler.php b/src/Features/ViteBundler.php index 987187c7..003984f7 100644 --- a/src/Features/ViteBundler.php +++ b/src/Features/ViteBundler.php @@ -7,19 +7,14 @@ namespace Stancl\Tenancy\Features; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Vite; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class ViteBundler implements Feature { - /** @var Application */ - protected $app; + public function __construct( + protected Application $app, + ) {} - public function __construct(Application $app) - { - $this->app = $app; - } - - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Vite::createAssetPathsUsing(function ($path, $secure = null) { return global_asset($path); diff --git a/src/Tenancy.php b/src/Tenancy.php index 86eb6d8d..81e9b1ea 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Support\Traits\Macroable; use Stancl\Tenancy\Concerns\DealsWithRouteContexts; use Stancl\Tenancy\Concerns\ManagesRLSPolicies; +use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; @@ -40,7 +41,7 @@ class Tenancy public static array $findWith = []; /** - * A list of bootstrappers that have been initialized. + * List of bootstrappers that have been initialized. * * This is used when reverting tenancy, mainly if an exception * occurs during bootstrapping, to ensure we don't revert @@ -53,6 +54,23 @@ class Tenancy */ public array $initializedBootstrappers = []; + /** + * List of features that have been bootstrapped. + * + * Since features may be bootstrapped multiple times during + * the request cycle (in TSP::boot() and any other times the user calls + * bootstrapFeatures()), we keep track of which features have already + * been bootstrapped so we do not bootstrap them again. Features are + * bootstrapped once and irreversible. + * + * The main point of this is that some features *need* to be bootstrapped + * very early (see #949), so we bootstrap them directly in TSP, but we + * also need the ability to *change* which features are used at runtime + * (mainly tests of this package) and bootstrap features again after making + * changes to config('tenancy.features'). + */ + protected array $bootstrappedFeatures = []; + /** Initialize tenancy for the passed tenant. */ public function initialize(Tenant|int|string $tenant): void { @@ -154,6 +172,27 @@ class Tenancy return in_array($bootstrapper, static::getBootstrappers(), true); } + /** + * Bootstrap configured Tenancy features. + * + * Normally, features are bootstrapped directly in TSP::boot(). However, if + * new features are enabled at runtime (e.g. during tests), this method may + * be called to bootstrap new features. It's idempotent and keeps track of + * which features have already been bootstrapped. Keep in mind that feature + * bootstrapping is irreversible. + */ + public function bootstrapFeatures(): void + { + foreach (config('tenancy.features') ?? [] as $feature) { + /** @var class-string $feature */ + + if (! in_array($feature, $this->bootstrappedFeatures)) { + app($feature)->bootstrap(); + $this->bootstrappedFeatures[] = $feature; + } + } + } + /** * @return Builder */ diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 557306b2..a7f27e63 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -40,15 +40,6 @@ class TenancyServiceProvider extends ServiceProvider // Make sure Tenancy is stateful. $this->app->singleton(Tenancy::class); - // Make sure features are bootstrapped as soon as Tenancy is instantiated. - $this->app->extend(Tenancy::class, function (Tenancy $tenancy) { - foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) { - $this->app[$feature]->bootstrap($tenancy); - } - - return $tenancy; - }); - // Make it possible to inject the current tenant by type hinting the Tenant contract. $this->app->bind(Tenant::class, function ($app) { return $app[Tenancy::class]->tenant; @@ -176,6 +167,11 @@ class TenancyServiceProvider extends ServiceProvider return $instance; }); + // Bootstrap features that are already enabled in the config. + // If more features are enabled at runtime, this method may be called + // multiple times, it keeps track of which features have already been bootstrapped. + $this->app->make(Tenancy::class)->bootstrapFeatures(); + Route::middlewareGroup('clone', []); Route::middlewareGroup('universal', []); Route::middlewareGroup('tenant', []); diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index a1588a24..1ec62f2a 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) { return json_encode(DB::select(request('q2'))); }); - tenancy(); // trigger features: todo@samuel remove after feature refactor + tenancy()->bootstrapFeatures(); if ($disallow) { expect(fn () => pest()->post('/central-sqli', [ diff --git a/tests/Features/RedirectTest.php b/tests/Features/RedirectTest.php index a4102070..a871f529 100644 --- a/tests/Features/RedirectTest.php +++ b/tests/Features/RedirectTest.php @@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () { 'tenancy.features' => [CrossDomainRedirect::class], ]); + tenancy()->bootstrapFeatures(); + Route::get('/foobar', function () { return 'Foo'; })->name('home'); diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index b06ddba9..cef5efbf 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -11,16 +11,21 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +beforeEach(function () { + config([ + 'tenancy.features' => [TenantConfig::class], + 'tenancy.bootstrappers' => [], + ]); + + tenancy()->bootstrapFeatures(); +}); + afterEach(function () { TenantConfig::$storageToConfigMap = []; }); test('nested tenant values are merged', function () { expect(config('whitelabel.theme'))->toBeNull(); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); @@ -32,6 +37,9 @@ test('nested tenant values are merged', function () { 'whitelabel' => ['config' => ['theme' => 'dark']], ]); + // todo0 one reason why this fails is that tenancy.features was empty + // at register() time + tenancy()->initialize($tenant); expect(config('whitelabel.theme'))->toBe('dark'); tenancy()->end(); @@ -39,10 +47,6 @@ test('nested tenant values are merged', function () { test('config is merged and removed', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); @@ -68,10 +72,6 @@ test('config is merged and removed', function () { test('the value can be set to multiple config keys', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); diff --git a/tests/Features/ViteBundlerTest.php b/tests/Features/ViteBundlerTest.php index 3934698f..17ee8e08 100644 --- a/tests/Features/ViteBundlerTest.php +++ b/tests/Features/ViteBundlerTest.php @@ -27,6 +27,7 @@ beforeEach(function () { test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { config(['tenancy.features' => [ViteBundler::class]]); + tenancy()->bootstrapFeatures(); withBootstrapping(); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 8c9c4124..48fbe691 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -42,6 +42,8 @@ beforeEach(function () { ], ]); + tenancy()->bootstrapFeatures(); + Event::listen( TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { From b2f95592a61585b0a3c7befecff326b5d5c4fb4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 31 Aug 2025 21:19:08 +0000 Subject: [PATCH 08/31] Fix code style (php-cs-fixer) --- src/Tenancy.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tenancy.php b/src/Tenancy.php index 81e9b1ea..95eeb950 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -185,7 +185,6 @@ class Tenancy { foreach (config('tenancy.features') ?? [] as $feature) { /** @var class-string $feature */ - if (! in_array($feature, $this->bootstrappedFeatures)) { app($feature)->bootstrap(); $this->bootstrappedFeatures[] = $feature; From 4e22c4dd6e20a36056b7afc5237298ea7f5ec3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 31 Aug 2025 23:37:51 +0200 Subject: [PATCH 09/31] Remove temp todo --- tests/Features/TenantConfigTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index cef5efbf..b3b628e7 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -37,9 +37,6 @@ test('nested tenant values are merged', function () { 'whitelabel' => ['config' => ['theme' => 'dark']], ]); - // todo0 one reason why this fails is that tenancy.features was empty - // at register() time - tenancy()->initialize($tenant); expect(config('whitelabel.theme'))->toBe('dark'); tenancy()->end(); From 13a2209f11677d956f8a096bde3dee10ee540418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 02:07:36 +0200 Subject: [PATCH 10/31] SQLite improvements - (BC BREAK) Remove $WAL static property. We instead just let Laravel use its journal_mode config now - Remove journal, wal, and shm files when deleting tenant DB - Check that the system is 64-bit when using NoAttach (we don't build 32 bit extensions) - Use local static instead of a class static property for caching loadExtensionSupported --- .../SQLiteDatabaseManager.php | 40 +++++-------------- src/Features/DisallowSqliteAttach.php | 23 +++-------- tests/TenantDatabaseManagerTest.php | 38 ++++++++++-------- 3 files changed, 38 insertions(+), 63 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 818539c2..f3181fa2 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; -use AssertionError; use Closure; use Illuminate\Database\Eloquent\Model; use PDO; @@ -19,13 +18,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ public static string|null $path = null; - /** - * Should the WAL journal mode be used for newly created databases. - * - * @see https://www.sqlite.org/pragma.html#pragma_journal_mode - */ - public static bool $WAL = true; - /* * If this isn't null, a connection to the tenant DB will be created * and passed to the provided closure, for the purpose of keeping the @@ -89,26 +81,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } - try { - if (file_put_contents($path = $this->getPath($name), '') === false) { - return false; - } - - // todo@sqlite we can just respect Laravel config for WAL now - if (static::$WAL) { - $pdo = new PDO('sqlite:' . $path); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - // @phpstan-ignore-next-line method.nonObject - assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.'); - } - - return true; - } catch (AssertionError $e) { - throw $e; - } catch (Throwable) { - return false; - } + return file_put_contents($this->getPath($name), '') !== false; } public function deleteDatabase(TenantWithDatabase $tenant): bool @@ -123,9 +96,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } + $path = $this->getPath($name); + try { - // todo@sqlite we should also remove any other files for the DB e.g. WAL - return unlink($this->getPath($name)); + unlink($path.'-journal'); + unlink($path.'-wal'); + unlink($path.'-shm'); + } catch (Throwable) {} + + try { + return unlink($path); } catch (Throwable) { return false; } diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index 16dd47f0..d7c57ac2 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -13,7 +13,6 @@ use Stancl\Tenancy\Contracts\Feature; class DisallowSqliteAttach implements Feature { - protected static bool|null $loadExtensionSupported = null; public static string|false|null $extensionPath = null; public function bootstrap(): void @@ -38,20 +37,12 @@ class DisallowSqliteAttach implements Feature protected function loadExtension(PDO $pdo): bool { - if (static::$loadExtensionSupported === null) { - // todo@sqlite refactor to local static - static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); - } + static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); - if (static::$loadExtensionSupported === false) { - return false; - } - - if (static::$extensionPath === false) { - return false; - } - - // todo@sqlite we may want to check for 64 bit + if ((! $loadExtensionSupported) || + (static::$extensionPath === false) || + (PHP_INT_SIZE !== 8) + ) return false; $suffix = match (PHP_OS_FAMILY) { 'Linux' => 'so', @@ -64,9 +55,7 @@ class DisallowSqliteAttach implements Feature $arm = $arch === 'aarch64' || $arch === 'arm64'; static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix)); - if (static::$extensionPath === false) { - return false; - } + if (static::$extensionPath === false) return false; $pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 051312da..a9f99829 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { SQLiteDatabaseManager::$path = null; @@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () { expect(database_path('foodb'))->toBe(config('database.connections.tenant.database')); }); -test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) { - $expected = $wal ? 'wal' : 'delete'; - if ($wal !== null) { - SQLiteDatabaseManager::$WAL = $wal; - } else { - // default behavior - $expected = 'wal'; - } - - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener()); +test('sqlite databases respect the template journal_mode config', function (string $journal_mode) { + withTenantDatabases(); + withBootstrapping(); + config([ + 'database.connections.sqlite.journal_mode' => $journal_mode, + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + ]); $tenant = Tenant::create([ 'tenancy_db_connection' => 'sqlite', @@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null $db = new PDO('sqlite:' . $dbPath); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected); + // Before we connect to the DB using Laravel, it will be in default delete mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete'); - // cleanup - SQLiteDatabaseManager::$WAL = true; -})->with([true, false, null]); + // This will trigger the logic in Laravel's SQLiteConnector + $tenant->run(fn () => DB::select('select 1')); + + $db = new PDO('sqlite:' . $dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Once we connect to the DB, it will be in the configured journal mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode); +})->with(['delete', 'wal']); test('schema manager uses schema to separate tenant dbs', function () { config([ From 364637dc23f6e920597cb1fabb6b41d9d67bb00c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Sep 2025 14:14:34 +0000 Subject: [PATCH 11/31] Fix code style (php-cs-fixer) --- .../TenantDatabaseManagers/SQLiteDatabaseManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index f3181fa2..b792f228 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -99,9 +99,9 @@ class SQLiteDatabaseManager implements TenantDatabaseManager $path = $this->getPath($name); try { - unlink($path.'-journal'); - unlink($path.'-wal'); - unlink($path.'-shm'); + unlink($path . '-journal'); + unlink($path . '-wal'); + unlink($path . '-shm'); } catch (Throwable) {} try { From d983bf954781554578787061647266c778c113cd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 3 Sep 2025 15:56:12 +0200 Subject: [PATCH 12/31] Add tenant parameter BEFORE existing prefixes by default, add tenantParameterBeforePrefix() to allow customizing this (#1393) --- src/Actions/CloneRoutesAsTenant.php | 16 ++++++++++++- tests/CloneActionTest.php | 36 +++++++++++++++++++---------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index ec60d880..f1cb1450 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -86,6 +86,7 @@ class CloneRoutesAsTenant { protected array $routesToClone = []; protected bool $addTenantParameter = true; + protected bool $tenantParameterBeforePrefix = true; protected string|null $domain = null; protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) protected Closure|null $shouldClone = null; @@ -177,6 +178,13 @@ class CloneRoutesAsTenant return $this; } + public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static + { + $this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix; + + return $this; + } + /** Clone an individual route. */ public function cloneRoute(Route|string $route): static { @@ -226,7 +234,13 @@ class CloneRoutesAsTenant $action->put('middleware', $middleware); if ($this->addTenantParameter) { - $action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); + $tenantParameter = '{' . PathTenantResolver::tenantParameterName() . '}'; + + $newPrefix = $this->tenantParameterBeforePrefix + ? $tenantParameter . '/' . $prefix + : $prefix . '/' . $tenantParameter; + + $action->put('prefix', $newPrefix); } /** @var Route $newRoute */ diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 656ad327..28a8ccd3 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst false, ]); -test('the clone action prefixes already prefixed routes correctly', function () { +test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) { $routes = [ RouteFacade::get('/home', fn () => true) ->middleware(['clone']) @@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function () ->prefix('prefix/'), ]; - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); + + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; $clonedRoutes = [ RouteFacade::getRoutes()->getByName('tenant.home'), @@ -206,9 +211,10 @@ test('the clone action prefixes already prefixed routes correctly', function () // The cloned route is prefixed correctly foreach ($clonedRoutes as $key => $route) { - expect($route->getPrefix())->toBe("prefix/{tenant}"); + expect($route->getPrefix())->toBe($expectedPrefix); $clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]); + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; expect($clonedRouteUrl) // Original prefix does not occur in the cloned route's URL @@ -216,14 +222,14 @@ test('the clone action prefixes already prefixed routes correctly', function () ->not()->toContain("//prefix") ->not()->toContain("prefix//") // Instead, the route is prefixed correctly - ->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}/{$routes[$key]->getName()}"); // The cloned route is accessible pest()->get($clonedRouteUrl)->assertOk(); } -}); +})->with([true, false]); -test('clone action trims trailing slashes from prefixes given to nested route groups', function () { +test('clone action trims trailing slashes from prefixes given to nested route groups', function (bool $tenantParameterBeforePrefix) { RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('')->group(function () { // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route @@ -237,7 +243,10 @@ test('clone action trims trailing slashes from prefixes given to nested route gr }); }); - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); @@ -245,17 +254,20 @@ test('clone action trims trailing slashes from prefixes given to nested route gr $landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing'); $homeRoute = RouteFacade::getRoutes()->getByName('tenant.home'); - expect($landingRoute->uri())->toBe('prefix/{tenant}'); - expect($homeRoute->uri())->toBe('prefix/{tenant}/home'); + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; + + expect($landingRoute->uri())->toBe($expectedPrefix); + expect($homeRoute->uri())->toBe("{$expectedPrefix}/home"); expect($clonedLandingUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}"); expect($clonedHomeRouteUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}/home"); -}); + ->toBe("http://localhost/{$expectedPrefixInUrl}/home"); +})->with([true, false]); test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () { // Should NOT be cloned, already has tenant parameter From c152031cc182c059629cfb27ea5dd8ac497420d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 18 Sep 2025 00:32:08 +0200 Subject: [PATCH 13/31] util: add static_properties.nu, more portable shebangs, PHP 8.5 beta --- composer.json | 2 +- static_properties.nu | 103 +++++++++++++++++++++++++++++++++++++++++++ t | 2 +- test | 2 +- 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100755 static_properties.nu diff --git a/composer.json b/composer.json index 2eab8837..e5b86b07 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ "docker-restart": "docker compose down && docker compose up -d", "docker-rebuild": [ "Composer\\Config::disableProcessTimeout", - "PHP_VERSION=8.4 docker compose up -d --no-deps --build" + "PHP_VERSION=8.5.0beta3 docker compose up -d --no-deps --build" ], "docker-rebuild-with-xdebug": [ "Composer\\Config::disableProcessTimeout", diff --git a/static_properties.nu b/static_properties.nu new file mode 100755 index 00000000..8b35e84e --- /dev/null +++ b/static_properties.nu @@ -0,0 +1,103 @@ +#!/usr/bin/env nu + +# Utility for exporting static properties used for configuration +def main []: nothing -> string { + "See --help for subcommands" +} + +# The current number of config static properties in the codebase +def "main count" [...paths: string]: nothing -> int { + props ...$paths | length +} + +# Available static properties, grouped by file, rendered as a table +def "main table" [...paths: string]: nothing -> string { + props ...$paths | table --theme rounded --expand +} + +# Plain text version of available static properties +def "main plain" [...paths: string]: nothing -> string { + props ...$paths + | each { $"// File: ($in.file)\n($in.props | str join "\n\n")"} + | str join "\n//------------------------------------------------------------\n\n" +} + +# Expressive Code formatting of available static properties, used in docs +def "main docs" [...paths: string]: nothing -> string { + (("{/* GENERATED_BEGIN */}\n" + (props ...$paths + | each { update props { each { if ($in | str ends-with "= [") { + $"($in)/* ... */];" + } else { $in }}}} + | each { $"```php /public static .*$/\n// File: ($in.file)\n($in.props | str join "\n\n")\n```"} + | str join "\n\n")) + + "\n{/* GENERATED_END */}") +} + +def props [...paths: string]: nothing -> table> { + ls ...(if ($paths | length) > 0 { + ($paths | each {|path| + if ($path | str contains "*") { + # already a glob expr + $path | into glob + } else if ($path | str ends-with ".php") { + # src/Foo/Bar.php + $path + } else { + # just 'src/Foo' passed + $"($path)/**/*.php" | into glob + } + }) + } else { + [("src/**/*.php" | into glob)] + }) + | each { { name: $in.name, content: (open $in.name) } } + | find -nr 'public static (?!.*function)' + | par-each {|file| + let lines = $file.content | lines + mut docblock_start = 0 + mut docblock_end = 0 + mut props = [] + for line in ($lines | enumerate) { + if ($line.item | str contains "/**") { + $docblock_start = $line.index + } + + if ($line.item | str contains "@internal") { + # Docblocks with @internal are ignored + $docblock_start = 0 + $docblock_end = 0 + } + + if ($line.item | str contains "*/") { + $docblock_end = $line.index + } + + if ( + ( + ( # Valid (non-internal) docblock + $docblock_start != 0 and + $docblock_end != 0 and + $docblock_end == ($line.index - 1) + ) or + ( # No docblock + $line.index != 0 and + (($lines | get ($line.index - 1)) | str index-of "*/") == -1 + ) + ) and + ($line.item | str trim | str index-of "public static") == 0 and + ($line.item | str trim | str index-of "public static function") == -1 + ) { + if ($docblock_start == 0) or ($docblock_end == 0) or ($docblock_end != ($line.index - 1)) { + $docblock_start = $line.index + $docblock_end = $line.index + } + $props = $props | append ($lines | slice $docblock_start..$line.index | each { str trim } | str join "\n") + $docblock_start = 0 + $docblock_end = 0 + } + } + + {file: $file.name, props: $props} + } + | where ($it.props | length) > 0 +} diff --git a/t b/t index 36d2d391..5b2c1f26 100755 --- a/t +++ b/t @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" diff --git a/test b/test index 0df8f63e..b63dbdb9 100755 --- a/test +++ b/test @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" From b320f8f33d0a215bf6af4d0b0a18c4936125f7be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 26 Sep 2025 11:29:14 +0200 Subject: [PATCH 14/31] Add TenantConfigBootstrapper, deprecate Feature implementation The feature was pretty much a soft-bootstrapper -- it listened to both Bootstrapped and Reverted. Bootstrappers have a few more protections in terms of error handling and safe reverting, so there's no point in (badly) re-implementing bootstrapper functionality within TenantConfig just so it could be a Feature. Going forward, all Features should be things that are mostly agnostic of the tenant state, and especially they should not use bootstrapped/ reverted events. Bootstrappers are simply more appropriate and safe. --- assets/config.php | 2 +- .../TenantConfigBootstrapper.php | 54 +++++++++++++++++++ src/Features/TenantConfig.php | 3 ++ tests/Features/TenantConfigTest.php | 27 +++------- tests/TestCase.php | 2 + 5 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 src/Bootstrappers/TenantConfigBootstrapper.php diff --git a/assets/config.php b/assets/config.php index 06bceccb..1eee26f0 100644 --- a/assets/config.php +++ b/assets/config.php @@ -178,6 +178,7 @@ return [ Bootstrappers\DatabaseSessionBootstrapper::class, // Configurable bootstrappers + // Bootstrappers\TenantConfigBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class, // Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true @@ -419,7 +420,6 @@ return [ 'features' => [ // Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\TelescopeTags::class, - // Stancl\Tenancy\Features\TenantConfig::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\ViteBundler::class, // Stancl\Tenancy\Features\DisallowSqliteAttach::class, diff --git a/src/Bootstrappers/TenantConfigBootstrapper.php b/src/Bootstrappers/TenantConfigBootstrapper.php new file mode 100644 index 00000000..98ec2cb0 --- /dev/null +++ b/src/Bootstrappers/TenantConfigBootstrapper.php @@ -0,0 +1,54 @@ + */ + public static array $storageToConfigMap = [ + // 'paypal_api_key' => 'services.paypal.api_key', + ]; + + public function __construct( + protected Repository $config, + ) {} + + public function bootstrap(Tenant $tenant): void + { + foreach (static::$storageToConfigMap as $storageKey => $configKey) { + /** @var Tenant&Model $tenant */ + $override = Arr::get($tenant, $storageKey); + + if (! is_null($override)) { + if (is_array($configKey)) { + foreach ($configKey as $key) { + $this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key); + + $this->config->set($key, $override); + } + } else { + $this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + } + + public function revert(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 10283da3..3e248cb6 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -13,6 +13,9 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\TenancyBootstrapped; +// todo@release remove this class + +/** @deprecated Use the TenantConfigBootstrapper instead. */ class TenantConfig implements Feature { public array $originalConfig = []; diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index b3b628e7..483e44a6 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -2,34 +2,27 @@ declare(strict_types=1); -use Illuminate\Support\Facades\Event; -use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Features\TenantConfig; -use Stancl\Tenancy\Listeners\BootstrapTenancy; -use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; beforeEach(function () { config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], + 'tenancy.bootstrappers' => [TenantConfigBootstrapper::class], ]); - tenancy()->bootstrapFeatures(); + withBootstrapping(); }); afterEach(function () { - TenantConfig::$storageToConfigMap = []; + TenantConfigBootstrapper::$storageToConfigMap = []; }); test('nested tenant values are merged', function () { expect(config('whitelabel.theme'))->toBeNull(); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'whitelabel.config.theme' => 'whitelabel.theme', ]; @@ -44,10 +37,8 @@ test('nested tenant values are merged', function () { test('config is merged and removed', function () { expect(config('services.paypal'))->toBe(null); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => 'services.paypal.public', 'paypal_api_private' => 'services.paypal.private', ]; @@ -69,10 +60,8 @@ test('config is merged and removed', function () { test('the value can be set to multiple config keys', function () { expect(config('services.paypal'))->toBe(null); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => [ 'services.paypal.public1', 'services.paypal.public2', diff --git a/tests/TestCase.php b/tests/TestCase.php index bdd43fd4..ceee6522 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use function Stancl\Tenancy\Tests\pest; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -193,6 +194,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(TenantConfigBootstrapper::class); } protected function getPackageProviders($app) From 3846fe88ec527c02293bfdd6f87734c3ea99fc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 26 Sep 2025 13:41:43 +0200 Subject: [PATCH 15/31] install: support starring using GH CLI --- src/Commands/Install.php | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 9f6a9c31..8521de5a 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands; use Closure; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Process; class Install extends Command { @@ -128,14 +129,27 @@ class Install extends Command public function askForSupport(): void { if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { - if (PHP_OS_FAMILY === 'Darwin') { - exec('open https://github.com/archtechx/tenancy'); + $ghVersion = Process::run('gh --version'); + $starred = false; + + // Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool + if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) { + $starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy'); + $starred = $starRequest->successful(); } - if (PHP_OS_FAMILY === 'Windows') { - exec('start https://github.com/archtechx/tenancy'); - } - if (PHP_OS_FAMILY === 'Linux') { - exec('xdg-open https://github.com/archtechx/tenancy'); + + if ($starred) { + $this->components->success('Repository starred via gh CLI, thank you!'); + } else { + if (PHP_OS_FAMILY === 'Darwin') { + exec('open https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Windows') { + exec('start https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Linux') { + exec('xdg-open https://github.com/archtechx/tenancy'); + } } } } From a0a9b8598204d629a9fcbef961ab125ef02a4824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 16/31] Refactor DatabaseConfig, minor DB manager improvements, resolve todos Notable changes: - CreateUserWithRLSPolicies: Clarify why we're creating a custom DatabaseConfing instance - HasDatabase: Clarify why we're ignoring tenancy_db_connection - DatabaseConfig: General refactor, clarify the role of the host conn - SQLiteDatabaseManager: Handle trailing DIRECTORY_SEPARATOR in static::$path - DisallowSqliteAttach: Don't throw any exceptions, just silently fail since the class isn't 100% portable - Clean up todos that are no longer relevant - Clean up dead code or comments in some database managers --- .../DatabaseTenancyBootstrapper.php | 2 +- src/Commands/CreateUserWithRLSPolicies.php | 11 ++++++-- src/Database/Concerns/HasDatabase.php | 3 +- src/Database/DatabaseConfig.php | 28 +++++++++---------- ...rmissionControlledMySQLDatabaseManager.php | 1 - ...ssionControlledPostgreSQLSchemaManager.php | 4 --- .../SQLiteDatabaseManager.php | 11 +++----- src/Features/DisallowSqliteAttach.php | 5 ++-- 8 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 33ff7b29..7f0bce0a 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -35,7 +35,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper // Better debugging, but breaks cached lookup, so we disable this in prod if (app()->environment('local') || app()->environment('testing')) { $database = $tenant->database()->getName(); - if (! $tenant->database()->manager()->databaseExists($database)) { // todo@dbRefactor does this call correctly use the host connection? + if (! $tenant->database()->manager()->databaseExists($database)) { throw new TenantDatabaseDoesNotExistException($database); } } diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 420df935..3998dc48 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command #[\SensitiveParameter] string $password, ): DatabaseConfig { + // This is a bit of a hack. We want to use our existing createUser() logic. + // That logic needs a DatabaseConfig instance. However, we aren't really working + // with any specific tenant here. We also *don't* want to use anything tenant-specific + // here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig + // instance for this purpose. The easiest way to do that is to grab an empty Tenant model + // (we use TenantWithDatabase in RLS) and manually create the host connection, just like + // DatabaseConfig::manager() would. We don't call that method since we want to use our existing + // PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager". + /** @var TenantWithDatabase $tenantModel */ $tenantModel = tenancy()->model(); - // Use a temporary DatabaseConfig instance to set the host connection $temporaryDbConfig = $tenantModel->database(); - $temporaryDbConfig->purgeHostConnection(); $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); diff --git a/src/Database/Concerns/HasDatabase.php b/src/Database/Concerns/HasDatabase.php index e1f4a55f..9388f168 100644 --- a/src/Database/Concerns/HasDatabase.php +++ b/src/Database/Concerns/HasDatabase.php @@ -28,7 +28,8 @@ trait HasDatabase } if ($key === $this->internalPrefix() . 'db_connection') { - // Remove DB connection because that's not used here + // Remove DB connection because that's not used for the connection *contents*. + // Instead the code uses getInternal('db_connection'). continue; } diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 7dbbc577..bd167761 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; -// todo@dbRefactor refactor host connection logic to make customizing the host connection easier class DatabaseConfig { /** The tenant whose database we're dealing with. */ @@ -115,7 +114,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); } @@ -137,7 +136,9 @@ class DatabaseConfig } if ($template = config('tenancy.database.template_tenant_connection')) { - return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); + return is_array($template) + ? array_merge($this->getCentralConnection(), $template) + : config("database.connections.{$template}"); } return $this->getCentralConnection(); @@ -176,10 +177,10 @@ class DatabaseConfig $config = $this->tenantConfig; $templateConnection = $this->getTemplateConnection(); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { - // We're removing the username and password because user with these credentials is not created yet - // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, - // consider creating a new connection and use it as `tenancy_db_connection` tenant config key + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + // We remove the username and password because the user with these credentials is not yet created. + // If you need to provide a username and a password when using a permission controlled database manager, + // consider creating a new connection and use it as `tenancy_db_connection`. unset($config['username'], $config['password']); } @@ -191,7 +192,7 @@ class DatabaseConfig } /** - * Purge the previous tenant connection before opening it for another tenant. + * Purge the previous host connection before opening it for another tenant. */ public function purgeHostConnection(): void { @@ -199,20 +200,20 @@ class DatabaseConfig } /** - * Get the TenantDatabaseManager for this tenant's connection. + * Get the TenantDatabaseManager for this tenant's host connection. * * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException */ public function manager(): Contracts\TenantDatabaseManager { - // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details + // Laravel persists the PDO connection, so we purge it to be able to change the connection details $this->purgeHostConnection(); // Create the tenant host connection config $tenantHostConnectionName = $this->getTenantHostConnectionName(); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); - $manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); + $manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver")); if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { $manager->setConnection($tenantHostConnectionName); @@ -222,12 +223,11 @@ class DatabaseConfig } /** - * todo@dbRefactor come up with a better name - * Get database manager class from the given connection config's driver. + * Get the TenantDatabaseManager for a given database driver. * * @throws DatabaseManagerNotRegisteredException */ - protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager + protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager { $databaseManagers = config('tenancy.database.managers'); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 8ea3e631..47ec11a2 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl { $database = $databaseConfig->getName(); $username = $databaseConfig->getUsername(); - $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index eca2ef87..b528d4e3 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -30,10 +30,6 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); // Grant permissions to any existing tables. This is used with RLS - // todo@dbRefactor refactor this along with the todo in TenantDatabaseManager - // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` - // but maybe moving it inside $createUser is wrong because some central user may migrate new tables - // while the RLS user should STILL get access to those tables foreach ($tables as $table) { $tableName = $table->table_name; diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index b792f228..34ad394d 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -14,7 +14,9 @@ use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { /** - * SQLite Database path without ending slash. + * SQLite database directory path. + * + * Defaults to database_path(). */ public static string|null $path = null; @@ -132,15 +134,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return $baseConfig; } - public function setConnection(string $connection): void - { - // - } - public function getPath(string $name): string { if (static::$path) { - return static::$path . DIRECTORY_SEPARATOR . $name; + return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } return database_path($name); diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index d7c57ac2..3d4d977e 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; -use Exception; use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; @@ -48,9 +47,11 @@ class DisallowSqliteAttach implements Feature 'Linux' => 'so', 'Windows' => 'dll', 'Darwin' => 'dylib', - default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), + default => 'error', }; + if ($suffix === 'error') return false; + $arch = php_uname('m'); $arm = $arch === 'aarch64' || $arch === 'arm64'; From f87f353cf9ba884954d800db9dd0ae82c06f3bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 17/31] docker-compose: Properly pass through PHP_VERSION Also revert composer.json docker-rebuild script to PHP 8.4, as PHP 8.5 beta doesn't currently support phpredis, rendering the Dockerfile unbuildable. --- composer.json | 2 +- docker-compose.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e5b86b07..2eab8837 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ "docker-restart": "docker compose down && docker compose up -d", "docker-rebuild": [ "Composer\\Config::disableProcessTimeout", - "PHP_VERSION=8.5.0beta3 docker compose up -d --no-deps --build" + "PHP_VERSION=8.4 docker compose up -d --no-deps --build" ], "docker-rebuild-with-xdebug": [ "Composer\\Config::disableProcessTimeout", diff --git a/docker-compose.yml b/docker-compose.yml index 34bd1cc1..70a68019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: . args: + PHP_VERSION: ${PHP_VERSION:-8.4} XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} depends_on: mysql: From e6cc6d67778833137c2149c841cec2960566c567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 18/31] phpstan: Remove ignore that is no longer necessary --- src/Overrides/TenancyUrlGenerator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index ed14d5b5..88ae54f3 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator */ public function route($name, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } @@ -125,7 +125,7 @@ class TenancyUrlGenerator extends UrlGenerator */ public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } From 211be22735167c842942d231b600828fdeaf8737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 19/31] misc: update .gitattributes and .nvim.lua Add export-ignore for CLAUDE.md and static_properties.nu Update nvim syntax for disabling the TailwindCSS LSP --- .gitattributes | 2 ++ .nvim.lua | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 3736c54d..513bd7da 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,7 @@ /.nvim.lua export-ignore /art export-ignore /coverage export-ignore +/CLAUDE.md export-ignore /CONTRIBUTING.md export-ignore /INTERNAL.md export-ignore /SUPPORT.md export-ignore @@ -19,6 +20,7 @@ /Dockerfile export-ignore /doctum export-ignore /phpunit.xml export-ignore +/static_properties.nu export-ignore /t export-ignore /test export-ignore /tests export-ignore diff --git a/.nvim.lua b/.nvim.lua index c9b5d9cb..5e7c5249 100644 --- a/.nvim.lua +++ b/.nvim.lua @@ -1,4 +1,3 @@ -- The tailwindcss LSP doesn't play nice with testbench due to the recursive --- `vendor` symlink in `testbench-core/laravel/vendor`, so we nuke its setup method here. --- This prevents the setup() call in neovim config from starting the client (or doing anything at all). -require('lspconfig').tailwindcss.setup = function () end +-- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here. +vim.lsp.enable('tailwindcss', false) From 3cf102ebd67a2b337857c99b953ba11b4ab1eb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 20/31] Update stubs, add PHP 8.5 todo Remove comments about shouldBeQueued(true) being preferable in production as that isn't necessarily true anymore with pending tenants (or even the absence of any "optimizations", they're all optional). Using queued tenant creation also requires some code changes in the tenant onboarding logic, so it is misleading to imply that it's a switch that should simply be turned on in production. Add DatabaseCacheBootstrapper to config.php as it was missing there. Remove note about MailConfigBootstrapper needing forceRefresh in the QueueTenancyBootstrapper as we now use a non-persistent queue bootstrapper by default. --- assets/TenancyServiceProvider.stub.php | 4 ++-- assets/config.php | 3 ++- src/Features/DisallowSqliteAttach.php | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 1a01e9a8..e0b69e6e 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -57,7 +57,7 @@ class TenancyServiceProvider extends ServiceProvider // Provision API keys, create S3 buckets, anything you want! ])->send(function (Events\TenantCreated $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. + })->shouldBeQueued(false), // Listeners\CreateTenantStorage::class, ], @@ -80,7 +80,7 @@ class TenancyServiceProvider extends ServiceProvider Jobs\DeleteDatabase::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + })->shouldBeQueued(false), ], Events\TenantMaintenanceModeEnabled::class => [], diff --git a/assets/config.php b/assets/config.php index 1eee26f0..d01cbff7 100644 --- a/assets/config.php +++ b/assets/config.php @@ -170,6 +170,7 @@ return [ Bootstrappers\DatabaseTenancyBootstrapper::class, Bootstrappers\CacheTenancyBootstrapper::class, // Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper + // Bootstrappers\DatabaseCacheBootstrapper::class, // Separates cache by DB rather than by prefix, must run after DatabaseTenancyBootstrapper Bootstrappers\FilesystemTenancyBootstrapper::class, Bootstrappers\QueueTenancyBootstrapper::class, // Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed @@ -181,7 +182,7 @@ return [ // Bootstrappers\TenantConfigBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class, - // Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true + // Bootstrappers\MailConfigBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class, diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index 3d4d977e..fbfa8e58 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -36,6 +36,8 @@ class DisallowSqliteAttach implements Feature protected function loadExtension(PDO $pdo): bool { + // todo@php85 In PHP 8.5, we can use setAuthorizer() instead of loading an extension. + // However, this is currently blocked on https://github.com/phpredis/phpredis/issues/2688 static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); if ((! $loadExtensionSupported) || From e1b86584146d4566e0cc60bef327994471c56d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 14 Oct 2025 17:11:47 +0200 Subject: [PATCH 21/31] Fix #1404: support universal routes in CheckTenantForMaintenanceMode This commit also corrects an Event::fake() call in a separate test, as general Event::fake() calls without specified events can lead to incorrect (and difficult to debug) behavior in some cases, since Tenancy depends on the event system being functional. --- .../CheckTenantForMaintenanceMode.php | 8 +++- tests/MaintenanceModeTest.php | 40 ++++++++++++++++--- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index 58fcd184..3e91902f 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -14,7 +14,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode public function handle($request, Closure $next) { if (! tenant()) { - throw new TenancyNotInitializedException; + // If there's no tenant, there's no tenant to check for maintenance mode. + // Since tenant identification middleware has higher priority than this + // middleware, a missing tenant would have already lead to request termination. + // (And even if priority were misconfigured, the request would simply get + // terminated *after* this middleware.) + // Therefore, we are likely on a universal route, in central context. + return $next($request); } if (tenant('maintenance_mode')) { diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 9c90f0d3..9959992b 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Illuminate\Support\Facades\Route; +use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled; +use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use function Stancl\Tenancy\Tests\pest; @@ -38,18 +40,46 @@ test('tenants can be in maintenance mode', function () { pest()->get('http://acme.localhost/foo')->assertStatus(200); }); -test('maintenance mode events are fired', function () { - $tenant = MaintenanceTenant::create(); +test('maintenance mode middleware can be used with universal routes', function () { + Route::get('/foo', function () { + return 'bar'; + })->middleware(['universal', InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); - Event::fake(); + $tenant = MaintenanceTenant::create(); + $tenant->domains()->create([ + 'domain' => 'acme.localhost', + ]); + + // Revert to central context after each request so that the tenant context + // from the request doesn't persist + $run = function (Closure $callback) { $callback(); tenancy()->end(); }; + + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); $tenant->putDownForMaintenance(); - Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class); + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(503)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); // Not affected by a tenant's maintenance mode $tenant->bringUpFromMaintenance(); - Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class); + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); +}); + +test('maintenance mode events are fired', function () { + $tenant = MaintenanceTenant::create(); + + Event::fake([TenantMaintenanceModeEnabled::class, TenantMaintenanceModeDisabled::class]); + + $tenant->putDownForMaintenance(); + + Event::assertDispatched(TenantMaintenanceModeEnabled::class); + + $tenant->bringUpFromMaintenance(); + + Event::assertDispatched(TenantMaintenanceModeDisabled::class); }); test('tenants can be put into maintenance mode using artisan commands', function() { From 5fdae28edcecf9f556e91785d37f9fd48b4e0be3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Oct 2025 15:26:19 +0000 Subject: [PATCH 22/31] Fix code style (php-cs-fixer) --- src/Middleware/CheckTenantForMaintenanceMode.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index 3e91902f..4b399e13 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; -use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Symfony\Component\HttpKernel\Exception\HttpException; class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode From ddf83c4b556dc4e0f1742979d418f7ac3ebafd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 23/31] Assert createDatabase() success Pretty much all errors that can happen in createDatabase() end up throwing an exception, however the function still does return a boolean (it bubbles up the value from the underlying $conn->statement() call) which should be checked in at least some way. --- src/Jobs/CreateDatabase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index 94132b7e..6cd035dc 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -40,7 +40,7 @@ class CreateDatabase implements ShouldQueue try { $databaseManager->ensureTenantCanBeCreated($this->tenant); - $this->tenant->database()->manager()->createDatabase($this->tenant); + assert($this->tenant->database()->manager()->createDatabase($this->tenant) === true); event(new DatabaseCreated($this->tenant)); } catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) { From 74634dfe4befbc8718dd06d6f3c88a948bafa362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 1 Sep 2025 21:09:47 +0200 Subject: [PATCH 24/31] Session scoping (cache bootstrapper): throw on incompatible driver --- .../CacheTenancyBootstrapper.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index a66aa9f8..5b889868 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -99,10 +99,20 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper { $names = $this->config->get('tenancy.cache.stores'); - if ( - $this->config->get('tenancy.cache.scope_sessions', true) && - in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) - ) { + if ($this->config->get('tenancy.cache.scope_sessions', true)) { + // These are the only cache driven session backends (see Laravel's config/session.php) + if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) + && ! app()->environment('local') + ) { + // We only throw this exception in prod to make configuration a little easier. Developers + // may have scope_sessions set to true while using different session drivers e.g. in tests. + // Previously we just silently ignored this, however since session scoping is of high importance + // in production, we make sure to notify the developer, by throwing an exception, that session + // scoping isn't happening as expected/configured due to an incompatible session driver. + throw new Exception('Session driver [' . $name . '] cannot be scoped by tenancy.cache.scope_session'); + } + + // Scoping sessions using this bootstrapper implicitly adds the session store to $names $names[] = $this->getSessionCacheStoreName(); } @@ -112,6 +122,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper $store = $this->config->get("cache.stores.{$name}"); if ($store === null || $store['driver'] === 'file') { + // 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper return false; } From 6049ade20edc530a60d4382368b0b8ee961d343e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 16 Oct 2025 00:15:28 +0200 Subject: [PATCH 25/31] Fix exception message Properly retrieve session driver name, previously $name was undefined --- src/Bootstrappers/CacheTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 5b889868..0d948591 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -109,7 +109,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper // Previously we just silently ignored this, however since session scoping is of high importance // in production, we make sure to notify the developer, by throwing an exception, that session // scoping isn't happening as expected/configured due to an incompatible session driver. - throw new Exception('Session driver [' . $name . '] cannot be scoped by tenancy.cache.scope_session'); + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session'); } // Scoping sessions using this bootstrapper implicitly adds the session store to $names From 91f6c61fcdfa6d0665384c323abe55f1da2ea073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 16 Oct 2025 01:09:53 +0200 Subject: [PATCH 26/31] Fix assert: run createDatabase() outside assert() assert() calls, including assert(foo()), can be entirely compiled out depending on the INI settings described here: https://www.php.net/manual/en/function.assert.php That in turn means even side effects of foo() can be entirely compiled out. Therefore, to ensure the call actually runs, we need to run it before the assert(), store its return value, and only then make assertions about the return value. --- src/Jobs/CreateDatabase.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index 6cd035dc..decdb445 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue try { $databaseManager->ensureTenantCanBeCreated($this->tenant); - assert($this->tenant->database()->manager()->createDatabase($this->tenant) === true); + $databaseCreated = $this->tenant->database()->manager()->createDatabase($this->tenant); + assert($databaseCreated); event(new DatabaseCreated($this->tenant)); } catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) { From fadf1001f8ffa3d8079dfd8ffa13770353321d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 19 Oct 2025 18:44:58 +0200 Subject: [PATCH 27/31] PHP 8.5 support This commit adds support for building a docker image based on PHP 8.5 (RC). It also removes some unused code in tests that was triggering deprecation warnings. For similar deprecation warnings coming from testbench we have a temporary patch script until this is resolved upstream. This commit also adds logic to the DisallowSqliteAttach feature leveraging the new native setAuthorizer() method, instead of loading a compiled extension. We also remove the unused `php` parameter from ci.yml --- .github/workflows/ci.yml | 1 - CONTRIBUTING.md | 6 ++++++ Dockerfile | 30 ++++++++++++++++++++++----- composer.json | 7 +++++-- src/Features/DisallowSqliteAttach.php | 22 +++++++++++++++----- tests/TenantDatabaseManagerTest.php | 9 -------- tests/TestCase.php | 3 --- 7 files changed, 53 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91699f08..ca7c20f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: matrix: include: - laravel: "^12.0" - php: "8.4" steps: - name: Checkout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee451d20..76af44d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,3 +49,9 @@ Use `composer phpstan` to run our phpstan suite. 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`. + +## PHP 8.5 + +To use PHP 8.5 during development, run: +- `PHP_VERSION=8.5.0RC2 composer docker-rebuild` to build the `test` container with PHP 8.5 +- `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core diff --git a/Dockerfile b/Dockerfile index fb1620cd..2123e727 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,8 @@ ARG PHP_VERSION=8.4 - FROM php:${PHP_VERSION}-cli-bookworm SHELL ["/bin/bash", "-c"] -RUN apt-get update && apt-get install -y --no-install-recommends \ - git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client +RUN apt-get update RUN apt-get install -y gnupg2 \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ @@ -12,18 +10,40 @@ RUN apt-get install -y gnupg2 \ && apt-get update \ && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18 +RUN apt-get install -y --no-install-recommends \ + git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client + RUN apt autoremove && apt clean RUN pecl install apcu && docker-php-ext-enable apcu RUN pecl install pcov && docker-php-ext-enable pcov -RUN pecl install redis && docker-php-ext-enable redis +RUN pecl install redis-6.3.0RC1 && docker-php-ext-enable redis RUN pecl install memcached && docker-php-ext-enable memcached -RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv RUN docker-php-ext-install zip && docker-php-ext-enable zip RUN docker-php-ext-install intl && docker-php-ext-enable intl RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql +RUN if [[ "${PHP_VERSION}" == *"8.5"* ]]; then \ + mkdir sqlsrv \ + && cd sqlsrv \ + && pecl download pdo_sqlsrv-5.12.0 \ + && tar xzf pdo_sqlsrv-5.12.0.tgz \ + && cd pdo_sqlsrv-5.12.0 \ + && sed -i 's/= dbh->error_mode;/= static_cast(dbh->error_mode);/' pdo_dbh.cpp \ + && sed -i 's/zval_ptr_dtor( &dbh->query_stmt_zval );/OBJ_RELEASE(dbh->query_stmt_obj);dbh->query_stmt_obj=NULL;/' php_pdo_sqlsrv_int.h \ + && phpize \ + && ./configure --with-php-config=$(which php-config) \ + && make -j$(nproc) \ + && cp modules/pdo_sqlsrv.so $(php -r 'echo ini_get("extension_dir");') \ + && cd / \ + && rm -rf /sqlsrv; \ +else \ + pecl install pdo_sqlsrv; \ +fi + +RUN docker-php-ext-enable pdo_sqlsrv + RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" diff --git a/composer.json b/composer.json index 2eab8837..393d0d8a 100644 --- a/composer.json +++ b/composer.json @@ -65,13 +65,16 @@ "docker-restart": "docker compose down && docker compose up -d", "docker-rebuild": [ "Composer\\Config::disableProcessTimeout", - "PHP_VERSION=8.4 docker compose up -d --no-deps --build" + "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" + "XDEBUG_ENABLED=true docker compose up -d --no-deps --build" ], "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", + "php85-patch": [ + "php -r '$file=\"vendor/orchestra/testbench-core/laravel/config/database.php\"; file_put_contents($file, str_replace(\"PDO::MYSQL_ATTR_SSL_CA\", \"Pdo\\\\Mysql::ATTR_SSL_CA\", file_get_contents($file)));'" + ], "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index fbfa8e58..d4412984 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -19,7 +19,7 @@ class DisallowSqliteAttach implements Feature // Handle any already resolved connections foreach (DB::getConnections() as $connection) { if ($connection instanceof SQLiteConnection) { - if (! $this->loadExtension($connection->getPdo())) { + if (! $this->setAuthorizer($connection->getPdo())) { return; } } @@ -28,16 +28,19 @@ class DisallowSqliteAttach implements Feature // Apply the change to all sqlite connections resolved in the future DB::extend('sqlite', function ($config, $name) { $conn = app(ConnectionFactory::class)->make($config, $name); - $this->loadExtension($conn->getPdo()); + $this->setAuthorizer($conn->getPdo()); return $conn; }); } - protected function loadExtension(PDO $pdo): bool + protected function setAuthorizer(PDO $pdo): bool { - // todo@php85 In PHP 8.5, we can use setAuthorizer() instead of loading an extension. - // However, this is currently blocked on https://github.com/phpredis/phpredis/issues/2688 + if (PHP_VERSION_ID >= 80500) { + $this->setNativeAuthorizer($pdo); + return true; + } + static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); if ((! $loadExtensionSupported) || @@ -64,4 +67,13 @@ class DisallowSqliteAttach implements Feature return true; } + + protected function setNativeAuthorizer(PDO $pdo): void + { + $pdo->setAuthorizer(static function (int $action): int { + return $action === 24 // SQLITE_ATTACH + ? Pdo\Sqlite::DENY + : Pdo\Sqlite::OK; + }); + } } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index a9f99829..0d83e70e 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -245,9 +245,6 @@ test('tenant database can be created and deleted on a foreign server', function 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); @@ -293,9 +290,6 @@ test('tenant database can be created on a foreign server by using the host from 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); @@ -333,9 +327,6 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index ceee6522..cbc6f57e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -144,9 +144,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], 'database.connections.sqlite.database' => ':memory:', 'database.connections.mysql.charset' => 'utf8mb4', From be93d6031c25b1f85bfd878323031e07ad8139c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Oct 2025 23:47:46 +0000 Subject: [PATCH 28/31] Fix code style (php-cs-fixer) --- src/Features/DisallowSqliteAttach.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index d4412984..36621b21 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -38,6 +38,7 @@ class DisallowSqliteAttach implements Feature { if (PHP_VERSION_ID >= 80500) { $this->setNativeAuthorizer($pdo); + return true; } @@ -72,8 +73,8 @@ class DisallowSqliteAttach implements Feature { $pdo->setAuthorizer(static function (int $action): int { return $action === 24 // SQLITE_ATTACH - ? Pdo\Sqlite::DENY - : Pdo\Sqlite::OK; + ? PDO\Sqlite::DENY + : PDO\Sqlite::OK; }); } } From 5dfb4843b9341fb1bfdea163bf41a5cbc5c297de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 19 Oct 2025 18:44:58 +0200 Subject: [PATCH 29/31] Resolve misc todos, fix phpstan error --- src/Concerns/DealsWithRouteContexts.php | 3 +-- src/Features/DisallowSqliteAttach.php | 1 + src/Listeners/ForgetTenantParameter.php | 2 -- src/Tenancy.php | 10 ++++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Concerns/DealsWithRouteContexts.php b/src/Concerns/DealsWithRouteContexts.php index 9a9b0871..fdf49cb1 100644 --- a/src/Concerns/DealsWithRouteContexts.php +++ b/src/Concerns/DealsWithRouteContexts.php @@ -14,10 +14,9 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Enums\RouteMode; -// todo@refactor move this logic to some dedicated static class? - /** * @mixin \Stancl\Tenancy\Tenancy + * @internal The public methods in this trait should not be understood to be a public stable API. */ trait DealsWithRouteContexts { diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index 36621b21..5cbfbf50 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -71,6 +71,7 @@ class DisallowSqliteAttach implements Feature protected function setNativeAuthorizer(PDO $pdo): void { + // @phpstan-ignore method.notFound $pdo->setAuthorizer(static function (int $action): int { return $action === 24 // SQLITE_ATTACH ? PDO\Sqlite::DENY diff --git a/src/Listeners/ForgetTenantParameter.php b/src/Listeners/ForgetTenantParameter.php index d159b967..46bf5690 100644 --- a/src/Listeners/ForgetTenantParameter.php +++ b/src/Listeners/ForgetTenantParameter.php @@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Resolvers\PathTenantResolver; -// todo@earlyIdReview - /** * Conditionally removes the tenant parameter from matched routes when using kernel path identification. * diff --git a/src/Tenancy.php b/src/Tenancy.php index 95eeb950..f9c9c9ae 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -33,7 +33,7 @@ class Tenancy public ?Closure $getBootstrappersUsing = null; /** Is tenancy fully initialized? */ - public bool $initialized = false; // todo@docs document the difference between $tenant being set and $initialized being true (e.g. end of initialize() method) + public bool $initialized = false; /** * List of relations to eager load when fetching a tenant via tenancy()->find(). @@ -139,10 +139,12 @@ class Tenancy return; } + // We fire both of these events before unsetting tenant so that listeners + // to both events can access the current tenant. Having separate events + // still has value as it's consistent with our other events and provides + // more granularity for event listeners, e.g. for ensuring something runs + // before standard TenancyEnded listeners such as RevertToCentralContext. event(new Events\EndingTenancy($this)); - - // todo@samuel find a way to refactor these two methods - event(new Events\TenancyEnded($this)); $this->tenant = null; From 99b79a5d08f38807ccf4a6be503c68c25a516d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 20 Oct 2025 02:08:25 +0200 Subject: [PATCH 30/31] SQLite DB manager: use setInternal() instead of hardcoded tenancy_db_* --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 34ad394d..898a30bc 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -78,7 +78,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager // or creating a closure holding a reference to it and passing that to register_shutdown_function(). $name = '_tenancy_inmemory_' . $tenant->getTenantKey(); - $tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]); + $tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared"); return true; } From aba7a506196f1a46ee868bccb0b11394c346f9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 22 Oct 2025 12:58:23 +0200 Subject: [PATCH 31/31] Minor fixes The change in SQLiteDatabaseManager wasn't properly saving the updated internal value. The check in CacheTenancyBootstrapper wasn't handling that local tests have a 'testing' environment, not local. However fixing only the condition would've still added the store to $names which would throw an exception down the line. We make sure to only throw the exception in prod, but also make sure to only add the store to $names if it is supported. --- .../CacheTenancyBootstrapper.php | 24 +++++++++---------- .../SQLiteDatabaseManager.php | 1 + 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 0d948591..20e09816 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -101,19 +101,19 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper if ($this->config->get('tenancy.cache.scope_sessions', true)) { // These are the only cache driven session backends (see Laravel's config/session.php) - if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) - && ! app()->environment('local') - ) { - // We only throw this exception in prod to make configuration a little easier. Developers - // may have scope_sessions set to true while using different session drivers e.g. in tests. - // Previously we just silently ignored this, however since session scoping is of high importance - // in production, we make sure to notify the developer, by throwing an exception, that session - // scoping isn't happening as expected/configured due to an incompatible session driver. - throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session'); + if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) { + if (app()->environment('production')) { + // We only throw this exception in prod to make configuration a little easier. Developers + // may have scope_sessions set to true while using different session drivers e.g. in tests. + // Previously we just silently ignored this, however since session scoping is of high importance + // in production, we make sure to notify the developer, by throwing an exception, that session + // scoping isn't happening as expected/configured due to an incompatible session driver. + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session'); + } + } else { + // Scoping sessions using this bootstrapper implicitly adds the session store to $names + $names[] = $this->getSessionCacheStoreName(); } - - // Scoping sessions using this bootstrapper implicitly adds the session store to $names - $names[] = $this->getSessionCacheStoreName(); } $names = array_unique($names); diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 898a30bc..295cf304 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -79,6 +79,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager $name = '_tenancy_inmemory_' . $tenant->getTenantKey(); $tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared"); + $tenant->save(); return true; }