Identity resolvers extract tenant identifiers from HTTP requests. They examine some part of the request — a subdomain, a path segment, a header, a cookie, or session data — and return the identifier string that represents the tenant.
Resolvers are deliberately separated from tenant providers. A resolver’s only job is to extract an identifier string from a request. It knows nothing about how tenants are stored or loaded. This separation means the same resolver works regardless of whether tenants live in Eloquent, a database table, or an external API. And the same provider works regardless of whether the identifier came from a subdomain, a header, or a cookie.
Resolvers fall into two categories based on how they interact with routing.
Subdomain and path resolvers embed the tenant identifier in the URL structure itself.
https://acme.example.com/dashboard (subdomain)
https://example.com/acme/dashboard (path)
These resolvers:
When you define a tenanted route group, routing resolvers configure the routes with the appropriate constraints. The subdomain resolver adds a domain constraint. The path resolver adds a path prefix. Both register a route parameter that captures the tenant identifier.
Routing resolvers also implement a fallback. If the route parameter isn’t available (error pages, routes outside the tenanted group, edge cases), they fall back to parsing the request directly — extracting the subdomain from the host or the segment from the path. This ensures resolution can still work even when Laravel’s routing didn’t capture the parameter.
Header, cookie, and session resolvers extract identifiers from request metadata rather than the URL.
X-Tenant: acme (header)
Cookie: tenant=acme (cookie)
Session: ['tenant' => 'acme'] (session)
These resolvers:
Non-routing resolvers examine the request after routing completes. They read the header value, decrypt the cookie, or fetch from the session. The URL stays clean, which is sometimes what you want (especially for APIs where tenancy is determined by an API key or token).
Resolvers declare which resolution hooks they support. Most resolvers work at both hooks (Routing and Middleware), but some have constraints.
The session resolver only works at the Middleware hook. Sessions aren’t available during routing — the session middleware hasn’t run yet. The resolver checks this and refuses to resolve at the wrong hook.
The cookie resolver works at both hooks but behaves differently. At the Routing hook, cookies haven’t been decrypted by Laravel’s middleware yet, so the resolver manually decrypts. At the Middleware hook, cookies are already decrypted.
Before attempting resolution, the system calls canResolve() to check if the resolver can run at the current hook.
This prevents wasted work and confusing errors.
See Resolution Hooks for details on when each hook fires.
After a tenant is identified, resolvers perform setup actions. This happens via a listener on the tenant identification event.
URL defaults. Routing resolvers register the tenant identifier as a URL default. This means generated URLs automatically include the tenant without you specifying it every time:
// Without URL defaults, you'd need:
route('dashboard', ['tenant' => $tenant->identifier]);
// With URL defaults (set by resolver setup):
route('dashboard'); // tenant parameter filled automatically
Cookie management. The cookie resolver queues a cookie containing the tenant identifier. On subsequent requests, this cookie identifies the tenant. When the tenant is cleared, the cookie is expired.
Session storage. The session resolver stores the tenant identifier in the session. Like cookies, this provides persistence across requests.
Settings repository. Path and subdomain resolvers store values in Sprout’s settings repository (URL path, URL domain). Service overrides and other components read these settings.
Setup also runs when the tenant is cleared (with a null tenant). This allows resolvers to clean up — expire cookies, forget session keys, clear URL defaults.
Extracts the identifier from the subdomain portion of the hostname.
acme.example.com → "acme"
Choose this when you want tenants to have their own subdomains. It’s visible, memorable, and users can bookmark tenant-specific URLs. Requires wildcard DNS or explicit subdomain configuration.
Configure with your parent domain:
'subdomain' => [
'driver' => 'subdomain',
'domain' => 'example.com',
],
Extracts the identifier from a URL path segment.
/acme/dashboard → "acme"
Choose this when subdomains aren’t feasible (shared hosting, SSL constraints) or when you want all tenants under a single domain. The tenant is still visible in the URL.
By default, uses the first path segment. Configure which segment if needed:
'path' => [
'driver' => 'path',
'segment' => 1, // first segment
],
Extracts the identifier from an HTTP request header.
X-Tenant-Identifier: acme → "acme"
Choose this for APIs where the tenant is specified programmatically. The client sets the header; users never see it. Good for machine-to-machine communication.
The resolver also adds the tenant identifier to responses, so clients can confirm which tenant was used.
Extracts the identifier from an encrypted cookie.
Cookie: Tenants-Identifier=<encrypted:acme> → "acme"
Choose this for persistent tenant selection across requests without embedding in URLs. Users visit once with an explicit tenant (via another resolver), then the cookie maintains it.
Useful for “remember my tenant” functionality or when combining with another resolver as a fallback.
Extracts the identifier from session storage.
Session['multitenancy.tenants'] = 'acme' → "acme"
Choose this when tenant selection happens through application logic (user picks from a dropdown) rather than URL structure. The session maintains the selection.
Only works at the Middleware hook — sessions aren’t available during routing.
Two resolvers conflict with their corresponding service overrides:
Cookie resolver + cookie override. The cookie override changes how Laravel’s cookie service works, modifying paths and domains globally. The cookie resolver needs predictable cookie behaviour. They can’t coexist.
Session resolver + session override. The session override creates tenant-specific session storage. But the session resolver needs to read the session before knowing the tenant (to determine which tenant’s session to use). They can’t coexist.
These aren’t bugs — they’re fundamental design constraints. The resolver needs the service to work one way; the override changes it to work another way. Choose one approach per service.
When you enable a conflicting combination, Sprout throws CompatibilityException at resolution time with a clear
message about the conflict.
Sprout provides two route macros that handle resolver configuration automatically:
Route::tenanted(function () {
Route::get('/dashboard', DashboardController::class);
}, 'subdomain', 'tenants');
Route::possiblyTenanted(function () {
Route::get('/about', AboutController::class);
}, 'header', 'tenants');
Route::tenanted() creates routes that require a tenant. Requests without a tenant throw NoTenantFoundException.
Route::possiblyTenanted() creates routes where tenants are optional. Resolution is attempted, but requests proceed
even without a tenant.
Both accept optional resolver and tenancy names. When omitted, they use configured defaults.
These macros orchestrate several operations internally:
configureRoute() to apply constraints (domains, prefixes)Routing resolvers configure routes via the configureRoute() method.
The subdomain resolver adds a domain constraint:
// Internally does something like:
$route->domain('{tenant}.example.com');
The path resolver adds a prefix:
// Internally does something like:
$route->prefix('{tenant}');
Both also apply regex pattern constraints if configured, ensuring the tenant parameter matches expected formats.
Non-routing resolvers don’t modify route matching — they extract identifiers from request metadata, not URL structure. However, the header resolver adds response middleware to echo the resolved tenant identifier back in response headers, useful for debugging and client verification.
Routing resolvers face a naming collision problem. What if two tenancies both use subdomain resolution? Both would need
a {tenant} parameter.
Sprout solves this with dynamic parameter names using placeholders, following the pattern
{tenancy}_{resolver}:
{tenants_subdomain}{organizations_path}This is transparent to your application. Each resolver provides a route() helper that generates URLs with the correct
parameter name.
A route can participate in multiple tenancies:
Route::tenanted(function () {
Route::tenanted(function () {
// Both organization AND team context
}, 'path', 'teams');
}, 'subdomain', 'organizations');
Each tenancy resolves independently. The dynamic parameter naming prevents URL collisions.
Generating URLs to tenanted routes requires the tenant identifier. Resolvers handle this through their route()
method.
Routing resolvers inject the tenant identifier into the URL parameters:
$resolver->route('dashboard', $tenancy, $tenant, [], true);
// Returns: https://acme.example.com/dashboard
Non-routing resolvers just delegate to Laravel’s route() helper — there’s nothing to inject.
During setup, routing resolvers register URL defaults, so for the current tenant you don’t need to think about it:
route('dashboard'); // Works automatically within tenant context
For generating URLs to other tenants, use Sprout’s route helper:
sprout()->route('dashboard', $otherTenant);
Resolvers are configured under multitenancy.resolvers:
'resolvers' => [
'subdomain' => [
'driver' => 'subdomain',
'domain' => env('TENANTED_DOMAIN'),
],
'api' => [
'driver' => 'header',
'header' => 'X-Tenant-ID',
],
],
Each resolver has a name (used when specifying which resolver a route uses) and a driver (which resolver class to instantiate). Additional options depend on the driver.
Configuration values support placeholders that are replaced at runtime:
{tenancy}, {resolver} — Lowercase versions{Tenancy}, {Resolver} — Capitalised versions (ucfirst){TENANCY}, {RESOLVER} — Uppercase versionsThis allows reusable configurations:
'header' => '{Tenancy}-Identifier', // Becomes "Tenants-Identifier"
Custom resolvers extend the system for identification methods Sprout doesn’t include.
Extend BaseIdentityResolver and implement resolveFromRequest():
class ApiKeyResolver extends BaseIdentityResolver
{
public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string
{
$apiKey = $request->header('X-API-Key');
if ($apiKey) {
// Look up tenant identifier from API key
return $this->lookupTenantByApiKey($apiKey);
}
return null;
}
}
If your resolver embeds the identifier in the URL (like subdomain or path), implement
IdentityResolverUsesParameters. The FindsIdentityInRouteParameter trait provides most of the implementation:
class CustomParameterResolver extends BaseIdentityResolver implements IdentityResolverUsesParameters
{
use FindsIdentityInRouteParameter;
public function __construct(string $name, array $hooks = [])
{
parent::__construct($name, $hooks);
$this->initialiseRouteParameter(null, '{tenancy}');
}
public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string
{
// Fallback when parameter isn't in route
}
public function configureRoute(RouteRegistrar $route, Tenancy $tenancy): void
{
// Set up route constraints
}
}
Register custom drivers with the resolver manager:
$sprout->resolvers()->register('api-key', function (array $config, string $name) {
return new ApiKeyResolver($name, $config);
});
Session resolver requires Middleware hook. It simply won’t work at the Routing hook — sessions don’t exist yet. The resolver enforces this and refuses to resolve.
Cookie resolver decrypts manually at Routing hook. Laravel’s cookie middleware hasn’t run yet, so cookies are still encrypted. The resolver handles this, but it’s worth knowing if you’re debugging.
Override conflicts are runtime errors. Sprout doesn’t prevent you from configuring conflicting resolver/override
combinations. The error happens when resolution is attempted. Check your configuration if you see
CompatibilityException.
Resolvers don’t validate tenants. A resolver returns whatever identifier it finds. If that identifier doesn’t correspond to a real tenant, the provider returns null, and the tenancy ends up without a tenant. Validation is the application’s responsibility.
Route caching works normally. The route macros generate standard Laravel route definitions, so route caching requires no special handling.
Fallback resolution for edge cases. Routing resolvers prefer route parameters but fall back to parsing the request directly. This handles error pages and routes outside tenanted groups where the parameter wasn’t captured.