ArtisanPack UI
DocsCoreSite Resolution

Site Resolution

Core v1.3.0

Several ArtisanPack UI packages scope their records per site — analytics keeps one set of statistics per site, bookings keeps one calendar per site, and...

Several ArtisanPack UI packages scope their records per site — analytics keeps one set of statistics per site, bookings keeps one calendar per site, and so on. Before Core 1.3 each package answered "which site is this request for?" for itself, in a shape of its own, from configuration of its own. An application installing two of them configured tenancy twice, in shapes that could not share a source of truth, so a single request could resolve to site 2 for one package while resolving to site 1 for another — silently.

Core 1.3 lifts that question into one contract, one resolver binding, and one configuration block. Everything else is an implementation detail behind it.

The contract

namespace ArtisanPackUI\Core\Contracts;

interface SiteResolver
{
	public function currentSiteId(): int|string|null;
}

Two things about that signature are deliberate:

  • It is keyed on the identifier, not a model. Core cannot depend on any package's Site model, and packages must not depend on each other's. The identifier is the only thing all of them can share; a package that needs the full record looks it up.
  • There is no Request parameter. A resolver that requires a request is unusable from a console command or queue worker, which is exactly where per-site iteration happens.

null means "no site in context", not "site not found". Consumers read it as "do not scope", which is what lets a single-tenant application install these packages and never configure anything.

Asking which site you are on

Consumers do not talk to a resolver directly — they ask the shared SiteContext, which is the one place a site becomes known:

use ArtisanPackUI\Core\Facades\ArtisanPackSite;
use ArtisanPackUI\Core\MultiTenancy\SiteContext;

// Facade
$siteId = ArtisanPackSite::currentSiteId();

// Helper
$siteId = apCurrentSiteId();

// Injection — preferred inside a service
public function __construct( protected SiteContext $siteContext ) {}

Going through the context rather than the resolver is what keeps packages in agreement: one context is shared for the whole request or job, it applies the enabled flag, and it honours any site pinned by the surrounding code.

Nothing is memoised. The resolver is asked on every call, because the site in context can change within a single process.

Pinning a site

A console command or queue worker iterating over sites pins each one for the duration of its work. The previous context is restored afterwards, including when the callback throws, and calls nest safely:

foreach ( $siteIds as $siteId ) {
	ArtisanPackSite::forSite( $siteId, function () {
		// Every package scopes to $siteId in here.
	} );
}

// Administrative work that deliberately spans every site.
ArtisanPackSite::withoutSite( fn () => Booking::query()->count() );

A pinned site wins over the resolver, and it wins even when site scoping is switched off in configuration: the enabled flag governs automatic resolution, whereas a forSite() call is an unambiguous instruction from the calling code.

ArtisanPackSite::setSiteId() pins without a scope, and ArtisanPackSite::forget() releases the pin. Prefer forSite() — a pin left behind by an early return or a swallowed exception is a cross-site read waiting to happen, and forSite() cannot leave one.

SiteContext and the resolver are bound with scoped(), not singleton(), so Laravel forgets them between Octane requests and between queue jobs. A pin that outlives its work therefore dies at the request or job boundary rather than scoping everything the worker does next. flush() is still available for resetting deliberately mid-process.

Configuration

One block, in config/artisanpack.php, is authoritative for the whole ecosystem:

'core' => [
	'multi_tenant' => [
		'enabled'   => env( 'ARTISANPACK_MULTI_TENANT_ENABLED', false ),
		'resolvers' => [
			ArtisanPackUI\Core\MultiTenancy\HookSiteResolver::class,
		],
	],
],

enabled defaults to false, so an application that installs these packages and configures nothing behaves as a single-tenant application: no site is ever put in context and every package leaves its queries alone.

These defaults are merged in by the service provider, so they apply whether or not the application has run vendor:publish --tag=artisanpack-config. Setting ARTISANPACK_MULTI_TENANT_ENABLED=true works in an unpublished application — the alternative would let an operator switch tenancy on, see nothing happen, and run a multi-site install with every query unscoped.

resolvers lists the strategies in order; the first to return an identifier wins. The list is assembled into a ChainSiteResolver and bound as the single shared SiteResolver. An empty list binds NullSiteResolver instead, so the contract is always resolvable and consumers can type-hint it unconditionally.

A configured class that is missing or does not implement SiteResolver raises SiteResolutionException at bind time rather than being dropped — a silently dropped resolver produces an application that scopes nothing and looks like it is working. The same applies to a resolvers value that is not a list at all ('resolvers' => HookSiteResolver::class, the brackets forgotten): that is a configuration fault, not an opt-out. Only an explicit [] switches resolution off.

Shipped resolvers

Resolver Behaviour
HookSiteResolver Applies the ap.cmsFramework.currentSite.resolve filter. The default.
NullSiteResolver Never puts a site in context. Bind this to switch tenancy off outright.
ChainSiteResolver Asks each resolver in turn and takes the first non-null answer.

HookSiteResolver and the CMS framework

HookSiteResolver applies the ap.cmsFramework.currentSite.resolve filter with a null default. The coupling flows one way: packages that scope by site listen, and artisanpack-ui/cms-framework never needs to know they exist. Where nothing answers the filter, the resolver returns null and the application behaves as single-tenant.

Nothing listening is ordinary. artisanpack-ui/hooks not being installed is different — there is then no filter to answer at all, so this resolver raises SiteResolutionException rather than returning null and unscoping every query in a deployment whose configuration says it is multi-site. That branch is unreachable while enabled is false, since no resolver is consulted then, which is what keeps HookSiteResolver safe as the shipped default for applications that have neither package.

A listener may answer with an integer, or with a string identifier — a numeric string is coerced to an integer, and any other string is trimmed and passed through for applications keyed on UUIDs. Anything else, including a string that is empty once trimmed, raises SiteResolutionException. Coercing an unusable answer to null would silently unscope every query the resolution feeds, which leaks one site's records into another; failing loudly is the safer direction.

One asymmetry to know about: "12" comes back as int(12), but "012" stays string("012"), because FILTER_VALIDATE_INT rejects leading zeros. A listener that zero-pads hands back a different PHP type from one that does not, which matters to any consumer using the identifier as an array key, in a strict === comparison, or in a cache key. Pick one format and keep to it.

Writing your own resolver

Resolve from a subdomain, a header, a session, or anywhere else:

namespace App\MultiTenancy;

use App\Models\Site;
use ArtisanPackUI\Core\Contracts\SiteResolver;
use Illuminate\Http\Request;

class SubdomainSiteResolver implements SiteResolver
{
	public function __construct( protected Request $request ) {}

	public function currentSiteId(): int|string|null
	{
		// Console commands and queue workers have no real request. Returning
		// null hands the decision to the next resolver, or to an explicitly
		// pinned site, rather than matching every one of them to whatever
		// domain the CLI happens to report.
		if ( ! app()->runningInConsole() ) {
			return Site::query()->where( 'domain', $this->request->getHost() )->value( 'id' );
		}

		return null;
	}
}

Register it ahead of the default:

'resolvers' => [
	App\MultiTenancy\SubdomainSiteResolver::class,
	ArtisanPackUI\Core\MultiTenancy\HookSiteResolver::class,
],

Two rules for implementations:

  • Do not memoise. Re-resolve on every call, or a worker looping over sites scopes its later iterations to the first site it saw.
  • Do not swallow failures. Returning null on error unscopes every query the resolution feeds. Let the exception escape.