1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-03-21 22:04:04 +00:00

[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).
This commit is contained in:
Samuel Štancl 2026-03-18 19:17:28 +01:00 committed by GitHub
parent 8f3ea6297f
commit c4960b76cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 18 deletions

View file

@ -17,6 +17,7 @@ jobs:
matrix: matrix:
include: include:
- laravel: "^12.0" - laravel: "^12.0"
- laravel: "^13.0"
steps: steps:
- name: Checkout - name: Checkout

View file

@ -18,17 +18,17 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^12.0", "illuminate/support": "^12.0|^13.0",
"laravel/tinker": "^2.0", "laravel/tinker": "^2.0|^3.0",
"ramsey/uuid": "^4.7.3", "ramsey/uuid": "^4.7.3",
"stancl/jobpipeline": "2.0.0-rc6", "stancl/jobpipeline": "2.0.0-rc7",
"stancl/virtualcolumn": "^1.5.0", "stancl/virtualcolumn": "^1.5.0",
"spatie/invade": "*", "spatie/invade": "*",
"laravel/prompts": "0.*" "laravel/prompts": "0.*"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^12.0", "laravel/framework": "^13.0",
"orchestra/testbench": "^10.0", "orchestra/testbench": "^10.0|^11.0",
"league/flysystem-aws-s3-v3": "^3.12.2", "league/flysystem-aws-s3-v3": "^3.12.2",
"doctrine/dbal": "^3.6.0", "doctrine/dbal": "^3.6.0",
"spatie/valuestore": "^1.2.5", "spatie/valuestore": "^1.2.5",

View file

@ -12,6 +12,17 @@ trait BelongsToPrimaryModel
abstract public function getRelationshipToPrimaryModel(): string; abstract public function getRelationshipToPrimaryModel(): string;
public static function bootBelongsToPrimaryModel(): void 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; $implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;

View file

@ -26,6 +26,17 @@ trait BelongsToTenant
} }
public static function bootBelongsToTenant(): void 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 // 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. // Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy.

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest; 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 // 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($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled);
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { 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); }))->toHaveCount($bootstrappedEnabled ? 1 : 0);
})->with([true, false]); })->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'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); 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(); 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) { 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); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->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'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); 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(); 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 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) { 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); }))->toHaveCount($scopeSessions ? 1 : 0);
Artisan::call('cache:clear memcached'); 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'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); 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(); 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) { 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); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->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'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); 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(); 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) { 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); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->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, true], // when the connection IS set, the session bootstrapper becomes necessary
[false, false], [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;
}
}