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

Merge branch 'master' into abort-deletion-without-database

This commit is contained in:
Samuel Štancl 2026-04-12 19:28:47 +02:00 committed by GitHub
commit 41e6e7c9c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1723 additions and 485 deletions

View file

@ -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
@ -39,7 +41,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.
*
@ -71,7 +73,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();
* ```
*
@ -84,26 +86,50 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
*/
class CloneRoutesAsTenant
{
/** @var list<Route|string> */
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<string> */
protected array $cloneRoutesWithMiddleware = ['clone'];
/** @var list<string> */
protected array $addTenantMiddleware = ['tenant'];
public function __construct(
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
{
// 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<Route> */
$routesToClone = collect($this->router->getRoutes()->get())
->filter(fn (Route $route) => $this->shouldBeCloned($route))
->all();
$this->routesToClone = $routesToClone;
}
foreach ($this->routesToClone as $route) {
@ -117,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);
@ -142,6 +170,20 @@ 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.
*
* @param list<string> $middleware
*/
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
{
@ -150,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;
@ -158,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<string> $middleware
*/
public function cloneRoutesWithMiddleware(array $middleware): static
{
$this->cloneRoutesWithMiddleware = $middleware;
@ -169,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;
@ -177,6 +229,13 @@ class CloneRoutesAsTenant
return $this;
}
public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static
{
$this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix;
return $this;
}
/** Clone an individual route. */
public function cloneRoute(Route|string $route): static
{
@ -185,6 +244,18 @@ class CloneRoutesAsTenant
return $this;
}
/**
* Clone individual routes.
*
* @param list<Route|string> $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
@ -226,7 +297,13 @@ class CloneRoutesAsTenant
$action->put('middleware', $middleware);
if ($this->addTenantParameter) {
$action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}');
$tenantParameter = '{' . PathTenantResolver::tenantParameterName() . '}';
$newPrefix = $this->tenantParameterBeforePrefix
? $tenantParameter . '/' . $prefix
: $prefix . '/' . $tenantParameter;
$action->put('prefix', $newPrefix);
}
/** @var Route $newRoute */
@ -244,17 +321,15 @@ 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';
return array_unique($processedMiddleware);
return array_unique(array_merge($processedMiddleware, $this->addTenantMiddleware));
}
/** Check if route already has tenant parameter or name prefix. */

View file

@ -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:
* [

View file

@ -99,11 +99,14 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
{
$names = $this->config->get('tenancy.cache.stores');
if (
$this->config->get('tenancy.cache.scope_sessions', true) &&
in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)
) {
$names[] = $this->getSessionCacheStoreName();
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)) {
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();
}
}
$names = array_unique($names);
@ -112,6 +115,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
$store = $this->config->get("cache.stores.{$name}");
if ($store === null || $store['driver'] === 'file') {
// 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper
return false;
}

View file

@ -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;

View file

@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');
}
// Better debugging, but breaks cached lookup in prod
if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149
// Better debugging, but breaks cached lookup, so we disable this in prod
if (app()->environment('local') || app()->environment('testing')) {
$database = $tenant->database()->getName();
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection?
if (! $tenant->database()->manager()->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database);
}
}

View file

@ -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
@ -78,6 +70,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 {
@ -98,22 +99,33 @@ 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' => '']));
}
}
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
@ -211,7 +223,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;

View file

@ -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:
* [

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class TenantConfigBootstrapper implements TenancyBootstrapper
{
public array $originalConfig = [];
/** @var array<string, string|array> */
public static array $storageToConfigMap = [
// 'paypal_api_key' => 'services.paypal.api_key',
];
public function __construct(
protected Repository $config,
) {}
public function bootstrap(Tenant $tenant): void
{
foreach (static::$storageToConfigMap as $storageKey => $configKey) {
/** @var Tenant&Model $tenant */
$override = Arr::get($tenant, $storageKey);
if (! is_null($override)) {
if (is_array($configKey)) {
foreach ($configKey as $key) {
$this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key);
$this->config->set($key, $override);
}
} else {
$this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
}
public function revert(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -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 () {

View file

@ -8,7 +8,7 @@ use Illuminate\Console\Command;
class CreatePendingTenants extends Command
{
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}';
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}';
protected $description = 'Create pending tenants.';

View file

@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command
#[\SensitiveParameter]
string $password,
): DatabaseConfig {
// This is a bit of a hack. We want to use our existing createUser() logic.
// That logic needs a DatabaseConfig instance. However, we aren't really working
// with any specific tenant here. We also *don't* want to use anything tenant-specific
// here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig
// instance for this purpose. The easiest way to do that is to grab an empty Tenant model
// (we use TenantWithDatabase in RLS) and manually create the host connection, just like
// DatabaseConfig::manager() would. We don't call that method since we want to use our existing
// PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager".
/** @var TenantWithDatabase $tenantModel */
$tenantModel = tenancy()->model();
// Use a temporary DatabaseConfig instance to set the host connection
$temporaryDbConfig = $tenantModel->database();
$temporaryDbConfig->purgeHostConnection();
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands;
use Closure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
class Install extends Command
{
@ -128,14 +129,27 @@ class Install extends Command
public function askForSupport(): void
{
if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) {
if (PHP_OS_FAMILY === 'Darwin') {
exec('open https://github.com/archtechx/tenancy');
$ghVersion = Process::run('gh --version');
$starred = false;
// Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool
if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) {
$starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy');
$starred = $starRequest->successful();
}
if (PHP_OS_FAMILY === 'Windows') {
exec('start https://github.com/archtechx/tenancy');
}
if (PHP_OS_FAMILY === 'Linux') {
exec('xdg-open https://github.com/archtechx/tenancy');
if ($starred) {
$this->components->success('Repository starred via gh CLI, thank you!');
} else {
if (PHP_OS_FAMILY === 'Darwin') {
exec('open https://github.com/archtechx/tenancy');
}
if (PHP_OS_FAMILY === 'Windows') {
exec('start https://github.com/archtechx/tenancy');
}
if (PHP_OS_FAMILY === 'Linux') {
exec('xdg-open https://github.com/archtechx/tenancy');
}
}
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Features\UserImpersonation;
/**
* Clears expired impersonation tokens.
*
* Tokens older than UserImpersonation::$ttl are considered expired.
*
* @see Stancl\Tenancy\Features\UserImpersonation
*/
class PurgeImpersonationTokens extends Command
{
protected $signature = 'tenants:purge-impersonation-tokens';
protected $description = 'Clear expired impersonation tokens.';
public function handle(): int
{
$this->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;
}
}

View file

@ -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());
}
}

View file

@ -14,10 +14,9 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Enums\RouteMode;
// todo@refactor move this logic to some dedicated static class?
/**
* @mixin \Stancl\Tenancy\Tenancy
* @internal The public methods in this trait should not be understood to be a public stable API.
*/
trait DealsWithRouteContexts
{

View file

@ -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'],
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());
}

View file

@ -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);

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Tenancy;
/** Additional features, like Telescope tags and tenant redirects. */
interface Feature
{
public function bootstrap(Tenancy $tenancy): void;
public function bootstrap(): void;
}

View file

@ -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;

View file

@ -17,12 +17,26 @@ 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());
}
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.

View file

@ -28,7 +28,8 @@ trait HasDatabase
}
if ($key === $this->internalPrefix() . 'db_connection') {
// Remove DB connection because that's not used here
// Remove DB connection because that's not used for the connection *contents*.
// Instead the code uses getInternal('db_connection').
continue;
}

View file

@ -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());
}

View file

@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
/**
* @property ?Carbon $pending_since
*
@ -50,46 +49,75 @@ trait HasPending
*/
public static function createPending(array $attributes = []): Model&Tenant
{
$tenant = static::create($attributes);
$tenant = null;
event(new CreatingPendingTenant($tenant));
// Update the pending_since value only after the tenant is created so it's
// Not marked as pending until finishing running the migrations, seeders, etc.
$tenant->update([
'pending_since' => now()->timestamp,
]);
try {
$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([
'pending_since' => now()->timestamp,
]);
}
event(new PendingTenantCreated($tenant));
return $tenant;
}
/** Pull a pending tenant. */
public static function pullPending(): Model&Tenant
/**
* Attributes to be set when a pending tenant is initially created.
*
* @param array<string, mixed> $attributes The attributes passed to createPending() (will be merged with the returned array)
* @return array<string, mixed>
*/
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.
*
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPending(array $attributes = []): Model&Tenant
{
/** @var Model&Tenant $pendingTenant */
$pendingTenant = static::pullPendingFromPool(true);
$pendingTenant = static::pullPendingFromPool(true, $attributes);
return $pendingTenant;
}
/** Try to pull a tenant from the pool of pending tenants. */
public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant
/**
* Try to pull a tenant from the pool of pending tenants.
*
* @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned.
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant
{
/** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first();
$tenant = DB::transaction(function () use ($attributes): ?Tenant {
/** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first();
if ($tenant !== null) {
event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
}
return $tenant;
});
if ($tenant === null) {
return $firstOrCreate ? static::create($attributes) : null;
}
event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
// Only triggered if a tenant that was pulled from the pool is returned
event(new PendingTenantPulled($tenant));
return $tenant;

View file

@ -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'));

View file

@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant;
use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
// todo@dbRefactor refactor host connection logic to make customizing the host connection easier
class DatabaseConfig
{
/** The tenant whose database we're dealing with. */
@ -115,7 +114,7 @@ class DatabaseConfig
{
$this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this));
}
@ -137,7 +136,9 @@ class DatabaseConfig
}
if ($template = config('tenancy.database.template_tenant_connection')) {
return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}");
return is_array($template)
? array_merge($this->getCentralConnection(), $template)
: config("database.connections.{$template}");
}
return $this->getCentralConnection();
@ -176,10 +177,10 @@ class DatabaseConfig
$config = $this->tenantConfig;
$templateConnection = $this->getTemplateConnection();
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key
if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We remove the username and password because the user with these credentials is not yet created.
// If you need to provide a username and a password when using a permission controlled database manager,
// consider creating a new connection and use it as `tenancy_db_connection`.
unset($config['username'], $config['password']);
}
@ -191,7 +192,7 @@ class DatabaseConfig
}
/**
* Purge the previous tenant connection before opening it for another tenant.
* Purge the previous host connection before opening it for another tenant.
*/
public function purgeHostConnection(): void
{
@ -199,20 +200,20 @@ class DatabaseConfig
}
/**
* Get the TenantDatabaseManager for this tenant's connection.
* Get the TenantDatabaseManager for this tenant's host connection.
*
* @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
*/
public function manager(): Contracts\TenantDatabaseManager
{
// Laravel caches the previous PDO connection, so we purge it to be able to change the connection details
// Laravel persists the PDO connection, so we purge it to be able to change the connection details
$this->purgeHostConnection();
// Create the tenant host connection config
$tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver"));
$manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver"));
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName);
@ -222,12 +223,11 @@ class DatabaseConfig
}
/**
* todo@name come up with a better name
* Get database manager class from the given connection config's driver.
* Get the TenantDatabaseManager for a given database driver.
*
* @throws DatabaseManagerNotRegisteredException
*/
protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager
protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager
{
$databaseManagers = config('tenancy.database.managers');

View file

@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
{
$database = $databaseConfig->getName();
$username = $databaseConfig->getUsername();
$hostname = $databaseConfig->connection()['host'];
$password = $databaseConfig->getPassword();
$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");

View file

@ -30,10 +30,6 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
$tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'");
// Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
// while the RLS user should STILL get access to those tables
foreach ($tables as $table) {
$tableName = $table->table_name;

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use AssertionError;
use Closure;
use Illuminate\Database\Eloquent\Model;
use PDO;
@ -15,17 +14,12 @@ use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager
{
/**
* SQLite Database path without ending slash.
* SQLite database directory path.
*
* Defaults to database_path().
*/
public static string|null $path = null;
/**
* Should the WAL journal mode be used for newly created databases.
*
* @see https://www.sqlite.org/pragma.html#pragma_journal_mode
*/
public static bool $WAL = true;
/*
* If this isn't null, a connection to the tenant DB will be created
* and passed to the provided closure, for the purpose of keeping the
@ -84,30 +78,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
// or creating a closure holding a reference to it and passing that to register_shutdown_function().
$name = '_tenancy_inmemory_' . $tenant->getTenantKey();
$tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]);
$tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared");
$tenant->save();
return true;
}
try {
if (file_put_contents($path = $this->getPath($name), '') === false) {
return false;
}
if (static::$WAL) {
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// @phpstan-ignore-next-line method.nonObject
assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.');
}
return true;
} catch (AssertionError $e) {
throw $e;
} catch (Throwable) {
return false;
}
return file_put_contents($this->getPath($name), '') !== false;
}
public function deleteDatabase(TenantWithDatabase $tenant): bool
@ -122,8 +99,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return true;
}
$path = $this->getPath($name);
try {
return unlink($this->getPath($name));
unlink($path . '-journal');
unlink($path . '-wal');
unlink($path . '-shm');
} catch (Throwable) {}
try {
return unlink($path);
} catch (Throwable) {
return false;
}
@ -150,15 +135,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return $baseConfig;
}
public function setConnection(string $connection): void
{
//
}
public function getPath(string $name): string
{
if (static::$path) {
return static::$path . DIRECTORY_SEPARATOR . $name;
return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
}
return database_path($name);

View file

@ -4,4 +4,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Events;
/**
* Importantly, listeners for this event should not switch tenancy context.
*
* This event is fired from within a database transaction.
*/
class PullingPendingTenant extends Contracts\TenantEvent {}

View file

@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features;
use Illuminate\Http\RedirectResponse;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class CrossDomainRedirect implements Feature
{
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
RedirectResponse::macro('domain', function (string $domain) {
/** @var RedirectResponse $this */

View file

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Exception;
use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Facades\DB;
use PDO;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class DisallowSqliteAttach implements Feature
{
protected static bool|null $loadExtensionSupported = null;
public static string|false|null $extensionPath = null;
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
// Handle any already resolved connections
foreach (DB::getConnections() as $connection) {
if ($connection instanceof SQLiteConnection) {
if (! $this->loadExtension($connection->getPdo())) {
if (! $this->setAuthorizer($connection->getPdo())) {
return;
}
}
@ -31,42 +28,54 @@ class DisallowSqliteAttach implements Feature
// Apply the change to all sqlite connections resolved in the future
DB::extend('sqlite', function ($config, $name) {
$conn = app(ConnectionFactory::class)->make($config, $name);
$this->loadExtension($conn->getPdo());
$this->setAuthorizer($conn->getPdo());
return $conn;
});
}
protected function loadExtension(PDO $pdo): bool
protected function setAuthorizer(PDO $pdo): bool
{
if (static::$loadExtensionSupported === null) {
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension');
if (PHP_VERSION_ID >= 80500) {
$this->setNativeAuthorizer($pdo);
return true;
}
if (static::$loadExtensionSupported === false) {
return false;
}
if (static::$extensionPath === false) {
return false;
}
static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
if ((! $loadExtensionSupported) ||
(static::$extensionPath === false) ||
(PHP_INT_SIZE !== 8)
) return false;
$suffix = match (PHP_OS_FAMILY) {
'Linux' => 'so',
'Windows' => 'dll',
'Darwin' => 'dylib',
default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY),
default => 'error',
};
if ($suffix === 'error') return false;
$arch = php_uname('m');
$arm = $arch === 'aarch64' || $arch === 'arm64';
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
if (static::$extensionPath === false) {
return false;
}
if (static::$extensionPath === false) return false;
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound
return true;
}
protected function setNativeAuthorizer(PDO $pdo): void
{
// @phpstan-ignore method.notFound
$pdo->setAuthorizer(static function (int $action): int {
return $action === 24 // SQLITE_ATTACH
? PDO\Sqlite::DENY
: PDO\Sqlite::OK;
});
}
}

View file

@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class TelescopeTags implements Feature
{
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
if (! class_exists(Telescope::class)) {
return;

View file

@ -12,8 +12,10 @@ use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\RevertedToCentralContext;
use Stancl\Tenancy\Events\TenancyBootstrapped;
use Stancl\Tenancy\Tenancy;
// todo@release remove this class
/** @deprecated Use the TenantConfigBootstrapper instead. */
class TenantConfig implements Feature
{
public array $originalConfig = [];
@ -27,7 +29,7 @@ class TenantConfig implements Feature
protected Repository $config,
) {}
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
/** @var Tenant $tenant */

View file

@ -17,9 +17,9 @@ class UserImpersonation implements Feature
/** The lifespan of impersonation tokens (in seconds). */
public static int $ttl = 60;
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
return UserImpersonation::modelClass()::create([
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId,
@ -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);

View file

@ -7,19 +7,14 @@ namespace Stancl\Tenancy\Features;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Vite;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class ViteBundler implements Feature
{
/** @var Application */
protected $app;
public function __construct(
protected Application $app,
) {}
public function __construct(Application $app)
{
$this->app = $app;
}
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
Vite::createAssetPathsUsing(function ($path, $secure = null) {
return global_asset($path);

View file

@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue
try {
$databaseManager->ensureTenantCanBeCreated($this->tenant);
$this->tenant->database()->manager()->createDatabase($this->tenant);
$databaseCreated = $this->tenant->database()->manager()->createDatabase($this->tenant);
assert($databaseCreated);
event(new DatabaseCreated($this->tenant));
} catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) {

View file

@ -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);
}
}
}

View file

@ -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());

View file

@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo@earlyIdReview
/**
* Conditionally removes the tenant parameter from matched routes when using kernel path identification.
*

View file

@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
@ -14,7 +13,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
public function handle($request, Closure $next)
{
if (! tenant()) {
throw new TenancyNotInitializedException;
// If there's no tenant, there's no tenant to check for maintenance mode.
// Since tenant identification middleware has higher priority than this
// middleware, a missing tenant would have already lead to request termination.
// (And even if priority were misconfigured, the request would simply get
// terminated *after* this middleware.)
// Therefore, we are likely on a universal route, in central context.
return $next($request);
}
if (tenant('maintenance_mode')) {

View file

@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Enums\RouteMode;
/**
* todo@name come up with a better name.
*
* Prevents accessing central domains in the tenant context/tenant domains in the central context.
* The access isn't prevented if the request is trying to access a route flagged as 'universal',
* or if this middleware should be skipped.
@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
}
// todo@samuel technically not an identification middleware but probably ok to keep this here
public function requestHasTenant(Request $request): bool
{
// This middleware is special in that it's not an identification middleware
// but still uses some logic from UsableWithEarlyIdentification, so we just
// need to implement this method here. It doesn't matter what it returns.
return false;
}
}

View file

@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator
*/
public function route($name, $parameters = [], $absolute = true)
{
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
}
@ -125,11 +125,19 @@ class TenancyUrlGenerator extends UrlGenerator
*/
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true)
{
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
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);
}

View file

@ -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.'
);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class SyncedResourceDeleted
{
public function __construct(
public Syncable&Model $model,
public TenantWithDatabase|null $tenant,
public bool $forceDelete,
) {}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Listeners\QueueableListener;
/**
* Cleans up pivot records related to the deleted tenant.
*
* The listener only cleans up the pivot tables specified
* in the $pivotTables property (see the property for details),
* and is intended for use with tables that do not have tenant
* foreign key constraints with onDelete('cascade').
*/
class DeleteAllTenantMappings extends QueueableListener
{
public static bool $shouldQueue = false;
/**
* Pivot tables to clean up after a tenant is deleted, in the
* ['table_name' => '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();
}
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
/**
* Deletes pivot records when a synced resource is deleted.
*
* If a SyncMaster (central resource) is deleted, all pivot records for that resource are deleted.
* If a Syncable (tenant resource) is deleted, only delete the pivot record for that tenant.
*/
class DeleteResourceMapping extends QueueableListener
{
public static bool $shouldQueue = false;
public function handle(SyncedResourceDeleted $event): void
{
$centralResource = $this->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;
}
}

View file

@ -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());
}
});
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
interface PivotWithCentralResource
{
/** @return class-string<\Illuminate\Database\Eloquent\Model&Syncable> */
public function getCentralResourceClass(): string;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
interface PivotWithRelation
{
/**
* E.g. return $this->users()->getModel().
*/
public function getRelatedModel(): Model;
}

View file

@ -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;
@ -19,37 +20,34 @@ 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::deleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
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) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
static::forceDeleting(static function (Syncable&Model $model) {
if ($model->shouldSync()) {
$model->triggerDeleteEvent(true);
}
});
static::restoring(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
$model->triggerRestoredEvent();
static::restoring(static function (Syncable&Model $model) {
if ($model instanceof SyncMaster && $model->shouldSync()) {
$model->triggerRestoreEvent();
}
});
}
@ -67,9 +65,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 */
@ -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())
@ -116,8 +119,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),
);
}
}

View file

@ -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;
}

View file

@ -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).
*

View file

@ -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;
@ -20,14 +21,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 +41,7 @@ trait TriggerSyncingEvents
}
});
static::deleting(function (self $pivot) {
static::deleting(static function (self $pivot) {
/**
* @var static&Pivot $pivot
* @var SyncMaster|null $centralResource
@ -79,13 +80,13 @@ 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) {
return $this->morphClass;
return Relation::getMorphedModel($this->morphClass) ?? $this->morphClass;
}
throw new CentralResourceNotAvailableInPivotException;

View file

@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch;
use Illuminate\Support\Traits\Macroable;
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
@ -24,11 +25,15 @@ class Tenancy
*/
public Tenant|null $tenant = null;
// todo@docblock
/**
* Custom callback for providing a list of bootstrappers to use.
* When this is null, config('tenancy.bootstrappers') is used.
* @var ?Closure(): list<TenancyBootstrapper>
*/
public ?Closure $getBootstrappersUsing = null;
/** Is tenancy fully initialized? */
public bool $initialized = false; // todo@docs document the difference between $tenant being set and $initialized being true (e.g. end of initialize() method)
public bool $initialized = false;
/**
* List of relations to eager load when fetching a tenant via tenancy()->find().
@ -36,7 +41,7 @@ class Tenancy
public static array $findWith = [];
/**
* A list of bootstrappers that have been initialized.
* List of bootstrappers that have been initialized.
*
* This is used when reverting tenancy, mainly if an exception
* occurs during bootstrapping, to ensure we don't revert
@ -49,6 +54,23 @@ class Tenancy
*/
public array $initializedBootstrappers = [];
/**
* List of features that have been bootstrapped.
*
* Since features may be bootstrapped multiple times during
* the request cycle (in TSP::boot() and any other times the user calls
* bootstrapFeatures()), we keep track of which features have already
* been bootstrapped so we do not bootstrap them again. Features are
* bootstrapped once and irreversible.
*
* The main point of this is that some features *need* to be bootstrapped
* very early (see #949), so we bootstrap them directly in TSP, but we
* also need the ability to *change* which features are used at runtime
* (mainly tests of this package) and bootstrap features again after making
* changes to config('tenancy.features').
*/
protected array $bootstrappedFeatures = [];
/** Initialize tenancy for the passed tenant. */
public function initialize(Tenant|int|string $tenant): void
{
@ -117,10 +139,12 @@ class Tenancy
return;
}
// We fire both of these events before unsetting tenant so that listeners
// to both events can access the current tenant. Having separate events
// still has value as it's consistent with our other events and provides
// more granularity for event listeners, e.g. for ensuring something runs
// before standard TenancyEnded listeners such as RevertToCentralContext.
event(new Events\EndingTenancy($this));
// todo@samuel find a way to refactor these two methods
event(new Events\TenancyEnded($this));
$this->tenant = null;
@ -128,15 +152,35 @@ 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
{
// If no callback for getting bootstrappers is set, we just return all of them.
$resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) {
// If no callback for getting bootstrappers is set, we return the ones in config.
$resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) {
return config('tenancy.bootstrappers');
};
// Here We instantiate the bootstrappers and return them.
// Here we instantiate the bootstrappers and return them.
return array_map('app', $resolve($this->tenant));
}
@ -150,6 +194,26 @@ class Tenancy
return in_array($bootstrapper, static::getBootstrappers(), true);
}
/**
* Bootstrap configured Tenancy features.
*
* Normally, features are bootstrapped directly in TSP::boot(). However, if
* new features are enabled at runtime (e.g. during tests), this method may
* be called to bootstrap new features. It's idempotent and keeps track of
* which features have already been bootstrapped. Keep in mind that feature
* bootstrapping is irreversible.
*/
public function bootstrapFeatures(): void
{
foreach (config('tenancy.features') ?? [] as $feature) {
/** @var class-string<Feature> $feature */
if (! in_array($feature, $this->bootstrappedFeatures)) {
app($feature)->bootstrap();
$this->bootstrappedFeatures[] = $feature;
}
}
}
/**
* @return Builder<Tenant&Model>
*/

View file

@ -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;
@ -40,15 +41,6 @@ class TenancyServiceProvider extends ServiceProvider
// Make sure Tenancy is stateful.
$this->app->singleton(Tenancy::class);
// Make sure features are bootstrapped as soon as Tenancy is instantiated.
$this->app->extend(Tenancy::class, function (Tenancy $tenancy) {
foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) {
$this->app[$feature]->bootstrap($tenancy);
}
return $tenancy;
});
// Make it possible to inject the current tenant by type hinting the Tenant contract.
$this->app->bind(Tenant::class, function ($app) {
return $app[Tenancy::class]->tenant;
@ -128,6 +120,7 @@ class TenancyServiceProvider extends ServiceProvider
Commands\MigrateFresh::class,
Commands\ClearPendingTenants::class,
Commands\CreatePendingTenants::class,
Commands\PurgeImpersonationTokens::class,
Commands\CreateUserWithRLSPolicies::class,
]);
@ -165,17 +158,23 @@ 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;
});
// Bootstrap features that are already enabled in the config.
// If more features are enabled at runtime, this method may be called
// multiple times, it keeps track of which features have already been bootstrapped.
$this->app->make(Tenancy::class)->bootstrapFeatures();
Route::middlewareGroup('clone', []);
Route::middlewareGroup('universal', []);
Route::middlewareGroup('tenant', []);

View file

@ -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
{

View file

@ -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
{

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/**
* Generates a UUIDv7 for the tenant key.
*/
class UUIDv7Generator implements UniqueIdentifierGenerator
{
public static function generate(Model $model): string|int
{
return Str::uuid7()->toString();
}
}

View file

@ -36,7 +36,12 @@ if (! function_exists('tenant')) {
}
if (! function_exists('tenant_asset')) {
// todo@docblock
/**
* Generate a URL to an asset in tenant storage.
*
* If app.asset_url is set, this helper suffixes that URL before appending the asset path.
* If it is not set, the stancl.tenancy.asset route is used.
*/
function tenant_asset(string|null $asset): string
{
if ($assetUrl = config('app.asset_url')) {