From 413da36c4a1b10fc78f887b283659e1df9b43d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 12 Aug 2025 13:12:43 +0200 Subject: [PATCH 01/14] Simplify validate workflow (use regex) --- .github/workflows/validate.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 73f6355b..5ddcd8a6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,14 +8,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Check for todo0 - run: '! grep -r "todo0" --exclude-dir=workflows .' - if: always() - - name: Check for todo1 - run: '! grep -r "todo1" --exclude-dir=workflows .' - if: always() - - name: Check for todo2 - run: '! grep -r "todo2" --exclude-dir=workflows .' + - name: Check for priority todos + run: '! grep -r "todo[0-9]" --exclude-dir=workflows .' if: always() - name: Check for non-todo skip()s in tests run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"' 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 02/14] 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 03/14] 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 d9f3525700699d50202e0ef3d1f6739b36aab50a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 25 Aug 2025 15:47:16 +0200 Subject: [PATCH 04/14] Add --force option to tenants:migrate-fresh (#1391) --- src/Commands/MigrateFresh.php | 8 +++++++- tests/CommandsTest.php | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 4e89cefd..d4733552 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Database\Console\Migrations\BaseCommand; use Illuminate\Database\QueryException; use Illuminate\Support\LazyCollection; @@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI; class MigrateFresh extends BaseCommand { - use HasTenantOptions, DealsWithMigrations, ParallelCommand; + use HasTenantOptions, DealsWithMigrations, ParallelCommand, ConfirmableTrait; protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; @@ -27,6 +28,7 @@ class MigrateFresh extends BaseCommand $this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null); $this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.'); + $this->addOption('force', null, InputOption::VALUE_NONE, 'Force the command to run when in production.', null); $this->addProcessesOption(); $this->setName('tenants:migrate-fresh'); @@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand public function handle(): int { + if (! $this->confirmToProceed()) { + return 1; + } + $success = true; if ($this->getProcesses() > 1) { diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 7ebb07a8..16aecd92 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -311,6 +311,21 @@ test('migrate fresh command works', function () { expect(DB::table('users')->exists())->toBeFalse(); }); +test('migrate fresh command respects force option in production', function () { + // Set environment to production + app()->detectEnvironment(fn() => 'production'); + + Tenant::create(); + + // Without --force in production, command should prompt for confirmation + pest()->artisan('tenants:migrate-fresh') + ->expectsConfirmation('Are you sure you want to run this command?'); + + // With --force, command should succeed without prompting + pest()->artisan('tenants:migrate-fresh', ['--force' => true]) + ->assertSuccessful(); +}); + test('run command with array of tenants works', function () { $tenantId1 = Tenant::create()->getTenantKey(); $tenantId2 = Tenant::create()->getTenantKey(); From 3b42c9e20ca8a502d20e627e2ecb8978732a6076 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 25 Aug 2025 15:57:15 +0200 Subject: [PATCH 05/14] [4.x] Use --database in tenants:migrate as the template connection (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make the `--database` option passed to `tenants:migrate` use the passed connection as the tenant connection template * Reset template connection regardless of process count --------- Co-authored-by: Samuel Štancl --- src/Commands/Migrate.php | 21 +++++++++++---- tests/CommandsTest.php | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 5348b509..6ecd6e14 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -51,13 +51,24 @@ class Migrate extends MigrateCommand return 1; } - if ($this->getProcesses() > 1) { - return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk); - })); + $originalTemplateConnection = config('tenancy.database.template_tenant_connection'); + + if ($database = $this->input->getOption('database')) { + config(['tenancy.database.template_tenant_connection' => $database]); } - return $this->migrateTenants($this->getTenants()) ? 0 : 1; + if ($this->getProcesses() > 1) { + $code = $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { + return $this->getTenants($chunk); + })); + } else { + $code = $this->migrateTenants($this->getTenants()) ? 0 : 1; + } + + // Reset the template tenant connection to the original one + config(['tenancy.database.template_tenant_connection' => $originalTemplateConnection]); + + return $code; } protected function childHandle(mixed ...$args): bool diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 16aecd92..a5b3b856 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Events\MigratingDatabase; beforeEach(function () { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { @@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () { expect(Schema::hasTable('users'))->toBeTrue(); }); +test('migrate command uses the passed database option as the template tenant connection', function () { + $originalTemplateConnection = config('tenancy.database.template_tenant_connection'); + + // Add a custom connection that will be used as the template for the tenant connection + // Identical to the default (mysql), just with different charset and collation + config(['database.connections.custom_connection' => [ + "driver" => "mysql", + "url" => "", + "host" => "mysql", + "port" => "3306", + "database" => "main", + "username" => "root", + "password" => "password", + "unix_socket" => "", + "charset" => "latin1", // Different from the default (utf8mb4) + "collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci) + "prefix" => "", + "prefix_indexes" => true, + "strict" => true, + "engine" => null, + "options" => [] + ]]); + + $templateConnectionDuringMigration = null; + $tenantConnectionDuringMigration = null; + + Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) { + $templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection'); + $tenantConnectionDuringMigration = DB::connection('tenant')->getConfig(); + }); + + // The original tenant template connection config remains default + expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection); + + Tenant::create(); + + // The original template connection is used when the --database option is not passed + pest()->artisan('tenants:migrate'); + expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection); + + Tenant::create(); + + // The migrate command temporarily uses the connection passed in the --database option + pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']); + expect($templateConnectionDuringMigration)->toBe('custom_connection'); + + // The tenant connection during migration actually used custom_connection's config + expect($tenantConnectionDuringMigration['charset'])->toBe('latin1'); + expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci'); + + // The tenant template connection config is restored to the original after migrating + expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection); +}); + test('migrate command only throws exceptions if skip-failing is not passed', function() { Tenant::create(); From 99d854ed8e87affed8f527791ab923d1e8e31f9a Mon Sep 17 00:00:00 2001 From: Farishrf Date: Mon, 25 Aug 2025 18:27:59 +0300 Subject: [PATCH 06/14] [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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 13/14] 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 14/14] 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 {