From f1e03fb9212c02c4cd4dc17160ba501e97962251 Mon Sep 17 00:00:00 2001 From: Jimish Gamit Date: Mon, 2 Mar 2026 12:26:03 +0530 Subject: [PATCH 1/4] feat: option to skip tenant --- src/Commands/Run.php | 3 ++- src/Concerns/HasTenantOptions.php | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 7dd69e0f..d3435ca2 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -17,7 +17,8 @@ class Run extends Command protected $description = 'Run a command for tenant(s)'; protected $signature = 'tenants:run {commandname : The artisan command.} - {--tenants=* : The tenant(s) to run the command for. Default: all}'; + {--tenants=* : The tenant(s) to run the command for. Default: all} + {--skip-tenants=* : The tenant(s) to skip}'; public function handle(): int { diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 5beb3268..42cebf79 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -10,7 +10,7 @@ use Stancl\Tenancy\Database\Concerns\PendingScope; use Symfony\Component\Console\Input\InputOption; /** - * Adds 'tenants' and 'with-pending' options. + * Adds 'tenants', 'skip-tenants', and 'with-pending' options. */ trait HasTenantOptions { @@ -18,6 +18,7 @@ trait HasTenantOptions { return array_merge([ ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null], + ['skip-tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to skip when running this command', null], ['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 ], parent::getOptions()); } @@ -42,6 +43,9 @@ trait HasTenantOptions ->when($this->option('tenants'), function ($query) { $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) + ->when($this->option('skip-tenants'), function ($query) { + $query->whereNotIn(tenancy()->model()->getTenantKeyName(), $this->option('skip-tenants')); + }) ->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) { $query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending')); }); From d0407fc58d3a659219103700b79a0f60d72e407b Mon Sep 17 00:00:00 2001 From: Jimish Gamit Date: Mon, 2 Mar 2026 13:18:19 +0530 Subject: [PATCH 2/4] fix: phpstan getOptions issue --- src/Concerns/HasTenantOptions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 42cebf79..5b41d31a 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -17,9 +17,9 @@ trait HasTenantOptions protected function getOptions() { return array_merge([ - ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null], - ['skip-tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to skip when running this command', null], - ['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('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null), + new InputOption('skip-tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to skip when running this command', 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 ], parent::getOptions()); } From d51a27d802de24d66c539d9db8618529039b764a Mon Sep 17 00:00:00 2001 From: Jimish Gamit Date: Mon, 2 Mar 2026 13:21:30 +0530 Subject: [PATCH 3/4] fix: TenantDump command getOptions phpstan fix --- src/Commands/TenantDump.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 32677efc..97f9d539 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -63,7 +63,7 @@ class TenantDump extends DumpCommand protected function getOptions(): array { return array_merge([ - ['tenant', null, InputOption::VALUE_OPTIONAL, '', null], + new InputOption('tenant', null, InputOption::VALUE_OPTIONAL, '', null), ], parent::getOptions()); } } From e68aedb5eb92ad3558a9314ab3f73e3e6cee9a0d Mon Sep 17 00:00:00 2001 From: Jimish Gamit Date: Mon, 2 Mar 2026 13:31:22 +0530 Subject: [PATCH 4/4] add: test cases implementation --- tests/CommandsTest.php | 131 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index a5b3b856..1cfa3c3b 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -515,3 +515,134 @@ test('migrate fresh command only deletes tenant databases if drop_tenant_databas expect($tenantHasDatabase($tenant))->toBe($shouldHaveDBAfterMigrateFresh); } })->with([true, false]); + +test('migrate command skips specified tenants', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); + + Artisan::call('tenants:migrate', [ + '--skip-tenants' => [$tenant2->getTenantKey()], + ]); + + tenancy()->initialize($tenant1); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); + + tenancy()->initialize($tenant2); + expect(Schema::hasTable('users'))->toBeFalse(); + tenancy()->end(); + + tenancy()->initialize($tenant3); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); +}); + +test('migrate command skips multiple tenants', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); + + Artisan::call('tenants:migrate', [ + '--skip-tenants' => [$tenant1->getTenantKey(), $tenant2->getTenantKey()], + ]); + + tenancy()->initialize($tenant1); + expect(Schema::hasTable('users'))->toBeFalse(); + tenancy()->end(); + + tenancy()->initialize($tenant2); + expect(Schema::hasTable('users'))->toBeFalse(); + tenancy()->end(); + + tenancy()->initialize($tenant3); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); +}); + +test('run command skips specified tenants', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); + + Artisan::call('tenants:migrate-fresh'); + + $id1 = $tenant1->getTenantKey(); + $id2 = $tenant2->getTenantKey(); + $id3 = $tenant3->getTenantKey(); + + pest()->artisan("tenants:run --skip-tenants=$id2 'foo foo --b=bar --c=xyz'") + ->expectsOutputToContain("Tenant: $id1") + ->doesntExpectOutputToContain("Tenant: $id2") + ->expectsOutputToContain("Tenant: $id3") + ->assertExitCode(0); +}); + +test('run command skips multiple tenants', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); + + Artisan::call('tenants:migrate-fresh'); + + $id1 = $tenant1->getTenantKey(); + $id2 = $tenant2->getTenantKey(); + $id3 = $tenant3->getTenantKey(); + + pest()->artisan("tenants:run --skip-tenants=$id1 --skip-tenants=$id2 'foo foo --b=bar --c=xyz'") + ->doesntExpectOutputToContain("Tenant: $id1") + ->doesntExpectOutputToContain("Tenant: $id2") + ->expectsOutputToContain("Tenant: $id3") + ->assertExitCode(0); +}); + +test('tenants and skip-tenants options can be used together', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant3 = Tenant::create(); + + Artisan::call('tenants:migrate-fresh'); + + $id1 = $tenant1->getTenantKey(); + $id2 = $tenant2->getTenantKey(); + $id3 = $tenant3->getTenantKey(); + + // Scope to tenant1+tenant2, then skip tenant2 — only tenant1 should run + pest()->artisan("tenants:run --tenants=$id1 --tenants=$id2 --skip-tenants=$id2 'foo foo --b=bar --c=xyz'") + ->expectsOutputToContain("Tenant: $id1") + ->doesntExpectOutputToContain("Tenant: $id2") + ->doesntExpectOutputToContain("Tenant: $id3") + ->assertExitCode(0); +}); + +test('migrate-fresh command skips specified tenants', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + // Migrate all tenants first so both have the users table + Artisan::call('tenants:migrate'); + + tenancy()->initialize($tenant1); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); + + tenancy()->initialize($tenant2); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); + + // migrate-fresh on tenant1 only (skip tenant2) + pest()->artisan('tenants:migrate-fresh', [ + '--skip-tenants' => [$tenant2->getTenantKey()], + '--force' => true, + ])->assertExitCode(0); + + // tenant1 should still have the table (re-created by migrate-fresh) + tenancy()->initialize($tenant1); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); + + // tenant2 was skipped, so its DB is untouched — table still exists + tenancy()->initialize($tenant2); + expect(Schema::hasTable('users'))->toBeTrue(); + tenancy()->end(); +});