mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 17:24:03 +00:00
Resolver refactor, path identification improvements (#41)
* resolver refactor * Fix code style (php-cs-fixer) * make tenant column used in PathTenantResolver configurable, fix phpstan errors, minor improvements * support binding route fields, write tests for customizable tenant columns * Invalidate cache for all possible columns in path resolver * implement proper cache separation logic for different columns used by PathTenantResolver * improve return type --------- Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
parent
dc430666ba
commit
0c11f29c19
17 changed files with 370 additions and 88 deletions
|
|
@ -35,14 +35,13 @@ class TenancyServiceProvider extends ServiceProvider
|
||||||
Jobs\CreateDatabase::class,
|
Jobs\CreateDatabase::class,
|
||||||
Jobs\MigrateDatabase::class,
|
Jobs\MigrateDatabase::class,
|
||||||
// Jobs\SeedDatabase::class,
|
// Jobs\SeedDatabase::class,
|
||||||
|
|
||||||
// Jobs\CreateStorageSymlinks::class,
|
// Jobs\CreateStorageSymlinks::class,
|
||||||
|
|
||||||
// Your own jobs to prepare the tenant.
|
// Your own jobs to prepare the tenant.
|
||||||
// Provision API keys, create S3 buckets, anything you want!
|
// Provision API keys, create S3 buckets, anything you want!
|
||||||
])->send(function (Events\TenantCreated $event) {
|
])->send(function (Events\TenantCreated $event) {
|
||||||
return $event->tenant;
|
return $event->tenant;
|
||||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
})->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production.
|
||||||
|
|
||||||
// Listeners\CreateTenantStorage::class,
|
// Listeners\CreateTenantStorage::class,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ return [
|
||||||
*
|
*
|
||||||
* If you use multiple forms of identification, you can set this to the "main" approach you use.
|
* If you use multiple forms of identification, you can set this to the "main" approach you use.
|
||||||
*/
|
*/
|
||||||
'default_middleware' => Middleware\InitializeTenancyByDomain::class,// todo@identification add this to a 'tenancy' mw group
|
'default_middleware' => Middleware\InitializeTenancyByDomain::class,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All of the identification middleware used by the package.
|
* All of the identification middleware used by the package.
|
||||||
|
|
@ -100,19 +100,21 @@ return [
|
||||||
Resolvers\DomainTenantResolver::class => [
|
Resolvers\DomainTenantResolver::class => [
|
||||||
'cache' => false,
|
'cache' => false,
|
||||||
'cache_ttl' => 3600, // seconds
|
'cache_ttl' => 3600, // seconds
|
||||||
'cache_store' => null, // default
|
'cache_store' => null, // null = default
|
||||||
],
|
],
|
||||||
Resolvers\PathTenantResolver::class => [
|
Resolvers\PathTenantResolver::class => [
|
||||||
'tenant_parameter_name' => 'tenant',
|
'tenant_parameter_name' => 'tenant',
|
||||||
|
'tenant_model_column' => null, // null = tenant key
|
||||||
|
'allowed_extra_model_columns' => [], // used with binding route fields
|
||||||
|
|
||||||
'cache' => false,
|
'cache' => false,
|
||||||
'cache_ttl' => 3600, // seconds
|
'cache_ttl' => 3600, // seconds
|
||||||
'cache_store' => null, // default
|
'cache_store' => null, // null = default
|
||||||
],
|
],
|
||||||
Resolvers\RequestDataTenantResolver::class => [
|
Resolvers\RequestDataTenantResolver::class => [
|
||||||
'cache' => false,
|
'cache' => false,
|
||||||
'cache_ttl' => 3600, // seconds
|
'cache_ttl' => 3600, // seconds
|
||||||
'cache_store' => null, // default
|
'cache_store' => null, // null = default
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -150,25 +152,6 @@ return [
|
||||||
// Stancl\Tenancy\Bootstrappers\Integrations\ScoutTenancyBootstrapper::class,
|
// Stancl\Tenancy\Bootstrappers\Integrations\ScoutTenancyBootstrapper::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pending tenants config.
|
|
||||||
* This is useful if you're looking for a way to always have a tenant ready to be used.
|
|
||||||
*/
|
|
||||||
'pending' => [
|
|
||||||
/**
|
|
||||||
* If disabled, pending tenants will be excluded from all tenant queries.
|
|
||||||
* You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting.
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
'count' => env('TENANCY_PENDING_COUNT', 5),
|
|
||||||
],
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
|
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
|
||||||
*/
|
*/
|
||||||
|
|
@ -348,6 +331,24 @@ return [
|
||||||
*/
|
*/
|
||||||
'default_route_mode' => RouteMode::CENTRAL,
|
'default_route_mode' => RouteMode::CENTRAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending tenants config.
|
||||||
|
* This is useful if you're looking for a way to always have a tenant ready to be used.
|
||||||
|
*/
|
||||||
|
'pending' => [
|
||||||
|
/**
|
||||||
|
* If disabled, pending tenants will be excluded from all tenant queries.
|
||||||
|
* You can still use ::withPending(), ::withoutPending() and ::onlyPending() to include or exclude the pending tenants regardless of this setting.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
'count' => env('TENANCY_PENDING_COUNT', 5),
|
||||||
|
],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters used by the tenants:migrate command.
|
* Parameters used by the tenants:migrate command.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Database\Concerns;
|
namespace Stancl\Tenancy\Database\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
|
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
|
||||||
use Stancl\Tenancy\Tenancy;
|
use Stancl\Tenancy\Tenancy;
|
||||||
|
|
@ -12,7 +13,7 @@ trait InvalidatesResolverCache
|
||||||
{
|
{
|
||||||
public static function bootInvalidatesResolverCache(): void
|
public static function bootInvalidatesResolverCache(): void
|
||||||
{
|
{
|
||||||
static::saved(function (Tenant $tenant) {
|
static::saved(function (Tenant&Model $tenant) {
|
||||||
foreach (Tenancy::cachedResolvers() as $resolver) {
|
foreach (Tenancy::cachedResolvers() as $resolver) {
|
||||||
/** @var CachedTenantResolver $resolver */
|
/** @var CachedTenantResolver $resolver */
|
||||||
$resolver = app($resolver);
|
$resolver = app($resolver);
|
||||||
|
|
|
||||||
18
src/Exceptions/TenantColumnNotWhitelistedException.php
Normal file
18
src/Exceptions/TenantColumnNotWhitelistedException.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Exceptions;
|
||||||
|
|
||||||
|
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||||
|
|
||||||
|
class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedException
|
||||||
|
{
|
||||||
|
public function __construct(int|string $tenant_id)
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)")
|
||||||
|
->title('Tenant could not be identified on this route because the used column is not whitelisted.')
|
||||||
|
->description('Please add the column to the list of allowed columns in the PathTenantResolver config.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,8 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified
|
||||||
public function __construct(int|string $tenant_id)
|
public function __construct(int|string $tenant_id)
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->tenantCouldNotBeIdentified("by tenant id: $tenant_id")
|
->tenantCouldNotBeIdentified("by tenant key: $tenant_id")
|
||||||
->title('Tenant could not be identified with that ID')
|
->title('Tenant could not be identified with that key')
|
||||||
->description('Are you sure the ID is correct and the tenant exists?');
|
->description('Are you sure the key is correct and the tenant exists?');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi
|
||||||
public function __construct(int|string $tenant_id)
|
public function __construct(int|string $tenant_id)
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->tenantCouldNotBeIdentified("on path with tenant id: $tenant_id")
|
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id")
|
||||||
->title('Tenant could not be identified on this path')
|
->title('Tenant could not be identified on this path')
|
||||||
->description('Did you forget to create a tenant for this path?');
|
->description('Did you forget to create a tenant for this path?');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class InitializeTenancyByPath extends IdentificationMiddleware implements Usable
|
||||||
|
|
||||||
// Only initialize tenancy if the route has the tenant parameter.
|
// Only initialize tenancy if the route has the tenant parameter.
|
||||||
// We don't want to initialize tenancy if the tenant is
|
// We don't want to initialize tenancy if the tenant is
|
||||||
// simply injected into some route controller action.
|
// simply injected into some central route action.
|
||||||
if (in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames())) {
|
if (in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames())) {
|
||||||
return $this->initializeTenancy(
|
return $this->initializeTenancy(
|
||||||
$request,
|
$request,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Closure;
|
||||||
use Illuminate\Routing\Route;
|
use Illuminate\Routing\Route;
|
||||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||||
|
|
||||||
|
// todo a lot of duplicate logic with PathTenantResolver, ideally remove this class
|
||||||
class PathIdentificationManager
|
class PathIdentificationManager
|
||||||
{
|
{
|
||||||
public static Closure|null $tenantParameterName = null;
|
public static Closure|null $tenantParameterName = null;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ namespace Stancl\Tenancy\Resolvers\Contracts;
|
||||||
|
|
||||||
use Illuminate\Contracts\Cache\Factory;
|
use Illuminate\Contracts\Cache\Factory;
|
||||||
use Illuminate\Contracts\Cache\Repository;
|
use Illuminate\Contracts\Cache\Repository;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
|
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||||
use Stancl\Tenancy\Contracts\TenantResolver;
|
use Stancl\Tenancy\Contracts\TenantResolver;
|
||||||
|
|
||||||
abstract class CachedTenantResolver implements TenantResolver
|
abstract class CachedTenantResolver implements TenantResolver
|
||||||
|
|
@ -19,13 +21,21 @@ abstract class CachedTenantResolver implements TenantResolver
|
||||||
$this->cache = $cache->store(static::cacheStore());
|
$this->cache = $cache->store(static::cacheStore());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a tenant using some value passed from the middleware.
|
||||||
|
*
|
||||||
|
* @throws TenantCouldNotBeIdentifiedException
|
||||||
|
*/
|
||||||
public function resolve(mixed ...$args): Tenant
|
public function resolve(mixed ...$args): Tenant
|
||||||
{
|
{
|
||||||
if (! static::shouldCache()) {
|
if (! static::shouldCache()) {
|
||||||
return $this->resolveWithoutCache(...$args);
|
$tenant = $this->resolveWithoutCache(...$args);
|
||||||
|
$this->resolved($tenant, ...$args);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = $this->getCacheKey(...$args);
|
$key = $this->formatCacheKey(...$args);
|
||||||
|
|
||||||
if ($tenant = $this->cache->get($key)) {
|
if ($tenant = $this->cache->get($key)) {
|
||||||
$this->resolved($tenant, ...$args);
|
$this->resolved($tenant, ...$args);
|
||||||
|
|
@ -35,44 +45,51 @@ abstract class CachedTenantResolver implements TenantResolver
|
||||||
|
|
||||||
$tenant = $this->resolveWithoutCache(...$args);
|
$tenant = $this->resolveWithoutCache(...$args);
|
||||||
$this->cache->put($key, $tenant, static::cacheTTL());
|
$this->cache->put($key, $tenant, static::cacheTTL());
|
||||||
|
$this->resolved($tenant, ...$args);
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function invalidateCache(Tenant $tenant): void
|
/**
|
||||||
|
* Invalidate this resolver's cache for a tenant.
|
||||||
|
*/
|
||||||
|
public function invalidateCache(Tenant&Model $tenant): void
|
||||||
{
|
{
|
||||||
if (! static::shouldCache()) {
|
if (! static::shouldCache()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->getArgsForTenant($tenant) as $args) {
|
foreach ($this->getPossibleCacheKeys($tenant) as $key) {
|
||||||
$this->cache->forget($this->getCacheKey(...$args));
|
$this->cache->forget($key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCacheKey(mixed ...$args): string
|
public function formatCacheKey(mixed ...$args): string
|
||||||
{
|
{
|
||||||
return '_tenancy_resolver:' . static::class . ':' . json_encode($args);
|
return '_tenancy_resolver:' . static::class . ':' . json_encode($args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a tenant using $args passed from middleware, without using cache.
|
||||||
|
*
|
||||||
|
* @throws TenantCouldNotBeIdentifiedException
|
||||||
|
*/
|
||||||
abstract public function resolveWithoutCache(mixed ...$args): Tenant;
|
abstract public function resolveWithoutCache(mixed ...$args): Tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a tenant has been resolved from cache or without cache.
|
||||||
|
*
|
||||||
|
* Used for side effects like removing the tenant parameter from the request route.
|
||||||
|
*/
|
||||||
public function resolved(Tenant $tenant, mixed ...$args): void
|
public function resolved(Tenant $tenant, mixed ...$args): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
abstract public function getPossibleCacheKeys(Tenant&Model $tenant): array;
|
||||||
* Get all possible argument combinations for resolve() which can be used for caching the tenant.
|
|
||||||
*
|
|
||||||
* This is used during tenant cache invalidation.
|
|
||||||
*
|
|
||||||
* @return array[]
|
|
||||||
*/
|
|
||||||
abstract public function getArgsForTenant(Tenant $tenant): array;
|
|
||||||
|
|
||||||
public static function shouldCache(): bool
|
public static function shouldCache(): bool
|
||||||
{
|
{
|
||||||
return config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false;
|
return (config('tenancy.identification.resolvers.' . static::class . '.cache') ?? false) && static::cacheTTL() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function cacheTTL(): int
|
public static function cacheTTL(): int
|
||||||
|
|
|
||||||
|
|
@ -56,21 +56,22 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getArgsForTenant(Tenant $tenant): array
|
public function getPossibleCacheKeys(Tenant&Model $tenant): array
|
||||||
{
|
{
|
||||||
if ($tenant instanceof SingleDomainTenant) {
|
if ($tenant instanceof SingleDomainTenant) {
|
||||||
/** @var SingleDomainTenant&Model $tenant */
|
$domains = array_filter([
|
||||||
return [
|
$tenant->getOriginal('domain'), // Previous domain
|
||||||
[$tenant->getOriginal('domain')], // Previous domain
|
$tenant->domain, // Current domain
|
||||||
[$tenant->domain], // Current domain
|
]);
|
||||||
];
|
} else {
|
||||||
|
/** @var Tenant&Model $tenant */
|
||||||
|
$tenant->unsetRelation('domains');
|
||||||
|
|
||||||
|
$domains = $tenant->domains->map(function (Domain&Model $domain) {
|
||||||
|
return $domain->domain;
|
||||||
|
})->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Tenant&Model $tenant */
|
return array_map(fn (string $domain) => $this->formatCacheKey($domain), $domains);
|
||||||
$tenant->unsetRelation('domains');
|
|
||||||
|
|
||||||
return $tenant->domains->map(function (Domain&Model $domain) {
|
|
||||||
return [$domain->domain];
|
|
||||||
})->toArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Resolvers;
|
namespace Stancl\Tenancy\Resolvers;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Routing\Route;
|
use Illuminate\Routing\Route;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
|
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
|
||||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
|
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
|
||||||
use Stancl\Tenancy\PathIdentificationManager;
|
|
||||||
|
|
||||||
class PathTenantResolver extends Contracts\CachedTenantResolver
|
class PathTenantResolver extends Contracts\CachedTenantResolver
|
||||||
{
|
{
|
||||||
|
|
@ -16,26 +17,30 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
|
||||||
/** @var Route $route */
|
/** @var Route $route */
|
||||||
$route = $args[0];
|
$route = $args[0];
|
||||||
|
|
||||||
/** @var string $id */
|
/** @var string $key */
|
||||||
$id = $route->parameter(static::tenantParameterName());
|
$key = $route->parameter(static::tenantParameterName());
|
||||||
|
$column = $route->bindingFieldFor(static::tenantParameterName()) ?? static::tenantModelColumn();
|
||||||
|
|
||||||
if ($id) {
|
if ($column !== static::tenantModelColumn() && ! in_array($column, static::allowedExtraModelColumns())) {
|
||||||
// Forget the tenant parameter so that we don't have to accept it in route action methods
|
throw new TenantColumnNotWhitelistedException($key);
|
||||||
$route->forgetParameter(static::tenantParameterName());
|
}
|
||||||
|
|
||||||
if ($tenant = tenancy()->find($id)) {
|
if ($key) {
|
||||||
|
if ($tenant = tenancy()->find($key, $column)) {
|
||||||
|
/** @var Tenant $tenant */
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TenantCouldNotBeIdentifiedByPathException($id);
|
throw new TenantCouldNotBeIdentifiedByPathException($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getArgsForTenant(Tenant $tenant): array
|
public function getPossibleCacheKeys(Tenant&Model $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
$columns = array_unique(array_merge(static::allowedExtraModelColumns(), [static::tenantModelColumn()]));
|
||||||
[$tenant->getTenantKey()],
|
$columnValuePairs = array_map(fn ($column) => [$column, $tenant->getAttribute($column)], $columns);
|
||||||
];
|
|
||||||
|
return array_map(fn ($columnValuePair) => $this->formatCacheKey(...$columnValuePair), $columnValuePairs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolved(Tenant $tenant, mixed ...$args): void
|
public function resolved(Tenant $tenant, mixed ...$args): void
|
||||||
|
|
@ -43,21 +48,22 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
|
||||||
/** @var Route $route */
|
/** @var Route $route */
|
||||||
$route = $args[0];
|
$route = $args[0];
|
||||||
|
|
||||||
$route->forgetParameter(PathIdentificationManager::getTenantParameterName());
|
// Forget the tenant parameter so that we don't have to accept it in route action methods
|
||||||
|
$route->forgetParameter(static::tenantParameterName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCacheKey(mixed ...$args): string
|
public function formatCacheKey(mixed ...$args): string
|
||||||
{
|
{
|
||||||
// todo@samuel fix the coupling here. when this is called from the cachedresolver, $args are the tenant key. when it's called from within this class, $args are a Route instance
|
// When called in resolve(), $args contains the route
|
||||||
// the logic shouldn't have to be coupled to where it's being called from
|
// When called in getPossibleCacheKeys(), $args contains the column-value pair
|
||||||
|
if ($args[0] instanceof Route) {
|
||||||
|
$column = $args[0]->bindingFieldFor(static::tenantParameterName()) ?? static::tenantModelColumn();
|
||||||
|
$value = $args[0]->parameter(static::tenantParameterName());
|
||||||
|
} else {
|
||||||
|
[$column, $value] = $args;
|
||||||
|
}
|
||||||
|
|
||||||
// todo@samuel also make the tenant column configurable
|
return parent::formatCacheKey($column, $value);
|
||||||
|
|
||||||
// $args[0] can be either a Route instance with the tenant key as a parameter
|
|
||||||
// Or the tenant key
|
|
||||||
$args = [$args[0] instanceof Route ? $args[0]->parameter(static::tenantParameterName()) : $args[0]];
|
|
||||||
|
|
||||||
return '_tenancy_resolver:' . static::class . ':' . json_encode($args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tenantParameterName(): string
|
public static function tenantParameterName(): string
|
||||||
|
|
@ -69,4 +75,15 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
|
||||||
{
|
{
|
||||||
return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.';
|
return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function tenantModelColumn(): string
|
||||||
|
{
|
||||||
|
return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
public static function allowedExtraModelColumns(): array
|
||||||
|
{
|
||||||
|
return config('tenancy.identification.resolvers.' . static::class . '.allowed_extra_model_columns') ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Resolvers;
|
namespace Stancl\Tenancy\Resolvers;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
|
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
|
||||||
|
|
||||||
|
|
@ -26,10 +27,10 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
|
||||||
throw new TenantCouldNotBeIdentifiedByRequestDataException($payload);
|
throw new TenantCouldNotBeIdentifiedByRequestDataException($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getArgsForTenant(Tenant $tenant): array
|
public function getPossibleCacheKeys(Tenant&Model $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
[$tenant->getTenantKey()],
|
$this->formatCacheKey($tenant->getTenantKey()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,10 +137,10 @@ class Tenancy
|
||||||
/**
|
/**
|
||||||
* Try to find a tenant using an ID.
|
* Try to find a tenant using an ID.
|
||||||
*/
|
*/
|
||||||
public static function find(int|string $id): Tenant|null
|
public static function find(int|string $id, string $column = null): (Tenant&Model)|null
|
||||||
{
|
{
|
||||||
/** @var (Tenant&Model)|null */
|
/** @var (Tenant&Model)|null $tenant */
|
||||||
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first();
|
$tenant = static::model()->firstWhere($column ?? static::model()->getTenantKeyName(), $id);
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Routing\Route;
|
use Illuminate\Routing\Route;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||||
use Stancl\Tenancy\PathIdentificationManager;
|
use Stancl\Tenancy\PathIdentificationManager;
|
||||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||||
|
|
@ -74,7 +77,7 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
|
||||||
|
|
||||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||||
|
|
||||||
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrievevd from the DB
|
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
|
||||||
})->with([
|
})->with([
|
||||||
DomainTenantResolver::class,
|
DomainTenantResolver::class,
|
||||||
PathTenantResolver::class,
|
PathTenantResolver::class,
|
||||||
|
|
@ -109,6 +112,7 @@ test('cache is invalidated when a tenants domain is changed', function () {
|
||||||
|
|
||||||
test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
|
test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
|
||||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
Tenant::create(['id' => 'foo']);
|
Tenant::create(['id' => 'foo']);
|
||||||
|
|
||||||
|
|
@ -127,6 +131,136 @@ test('PathTenantResolver forgets the tenant route parameter when the tenant is r
|
||||||
pest()->assertEmpty(DB::getQueryLog()); // resolved from cache
|
pest()->assertEmpty(DB::getQueryLog()); // resolved from cache
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PathTenantResolver properly separates cache for each tenant column', function () {
|
||||||
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
|
||||||
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
|
||||||
|
Tenant::$extraCustomColumns = ['slug'];
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
$t1 = Tenant::create(['id' => 'foo', 'slug' => 'bar']);
|
||||||
|
$t2 = Tenant::create(['id' => 'bar', 'slug' => 'foo']);
|
||||||
|
|
||||||
|
RouteFacade::get('x/{tenant}/a', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
RouteFacade::get('x/{tenant:slug}/b', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
DB::flushQueryLog();
|
||||||
|
|
||||||
|
$redisKeys = fn () => array_map(
|
||||||
|
fn (string $key) => str($key)->after('PathTenantResolver:')->toString(),
|
||||||
|
Redis::connection('cache')->keys('*')
|
||||||
|
);
|
||||||
|
|
||||||
|
pest()->get("/x/foo/a")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(1);
|
||||||
|
expect(DB::getRawQueryLog()[0]['raw_query'])->toBe("select * from `tenants` where `id` = 'foo' limit 1");
|
||||||
|
expect($redisKeys())->toEqualCanonicalizing([
|
||||||
|
'["id","foo"]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
pest()->get("/x/bar/b")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(2);
|
||||||
|
expect(DB::getRawQueryLog()[1]['raw_query'])->toBe("select * from `tenants` where `slug` = 'bar' limit 1");
|
||||||
|
expect($redisKeys())->toEqualCanonicalizing([
|
||||||
|
'["id","foo"]',
|
||||||
|
'["slug","bar"]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test if cache hits
|
||||||
|
pest()->get("/x/foo/a")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(2); // unchanged
|
||||||
|
|
||||||
|
pest()->get("/x/bar/b")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(2); // unchanged
|
||||||
|
|
||||||
|
// Make requests for a tenant that has reversed values for the columns
|
||||||
|
pest()->get("/x/bar/a")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(3); // +1
|
||||||
|
expect(DB::getRawQueryLog()[2]['raw_query'])->toBe("select * from `tenants` where `id` = 'bar' limit 1");
|
||||||
|
expect($redisKeys())->toEqualCanonicalizing([
|
||||||
|
'["id","foo"]',
|
||||||
|
'["slug","bar"]',
|
||||||
|
'["id","bar"]', // added
|
||||||
|
]);
|
||||||
|
|
||||||
|
pest()->get("/x/foo/b")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4);
|
||||||
|
expect(DB::getRawQueryLog()[3]['raw_query'])->toBe("select * from `tenants` where `slug` = 'foo' limit 1");
|
||||||
|
expect($redisKeys())->toEqualCanonicalizing([
|
||||||
|
'["id","foo"]',
|
||||||
|
'["slug","bar"]',
|
||||||
|
'["id","bar"]',
|
||||||
|
'["slug","foo"]', // added
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test if cache hits for the tenant with reversed values
|
||||||
|
pest()->get("/x/bar/a")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(4); // unchanged
|
||||||
|
|
||||||
|
pest()->get("/x/foo/b")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(4); // unchanged
|
||||||
|
|
||||||
|
// Try to resolve the previous tenant again, confirming the cache values for the new tenant are properly separated from the previous tenant
|
||||||
|
pest()->get("/x/foo/a")->assertSee('foo');
|
||||||
|
pest()->get("/x/foo/b")->assertSee('bar');
|
||||||
|
pest()->get("/x/bar/a")->assertSee('bar');
|
||||||
|
pest()->get("/x/bar/b")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(4); // unchanged
|
||||||
|
|
||||||
|
$t1->update(['random_value' => 'just to clear cache']);
|
||||||
|
expect($redisKeys())->toEqualCanonicalizing([
|
||||||
|
// '["id","foo"]', // these two have been removed
|
||||||
|
// '["slug","bar"]',
|
||||||
|
'["id","bar"]',
|
||||||
|
'["slug","foo"]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$t2->update(['random_value' => 'just to clear cache']);
|
||||||
|
expect($redisKeys())->toBe([]);
|
||||||
|
|
||||||
|
DB::flushQueryLog();
|
||||||
|
|
||||||
|
// Cache gets repopulated
|
||||||
|
pest()->get("/x/foo/a")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(1);
|
||||||
|
expect(count($redisKeys()))->toBe(1);
|
||||||
|
|
||||||
|
pest()->get("/x/foo/b")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(2);
|
||||||
|
expect(count($redisKeys()))->toBe(2);
|
||||||
|
|
||||||
|
pest()->get("/x/bar/a")->assertSee('bar');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(3);
|
||||||
|
expect(count($redisKeys()))->toBe(3);
|
||||||
|
|
||||||
|
pest()->get("/x/bar/b")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4);
|
||||||
|
expect(count($redisKeys()))->toBe(4);
|
||||||
|
|
||||||
|
// After which, the cache becomes active again
|
||||||
|
pest()->get("/x/foo/a")->assertSee('foo');
|
||||||
|
pest()->get("/x/foo/b")->assertSee('bar');
|
||||||
|
pest()->get("/x/bar/a")->assertSee('bar');
|
||||||
|
pest()->get("/x/bar/b")->assertSee('foo');
|
||||||
|
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
|
||||||
|
expect(count($redisKeys()))->toBe(4); // unchanged
|
||||||
|
|
||||||
|
Tenant::$extraCustomColumns = []; // reset
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the argument for the resolver – tenant key, or a route instance with the tenant parameter.
|
* Return the argument for the resolver – tenant key, or a route instance with the tenant parameter.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,12 @@ use Stancl\Tenancy\Database\Models;
|
||||||
*/
|
*/
|
||||||
class Tenant extends Models\Tenant implements TenantWithDatabase
|
class Tenant extends Models\Tenant implements TenantWithDatabase
|
||||||
{
|
{
|
||||||
|
public static array $extraCustomColumns = [];
|
||||||
|
|
||||||
use HasDatabase, HasDomains, HasPending, MaintenanceMode;
|
use HasDatabase, HasDomains, HasPending, MaintenanceMode;
|
||||||
|
|
||||||
|
public static function getCustomColumns(): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::getCustomColumns(), static::$extraCustomColumns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Contracts\Http\Kernel;
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
|
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
|
||||||
|
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
|
||||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
|
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||||
|
|
@ -15,6 +18,7 @@ beforeEach(function () {
|
||||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'tenant']);
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'tenant']);
|
||||||
|
|
||||||
InitializeTenancyByPath::$onFail = null;
|
InitializeTenancyByPath::$onFail = null;
|
||||||
|
Tenant::$extraCustomColumns = [];
|
||||||
|
|
||||||
Route::group([
|
Route::group([
|
||||||
'prefix' => '/{tenant}',
|
'prefix' => '/{tenant}',
|
||||||
|
|
@ -160,3 +164,84 @@ test('central route can have a parameter with the same name as the tenant parame
|
||||||
|
|
||||||
expect(tenancy()->initialized)->toBeFalse();
|
expect(tenancy()->initialized)->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the tenant model column can be customized in the config', function () {
|
||||||
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']);
|
||||||
|
Tenant::$extraCustomColumns = ['slug'];
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'slug' => 'acme',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::get('/{tenant}/foo', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
|
||||||
|
expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
|
||||||
|
|
||||||
|
Tenant::$extraCustomColumns = []; // static property reset
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tenant model column can be customized in the route definition', function () {
|
||||||
|
Tenant::$extraCustomColumns = ['slug'];
|
||||||
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'slug' => 'acme',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::get('/{tenant}/foo', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
Route::get('/{tenant:slug}/bar', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
|
||||||
|
// No binding field defined
|
||||||
|
pest()->get($tenant->getTenantKey() . '/foo')->assertSee($tenant->getTenantKey());
|
||||||
|
expect(fn () => pest()->get('/acme/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
|
||||||
|
|
||||||
|
// Binding field defined
|
||||||
|
pest()->get('/acme/bar')->assertSee($tenant->getTenantKey());
|
||||||
|
expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
|
||||||
|
|
||||||
|
Tenant::$extraCustomColumns = []; // static property reset
|
||||||
|
});
|
||||||
|
|
||||||
|
test('any extra model column needs to be whitelisted', function () {
|
||||||
|
Tenant::$extraCustomColumns = ['slug'];
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'slug' => 'acme',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::get('/{tenant:slug}/foo', function () {
|
||||||
|
return tenant()->getTenantKey();
|
||||||
|
})->middleware(InitializeTenancyByPath::class);
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
expect(fn () => pest()->get('/acme/foo'))->toThrow(TenantColumnNotWhitelistedException::class);
|
||||||
|
|
||||||
|
// After whitelisting the column it works
|
||||||
|
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
|
||||||
|
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
|
||||||
|
|
||||||
|
Tenant::$extraCustomColumns = []; // static property reset
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ test('cache is invalidated when a single domain tenants domain is updated', func
|
||||||
DB::flushQueryLog();
|
DB::flushQueryLog();
|
||||||
expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue();
|
expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue();
|
||||||
pest()->assertNotEmpty(DB::getQueryLog()); // resolving using current subdomain for the first time
|
pest()->assertNotEmpty(DB::getQueryLog()); // resolving using current subdomain for the first time
|
||||||
|
|
||||||
DB::flushQueryLog();
|
DB::flushQueryLog();
|
||||||
expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue();
|
expect($tenant->is(app(DomainTenantResolver::class)->resolve('baz')))->toBeTrue();
|
||||||
pest()->assertEmpty(DB::getQueryLog()); // resolving using current subdomain for the second time
|
pest()->assertEmpty(DB::getQueryLog()); // resolving using current subdomain for the second time
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue