From fb654e7a6bb3a6163ffd2362d7f9f1b770e559b7 Mon Sep 17 00:00:00 2001 From: Samuel Mwangi Date: Mon, 30 Mar 2026 10:44:53 +0300 Subject: [PATCH 1/5] [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 2/5] [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); + } +} From e31249dd097b8b3b526808c378fbc5db1bc941fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 13 Apr 2026 23:57:59 +0200 Subject: [PATCH 3/5] Prevent mkdir() race conditions in FilesystemTenancyBootstrapper (#1453) This prevents race conditions that may occur if there are two concurrent processes trying to create the storage path for the tenant. The storagePath() method runs during bootstrap() which can easily happen in two places at once. The race condition specifically occurs in between the is_dir() check and the mkdir() call, the latter producing an exception if the dir already exist. We simply ignore any error coming out of mkdir() and then check for success separately. We could omit that success check since failure is unlikely and would only occur due to a server misconfiguration that would manifest itself in other ways as well, but this way the simple TOC/TOU race condition is prevented while other errors are still reported. We apply the same change to the mkdir() in scopeSessions() as the logic is similar. Resolves #1452 --- .../FilesystemTenancyBootstrapper.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index af2b809f..56091600 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; +use Exception; use Illuminate\Foundation\Application; use Illuminate\Session\FileSessionHandler; use Illuminate\Support\Facades\Storage; @@ -75,8 +76,13 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper : $this->originalStoragePath . '/framework/cache'; if (! is_dir($path)) { - // Create tenant framework/cache directory if it does not exist - mkdir($path, 0750, true); + // Create tenant framework/cache directory if it does not exist. + // We ignore errors due to TOCTOU race conditions, instead we check for success below. + @mkdir($path, 0750, true); + + if (! is_dir($path)) { + throw new Exception("Unable to create tenant storage directory [{$path}]."); + } } if ($suffix === false) { @@ -222,8 +228,13 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper : $this->originalStoragePath . '/framework/sessions'; if (! is_dir($path)) { - // Create tenant framework/sessions directory if it does not exist - mkdir($path, 0750, true); + // Create tenant framework/sessions directory if it does not exist. + // We ignore errors due to TOCTOU race conditions, instead we check for success below. + @mkdir($path, 0750, true); + + if (! is_dir($path)) { + throw new Exception("Unable to create tenant session directory [{$path}]."); + } } $this->app['config']['session.files'] = $path; From c32f52ce7cb9e705cbf1f5a5e884e466c8dde319 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Wed, 15 Apr 2026 11:09:22 +0200 Subject: [PATCH 4/5] phpstan fix: Scope generics phpstan started failing with '... implements generic interface Illuminate\Database\Eloquent\Scope but does not specify its types: TModel'. We solve this by adding an implements docblock to the scopes implementing that interface. They're fairly generic - we just use the Model type itself in the code - so we use Model for the type parameter. --- src/Database/Concerns/PendingScope.php | 1 + src/Database/ParentModelScope.php | 1 + src/Database/TenantScope.php | 1 + 3 files changed, 3 insertions(+) diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index 712de6c7..52b8eb19 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; +/** @implements Scope */ class PendingScope implements Scope { /** diff --git a/src/Database/ParentModelScope.php b/src/Database/ParentModelScope.php index ee1a200e..44f4ac12 100644 --- a/src/Database/ParentModelScope.php +++ b/src/Database/ParentModelScope.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; +/** @implements Scope */ class ParentModelScope implements Scope { /** diff --git a/src/Database/TenantScope.php b/src/Database/TenantScope.php index 050da365..94ff4572 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; use Stancl\Tenancy\Tenancy; +/** @implements Scope */ class TenantScope implements Scope { /** From ab2a4d84385b2fd857cf9f8fb9e950f208898d22 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 22 Apr 2026 14:32:53 +0200 Subject: [PATCH 5/5] Fix chaining `withoutPending()` with `where()` (#1457) At the moment, `where()` cannot be used correctly while using `withoutPending()`. For example, if we have a single non-pending tenant in our DB (with ID 'foo'), queries like `Tenant::withoutPending()->where('id', 'nonexistent')->first()`will incorrectly return the non-pending tenant ('foo'). This is because `withoutPending()` does `$builder->whereNull('data->pending_since')->orWhereNull('data')`. These two aren't grouped, so `withoutPending()->where('id', 'nonexistent')` basically translates to "WHERE data->pending_since IS NULL **OR (data IS NULL AND id = 'nonexistent')**". So the query will include all tenants whose `pending_since` is null (= all non-pending tenants). Grouping `->whereNull('data->pending_since')->orWhereNull('data')` in a closure passed to a separate `where()` fixes this issue. --- src/Database/Concerns/PendingScope.php | 6 ++++-- tests/PendingTenantsTest.php | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index 52b8eb19..99a5ef59 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -58,8 +58,10 @@ class PendingScope implements Scope { $builder->macro('withoutPending', function (Builder $builder) { $builder->withoutGlobalScope(static::class) - ->whereNull($builder->getModel()->getColumnForQuery('pending_since')) - ->orWhereNull($builder->getModel()->getDataColumn()); + ->where(function (Builder $query) { + $query->whereNull($query->getModel()->getColumnForQuery('pending_since')) + ->orWhereNull($query->getModel()->getDataColumn()); + }); return $builder; }); diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index a90aceed..433b85fb 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -111,6 +111,18 @@ test('a new tenant gets created while pulling a pending tenant if the pending po expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants }); +test('withoutPending chained with where clauses returns correct results', function () { + $tenant = Tenant::create(); + $pendingTenant = Tenant::createPending(); + + // The query returned the correct tenant + expect(Tenant::withoutPending()->where('id', $tenant->id)->first()->id)->toBe($tenant->id); + // No tenant with this ID exists, the query returns null + expect(Tenant::withoutPending()->where('id', Str::random(8) . 'nonexistent-id')->first())->toBeNull(); + // withoutPending() correctly excludes the pending tenant from the query + expect(Tenant::withoutPending()->where('id', $pendingTenant->id)->first())->toBeNull(); +}); + test('pending tenants are included in all queries based on the include_in_queries config', function () { Tenant::createPending();