From 950ff0fbfd8f09a6b6738291bf0aba26831ff8bf Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 10:35:54 +0200 Subject: [PATCH 01/10] Make --with-pending override include_in_queries when passed Pasing --with-pending, --with-pending=1 or --with-pending=true now makes tenant commands include pending tenants regardless of the include_in_queries config, and passing --with-pending=false, --with-pending=0 or --with-pending=foo makes the commands exclude pending tenants. --- src/Concerns/HasTenantOptions.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index c1ea221f..78d0ba04 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -18,7 +18,7 @@ trait HasTenantOptions { return array_merge([ new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null), - new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs + new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'), // todo@pending mention in docs ], parent::getOptions()); } @@ -43,7 +43,11 @@ trait HasTenantOptions $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) ->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) { - $query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending')); + $includePending = $this->input->hasParameterOption('--with-pending') + ? filter_var($this->option('with-pending') ?? true, FILTER_VALIDATE_BOOLEAN) + : config('tenancy.pending.include_in_queries'); + + $query->withPending($includePending); }); } From 6b4d22bb92b5b89fd124e71f497100c9b99f3b8c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 10:39:42 +0200 Subject: [PATCH 02/10] Update --with-pending tests Cover that --with-pending can now accept values, and that the option takes precedence over include_in_queries config. Also simplify/improve existing assertions. --- tests/PendingTenantsTest.php | 64 ++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 433b85fb..54730cc8 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -154,8 +154,8 @@ test('pending events are dispatched', function () { Event::assertDispatched(PendingTenantPulled::class); }); -test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() { - config(['tenancy.pending.include_in_queries' => false]); +test('commands include tenants based on the include_in_queries config when --with-pending is not passed', function (bool $includeInQueries) { + config(['tenancy.pending.include_in_queries' => $includeInQueries]); $tenants = collect([ Tenant::create(), @@ -164,21 +164,21 @@ test('commands do not run for pending tenants if tenancy.pending.include_in_quer Tenant::createPending(), ]); - pest()->artisan('tenants:migrate --with-pending'); + $command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo'"); - $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'"); + $tenants->each(function ($tenant) use ($command, $includeInQueries) { + if ($tenant->pending() && ! $includeInQueries) { + $command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"); + } else { + $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"); + } + }); - $pendingTenants = $tenants->filter->pending(); - $readyTenants = $tenants->reject->pending(); + $command->assertSuccessful(); +})->with([true, false]); - $pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}")); - $readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); - - $artisan->assertExitCode(0); -}); - -test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() { - config(['tenancy.pending.include_in_queries' => true]); +test('commands include pending tenants when truthy --with-pending is passed', function (bool $includeInQueries) { + config(['tenancy.pending.include_in_queries' => $includeInQueries]); $tenants = collect([ Tenant::create(), @@ -187,17 +187,18 @@ test('commands run for pending tenants too if tenancy.pending.include_in_queries Tenant::createPending(), ]); - pest()->artisan('tenants:migrate --with-pending'); + foreach (['--with-pending', '--with-pending=true', '--with-pending=1'] as $option) { + $command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}"); - $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'"); + // Pending tenants are included regardless of tenancy.pending.include_in_queries + $tenants->each(fn ($tenant) => $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); - $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); + $command->assertSuccessful(); + } +})->with([true, false]); - $artisan->assertExitCode(0); -}); - -test('commands run for pending tenants too if the with pending option is passed', function() { - config(['tenancy.pending.include_in_queries' => false]); +test('commands exclude pending tenants when falsy --with-pending is passed', function (bool $includeInQueries) { + config(['tenancy.pending.include_in_queries' => $includeInQueries]); $tenants = collect([ Tenant::create(), @@ -206,14 +207,21 @@ test('commands run for pending tenants too if the with pending option is passed' Tenant::createPending(), ]); - pest()->artisan('tenants:migrate --with-pending'); + foreach (['--with-pending=false', '--with-pending=0'] as $option) { + $command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}"); - $artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending"); + $tenants->each(function ($tenant) use ($command) { + if ($tenant->pending()) { + // Pending tenants are excluded regardless of tenancy.pending.include_in_queries + $command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"); + } else { + $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"); + } + }); - $tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}")); - - $artisan->assertExitCode(0); -}); + $command->assertSuccessful(); + } +})->with([true, false]); test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) { Schema::table('tenants', function (Blueprint $table) { From b592d3dad43384c1e677a0c1e1ba9b4d50c097cd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 10:51:15 +0200 Subject: [PATCH 03/10] Give pending_since to pending tenants during creation Instead of creating pending tenants without pending_since, letting job pipelines (e.g. TenantCreated) run, and only after that, setting the tenant's pending_since, set it while creating. This allows checking tenant's pending status accurately e.g. in the CreateDatabase job pipeline. --- src/Database/Concerns/HasPending.php | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 0a572680..312f9dea 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -49,18 +49,15 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = null; + $tenant = static::make(array_merge( + ['pending_since' => now()->timestamp], + static::getPendingAttributes($attributes), + $attributes + )); - try { - $tenant = static::create(array_merge(static::getPendingAttributes($attributes), $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 CreatingPendingTenant($tenant)); + + $tenant->save(); event(new PendingTenantCreated($tenant)); From 66114e6835a09eebf3666eee7b6093de9462440d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 10:55:42 +0200 Subject: [PATCH 04/10] Default to including pending tenants in MigrateDatabase and SeedDatabase Now that tenants are created with pending_since right away, the migrate and seed database jobs need to include pending tenants for creating pending tenants to work fully. The jobs can still exclude pending tenants if $includePending is set to false. Co-authored-by: Copilot --- src/Jobs/MigrateDatabase.php | 7 +++++++ src/Jobs/SeedDatabase.php | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/Jobs/MigrateDatabase.php b/src/Jobs/MigrateDatabase.php index 424dacc9..b0746da4 100644 --- a/src/Jobs/MigrateDatabase.php +++ b/src/Jobs/MigrateDatabase.php @@ -17,6 +17,12 @@ class MigrateDatabase implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Should pending tenants be included while migrating, + * regardless of the tenancy.pending.include_in_queries config value. + */ + public static bool $includePending = true; + public function __construct( protected TenantWithDatabase&Model $tenant, ) {} @@ -25,6 +31,7 @@ class MigrateDatabase implements ShouldQueue { Artisan::call('tenants:migrate', [ '--tenants' => [$this->tenant->getTenantKey()], + '--with-pending' => static::$includePending, ]); } } diff --git a/src/Jobs/SeedDatabase.php b/src/Jobs/SeedDatabase.php index 9958695e..07aa0483 100644 --- a/src/Jobs/SeedDatabase.php +++ b/src/Jobs/SeedDatabase.php @@ -17,6 +17,12 @@ class SeedDatabase implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Should pending tenants be included while seeding, + * regardless of the tenancy.pending.include_in_queries config value. + */ + public static bool $includePending = true; + public function __construct( protected TenantWithDatabase&Model $tenant, ) {} @@ -25,6 +31,7 @@ class SeedDatabase implements ShouldQueue { Artisan::call('tenants:seed', [ '--tenants' => [$this->tenant->getTenantKey()], + '--with-pending' => static::$includePending, ]); } } From f309dcc65cd72701d7bd2cceeebd37a2a7ff5a4f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 10:59:04 +0200 Subject: [PATCH 05/10] Test pending tenant creation with job pipelines Co-authored-by: Copilot --- tests/PendingTenantsTest.php | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 54730cc8..6d492abf 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -16,10 +16,25 @@ use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Events\TenantCreated; +use Stancl\JobPipeline\JobPipeline; +use Stancl\Tenancy\Jobs\CreateDatabase; +use Stancl\Tenancy\Jobs\MigrateDatabase; +use Stancl\Tenancy\Jobs\SeedDatabase; +use Stancl\Tenancy\Tests\Etc\User; +use Stancl\Tenancy\Tests\Etc\TestSeeder; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Events\TenancyInitialized; +use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Events\TenancyEnded; +use Stancl\Tenancy\Listeners\RevertToCentralContext; beforeEach($cleanup = function () { Tenant::$extraCustomColumns = []; Tenant::$getPendingAttributesUsing = null; + + MigrateDatabase::$includePending = true; + SeedDatabase::$includePending = true; }); afterEach($cleanup); @@ -244,3 +259,72 @@ test('pending tenants can have default attributes for non-nullable columns', fun else expect($fn)->toThrow(QueryException::class); })->with([true, false]); + +test('pending tenant databases can be migrated using a job unless configured otherwise', function (bool $includeInQueries, bool $migrateWithPending) { + config([ + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + 'tenancy.pending.include_in_queries' => $includeInQueries, + ]); + + MigrateDatabase::$includePending = $migrateWithPending; + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + Event::listen(TenantCreated::class, JobPipeline::make([ + CreateDatabase::class, + MigrateDatabase::class, + ])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $pendingTenant = Tenant::createPending(); + + expect(Schema::hasTable('users'))->toBeFalse(); + + tenancy()->initialize($pendingTenant); + + // MigrateDatabase includes/excludes pending tenants based on its $includePending property, + // regardless of the tenancy.pending.include_in_queries config. + expect(Schema::hasTable('users'))->toBe($migrateWithPending); +})->with([ + 'include pending in queries' => [true], + 'exclude pending from queries' => [false], +])->with([ + 'migrate with pending' => [true], + 'migrate without pending' => [false], +]); + +test('pending tenant databases can be seeded using a job unless configured otherwise', function ($includeInQueries, $seedWithPending) { + config([ + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + 'tenancy.pending.include_in_queries' => $includeInQueries, + 'tenancy.seeder_parameters.--class' => TestSeeder::class, + ]); + + MigrateDatabase::$includePending = true; + SeedDatabase::$includePending = $seedWithPending; + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + Event::listen(TenantCreated::class, JobPipeline::make([ + CreateDatabase::class, + MigrateDatabase::class, + SeedDatabase::class, + ])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $pendingTenant = Tenant::createPending(); + + tenancy()->initialize($pendingTenant); + + // SeedDatabase includes/excludes pending tenants based on its $includePending property, + // regardless of the tenancy.pending.include_in_queries config. + expect(User::where('email', 'seeded@user')->exists())->toBe($seedWithPending); +})->with([ + 'include pending in queries' => [true], + 'exclude pending from queries' => [false], +])->with([ + 'seed with pending' => [true], + 'seed without pending' => [false], +]); From 0bb112dbdf164272e2dd4101a166abd40af1b68f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 12:19:09 +0200 Subject: [PATCH 06/10] Cover invalid --with-pending values in tests Include --with-pending=foo as the fasly option in the falsy --with-pending test. --- tests/PendingTenantsTest.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 6d492abf..36c9203c 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -202,7 +202,11 @@ test('commands include pending tenants when truthy --with-pending is passed', fu Tenant::createPending(), ]); - foreach (['--with-pending', '--with-pending=true', '--with-pending=1'] as $option) { + foreach ([ + '--with-pending', + '--with-pending=true', + '--with-pending=1' + ] as $option) { $command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}"); // Pending tenants are included regardless of tenancy.pending.include_in_queries @@ -222,7 +226,11 @@ test('commands exclude pending tenants when falsy --with-pending is passed', fun Tenant::createPending(), ]); - foreach (['--with-pending=false', '--with-pending=0'] as $option) { + foreach ([ + '--with-pending=false', + '--with-pending=0', + '--with-pending=foo' // Invalid values are treated as false + ] as $option) { $command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}"); $tenants->each(function ($tenant) use ($command) { From e3673f5557bfb2efd4a7b60cea2eb8673e817f14 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 12:28:31 +0200 Subject: [PATCH 07/10] Dispatch pending tenant creating/created events in the appropriate hooks --- src/Database/Concerns/HasPending.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 312f9dea..03fdc26b 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -28,6 +28,18 @@ trait HasPending public static function bootHasPending(): void { static::addGlobalScope(new PendingScope()); + + static::creating(function (self $tenant): void { + if ($tenant->pending()) { + event(new CreatingPendingTenant($tenant)); + } + }); + + static::created(function (self $tenant): void { + if ($tenant->pending()) { + event(new PendingTenantCreated($tenant)); + } + }); } /** Initialize the trait. */ @@ -49,19 +61,11 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = static::make(array_merge( + return static::create(array_merge( ['pending_since' => now()->timestamp], static::getPendingAttributes($attributes), $attributes )); - - event(new CreatingPendingTenant($tenant)); - - $tenant->save(); - - event(new PendingTenantCreated($tenant)); - - return $tenant; } /** From e81e6ac6518e7fc8753d6be4c913b9417fbcdec2 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 12:47:42 +0200 Subject: [PATCH 08/10] Add regression test for jobs recognizing pending tenants Reverting the HasPending changes (in createPending(), create tenant without pending_since, and only after that, update the tenant to give it pending_since) makes the test fail. --- tests/PendingTenantsTest.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 36c9203c..c2886008 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -336,3 +336,34 @@ test('pending tenant databases can be seeded using a job unless configured other 'seed with pending' => [true], 'seed without pending' => [false], ]); + +test('jobs that run before tenants get fully created recognize pending tenants', function () { + config([ + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + ]); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + Event::listen(TenantCreated::class, JobPipeline::make([ + CreateDatabase::class, + PendingTenantJob::class, + ])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + Tenant::createPending(); + + expect(app('tenant_is_pending'))->toBeTrue(); +}); + +class PendingTenantJob +{ + public function __construct( + public Tenant $tenant, + ) {} + + public function handle() + { + app()->instance('tenant_is_pending', $this->tenant->pending()); + } +} From 4764b99c8f4543f5021f0265663955fab382d16a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Apr 2026 12:50:31 +0200 Subject: [PATCH 09/10] Merge pending_since into the other attributes during pending tenant creation Ensures that createPending always creates tenants with pending_since set to the current timestamp. --- src/Database/Concerns/HasPending.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 03fdc26b..04fcccc1 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -62,9 +62,9 @@ trait HasPending public static function createPending(array $attributes = []): Model&Tenant { return static::create(array_merge( - ['pending_since' => now()->timestamp], static::getPendingAttributes($attributes), - $attributes + $attributes, + ['pending_since' => now()->timestamp], )); } From 0c463ead287a0d6fa10f3e8769ed5e9fff25a717 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 14:21:12 +0200 Subject: [PATCH 10/10] Resolve docs to-do Resolved in https://github.com/archtechx/tenancy-docs/pull/5 --- src/Concerns/HasTenantOptions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 78d0ba04..3933c469 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -18,7 +18,7 @@ trait HasTenantOptions { return array_merge([ new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null), - new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'), // todo@pending mention in docs + new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'), ], parent::getOptions()); }