From 6523f24a608593b5251c677ed54fd706e48c7928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 27 Oct 2025 17:54:39 +0100 Subject: [PATCH 01/31] Pending tenants: Add getPendingAttributes() This method lets the user specify default values for custom non-nullable columns. The primary use case is when the tenants table has a column like 'slug' and createPending() is called with no value for 'slug'. This would produce an exception due to the column having no default value. Here, getPendingAttributes() can set an initial dummy slug (like a randomly generated string) before it's overwritten during a pull. getPendingAttributes() accepts an $attributes array which corresponds to the attributes passed to createPending(). The array returned from getPendingAttributes() is ultimately merged with $attributes, so the user doesn't need to use the $attributes value in getPendingAttributes(), however it serves to provide more context when the pending attributes might be dependent on $attributes and therefore derived from the $attributes actually being used. Also fixed the `finally` branch in createPending() as it was potentially referencing the $tenant variable before it was initialized. --- src/Database/Concerns/HasPending.php | 17 ++++++++++++-- tests/Etc/Tenant.php | 7 ++++++ tests/PendingTenantsTest.php | 33 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 34a66544..0a572680 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -49,13 +49,15 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { + $tenant = null; + try { - $tenant = static::create($attributes); + $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([ + $tenant?->update([ 'pending_since' => now()->timestamp, ]); } @@ -65,6 +67,17 @@ trait HasPending return $tenant; } + /** + * Attributes to be set when a pending tenant is initially created. + * + * @param array $attributes The attributes passed to createPending() (will be merged with the returned array) + * @return array + */ + public static function getPendingAttributes(array $attributes): array + { + return []; + } + /** * Pull a pending tenant from the pool or create a new one if the pool is empty. * diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 731a179b..72570c50 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc; +use Closure; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; @@ -16,6 +17,7 @@ use Stancl\Tenancy\Database\Models; class Tenant extends Models\Tenant implements TenantWithDatabase { public static array $extraCustomColumns = []; + public static ?Closure $getPendingAttributesUsing = null; use HasDatabase, HasDomains, HasPending; @@ -23,4 +25,9 @@ class Tenant extends Models\Tenant implements TenantWithDatabase { return array_merge(parent::getCustomColumns(), static::$extraCustomColumns); } + + public static function getPendingAttributes(array $attributes): array + { + return static::$getPendingAttributesUsing ? (static::$getPendingAttributesUsing)($attributes) : []; + } } diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 3339baaf..a90aceed 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use Illuminate\Database\QueryException; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Stancl\Tenancy\Commands\ClearPendingTenants; use Stancl\Tenancy\Commands\CreatePendingTenants; use Stancl\Tenancy\Events\CreatingPendingTenant; @@ -13,6 +17,13 @@ use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +beforeEach($cleanup = function () { + Tenant::$extraCustomColumns = []; + Tenant::$getPendingAttributesUsing = null; +}); + +afterEach($cleanup); + test('tenants are correctly identified as pending', function (){ Tenant::createPending(); @@ -191,3 +202,25 @@ test('commands run for pending tenants too if the with pending option is passed' $artisan->assertExitCode(0); }); + +test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) { + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + Tenant::$extraCustomColumns = ['slug']; + if ($withPendingAttributes) Tenant::$getPendingAttributesUsing = fn () => [ + 'slug' => Str::random(8), + ]; + + $fn = fn () => Tenant::createPending(); + + // If there are non-nullable custom columns, and createPending() is called + // on its own without any values passed for those columns (as it would be called + // by the tenants:pending-create artisan command), we expect it to fail, unless + // getPendingAttributes() provides default values for those custom columns. + if ($withPendingAttributes) + expect($fn)->not()->toThrow(QueryException::class); + else + expect($fn)->toThrow(QueryException::class); +})->with([true, false]); From 469595534e2d706ddb66be418fefeee7a64852d1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Oct 2025 13:26:50 +0100 Subject: [PATCH 02/31] [4.x] Make TenancyUrlGenerator inherit the original UrlGenerator's scheme (http or https) (#1390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, when using UrlGeneratorBootstrapper, and your app had a `https://` url, in tenant context, the url would have the `http://` scheme. Now, the bootstrapper makes sure that the TenancyUrlGenerator inherits the original UrlGenerator's scheme. So if your app has e.g. url "https://some-url.test", `route('home')` in tenant context will return "http**s**://some-url.test/home" (originally, you'd get "http://some-url.test/home" - the original scheme - https - wouldn't be respected in the tenant context). This PR addresses the issue reported on Discord (https://discord.com/channels/976506366502006874/976506736120823909/1399012794514411621). --------- Co-authored-by: github-actions[bot] Co-authored-by: Samuel Štancl --- .../UrlGeneratorBootstrapper.php | 5 +++ .../UrlGeneratorBootstrapperTest.php | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index 6c923d21..3708d636 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; @@ -78,6 +79,10 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper } } + // Inherit scheme (http/https) from the original generator + $originalScheme = Str::before($this->originalUrlGenerator->formatScheme(), '://'); + $newGenerator->forceScheme($originalScheme); + $newGenerator->defaults($defaultParameters); $newGenerator->setSessionResolver(function () { diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 39fcc475..647422da 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -18,7 +18,6 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; - use function Stancl\Tenancy\Tests\pest; beforeEach(function () { @@ -80,6 +79,44 @@ test('tenancy url generator can prefix route names passed to the route helper', expect(route('home'))->toBe('http://localhost/central/home'); }); +test('tenancy url generator inherits scheme from original url generator', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/home', fn () => '')->name('home'); + + // No scheme forced, default is HTTP + expect(app('url')->formatScheme())->toBe('http://'); + + $tenant = Tenant::create(); + + // Force the original URL generator to use HTTPS + app('url')->forceScheme('https'); + + // Original generator uses HTTPS + expect(app('url')->formatScheme())->toBe('https://'); + + // Check that TenancyUrlGenerator inherits the HTTPS scheme + tenancy()->initialize($tenant); + expect(app('url')->formatScheme())->toBe('https://'); // Should inherit HTTPS + expect(route('home'))->toBe('https://localhost/home'); + + tenancy()->end(); + + // After ending tenancy, the original generator should still have the original scheme (HTTPS) + expect(route('home'))->toBe('https://localhost/home'); + + // Use HTTP scheme + app('url')->forceScheme('http'); + expect(app('url')->formatScheme())->toBe('http://'); + + tenancy()->initialize($tenant); + expect(app('url')->formatScheme())->toBe('http://'); // Should inherit scheme (HTTP) + expect(route('home'))->toBe('http://localhost/home'); + + tenancy()->end(); + expect(route('home'))->toBe('http://localhost/home'); +}); + test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); From 0dc187510b8d7fe24017483fccec71aaef32d95a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 28 Oct 2025 14:14:52 +0100 Subject: [PATCH 03/31] [4.x] Clean up expired impersonation tokens instead of just aborting, add command for cleaning up expired tokens (#1387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes the expired/invalid tenant impersonation tokens get deleted instead of just aborting with 403. The PR also adds a command (ClearExpiredImpersonationTokens) used like `php artisan tenants:purge-impersonation-tokens`. As the name suggests, it clears all expired impersonation tokens (= tokens older than `UserImpersonation::$ttl`). Resolves #1348 --------- Co-authored-by: Samuel Štancl --- src/Commands/PurgeImpersonationTokens.php | 38 ++++++++ src/Features/UserImpersonation.php | 12 ++- src/TenancyServiceProvider.php | 1 + tests/TenantUserImpersonationTest.php | 112 ++++++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/Commands/PurgeImpersonationTokens.php diff --git a/src/Commands/PurgeImpersonationTokens.php b/src/Commands/PurgeImpersonationTokens.php new file mode 100644 index 00000000..b64b29f8 --- /dev/null +++ b/src/Commands/PurgeImpersonationTokens.php @@ -0,0 +1,38 @@ +components->info('Deleting expired impersonation tokens.'); + + $expirationDate = now()->subSeconds(UserImpersonation::$ttl); + + $impersonationTokenModel = UserImpersonation::modelClass(); + + $deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate) + ->delete(); + + $this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.'); + + return 0; + } +} diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index ac478d07..d286b8ba 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -44,12 +44,20 @@ class UserImpersonation implements Feature $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; - abort_if($tokenExpired, 403); + if ($tokenExpired) { + $token->delete(); + + abort(403); + } $tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn()); $currentTenantId = (string) tenant()->getTenantKey(); - abort_unless($tokenTenantId === $currentTenantId, 403); + if ($tokenTenantId !== $currentTenantId) { + $token->delete(); + + abort(403); + } Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember); diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index a7f27e63..9b32f088 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -119,6 +119,7 @@ class TenancyServiceProvider extends ServiceProvider Commands\MigrateFresh::class, Commands\ClearPendingTenants::class, Commands\CreatePendingTenants::class, + Commands\PurgeImpersonationTokens::class, Commands\CreateUserWithRLSPolicies::class, ]); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 48fbe691..ea679357 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; use function Stancl\Tenancy\Tests\pest; +use Symfony\Component\HttpKernel\Exception\HttpException; beforeEach(function () { pest()->artisan('migrate', [ @@ -294,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function ( ->toBeInstanceOf(ImpersonationToken::class); }); +test('expired tokens are cleaned up before aborting', function () { + $tenant = Tenant::create(); + migrateTenants(); + + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + + // Make the token expired + $token->update([ + 'created_at' => Carbon::now()->subSeconds(100), + ]); + + expect(ImpersonationToken::find($token->token))->not()->toBeNull(); + + tenancy()->initialize($tenant); + + // Try to use the expired token - should clean up and abort + expect(fn() => UserImpersonation::makeResponse($token->token)) + ->toThrow(HttpException::class); // Abort with 403 + + expect(ImpersonationToken::find($token->token))->toBeNull(); +}); + +test('tokens are cleaned up when in wrong tenant context before aborting', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + migrateTenants(); + + $user = $tenant1->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + $token = tenancy()->impersonate($tenant1, $user->id, '/dashboard'); + + expect(ImpersonationToken::find($token->token))->not->toBeNull(); + + tenancy()->initialize($tenant2); + + // Try to use the token in wrong tenant context - should clean up and abort + expect(fn() => UserImpersonation::makeResponse($token->token)) + ->toThrow(HttpException::class); // Abort with 403 + + expect(ImpersonationToken::find($token->token))->toBeNull(); +}); + +test('expired impersonation tokens can be cleaned up using a command', function () { + $tenant = Tenant::create(); + migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + // Create tokens + $oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + + // Make two of the tokens expired by updating their created_at + $oldToken->update([ + 'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10), + ]); + + $anotherOldToken->update([ + 'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10), + ]); + + // All tokens exist + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); + expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull(); + expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull(); + + pest()->artisan('tenants:purge-impersonation-tokens') + ->assertExitCode(0) + ->expectsOutputToContain('2 expired impersonation tokens deleted'); + + // The expired tokens were deleted + expect(ImpersonationToken::find($oldToken->token))->toBeNull(); + expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull(); + // The active token still exists + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); + + // Update the active token to make it expired according to the default ttl (60s) + $activeToken->update([ + 'created_at' => Carbon::now()->subSeconds(70), + ]); + + // With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s) + UserImpersonation::$ttl = 80; + pest()->artisan('tenants:purge-impersonation-tokens') + ->assertExitCode(0) + ->expectsOutputToContain('0 expired impersonation tokens deleted'); + + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); +}); + function migrateTenants() { pest()->artisan('tenants:migrate')->assertExitCode(0); From d274d8c902b29bf7758b94b8b0944aa18d2d81a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 29 Oct 2025 19:24:06 +0100 Subject: [PATCH 04/31] pending tenants: minor cleanup --- README.md | 4 +-- assets/config.php | 2 +- src/Concerns/HasTenantOptions.php | 2 +- src/Database/Concerns/PendingScope.php | 38 ++++++-------------------- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 799dc11f..1f51b1bf 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ You won't have to change a thing in your application's code. - :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes - :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains) -### [Documentation](https://tenancy-v4.pages.dev/) +### [Documentation](https://v4.tenancyforlaravel.com) -Documentation can be found here: https://tenancy-v4.pages.dev/ +Documentation can be found here: https://v4.tenancyforlaravel.com ### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md) diff --git a/assets/config.php b/assets/config.php index d01cbff7..ce74d3bf 100644 --- a/assets/config.php +++ b/assets/config.php @@ -444,7 +444,6 @@ return [ /** * Pending tenants config. - * This is useful if you're looking for a way to always have a tenant ready to be used. */ 'pending' => [ /** @@ -453,6 +452,7 @@ return [ * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.) */ 'include_in_queries' => true, + /** * Defines how many pending tenants you want to have ready in the pending tenant pool. * This depends on the volume of tenants you're creating. diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 8cd105ba..5beb3268 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -18,7 +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], - ['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'], + ['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()); } diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index d83a37dd..712de6c7 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -10,13 +10,6 @@ use Illuminate\Database\Eloquent\Scope; class PendingScope implements Scope { - /** - * All of the extensions to be added to the builder. - * - * @var string[] - */ - protected $extensions = ['WithPending', 'WithoutPending', 'OnlyPending']; - /** * Apply the scope to a given Eloquent query builder. * @@ -32,26 +25,21 @@ class PendingScope implements Scope } /** - * Extend the query builder with the needed functions. + * Add methods to the query builder. * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - public function extend(Builder $builder) + public function extend(Builder $builder): void { - foreach ($this->extensions as $extension) { - $this->{"add{$extension}"}($builder); - } + $this->addWithPending($builder); + $this->addWithoutPending($builder); + $this->addOnlyPending($builder); } + /** - * Add the with-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addWithPending(Builder $builder) + protected function addWithPending(Builder $builder): void { $builder->macro('withPending', function (Builder $builder, $withPending = true) { if (! $withPending) { @@ -63,13 +51,9 @@ class PendingScope implements Scope } /** - * Add the without-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addWithoutPending(Builder $builder) + protected function addWithoutPending(Builder $builder): void { $builder->macro('withoutPending', function (Builder $builder) { $builder->withoutGlobalScope(static::class) @@ -81,13 +65,9 @@ class PendingScope implements Scope } /** - * Add the only-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addOnlyPending(Builder $builder) + protected function addOnlyPending(Builder $builder): void { $builder->macro('onlyPending', function (Builder $builder) { $builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since')); From 36153a949ba17ccf40a7e8ac8eccaea833d76368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 30 Oct 2025 02:31:49 +0100 Subject: [PATCH 05/31] docblocks: change TenantConfig references to TenantConfigBootstrapper --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- src/Bootstrappers/MailConfigBootstrapper.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 32bc54bf..66fee704 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -15,7 +15,7 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager; class BroadcastingConfigBootstrapper implements TenancyBootstrapper { /** - * Tenant properties to be mapped to config (similarly to the TenantConfig feature). + * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper). * * For example: * [ diff --git a/src/Bootstrappers/MailConfigBootstrapper.php b/src/Bootstrappers/MailConfigBootstrapper.php index 60028cc1..dcbf46d2 100644 --- a/src/Bootstrappers/MailConfigBootstrapper.php +++ b/src/Bootstrappers/MailConfigBootstrapper.php @@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant; class MailConfigBootstrapper implements TenancyBootstrapper { /** - * Tenant properties to be mapped to config (similarly to the TenantConfig feature). + * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper). * * For example: * [ From b967d1647aa3f4b6dbfd60559387809b67c4324d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 15:45:48 +0100 Subject: [PATCH 06/31] Add UUIDv7Generator Also correct docblock for ULIDGenerator and add missing @see annotations in the config file. --- assets/config.php | 2 ++ .../ULIDGenerator.php | 2 +- .../UUIDGenerator.php | 2 +- .../UUIDv7Generator.php | 20 +++++++++++++++++++ tests/TenantModelTest.php | 15 ++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/UniqueIdentifierGenerators/UUIDv7Generator.php diff --git a/assets/config.php b/assets/config.php index ce74d3bf..76441036 100644 --- a/assets/config.php +++ b/assets/config.php @@ -48,6 +48,8 @@ return [ * SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs). * * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator diff --git a/src/UniqueIdentifierGenerators/ULIDGenerator.php b/src/UniqueIdentifierGenerators/ULIDGenerator.php index 17b62898..d099c824 100644 --- a/src/UniqueIdentifierGenerators/ULIDGenerator.php +++ b/src/UniqueIdentifierGenerators/ULIDGenerator.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a ULID for the tenant key. */ class ULIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDGenerator.php b/src/UniqueIdentifierGenerators/UUIDGenerator.php index f8bf4b9c..a537b666 100644 --- a/src/UniqueIdentifierGenerators/UUIDGenerator.php +++ b/src/UniqueIdentifierGenerators/UUIDGenerator.php @@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a UUIDv4 for the tenant key. */ class UUIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDv7Generator.php b/src/UniqueIdentifierGenerators/UUIDv7Generator.php new file mode 100644 index 00000000..274b17b8 --- /dev/null +++ b/src/UniqueIdentifierGenerators/UUIDv7Generator.php @@ -0,0 +1,20 @@ +toString(); + } +} diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index 4c6e77e1..8ee2ae78 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; +use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator; use function Stancl\Tenancy\Tests\pest; @@ -94,6 +95,20 @@ test('ulid ids are supported', function () { expect($tenant2->id > $tenant1->id)->toBeTrue(); }); +test('uuidv7 ids are supported', function () { + app()->bind(UniqueIdentifierGenerator::class, UUIDv7Generator::class); + + $tenant1 = Tenant::create(); + expect($tenant1->id)->toBeString(); + expect(strlen($tenant1->id))->toBe(36); + + $tenant2 = Tenant::create(); + expect($tenant2->id)->toBeString(); + expect(strlen($tenant2->id))->toBe(36); + + expect($tenant2->id > $tenant1->id)->toBeTrue(); +}); + test('hex ids are supported', function () { app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); From 0ef4dfd23051bd1cc8f06bccfcba05e2fdc8881b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 15:47:15 +0100 Subject: [PATCH 07/31] DB cache bootstrapper: setConnection() instead of purge() (#1408) By purging stores, we "detach" existing cache stores from the CacheManager, making them impossible to adjust in the future. We also unnecessarily recreate them on every tenancy bootstrap/revert. A simpler case where this causes problems is defining a RateLimiter in a service provider. That injects a single cache store into the rate limiter singleton, which then becomes a completely independent object after tenancy is initialized due to the purge. This in turn means the central and tenant contexts share the rate limiter cache instead of using separate caches as one would expect. --- .../DatabaseCacheBootstrapper.php | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php index ae547471..0e41849f 100644 --- a/src/Bootstrappers/DatabaseCacheBootstrapper.php +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -63,13 +63,17 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper $stores = $this->scopedStoreNames(); foreach ($stores as $storeName) { - $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); - $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection"); + $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection") ?? config('tenancy.database.central_connection'); + $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection") ?? config('tenancy.database.central_connection'); $this->config->set("cache.stores.{$storeName}.connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant'); - $this->cache->purge($storeName); + /** @var DatabaseStore $store */ + $store = $this->cache->store($storeName)->getStore(); + + $store->setConnection(DB::connection('tenant')); + $store->setLockConnection(DB::connection('tenant')); } if (static::$adjustGlobalCacheManager) { @@ -78,8 +82,8 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper // *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that // (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution. $originalConnections = array_combine($stores, array_map(fn (string $storeName) => [ - 'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), - 'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), + 'connection' => $this->originalConnections[$storeName], + 'lockConnection' => $this->originalLockConnections[$storeName], ], $stores)); TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) { @@ -100,7 +104,11 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper $this->config->set("cache.stores.{$storeName}.connection", $originalConnection); $this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]); - $this->cache->purge($storeName); + /** @var DatabaseStore $store */ + $store = $this->cache->store($storeName)->getStore(); + + $store->setConnection(DB::connection($this->originalConnections[$storeName])); + $store->setLockConnection(DB::connection($this->originalLockConnections[$storeName])); } TenancyServiceProvider::$adjustCacheManagerUsing = null; From cab8ecebeced03306d4121eb7a397d37310278dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 21:16:39 +0100 Subject: [PATCH 08/31] Create tenant storage directories in FilesystemTenancyBootstrapper (#1410) This is because the CreateTenantStorage listener only runs when a tenant is created, but in multi-server setups the directory may need to be created each time a tenant is *used*, not just created. Also changed the listeners to use TenantEvent instead of specific events, to make it possible to use them with other events, such as TenancyBootstrapped. Also update permission bits in a few mkdir() calls to better scope data to the current OS user. Also fix a typo in CacheTenancyBootstrapper (exception message). --- .../CacheTenancyBootstrapper.php | 2 +- .../FilesystemTenancyBootstrapper.php | 11 +++++++++- src/Listeners/CreateTenantStorage.php | 13 +++++++++--- src/Listeners/DeleteTenantStorage.php | 4 ++-- .../FilesystemTenancyBootstrapperTest.php | 21 +++++++++++++++++++ tests/SessionSeparationTest.php | 1 + 6 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 20e09816..9d87e19a 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -108,7 +108,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 [' . $this->config->get('session.driver') . '] 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_sessions'); } } else { // Scoping sessions using this bootstrapper implicitly adds the session store to $names diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index d5088c5c..2c2d9ec9 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -78,6 +78,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper return; } + $path = $suffix + ? $this->tenantStoragePath($suffix) . '/framework/cache' + : $this->originalStoragePath . '/framework/cache'; + + if (! is_dir($path)) { + // Create tenant framework/cache directory if it does not exist + mkdir($path, 0750, true); + } + if ($suffix === false) { $this->app->useStoragePath($this->originalStoragePath); } else { @@ -211,7 +220,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if (! is_dir($path)) { // Create tenant framework/sessions directory if it does not exist - mkdir($path, 0755, true); + mkdir($path, 0750, true); } $this->app['config']['session.files'] = $path; diff --git a/src/Listeners/CreateTenantStorage.php b/src/Listeners/CreateTenantStorage.php index 73da89fc..3bebb731 100644 --- a/src/Listeners/CreateTenantStorage.php +++ b/src/Listeners/CreateTenantStorage.php @@ -4,18 +4,25 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; -use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\Contracts\TenantEvent; +/** + * Can be used to manually create framework directories in the tenant storage when storage_path() is scoped. + * + * Useful when using real-time facades which use the framework/cache directory. + * + * Generally not needed anymore as the directory is also created by the FilesystemTenancyBootstrapper. + */ class CreateTenantStorage { - public function handle(TenantCreated $event): void + public function handle(TenantEvent $event): void { $storage_path = tenancy()->run($event->tenant, fn () => storage_path()); $cache_path = "$storage_path/framework/cache"; if (! is_dir($cache_path)) { // Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades) - mkdir($cache_path, 0777, true); + mkdir($cache_path, 0750, true); } } } diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php index 25adc4f4..ec360073 100644 --- a/src/Listeners/DeleteTenantStorage.php +++ b/src/Listeners/DeleteTenantStorage.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; use Illuminate\Support\Facades\File; -use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Events\Contracts\TenantEvent; class DeleteTenantStorage { - public function handle(DeletingTenant $event): void + public function handle(TenantEvent $event): void { $path = tenancy()->run($event->tenant, fn () => storage_path()); diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 857e0eac..706a7882 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -200,3 +200,24 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); + +test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath + ]); + + $centralStoragePath = storage_path(); + + tenancy()->initialize($tenant = Tenant::create()); + + if ($suffixStoragePath) { + expect(storage_path('framework/cache'))->toBe($centralStoragePath . "/tenant{$tenant->id}/framework/cache"); + expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeTrue(); + } else { + expect(storage_path('framework/cache'))->toBe($centralStoragePath . '/framework/cache'); + expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse(); + } +})->with([true, false]); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 02b018d1..d699bc61 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -56,6 +56,7 @@ test('file sessions are separated', function (bool $scopeSessions) { if ($scopeSessions) { expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); + expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue(); } else { expect($sessionPath())->toBe(storage_path('framework/sessions')); } From 510358b9beef8a7be8405585ee9a617ac5bd7a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 5 Nov 2025 14:53:07 +0100 Subject: [PATCH 09/31] Config: scope_sessions = true only with supported drivers, always throw With the previous implementation, many users would use the default config that enables scope_sessions. They would then deploy the app to production and get the exception there since they use the `database` session driver which is scoped by a different mechanism. The idea behind throwing the exception only in prod was to make it easy to use different setups locally without getting annoying exceptions, while notifying users that a security feature they enabled isn't running in production. However, a better way of doing this is to just throw the exception consistently in all setups and use a sane default for enabling the scope_sessions setting based on the SESSION_DRIVER env var. Users are always encouraged to read the session scoping docs to make sure their session scoping configuration makes sense for their specific setup, but this is a good balance for providing solid security out of the box for most setups without requiring users to configure things manually. --- assets/config.php | 2 +- src/Bootstrappers/CacheTenancyBootstrapper.php | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/assets/config.php b/assets/config.php index 76441036..f15a843a 100644 --- a/assets/config.php +++ b/assets/config.php @@ -313,7 +313,7 @@ return [ * * Note: This will implicitly add your configured session store to the list of prefixed stores above. */ - 'scope_sessions' => true, + 'scope_sessions' => in_array(env('SESSION_DRIVER'), ['redis', 'memcached', 'dynamodb', 'apc'], true), 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. ], diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 9d87e19a..97bd7d24 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -102,14 +102,7 @@ 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)) { - 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_sessions'); - } + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions'); } else { // Scoping sessions using this bootstrapper implicitly adds the session store to $names $names[] = $this->getSessionCacheStoreName(); From 947894fa1d40b7f651f55d45bc56746eaacc60d2 Mon Sep 17 00:00:00 2001 From: Hayatunnabi Nabil Date: Sat, 8 Nov 2025 05:52:08 +0600 Subject: [PATCH 10/31] [4.x] Fix dropRLSPolicies() (#1413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?` parameters are not supported in these statements, so we have to use string interpolation like in other related code. --------- Co-authored-by: Samuel Štancl --- src/Concerns/ManagesRLSPolicies.php | 2 +- tests/RLS/PolicyTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Concerns/ManagesRLSPolicies.php b/src/Concerns/ManagesRLSPolicies.php index 6b804fb7..f6329d0e 100644 --- a/src/Concerns/ManagesRLSPolicies.php +++ b/src/Concerns/ManagesRLSPolicies.php @@ -26,7 +26,7 @@ trait ManagesRLSPolicies $policies = static::getRLSPolicies($table); foreach ($policies as $policy) { - DB::statement('DROP POLICY ? ON ?', [$policy, $table]); + DB::statement("DROP POLICY {$policy} ON {$table}"); } return count($policies); diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php index ee9bf5cc..b790343e 100644 --- a/tests/RLS/PolicyTest.php +++ b/tests/RLS/PolicyTest.php @@ -17,6 +17,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; +use Stancl\Tenancy\Tenancy; use function Stancl\Tenancy\Tests\pest; beforeEach(function () { @@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s TraitRLSManager::class, ]); +test('dropRLSPolicies only drops RLS policies', function () { + DB::statement('CREATE POLICY "comments_dummy_rls_policy" ON comments USING (true)'); + DB::statement('CREATE POLICY "comments_foo_policy" ON comments USING (true)'); // non-RLS policy + + $policyCount = fn () => count(DB::select("SELECT policyname FROM pg_policies WHERE tablename = 'comments'")); + + expect($policyCount())->toBe(2); + + $removed = Tenancy::dropRLSPolicies('comments'); + + expect($removed)->toBe(1); + + // Only the non-RLS policy remains + expect($policyCount())->toBe(1); +}); + test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) { CreateUserWithRLSPolicies::$forceRls = $forceRls; From 69bf76842496a9b29b48b04dacae38c2e34f1f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 8 Nov 2025 01:07:53 +0100 Subject: [PATCH 11/31] Cloning: remove route context middleware flags during cloning Previously, if a universal route was cloned without a cloneRoutesWithMiddleware(['universal']) call, i.e. it had both 'clone' and 'universal' flags, with only the former triggering cloning, the 'universal' flag would be included in the middleware of the cloned route. Now, we make sure to remove all context flags -- central, tenant, universal -- in the first step of processing middleware, before adding just 'tenant'. --- src/Actions/CloneRoutesAsTenant.php | 6 +++--- tests/CloneActionTest.php | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index f1cb1450..87afe1d7 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -39,7 +39,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * Routes with names that are already prefixed won't be cloned - but that's just the default behavior. * The cloneUsing() method allows you to completely override the default behavior and customize how the cloned routes will be defined. * - * After cloning, only top-level middleware in $cloneRoutesWithMiddleware will be removed + * After cloning, only top-level middleware in $cloneRoutesWithMiddleware (as well as any route context flags) will be removed * from the new route (so by default, 'clone' will be omitted from the new route's MW). * Middleware groups are preserved as-is, even if they contain cloning middleware. * @@ -258,12 +258,12 @@ class CloneRoutesAsTenant return $newRoute; } - /** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */ + /** Removes top-level cloneRoutesWithMiddleware and context flags, adds 'tenant' middleware. */ protected function processMiddlewareForCloning(array $middleware): array { $processedMiddleware = array_filter( $middleware, - fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) + fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) && ! in_array($mw, ['central', 'tenant', 'universal']) ); $processedMiddleware[] = 'tenant'; diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 28a8ccd3..b50a1b2f 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -401,3 +401,24 @@ test('tenant parameter addition can be controlled by setting addTenantParameter' $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central'); } })->with([true, false]); + +test('existing context flags are removed during cloning', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone', 'central']); + RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone', 'universal']); + + $cloneAction = app(CloneRoutesAsTenant::class); + + // Clone foo route + $cloneAction->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName()) + ->toContain('tenant.foo'); + expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo'))) + ->not()->toContain('central'); + + // Clone bar route + $cloneAction->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName()) + ->toContain('tenant.foo', 'tenant.bar'); + expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo'))) + ->not()->toContain('universal'); +}); From 97c5afd2cfe36e4f1e999ea10fd703d858303dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 8 Nov 2025 18:39:28 +0100 Subject: [PATCH 12/31] Cloning: clarify case where neither paths nor domains differ In such a case, the cloned route will actually *override* the original route, rather than being unused as the original docblock claimed. Also adds a static make() function for convenience. --- src/Actions/CloneRoutesAsTenant.php | 7 ++++++- tests/CloneActionTest.php | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index 87afe1d7..120ab0d0 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -71,7 +71,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * // cloned route can be customized using domain(string|null). By default, the cloned route will not be scoped to a domain, * // unless a domain() call is used. It's important to keep in mind that: * // 1. When addTenantParameter(false) is used, the paths will be the same, thus domains must differ. - * // 2. If the original route (with the same path) has no domain, the cloned route will never be used due to registration order. + * // 2. If the original route has no domain, the cloned route will override the original route as they will directly conflict. * $cloneAction->addTenantParameter(false)->cloneRoutesWithMiddleware(['clone'])->cloneRoute('no-tenant-parameter')->handle(); * ``` * @@ -96,6 +96,11 @@ class CloneRoutesAsTenant protected Router $router, ) {} + public static function make(): static + { + return app(static::class); + } + /** Clone routes. This resets routesToClone() but not other config. */ public function handle(): void { diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index b50a1b2f..74625994 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -422,3 +422,18 @@ test('existing context flags are removed during cloning', function () { expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo'))) ->not()->toContain('universal'); }); + +test('cloning a route without a prefix or differing domains overrides the original route', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']); + + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('foo'); + + $cloneAction = CloneRoutesAsTenant::make(); + $cloneAction->cloneRoute('foo') + ->addTenantParameter(false) + ->tenantParameterBeforePrefix(false) + ->handle(); + + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo'); +}); From 197513dd84285c1ce9abe07c0bc59ab6ccb4d597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sat, 8 Nov 2025 18:39:28 +0100 Subject: [PATCH 13/31] Cloning: addTenantMiddleware() for specifying ID MW for cloned route Previously, tenant identification middleware was typically specified for the cloned route by "inheriting" it from the central route, which necessarily meant that the central route had to also be marked as universal so it could continue working in the central context -- despite presumably not being usable in the tenant context, thus being universal for no proper reason. In such cases, universal routes were used mainly as a mechanism for specifying the tenant identification middleware to use on the cloned tenant route. Given that recent refactors of the cloning feature have made it more customizable and a bit nicer to use "multiple times", i.e. run handle() with a few different configurations of the action, letting the developer specify the used tenant middleware using a method like this only makes sense. The feature also becomes more independently usable and not just a "hack for universal routes with path identification". --- src/Actions/CloneRoutesAsTenant.php | 19 ++++++++++++++++--- tests/CloneActionTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index 120ab0d0..abe2cbcd 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -30,6 +30,8 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * By providing a callback to shouldClone(), you can change how it's determined if a route should be cloned if you don't want to use middleware flags. * * Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'. + * The addition of the 'tenant' middleware can be controlled using addTenantMiddleware(array). You can specify the identification + * middleware to be used on the cloned route using that method -- instead of using the approach that "inherits" it from a universal route. * * The addition of the tenant parameter can be controlled using addTenantParameter(true|false). Note that if you decide to disable * tenant parameter addition, the routes MUST differ in domains. This can be controlled using the domain(string|null) method. The @@ -91,6 +93,7 @@ class CloneRoutesAsTenant protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) protected Closure|null $shouldClone = null; protected array $cloneRoutesWithMiddleware = ['clone']; + protected array $addTenantMiddleware = ['tenant']; public function __construct( protected Router $router, @@ -148,6 +151,18 @@ class CloneRoutesAsTenant return $this; } + /** + * The tenant middleware to be added to the cloned route. + * + * If used with early identification, make sure to include 'tenant' in this array. + */ + public function addTenantMiddleware(array $middleware): static + { + $this->addTenantMiddleware = $middleware; + + return $this; + } + /** The domain the cloned route should use. Set to null if it shouldn't be scoped to a domain. */ public function domain(string|null $domain): static { @@ -271,9 +286,7 @@ class CloneRoutesAsTenant fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) && ! in_array($mw, ['central', 'tenant', 'universal']) ); - $processedMiddleware[] = 'tenant'; - - return array_unique($processedMiddleware); + return array_unique(array_merge($processedMiddleware, $this->addTenantMiddleware)); } /** Check if route already has tenant parameter or name prefix. */ diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 74625994..8fc66c56 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -437,3 +437,30 @@ test('cloning a route without a prefix or differing domains overrides the origin expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo'); }); + +test('addTenantMiddleware can be used to specify the tenant middleware for the cloned route', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']); + RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']); + + $cloneAction = app(CloneRoutesAsTenant::class); + + $cloneAction->cloneRoute('foo')->addTenantMiddleware([InitializeTenancyByPath::class])->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + $cloned = RouteFacade::getRoutes()->getByName('tenant.foo'); + expect($cloned->uri())->toBe('{tenant}/foo'); + expect($cloned->getName())->toBe('tenant.foo'); + expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByPath::class]); + + $cloneAction->cloneRoute('bar') + ->addTenantMiddleware([InitializeTenancyByDomain::class]) + ->domain('foo.localhost') + ->addTenantParameter(false) + ->tenantParameterBeforePrefix(false) + ->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar'); + $cloned = RouteFacade::getRoutes()->getByName('tenant.bar'); + expect($cloned->uri())->toBe('bar'); + expect($cloned->getName())->toBe('tenant.bar'); + expect($cloned->getDomain())->toBe('foo.localhost'); + expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]); +}); From 6ef4b91744d8745f90ddde3d511bb0c325530351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Sun, 9 Nov 2025 01:27:29 +0100 Subject: [PATCH 14/31] Cloning: improve type annotations, add cloneRoutes() for convenience --- src/Actions/CloneRoutesAsTenant.php | 55 +++++++++++++++++++++++++---- tests/CloneActionTest.php | 11 ++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index abe2cbcd..6e988907 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -86,13 +86,27 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; */ class CloneRoutesAsTenant { + /** @var list */ 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) + + /** + * The callback should accept a Route instance or the route name (string). + * + * @var ?Closure(Route|string): void + */ + protected Closure|null $cloneUsing = null; + + /** @var ?Closure(Route): bool */ protected Closure|null $shouldClone = null; + + /** @var list */ protected array $cloneRoutesWithMiddleware = ['clone']; + + /** @var list */ protected array $addTenantMiddleware = ['tenant']; public function __construct( @@ -110,9 +124,12 @@ class CloneRoutesAsTenant // If no routes were specified using cloneRoute(), get all routes // and for each, determine if it should be cloned if (! $this->routesToClone) { - $this->routesToClone = collect($this->router->getRoutes()->get()) + /** @var list */ + $routesToClone = collect($this->router->getRoutes()->get()) ->filter(fn (Route $route) => $this->shouldBeCloned($route)) ->all(); + + $this->routesToClone = $routesToClone; } foreach ($this->routesToClone as $route) { @@ -126,7 +143,9 @@ class CloneRoutesAsTenant if (is_string($route)) { $this->router->getRoutes()->refreshNameLookups(); - $route = $this->router->getRoutes()->getByName($route); + $routeName = $route; + $route = $this->router->getRoutes()->getByName($routeName); + assert(! is_null($route), "Route [{$routeName}] was meant to be cloned but does not exist."); } $this->createNewRoute($route); @@ -155,6 +174,8 @@ class CloneRoutesAsTenant * The tenant middleware to be added to the cloned route. * * If used with early identification, make sure to include 'tenant' in this array. + * + * @param list $middleware */ public function addTenantMiddleware(array $middleware): static { @@ -171,7 +192,11 @@ class CloneRoutesAsTenant return $this; } - /** Provide a custom callback for cloning routes, instead of the default behavior. */ + /** + * Provide a custom callback for cloning routes, instead of the default behavior. + * + * @param ?Closure(Route|string): void $cloneUsing + */ public function cloneUsing(Closure|null $cloneUsing): static { $this->cloneUsing = $cloneUsing; @@ -179,7 +204,11 @@ class CloneRoutesAsTenant return $this; } - /** Specify which middleware should serve as "flags" telling this action to clone those routes. */ + /** + * Specify which middleware should serve as "flags" telling this action to clone those routes. + * + * @param list $middleware + */ public function cloneRoutesWithMiddleware(array $middleware): static { $this->cloneRoutesWithMiddleware = $middleware; @@ -190,7 +219,9 @@ class CloneRoutesAsTenant /** * Provide a custom callback for determining whether a route should be cloned. * Overrides the default middleware-based detection. - * */ + * + * @param Closure(Route): bool $shouldClone + */ public function shouldClone(Closure|null $shouldClone): static { $this->shouldClone = $shouldClone; @@ -213,6 +244,18 @@ class CloneRoutesAsTenant return $this; } + /** + * Clone individual routes. + * + * @param list $routes + */ + public function cloneRoutes(array $routes): static + { + $this->routesToClone = array_merge($this->routesToClone, $routes); + + return $this; + } + protected function shouldBeCloned(Route $route): bool { // Don't clone routes that already have tenant parameter or prefix diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 8fc66c56..ab9c5e9b 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -464,3 +464,14 @@ test('addTenantMiddleware can be used to specify the tenant middleware for the c expect($cloned->getDomain())->toBe('foo.localhost'); expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]); }); + +test('cloneRoutes can be used to clone multiple routes', function () { + RouteFacade::get('/foo', fn () => true)->name('foo'); + $bar = RouteFacade::get('/bar', fn () => true)->name('bar'); + RouteFacade::get('/baz', fn () => true)->name('baz'); + + CloneRoutesAsTenant::make()->cloneRoutes(['foo', $bar])->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('tenant.baz'); +}); From 2aca784c0b723606f55a4aeea6bf4b5f4cd6b0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 10 Nov 2025 17:31:02 +0100 Subject: [PATCH 15/31] Cloning: remove comments in TSP stub in favor of referencing class docs --- assets/TenancyServiceProvider.stub.php | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index e0b69e6e..46f35515 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -242,24 +242,7 @@ class TenancyServiceProvider extends ServiceProvider /** @var CloneRoutesAsTenant $cloneRoutes */ $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); - // The cloning action has two modes: - // 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property. - // You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action. - // - // By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle() - // will clone all routes that have either 'clone' or 'universal' middleware (mentioning 'universal' since that's a common use case). - // - // Also, you can use the shouldClone() method to provide a custom closure that determines if a route should be cloned. - // - // 2. Clone only the routes that were manually added to the action using cloneRoute(). - // - // Regardless of the mode, you can provide a custom closure for defining the cloned route, e.g.: - // $cloneRoutesAction->cloneUsing(function (Route $route) { - // RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName()); - // })->handle(); - // This will make all cloned routes use the custom closure to define the cloned route instead of the default behavior. - // See Stancl\Tenancy\Actions\CloneRoutesAsTenant for more details. - + /** See CloneRoutesAsTenant for usage details. */ $cloneRoutes->handle(); } From 0cd0bc44b1c0bdb41d26b7e902755d2daaa4b795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 11 Nov 2025 02:06:03 +0100 Subject: [PATCH 16/31] config: ignore port in default central_domains value Recent Laravel installations often have http://localhost:8000 as APP_URL, so we make sure to strip any port suffix from the default central domain derived from APP_URL. --- assets/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/config.php b/assets/config.php index f15a843a..2a3a07e2 100644 --- a/assets/config.php +++ b/assets/config.php @@ -64,7 +64,7 @@ return [ * Only relevant if you're using the domain or subdomain identification middleware. */ 'central_domains' => [ - str(env('APP_URL'))->after('://')->before('/')->toString(), + str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(), ], /** From 45cf7029af2ed4785ed779005fd710c3e74f9b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 14 Nov 2025 10:58:35 +0100 Subject: [PATCH 17/31] globalUrl: useAssetOrigin() instead of setAssetRoot() This change was prompted by a phpstan failure after a recent update. While making this change, I noticed we don't need the macro anymore as useAssetOrigin() was added to the UrlGenerator earlier this year, simplifying our implementation. --- phpstan.neon | 4 ---- .../FilesystemTenancyBootstrapper.php | 14 +++----------- src/TenancyServiceProvider.php | 10 ++++++---- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 2c6e3d69..bb97e3a0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -46,10 +46,6 @@ parameters: message: '#PHPDoc tag \@param has invalid value \(dynamic#' paths: - src/helpers.php - - - message: '#Illuminate\\Routing\\UrlGenerator#' - paths: - - src/Bootstrappers/FilesystemTenancyBootstrapper.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 2c2d9ec9..faa02de7 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Foundation\Application; -use Illuminate\Routing\UrlGenerator; use Illuminate\Session\FileSessionHandler; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -22,13 +21,6 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper ) { $this->originalAssetUrl = $this->app['config']['app.asset_url']; $this->originalStoragePath = $app->storagePath(); - - $this->app['url']->macro('setAssetRoot', function ($root) { - /** @var UrlGenerator $this */ - $this->assetRoot = $root; - - return $this; - }); } public function bootstrap(Tenant $tenant): void @@ -107,16 +99,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if ($suffix === false) { $this->app['config']['app.asset_url'] = $this->originalAssetUrl; - $this->app['url']->setAssetRoot($this->originalAssetUrl); + $this->app['url']->useAssetOrigin($this->originalAssetUrl); return; } if ($this->originalAssetUrl) { $this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix"; - $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); + $this->app['url']->useAssetOrigin($this->app['config']['app.asset_url']); } else { - $this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); + $this->app['url']->useAssetOrigin($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); } } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 9b32f088..afd20fb6 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy; use Closure; use Illuminate\Cache\CacheManager; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Support\Facades\Event; @@ -157,12 +158,13 @@ class TenancyServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/../assets/routes.php'); } - $this->app->singleton('globalUrl', function ($app) { + $this->app->singleton('globalUrl', function (Container $app) { if ($app->bound(FilesystemTenancyBootstrapper::class)) { - $instance = clone $app['url']; - $instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalAssetUrl); + /** @var \Illuminate\Routing\UrlGenerator */ + $instance = clone $app->make('url'); + $instance->useAssetOrigin($app->make(FilesystemTenancyBootstrapper::class)->originalAssetUrl); } else { - $instance = $app['url']; + $instance = $app->make('url'); } return $instance; From 44e8ec8abf58a7193699e30b926fe96fb5145a45 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 3 Nov 2025 17:33:12 +0100 Subject: [PATCH 18/31] Syncing: SyncedResourceDeleted event and DeleteResourceMapping listener Also move pivot record deletion to that listener and improve tests The 'tenant pivot records are deleted along with the tenants to which they belong to' test is failing in this commit -- the listener for deleting mappings when a *tenant* is deleted is only implemented in the next commit. The only change done here is to re-add FKs (necessary for passing *in this commit* in that specific dataset variant) that were removed from the default test migration as we now have the DeleteResourceMapping listener that's enabled by default. --- assets/TenancyServiceProvider.stub.php | 3 + .../Events/SyncedResourceDeleted.php | 18 +++++ .../Listeners/DeleteResourceMapping.php | 60 +++++++++++++++++ .../Listeners/DeleteResourcesInTenants.php | 7 -- src/ResourceSyncing/ResourceSyncing.php | 15 +++-- src/ResourceSyncing/SyncMaster.php | 4 +- src/ResourceSyncing/Syncable.php | 2 + tests/Etc/ResourceSyncing/CentralUser.php | 1 + ...05_11_000002_create_tenant_users_table.php | 3 - tests/ResourceSyncingTest.php | 67 ++++++++++++++++++- 10 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 src/ResourceSyncing/Events/SyncedResourceDeleted.php create mode 100644 src/ResourceSyncing/Listeners/DeleteResourceMapping.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 46f35515..2e7819a5 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -129,6 +129,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ], + ResourceSyncing\Events\SyncedResourceDeleted::class => [ + ResourceSyncing\Listeners\DeleteResourceMapping::class, + ], ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ], diff --git a/src/ResourceSyncing/Events/SyncedResourceDeleted.php b/src/ResourceSyncing/Events/SyncedResourceDeleted.php new file mode 100644 index 00000000..941e1841 --- /dev/null +++ b/src/ResourceSyncing/Events/SyncedResourceDeleted.php @@ -0,0 +1,18 @@ +getCentralResource($event->model); + + if (! $centralResource) { + return; + } + + // Delete pivot records if the central resource doesn't use soft deletes + // or the central resource was deleted using forceDelete() + if ($event->forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { + Pivot::withoutEvents(function () use ($centralResource, $event) { + // If detach() is called with null -- if $event->tenant is null -- this means a central resource was deleted and detaches all tenants. + // If detach() is called with a specific tenant, it means the resource was deleted in that tenant, and we only delete that single mapping. + $centralResource->tenants()->detach($event->tenant); + }); + } + } + + public function getCentralResource(Syncable&Model $resource): SyncMaster|null + { + if ($resource instanceof SyncMaster) { + return $resource; + } + + $centralResourceClass = $resource->getCentralModelName(); + + /** @var (SyncMaster&Model)|null $centralResource */ + $centralResource = $centralResourceClass::firstWhere( + $resource->getGlobalIdentifierKeyName(), + $resource->getGlobalIdentifierKey() + ); + + return $centralResource; + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php index 6876f476..7b071a27 100644 --- a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php +++ b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\ResourceSyncing\Listeners; -use Illuminate\Database\Eloquent\SoftDeletes; use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; @@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { $this->deleteSyncedResource($centralResource, $forceDelete); - - // Delete pivot records if the central resource doesn't use soft deletes - // or the central resource was deleted using forceDelete() - if ($forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { - $centralResource->tenants()->detach(tenant()); - } }); } } diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index f0d8cc12..fb008966 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; @@ -25,8 +26,8 @@ trait ResourceSyncing } }); - static::deleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + static::deleted(function (Syncable&Model $model) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(); } }); @@ -42,14 +43,14 @@ trait ResourceSyncing if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { static::forceDeleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(true); } }); static::restoring(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { - $model->triggerRestoredEvent(); + if ($model instanceof SyncMaster && $model->shouldSync()) { + $model->triggerRestoreEvent(); } }); } @@ -67,9 +68,11 @@ trait ResourceSyncing /** @var SyncMaster&Model $this */ event(new SyncMasterDeleted($this, $forceDelete)); } + + event(new SyncedResourceDeleted($this, tenant(), $forceDelete)); } - public function triggerRestoredEvent(): void + public function triggerRestoreEvent(): void { if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) { /** @var SyncMaster&Model $this */ diff --git a/src/ResourceSyncing/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php index 882aeb54..290546cb 100644 --- a/src/ResourceSyncing/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -25,7 +25,5 @@ interface SyncMaster extends Syncable public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void; - public function triggerDeleteEvent(bool $forceDelete = false): void; - - public function triggerRestoredEvent(): void; + public function triggerRestoreEvent(): void; } diff --git a/src/ResourceSyncing/Syncable.php b/src/ResourceSyncing/Syncable.php index 3d5288f1..c38b02ea 100644 --- a/src/ResourceSyncing/Syncable.php +++ b/src/ResourceSyncing/Syncable.php @@ -16,6 +16,8 @@ interface Syncable public function triggerSyncEvent(): void; + public function triggerDeleteEvent(bool $forceDelete = false): void; + /** * Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). * diff --git a/tests/Etc/ResourceSyncing/CentralUser.php b/tests/Etc/ResourceSyncing/CentralUser.php index 1533bd21..ece09550 100644 --- a/tests/Etc/ResourceSyncing/CentralUser.php +++ b/tests/Etc/ResourceSyncing/CentralUser.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster; class CentralUser extends Model implements SyncMaster { use ResourceSyncing, CentralConnection; + protected $guarded = []; public $timestamps = false; diff --git a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php index 0aafd23c..dcd667a6 100644 --- a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php +++ b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php @@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration $table->string('global_user_id'); $table->unique(['tenant_id', 'global_user_id']); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); - $table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade'); }); } diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 3250c37a..c64a9806 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -46,6 +46,10 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\QueryException; use function Stancl\Tenancy\Tests\pest; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -92,6 +96,7 @@ beforeEach(function () { CentralUser::$creationAttributes = $creationAttributes; Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); + Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); @@ -890,9 +895,13 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $ 'basic pivot' => false, ]); -test('tenant pivot records are deleted along with the tenants to which they belong to', function() { +test('tenant pivot records are deleted along with the tenants to which they belong to', function(bool $dbLevelOnCascadeDelete) { [$tenant] = createTenantsAndRunMigrations(); + if ($dbLevelOnCascadeDelete) { + addFkConstraintsToTenantUsersPivot(); + } + $syncMaster = CentralUser::create([ 'global_id' => 'cascade_user', 'name' => 'Central user', @@ -907,6 +916,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo // Deleting tenant deletes its pivot records expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); +})->with([ + 'db level on cascade delete' => true, + 'event-based on cascade delete' => false, +]); + +test('pivot record is automatically deleted with the tenant resource', function() { + [$tenant] = createTenantsAndRunMigrations(); + + $syncMaster = CentralUser::create([ + 'global_id' => 'cascade_user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'cascade_user', + ]); + + $syncMaster->tenants()->attach($tenant); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); + + $tenant->run(function () { + TenantUser::firstWhere('global_id', 'cascade_user')->delete(); + }); + + // Deleting tenant resource deletes its pivot record + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); + + // The same works with forceDelete + addExtraColumns(true); + + $syncMaster = CentralUserWithSoftDeletes::create([ + 'global_id' => 'force_cascade_user', + 'name' => 'Central user', + 'email' => 'central2@localhost', + 'password' => 'password', + 'role' => 'force_cascade_user', + 'foo' => 'bar', + ]); + + $syncMaster->tenants()->attach($tenant); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); + + $tenant->run(function () { + TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete(); + }); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); }); test('trashed resources are synced correctly', function () { @@ -1265,6 +1322,14 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); +function addFkConstraintsToTenantUsersPivot(): void +{ + Schema::table('tenant_users', function (Blueprint $table) { + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade'); + }); +} + /** * Create two tenants and run migrations for those tenants. * From e079803025d8ee5fbffb20bbec93bf7565cb2678 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 4 Nov 2025 16:52:39 +0100 Subject: [PATCH 19/31] Syncing: Add DeleteAllTenantMappings listener --- assets/TenancyServiceProvider.stub.php | 2 + .../Listeners/DeleteAllTenantMappings.php | 40 ++++++++++++ tests/ResourceSyncingTest.php | 63 +++++++++++++++---- 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 2e7819a5..a1e681d7 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -81,6 +81,8 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), + + // ResourceSyncing\Listeners\DeleteAllTenantMappings::class, ], Events\TenantMaintenanceModeEnabled::class => [], diff --git a/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php new file mode 100644 index 00000000..58dd50a2 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php @@ -0,0 +1,40 @@ + 'tenant_key_column'] format. + * + * Since we cannot automatically detect which pivot tables + * are being used, they have to be specified here manually. + * + * The default value follows the polymorphic table used by default. + */ + public static array $pivotTables = ['tenant_resources' => 'tenant_id']; + + public function handle(TenantDeleted $event): void + { + foreach (static::$pivotTables as $table => $tenantKeyColumn) { + DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->delete(); + } + } +} diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index c64a9806..2f7417b0 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -48,7 +48,9 @@ use Illuminate\Database\QueryException; use function Stancl\Tenancy\Tests\pest; use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; +use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings; use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; beforeEach(function () { @@ -73,6 +75,7 @@ beforeEach(function () { CreateTenantResource::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; + DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id']; // Reset global scopes on models (should happen automatically but to make this more explicit) Model::clearBootedModels(); @@ -895,30 +898,51 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $ 'basic pivot' => false, ]); -test('tenant pivot records are deleted along with the tenants to which they belong to', function(bool $dbLevelOnCascadeDelete) { +test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) { [$tenant] = createTenantsAndRunMigrations(); - if ($dbLevelOnCascadeDelete) { - addFkConstraintsToTenantUsersPivot(); + if ($morphPivot) { + config(['tenancy.models.tenant' => MorphTenant::class]); + $centralUserModel = BaseCentralUser::class; + + // The default pivot table, no need to configure the listener + $pivotTable = 'tenant_resources'; + } else { + $centralUserModel = CentralUser::class; + + // Custom pivot table + $pivotTable = 'tenant_users'; } - $syncMaster = CentralUser::create([ - 'global_id' => 'cascade_user', + if ($dbLevelOnCascadeDelete) { + addTenantIdConstraintToPivot($pivotTable); + } else { + // Event-based cleanup + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id']; + } + + $syncMaster = $centralUserModel::create([ + 'global_id' => 'user', 'name' => 'Central user', 'email' => 'central@localhost', 'password' => 'password', - 'role' => 'cascade_user', + 'role' => 'user', ]); $syncMaster->tenants()->attach($tenant); + // Pivot records should be deleted along with the tenant $tenant->delete(); - // Deleting tenant deletes its pivot records - expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); + expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); })->with([ 'db level on cascade delete' => true, 'event-based on cascade delete' => false, +])->with([ + 'polymorphic pivot' => true, + 'basic pivot' => false, ]); test('pivot record is automatically deleted with the tenant resource', function() { @@ -966,6 +990,24 @@ test('pivot record is automatically deleted with the tenant resource', function( expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); }); +test('DeleteAllTenantMappings handles incorrect configuration correctly', function() { + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + // Existing table, non-existent tenant key column + // The listener should throw an 'unknown column' exception + DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column']; + + // Should throw an exception when tenant is deleted + expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'"); + + // Non-existent table + DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column']; + + expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist"); +}); + test('trashed resources are synced correctly', function () { [$tenant1, $tenant2] = createTenantsAndRunMigrations(); migrateUsersTableForTenants(); @@ -1322,11 +1364,10 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); -function addFkConstraintsToTenantUsersPivot(): void +function addTenantIdConstraintToPivot(string $pivotTable): void { - Schema::table('tenant_users', function (Blueprint $table) { + Schema::table($pivotTable, function (Blueprint $table) { $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); - $table->foreign('global_user_id')->references('global_id')->on('users')->onDelete('cascade'); }); } From 072fcc632693e1be8131c11e8a9b0012299fc9c7 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 18 Nov 2025 04:02:48 +0100 Subject: [PATCH 20/31] Syncing: move global ID generation logic to an overridable method Also make all resource syncing-related listener closures static. Also correct return type for getGlobalIdentifierKey to string|int. (We intentionally do not support returning null like many other "get x key" methods would since such a case might break resource syncing logic. This is also why we use inline getAttribute() in the creating listener instead of calling the method.) --- assets/TenancyServiceProvider.stub.php | 4 ++- src/ResourceSyncing/ResourceSyncing.php | 29 ++++++++++++-------- src/ResourceSyncing/TriggerSyncingEvents.php | 6 ++-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index a1e681d7..f1b00c88 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -146,7 +146,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ ResourceSyncing\Listeners\DeleteResourceInTenant::class, ], - // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) + + // Fired only when a synced resource is changed (as a result of syncing) + // in a different DB than DB from which the change originates (to avoid infinite loops) ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], // Storage symlinks diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index fb008966..272b7bd7 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -20,35 +20,32 @@ trait ResourceSyncing { public static function bootResourceSyncing(): void { - static::saved(function (Syncable&Model $model) { + static::saved(static function (Syncable&Model $model) { if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { $model->triggerSyncEvent(); } }); - static::deleted(function (Syncable&Model $model) { + static::deleted(static function (Syncable&Model $model) { if ($model->shouldSync()) { $model->triggerDeleteEvent(); } }); - static::creating(function (Syncable&Model $model) { - if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { - $model->setAttribute( - $model->getGlobalIdentifierKeyName(), - app(UniqueIdentifierGenerator::class)->generate($model) - ); + static::creating(static function (Syncable&Model $model) { + if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) { + $model->generateGlobalIdentifierKey(); } }); if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { - static::forceDeleting(function (Syncable&Model $model) { + static::forceDeleting(static function (Syncable&Model $model) { if ($model->shouldSync()) { $model->triggerDeleteEvent(true); } }); - static::restoring(function (Syncable&Model $model) { + static::restoring(static function (Syncable&Model $model) { if ($model instanceof SyncMaster && $model->shouldSync()) { $model->triggerRestoreEvent(); } @@ -119,8 +116,18 @@ trait ResourceSyncing return 'global_id'; } - public function getGlobalIdentifierKey(): string + public function getGlobalIdentifierKey(): string|int { return $this->getAttribute($this->getGlobalIdentifierKeyName()); } + + protected function generateGlobalIdentifierKey(): void + { + if (! app()->bound(UniqueIdentifierGenerator::class)) return; + + $this->setAttribute( + $this->getGlobalIdentifierKeyName(), + app(UniqueIdentifierGenerator::class)->generate($this), + ); + } } diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index eec1b13d..da79df3a 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -20,14 +20,14 @@ trait TriggerSyncingEvents { public static function bootTriggerSyncingEvents(): void { - static::saving(function (self $pivot) { + static::saving(static function (self $pivot) { // Try getting the central resource to see if it is available // If it is not available, throw an exception to interrupt the saving process // And prevent creating a pivot record without a central resource $pivot->getCentralResourceAndTenant(); }); - static::saved(function (self $pivot) { + static::saved(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource @@ -40,7 +40,7 @@ trait TriggerSyncingEvents } }); - static::deleting(function (self $pivot) { + static::deleting(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource From 04a20ca93054c2bb3e37922283e93f7361bf0a42 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 25 Nov 2025 04:29:28 +0100 Subject: [PATCH 21/31] [MINOR BC BREAK] Syncing: PivotWithRelation -> PivotWithCentralResource The old names of the class and method were misleading. We don't actually need any relation. And we don't even need a model instance as we were returning previously -- the only use of that method was in TriggerSyncingEvents which would immediately use ::class on the returned value. Therefore, all we are asking for in this interface is just the central resource class. --- ...entralResourceNotAvailableInPivotException.php | 2 +- src/ResourceSyncing/PivotWithCentralResource.php | 11 +++++++++++ src/ResourceSyncing/PivotWithRelation.php | 15 --------------- src/ResourceSyncing/TriggerSyncingEvents.php | 6 +++--- tests/Etc/ResourceSyncing/CustomPivot.php | 15 ++++----------- tests/ResourceSyncingTest.php | 8 ++++---- 6 files changed, 23 insertions(+), 34 deletions(-) create mode 100644 src/ResourceSyncing/PivotWithCentralResource.php delete mode 100644 src/ResourceSyncing/PivotWithRelation.php diff --git a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php index d20415be..fbb918dd 100644 --- a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php +++ b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php @@ -13,7 +13,7 @@ class CentralResourceNotAvailableInPivotException extends Exception parent::__construct( 'Central resource is not accessible in pivot model. To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). - To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' + To make this work both ways, you can make your pivot implement PivotWithCentralResource and return the related model in getCentralResourceClass() or extend MorphPivot.' ); } } diff --git a/src/ResourceSyncing/PivotWithCentralResource.php b/src/ResourceSyncing/PivotWithCentralResource.php new file mode 100644 index 00000000..07efcc2e --- /dev/null +++ b/src/ResourceSyncing/PivotWithCentralResource.php @@ -0,0 +1,11 @@ + */ + public function getCentralResourceClass(): string; +} diff --git a/src/ResourceSyncing/PivotWithRelation.php b/src/ResourceSyncing/PivotWithRelation.php deleted file mode 100644 index 4936d1fe..00000000 --- a/src/ResourceSyncing/PivotWithRelation.php +++ /dev/null @@ -1,15 +0,0 @@ -users()->getModel(). - */ - public function getRelatedModel(): Model; -} diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index da79df3a..2f8914b5 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -79,9 +79,9 @@ trait TriggerSyncingEvents */ protected function getResourceClass(): string { - /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithRelation)) $this */ - if ($this instanceof PivotWithRelation) { - return $this->getRelatedModel()::class; + /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithCentralResource)) $this */ + if ($this instanceof PivotWithCentralResource) { + return $this->getCentralResourceClass(); } if ($this instanceof MorphPivot) { diff --git a/tests/Etc/ResourceSyncing/CustomPivot.php b/tests/Etc/ResourceSyncing/CustomPivot.php index 00a019c9..2ffca4c0 100644 --- a/tests/Etc/ResourceSyncing/CustomPivot.php +++ b/tests/Etc/ResourceSyncing/CustomPivot.php @@ -4,20 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Stancl\Tenancy\ResourceSyncing\PivotWithRelation; +use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource; use Stancl\Tenancy\ResourceSyncing\TenantPivot; -class CustomPivot extends TenantPivot implements PivotWithRelation +class CustomPivot extends TenantPivot implements PivotWithCentralResource { - public function users(): BelongsToMany + public function getCentralResourceClass(): string { - return $this->belongsToMany(CentralUser::class); - } - - public function getRelatedModel(): Model - { - return $this->users()->getModel(); + return CentralUser::class; } } diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 2f7417b0..826ed780 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -263,7 +263,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant expect(TenantUser::all())->toHaveCount(0); }); - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($createCentralUser()); $createCentralUser()->tenants()->attach($tenant); @@ -287,7 +287,7 @@ test('detaching central users from tenants or vice versa force deletes the synce migrateUsersTableForTenants(); if ($attachUserToTenant) { - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($centralUser); } else { $centralUser->tenants()->attach($tenant); @@ -298,7 +298,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUser); } else { $centralUser->tenants()->detach($tenant); @@ -333,7 +333,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); } else { $centralUserWithSoftDeletes->tenants()->detach($tenant); From 159e600a9b878cceb459436f6ab3ccc3e4a8d044 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 1 Dec 2025 10:28:58 +0100 Subject: [PATCH 22/31] Syncing: support morph maps in TriggerSyncingEvents --- src/ResourceSyncing/TriggerSyncingEvents.php | 3 +- tests/ResourceSyncingTest.php | 48 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index 2f8914b5..059eb579 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\ResourceSyncing; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Eloquent\Relations\Relation; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; @@ -85,7 +86,7 @@ trait TriggerSyncingEvents } if ($this instanceof MorphPivot) { - return $this->morphClass; + return Relation::getMorphedModel($this->morphClass) ?? $this->morphClass; } throw new CentralResourceNotAvailableInPivotException; diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 826ed780..11a172c5 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -52,6 +52,7 @@ use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings; use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; +use Illuminate\Database\Eloquent\Relations\Relation; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -1364,6 +1365,53 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); +test('attach and detach events are handled correctly when using morph maps', function() { + config(['tenancy.models.tenant' => MorphTenant::class]); + [$tenant] = createTenantsAndRunMigrations(); + migrateCompaniesTableForTenants(); + + Relation::morphMap([ + 'users' => BaseCentralUser::class, + 'companies' => CentralCompany::class, + ]); + + $centralUser = BaseCentralUser::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'user', + ]); + + $centralCompany = CentralCompany::create([ + 'global_id' => 'company', + 'name' => 'Central company', + 'email' => 'company@localhost', + ]); + + $tenant->users()->attach($centralUser); + $tenant->companies()->attach($centralCompany); + + // Assert all tenant_resources mappings actually use the configured morph map + expect(DB::table('tenant_resources')->count()) + ->toBe(DB::table('tenant_resources')->whereIn('tenant_resources_type', ['users', 'companies'])->count()); + + tenancy()->initialize($tenant); + + expect(BaseTenantUser::whereGlobalId('user')->first())->not()->toBeNull(); + expect(TenantCompany::whereGlobalId('company')->first())->not()->toBeNull(); + + tenancy()->end(); + + $tenant->users()->detach($centralUser); + $tenant->companies()->detach($centralCompany); + + tenancy()->initialize($tenant); + + expect(BaseTenantUser::whereGlobalId('user')->first())->toBeNull(); + expect(TenantCompany::whereGlobalId('company')->first())->toBeNull(); +}); + function addTenantIdConstraintToPivot(string $pivotTable): void { Schema::table($pivotTable, function (Blueprint $table) { From 7955aae6d596bab38caee96c0e067df07d9a06d9 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Fri, 21 Nov 2025 00:06:33 +0100 Subject: [PATCH 23/31] TSP stub: remove unnecessary imports Also update PHP 8.5 steps in CONTRIBUTING.md since PHP 8.5 is released now. --- CONTRIBUTING.md | 2 +- assets/TenancyServiceProvider.stub.php | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76af44d9..6e6055af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,5 +53,5 @@ 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 +- `PHP_VERSION=8.5.0 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/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index f1b00c88..603e44e7 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Providers; -use Illuminate\Routing\Route; use Stancl\Tenancy\Jobs; use Stancl\Tenancy\Events; use Stancl\Tenancy\ResourceSyncing; @@ -14,12 +13,8 @@ use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; -use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Illuminate\Contracts\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Route as RouteFacade; -use Stancl\Tenancy\Middleware\InitializeTenancyByPath; -use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; -use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper; +use Illuminate\Support\Facades\Route; /** * Tenancy for Laravel. @@ -207,7 +202,7 @@ class TenancyServiceProvider extends ServiceProvider // // To make Livewire v3 work with Tenancy, make the update route universal. // Livewire::setUpdateRoute(function ($handle) { - // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); + // return Route::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); // }); } @@ -228,7 +223,7 @@ class TenancyServiceProvider extends ServiceProvider { $this->app->booted(function () { if (file_exists(base_path('routes/tenant.php'))) { - RouteFacade::namespace(static::$controllerNamespace) + Route::namespace(static::$controllerNamespace) ->middleware('tenant') ->group(base_path('routes/tenant.php')); } From 3c0e21b726c9a17ce27f1a8ea18a227c854a7c26 Mon Sep 17 00:00:00 2001 From: Victor R <39545521+viicslen@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:17:11 -0500 Subject: [PATCH 24/31] [4.x] Filesystem bootstrapper: scoped disk support (#1402) Fixes #1401 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: lukinovec Co-authored-by: Samuel Stancl --- composer.json | 3 +- .../FilesystemTenancyBootstrapper.php | 13 ++++++- .../FilesystemTenancyBootstrapperTest.php | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 393d0d8a..b03e1b2f 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", "pestphp/pest": "^3.0", - "larastan/larastan": "^3.0" + "larastan/larastan": "^3.0", + "league/flysystem-path-prefixing": "^3.0" }, "autoload": { "psr-4": { diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index faa02de7..af2b809f 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -114,7 +114,18 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper protected function forgetDisks(): void { - Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); + $tenantDisks = $this->app['config']['tenancy.filesystem.disks']; + $scopedDisks = []; + + foreach ($this->app['config']['filesystems.disks'] as $name => $disk) { + if (isset($disk['driver'], $disk['disk']) + && $disk['driver'] === 'scoped' + && in_array($disk['disk'], $tenantDisks, true)) { + $scopedDisks[] = $name; + } + } + + Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks)); } protected function diskRoot(string $disk, Tenant|false $tenant): void diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 706a7882..628b974e 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -221,3 +221,39 @@ test('the framework/cache directory is created when storage_path is scoped', fun expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse(); } })->with([true, false]); + +test('scoped disks are scoped per tenant', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.scoped_disk' => [ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'scoped_disk_prefix', + ], + ]); + + $tenant = Tenant::create(); + + Storage::disk('scoped_disk')->put('foo.txt', 'central'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central'); + + tenancy()->initialize($tenant); + + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null); + Storage::disk('scoped_disk')->put('foo.txt', 'tenant'); + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant'); + + tenancy()->end(); + + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); + Storage::disk('scoped_disk')->put('foo.txt', 'central2'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2'); + + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2'); + expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); +}); + From e3701f1cc127a10f7f7f28cc1ab9608c442fcfb9 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Mon, 29 Dec 2025 03:50:05 +0530 Subject: [PATCH 25/31] [4.x] Add more relation type annotations (#1424) This pull request adds improved PHPDoc type annotations to several Eloquent relationship methods, enhancing static analysis and developer experience. These changes clarify the expected return types for relationships, making the codebase easier to understand and work with. Relationship method type annotations: * Added a detailed return type annotation to the `tenant` method in the `BelongsToTenant` trait, specifying the related model and the current class. * Added a detailed return type annotation to the `domains` method in the `HasDomains` trait, specifying the related model and the current class. * Added a detailed return type annotation to the `tenants` method in the `ResourceSyncing` class, specifying the related model and the current class. --- src/Database/Concerns/BelongsToTenant.php | 3 +++ src/Database/Concerns/HasDomains.php | 6 +++++- src/ResourceSyncing/ResourceSyncing.php | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index f26a7ff8..da5dc84a 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -17,6 +17,9 @@ trait BelongsToTenant { use FillsCurrentTenant; + /** + * @return BelongsTo<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Tenant, $this> + */ public function tenant(): BelongsTo { return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); diff --git a/src/Database/Concerns/HasDomains.php b/src/Database/Concerns/HasDomains.php index ae3aed42..1c185a27 100644 --- a/src/Database/Concerns/HasDomains.php +++ b/src/Database/Concerns/HasDomains.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Database\Eloquent\Relations\HasMany; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Tenancy; @@ -14,7 +15,10 @@ use Stancl\Tenancy\Tenancy; */ trait HasDomains { - public function domains() + /** + * @return HasMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Domain, $this> + */ + public function domains(): HasMany { return $this->hasMany(config('tenancy.models.domain'), Tenancy::tenantKeyColumn()); } diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index 272b7bd7..7799e9ba 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -105,6 +105,9 @@ trait ResourceSyncing return true; } + /** + * @return BelongsToMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Database\Contracts\TenantWithDatabase, $this> + */ public function tenants(): BelongsToMany { return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', $this->getGlobalIdentifierKeyName()) From 37b2a91aa9f08ba097b8ac3c00bab5284c4ab9c0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 14 Jan 2026 11:18:15 +0100 Subject: [PATCH 26/31] [4.x] Fix URL override example in TenancyServiceProvider stub (#1426) This PR fixes the URL override example in TenancyServiceProvider stub (the commented `overrideUrlInTenantContext()` segment). If the tenant doesn't have any domain, set the root URL back to the original one. --- assets/TenancyServiceProvider.stub.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 603e44e7..1cb358de 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -167,6 +167,10 @@ class TenancyServiceProvider extends ServiceProvider // ? $tenant->domain // : $tenant->domains->first()->domain; // + // if (is_null($tenantDomain)) { + // return $originalRootUrl; + // } + // // $scheme = str($originalRootUrl)->before('://'); // // if (str_contains($tenantDomain, '.')) { From 16861d25998d9b281a9a8901e3bb41d119595a20 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 9 Mar 2026 02:07:02 +0100 Subject: [PATCH 27/31] [4.x] Make `URL::temporarySignedRoute()` respect the bypass parameter (#1438) Using `URL::temporarySignedRoute()` in tenant context with `UrlGeneratorBootstrapper` enabled doesn't work the same as `route()`. The bypass parameter doesn't actually bypass the route name prefixing. `route()` is called in the `parent::temporarySignedRoute()` call, and because the bypass parameter is removed before calling `parent::temporarySignedRoute()`, the underlying `route()` call doesn't get the bypass parameter and it ends up attempting to generate URL for a route with the name prefixed with 'tenant.'. This PR adds the bypass parameter back after `prepareRouteInputs()`, so that `parent::temporarySignedRoute()` receives it, and the underlying `route()` call respects it. Also added basic tests for the `URL::temporarySignedRoute()` behavior (the new bypass parameter test works as a regression test). --- src/Overrides/TenancyUrlGenerator.php | 10 ++++- .../UrlGeneratorBootstrapperTest.php | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 88ae54f3..f7ed9a84 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -129,7 +129,15 @@ class TenancyUrlGenerator extends UrlGenerator throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type + $wrappedParameters = Arr::wrap($parameters); + + [$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type + + if (isset($wrappedParameters[static::$bypassParameter])) { + // If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it, + // so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well. + $parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter]; + } return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute); } diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 647422da..f089207a 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -2,6 +2,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Routing\UrlGenerator; +use Illuminate\Support\Facades\URL; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; @@ -25,12 +26,16 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false; + TenancyUrlGenerator::$overrides = []; + TenancyUrlGenerator::$bypassParameter = 'central'; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; }); afterEach(function () { TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false; + TenancyUrlGenerator::$overrides = []; + TenancyUrlGenerator::$bypassParameter = 'central'; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; }); @@ -359,3 +364,40 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl) ->not()->toContain('bypassParameter'); }); + +test('the temporarySignedRoute method can automatically prefix the passed route name', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/{tenant}/foo', fn () => 'foo')->name('tenant.foo')->middleware([InitializeTenancyByPath::class]); + + TenancyUrlGenerator::$prefixRouteNames = true; + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Route name ('foo') gets prefixed automatically (will be 'tenant.foo') + $tenantSignedUrl = URL::temporarySignedRoute('foo', now()->addMinutes(2), ['tenant' => $tenantKey = $tenant->getTenantKey()]); + + expect($tenantSignedUrl)->toContain("localhost/{$tenantKey}/foo"); +}); + +test('the bypass parameter works correctly with temporarySignedRoute', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/foo', fn () => 'foo')->name('central.foo'); + + TenancyUrlGenerator::$prefixRouteNames = true; + TenancyUrlGenerator::$bypassParameter = 'central'; + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Bypass parameter allows us to generate URL for the 'central.foo' route in tenant context + $centralSignedUrl = URL::temporarySignedRoute('central.foo', now()->addMinutes(2), ['central' => true]); + + expect($centralSignedUrl) + ->toContain('localhost/foo') + ->not()->toContain('central='); // Bypass parameter gets removed from the generated URL +}); From 8f3ea6297f3b24e972a7598c01f202064edfb2f7 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 9 Mar 2026 02:11:07 +0100 Subject: [PATCH 28/31] phpstan: change InputOption syntax --- src/Commands/TenantDump.php | 2 +- src/Concerns/HasTenantOptions.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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()); } } diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 5beb3268..c1ea221f 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -17,8 +17,8 @@ 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], - ['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('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 c4960b76cb9978aa3601d61ab5ec491d2766b1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 18 Mar 2026 19:17:28 +0100 Subject: [PATCH 29/31] [4.x] Laravel 13 support (#1443) - Update ci.yml and composer.json - Wrap single database tenancy trait scopes in whenBooted() - Update SessionSeparationTest to use laravel-cache- prefix in L13 and laravel_cache_ in <=L12. Our own prefix remains tenant_%tenant%_ (as configured in tenancy.cache.prefix). We could update this to be tenant-%tenant%- from now on for consistency with Laravel's prefixes (changed in https://github.com/laravel/framework/pull/56172) but I'm not sure yet. _ seems to read a bit better but perhaps consistency is more important. We may change this later and it can be adjusted in userland easily (since it's just a config option). --- .github/workflows/ci.yml | 1 + composer.json | 10 ++--- .../Concerns/BelongsToPrimaryModel.php | 11 ++++++ src/Database/Concerns/BelongsToTenant.php | 11 ++++++ tests/SessionSeparationTest.php | 37 ++++++++++++------- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca7c20f6..48b1ffd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: matrix: include: - laravel: "^12.0" + - laravel: "^13.0" steps: - name: Checkout diff --git a/composer.json b/composer.json index b03e1b2f..4f843504 100644 --- a/composer.json +++ b/composer.json @@ -18,17 +18,17 @@ "require": { "php": "^8.4", "ext-json": "*", - "illuminate/support": "^12.0", - "laravel/tinker": "^2.0", + "illuminate/support": "^12.0|^13.0", + "laravel/tinker": "^2.0|^3.0", "ramsey/uuid": "^4.7.3", - "stancl/jobpipeline": "2.0.0-rc6", + "stancl/jobpipeline": "2.0.0-rc7", "stancl/virtualcolumn": "^1.5.0", "spatie/invade": "*", "laravel/prompts": "0.*" }, "require-dev": { - "laravel/framework": "^12.0", - "orchestra/testbench": "^10.0", + "laravel/framework": "^13.0", + "orchestra/testbench": "^10.0|^11.0", "league/flysystem-aws-s3-v3": "^3.12.2", "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", diff --git a/src/Database/Concerns/BelongsToPrimaryModel.php b/src/Database/Concerns/BelongsToPrimaryModel.php index 2c8c435f..ca3ba66f 100644 --- a/src/Database/Concerns/BelongsToPrimaryModel.php +++ b/src/Database/Concerns/BelongsToPrimaryModel.php @@ -12,6 +12,17 @@ trait BelongsToPrimaryModel abstract public function getRelationshipToPrimaryModel(): string; public static function bootBelongsToPrimaryModel(): void + { + if (method_exists(static::class, 'whenBooted')) { + // Laravel 13 + // For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92 + static::whenBooted(fn () => static::configureBelongsToPrimaryModelScope()); + } else { + static::configureBelongsToPrimaryModelScope(); + } + } + + protected static function configureBelongsToPrimaryModelScope() { $implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS; diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index da5dc84a..5c0f50fb 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -26,6 +26,17 @@ trait BelongsToTenant } public static function bootBelongsToTenant(): void + { + if (method_exists(static::class, 'whenBooted')) { + // Laravel 13 + // For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92 + static::whenBooted(fn () => static::configureBelongsToTenantScope()); + } else { + static::configureBelongsToTenantScope(); + } + } + + protected static function configureBelongsToTenantScope(): void { // If TraitRLSManager::$implicitRLS is true or this model implements RLSModel // Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy. diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index d699bc61..6c7a8aa1 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Tests\Etc\Tenant; + use function Stancl\Tenancy\Tests\pest; // todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup @@ -100,7 +101,7 @@ test('redis sessions are separated using the redis bootstrapper', function (bool expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled); expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { - return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_"); + return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_")); }))->toHaveCount($bootstrappedEnabled ? 1 : 0); })->with([true, false]); @@ -118,13 +119,13 @@ test('redis sessions are separated using the cache bootstrapper', function (bool Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { - return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -148,14 +149,14 @@ test('memcached sessions are separated using the cache bootstrapper', function ( Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); Artisan::call('cache:clear memcached'); @@ -177,13 +178,13 @@ test('dynamodb sessions are separated using the cache bootstrapper', function (b Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -202,13 +203,13 @@ test('apc sessions are separated using the cache bootstrapper', function (bool $ Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -250,3 +251,13 @@ test('database sessions are separated regardless of whether the session bootstra // [false, true], // when the connection IS set, the session bootstrapper becomes necessary [false, false], ]); + +function formatLaravelCacheKey(string $suffix = '', string $prefix = ''): string +{ + // todo@release if we drop Laravel 12 support we can just switch to - syntax everywhere + if (version_compare(app()->version(), '13.0.0') >= 0) { + return $prefix . 'laravel-cache-' . $suffix; + } else { + return $prefix . 'laravel_cache_' . $suffix; + } +} From fb654e7a6bb3a6163ffd2362d7f9f1b770e559b7 Mon Sep 17 00:00:00 2001 From: Samuel Mwangi Date: Mon, 30 Mar 2026 10:44:53 +0300 Subject: [PATCH 30/31] [4.x] Update Pest to v4 (#1430) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4f843504..180cbaab 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "league/flysystem-aws-s3-v3": "^3.12.2", "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", - "pestphp/pest": "^3.0", + "pestphp/pest": "^4.0", "larastan/larastan": "^3.0", "league/flysystem-path-prefixing": "^3.0" }, From 60dd5226c44adeb3e005f137f41bd67adf2a7d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 8 Apr 2026 19:21:43 +0200 Subject: [PATCH 31/31] [4.x] Add Tenancy::reinitialize() method (#1449) Some bootstrappers read attributes of the tenant during bootstrap() but don't respond to changes made to the tenant afterwards. Therefore, when making changes to the tenant that'd affect the behavior of a bootstrapper, it's necessary to reinitialize tenancy (if it matters that changes are reflected immediately). This adds a convenience helper for that purpose. --- src/Tenancy.php | 20 +++++++++++++++++++ tests/AutomaticModeTest.php | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/Tenancy.php b/src/Tenancy.php index f9c9c9ae..a2271bed 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -152,6 +152,26 @@ class Tenancy $this->initialized = false; } + /** + * End tenancy and initialize it again for the current tenant. + * + * This can be helpful when changing "dependencies" of bootstrappers such as + * attributes of the current tenant that are only read once, during bootstrap(). + * + * If tenancy is not initialized, this method is a no-op. + */ + public function reinitialize(): void + { + if ($this->tenant === null) { + return; + } + + $tenant = $this->tenant; + $this->end(); + + $this->initialize($tenant); + } + /** @return TenancyBootstrapper[] */ public function getBootstrappers(): array { diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index fbeb06fc..599d14d9 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -103,6 +103,33 @@ test('central helper doesnt change tenancy state when called in central context' expect(tenant())->toBeNull(); }); +test('reinitialize method does nothing in the central context', function () { + expect(tenancy()->initialized)->toBe(false); + expect(fn () => tenancy()->reinitialize())->not()->toThrow(\Throwable::class); + expect(tenancy()->initialized)->toBe(false); +}); + +test('reinitialize method runs bootstrappers again for the current tenant', function () { + config(['tenancy.bootstrappers' => [ + ReinitBootstrapper::class, + ]]); + + tenancy()->initialize($tenant = Tenant::create(['reinit_bootstrapper_key' => 'foo'])); + + expect(tenant()->getKey())->toBe($tenant->getKey()); + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo'); + + $tenant->update(['reinit_bootstrapper_key' => 'bar']); + + // Unchanged until we reinitialize... + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo'); + + tenancy()->reinitialize(); + + expect(tenant()->getKey())->toBe($tenant->getKey()); + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('bar'); +}); + class MyBootstrapper implements TenancyBootstrapper { public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void @@ -115,3 +142,16 @@ class MyBootstrapper implements TenancyBootstrapper app()->instance('tenancy_ended', true); } } + +class ReinitBootstrapper implements TenancyBootstrapper +{ + public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void + { + app()->instance('tenancy_reinit_bootstrapper_key', $tenant->getAttribute('reinit_bootstrapper_key')); + } + + public function revert(): void + { + app()->instance('tenancy_reinit_bootstrapper_key', null); + } +}