Note: During the drafting of this RFC, significant constraints were identified that limit the practical use cases for stacked resolution. The primary motivating use case — domain resolution with subdomain fallback — is better served by building fallback directly into Canopy’s domain resolver (see RFC-0002). This RFC remains open for community feedback on whether a generic stacking mechanism is valuable for other use cases.
This RFC proposes a StackedResolver for Core that allows multiple identity resolvers to be chained in order of
precedence. When a request arrives, resolvers are tried in sequence until one successfully identifies a tenant. The
tenancy records the actual resolver that succeeded (not the stacked resolver), preserving existing behaviour for URL
generation and setup.
Important constraint: Stacking multiple resolvers that use route parameters (subdomain, path, domain) has significant limitations around route configuration and URL generation. The stack works best when combining non-parameter resolvers (header, cookie, session) with at most one parameter-based resolver.
Applications often need to support multiple identification methods simultaneously:
Currently, each tenancy is configured with a single resolver. Supporting multiple methods requires workarounds or custom resolver implementations that duplicate the stacking logic.
Core already has a StackedOverride for layering service overrides. A StackedResolver would follow the same pattern
for identity resolution.
IdentityResolver::route()IdentityResolver contractIdentityResolver::route())Tenancy contractBefore detailing the design, it’s important to understand a fundamental limitation of stacking resolvers.
Identity resolvers fall into two categories based on how they interact with routing:
Non-routing resolvers — These extract the identifier from request metadata without affecting route definitions:
Routing resolvers — These require specific route configuration and embed the identifier in the URL:
->domain('{tenant}.example.com'), identifier is in the hostname->prefix('{tenant}'), identifier is in the URL pathStacking only works cleanly in limited scenarios:
Works well: Non-routing resolvers only
'resolvers' => ['header', 'cookie', 'session']
Routes are defined normally. Each resolver tries to find an identifier from its source. URL generation is unaffected since none of these resolvers modify URLs.
Works with caveats: One routing resolver + non-routing fallbacks
'resolvers' => ['subdomain', 'header']
Routes are defined with subdomain configuration. The subdomain resolver is “primary” for routing. Header acts as a fallback for API clients that can’t use subdomains. However:
Does not work: Multiple routing resolvers
'resolvers' => ['subdomain', 'path'] // Problematic
These resolvers have fundamentally incompatible route configurations:
->domain('{tenant}.example.com')->prefix('{tenant}')You cannot define a single route that works for both.
Does not work: Path resolver in any stack
The path resolver is uniquely problematic because the tenant identifier is embedded in the URL path. There’s no way to:
/dashboard and /{tenant}/dashboardWith subdomain, you can at least generate a relative URL and prepend the domain. With path, the identifier IS part of the path — there’s no separation.
Given these constraints, StackedResolver is only suitable for:
The path resolver should generally NOT be used in a stack unless all other resolvers in the stack are non-routing resolvers AND the path resolver is positioned as the primary (first) resolver.
For scenarios requiring truly different URL structures, the recommended approach is separate route groups rather than stacking:
// Subdomain-based routes
Route::sprout('subdomain', 'tenants', function () {
Route::get('/dashboard', DashboardController::class);
});
// Path-based routes (different URLs, same controllers)
Route::sprout('path', 'tenants', function () {
Route::get('/dashboard', DashboardController::class);
});
This duplicates route definitions but avoids the fundamental incompatibility.
A stacked resolver is configured by specifying an ordered list of resolvers:
// config/sprout.php
'tenancies' => [
'default' => [
'resolver' => 'stack',
// ...
],
],
'resolvers' => [
'stack' => [
'driver' => 'stack',
'resolvers' => ['domain', 'subdomain', 'path'],
],
'domain' => [
'driver' => 'domain',
// Canopy configuration...
],
'subdomain' => [
'driver' => 'subdomain',
'domain' => env('APP_DOMAIN'),
],
'path' => [
'driver' => 'path',
'segment' => 1,
],
],
Resolvers are tried in the order specified. The first resolver to return a non-null identifier wins.
Request
│
▼
┌─────────────────────────────────────────┐
│ StackedResolver │
├─────────────────────────────────────────┤
│ 1. Check 'domain' canResolve() │
│ → true │
│ Call resolveFromRequest() │
│ → null (no matching domain) │
│ │
│ 2. Check 'subdomain' canResolve() │
│ → true │
│ Call resolveFromRequest() │
│ → "acme" ✓ │
│ │
│ 3. Skip remaining resolvers │
│ │
│ Return identifier: "acme" │
│ Set tenancy->resolvedVia(subdomain) │
└─────────────────────────────────────────┘
│
▼
Tenancy uses identifier with TenantProvider
The StackedResolver implements IdentityResolver and delegates to child resolvers:
class StackedResolver implements IdentityResolver
{
/** @var list<IdentityResolver> */
private array $resolvers;
private ?IdentityResolver $matched = null;
public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string
{
foreach ($this->resolvers as $resolver) {
if (! $resolver->canResolve($request, $tenancy, $hook)) {
continue;
}
$identifier = $resolver->resolveFromRequest($request, $tenancy);
if ($identifier !== null) {
$this->matched = $resolver;
return $identifier;
}
}
return null;
}
public function getMatchedResolver(): ?IdentityResolver
{
return $this->matched;
}
}
The key behaviour: when the stacked resolver succeeds, the tenancy should record the child resolver that actually matched, not the stacked resolver itself.
This requires coordination between the resolution process and the tenancy. Options:
Option A: StackedResolver sets it directly
The stacked resolver calls $tenancy->resolvedVia() with the matched child resolver before returning:
public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string
{
foreach ($this->resolvers as $resolver) {
// ...
if ($identifier !== null) {
$tenancy->resolvedVia($resolver);
return $identifier;
}
}
return null;
}
This works but has the resolver modifying tenancy state, which may be unexpected.
Option B: Resolution orchestrator checks for stacking
The code that calls resolveFromRequest and then resolvedVia checks if the resolver is a stack and unwraps:
$identifier = $resolver->resolveFromRequest($request, $tenancy);
if ($identifier !== null) {
$actualResolver = $resolver instanceof StackedResolver
? $resolver->getMatchedResolver()
: $resolver;
$tenancy->resolvedVia($actualResolver);
}
This keeps resolvers stateless but requires the orchestrator to know about stacking.
Option C: StackedResolver is transparent
The stacked resolver implements a marker interface, and resolvedVia() on the tenancy unwraps it:
// In Tenancy implementation
public function resolvedVia(IdentityResolver $resolver): static
{
if ($resolver instanceof StackedResolver) {
$resolver = $resolver->getMatchedResolver();
}
$this->resolver = $resolver;
return $this;
}
This keeps calling code unchanged but makes the tenancy aware of stacking.
The StackedResolver needs to implement all IdentityResolver methods. For methods that operate on the matched
resolver:
public function setup(Tenancy $tenancy, ?Tenant $tenant): void
{
// Delegate to matched resolver if available
$this->matched?->setup($tenancy, $tenant);
}
public function route(string $name, Tenancy $tenancy, Tenant $tenant, array $parameters = [], bool $absolute = true): string
{
if ($this->matched === null) {
throw new RuntimeException('Cannot generate route: no resolver matched');
}
return $this->matched->route($name, $tenancy, $tenant, $parameters, $absolute);
}
public function configureRoute(RouteRegistrar $route, Tenancy $tenancy): void
{
// Apply configuration from all resolvers that might match
foreach ($this->resolvers as $resolver) {
$resolver->configureRoute($route, $tenancy);
}
}
public function canResolve(Request $request, Tenancy $tenancy, ResolutionHook $hook): bool
{
// Can resolve if any child can resolve
foreach ($this->resolvers as $resolver) {
if ($resolver->canResolve($request, $tenancy, $hook)) {
return true;
}
}
return false;
}
Some identity resolvers implement IdentityResolverUsesParameters, which adds resolveFromRoute() for route
parameter-based resolution. The subdomain resolver, path resolver, and Canopy’s domain resolver all implement this
interface.
The StackedResolver implements IdentityResolverUsesParameters but proxies resolveFromRoute() calls to
resolveFromRequest(). This works because all resolvers that implement the parameters interface also provide a working
resolveFromRequest() implementation.
class StackedResolver implements IdentityResolver, IdentityResolverUsesParameters
{
public function resolveFromRoute(Route $route, Tenancy $tenancy, Request $request): ?string
{
// Proxy to request-based resolution, which all child resolvers support
return $this->resolveFromRequest($request, $tenancy);
}
public function getRouteParameterName(Tenancy $tenancy): string
{
throw new BadMethodCallException(
'StackedResolver does not have a single route parameter name. ' .
'Child resolvers may have different parameter names.'
);
}
}
This means stacked resolution always uses the request-based path internally, even when triggered via route parameters.
The getRouteParameterName() method still throws since there’s no single parameter name that applies — but this method
is only used during route configuration, not resolution.
When routes are configured with a stacked resolver, there’s a fundamental constraint: resolvers that use route parameters have incompatible route structures.
Consider a stack of [domain, subdomain, path]:
acme.com/dashboard){tenant}.example.com/dashboardexample.com/{tenant}/dashboardThese cannot be combined into a single route definition. A route registered with subdomain parameters won’t match path-based requests, and vice versa.
Implication: Stacked resolvers work best when:
Non-parameter resolvers only: Header, cookie, and session resolvers don’t modify route structure. A stack of these works fine.
One parameter resolver + non-parameter resolvers: You can stack [header, subdomain] where subdomain is the “
primary” and header is a fallback for API clients. Routes are configured for subdomain, header resolution just works
because it doesn’t need parameters.
Separate route groups per resolver: Register different route groups for each parameter-based resolver, each configured appropriately. The stack handles resolution, but route configuration is done per-resolver.
// Option 3: Separate route groups
Route::tenanted('subdomain', function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard.subdomain');
});
Route::tenanted('path', function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard.path');
});
// Resolution uses the stack, routes are separate
For configureRoute():
Given these constraints, the stacked resolver should NOT call configureRoute() on all children. Instead:
public function configureRoute(RouteRegistrar $route, Tenancy $tenancy): void
{
// Do nothing - route configuration must be handled per-resolver
// when using parameter-based resolvers in a stack
}
Or, allow configuration of a “primary” resolver for route purposes:
'resolvers' => [
'stack' => [
'driver' => 'stack',
'resolvers' => ['domain', 'subdomain'],
'routes' => 'subdomain', // Use subdomain for route configuration
],
],
public function configureRoute(RouteRegistrar $route, Tenancy $tenancy): void
{
// Delegate to the configured route resolver
$this->routeResolver?->configureRoute($route, $tenancy);
}
The route() method has the same constraint. The matched resolver’s route() method expects routes to be configured in
a compatible way.
If a tenant was identified via subdomain but routes were configured for path-based resolution, URL generation will produce incorrect URLs or fail.
Recommendation: When using stacked resolvers with parameter-based resolution, the resolver used for route configuration should match the resolver most likely to be used. Alternatively, implement resolver-aware route naming:
// Generate URL using the resolver that identified the tenant
$tenancy->resolver()->route('dashboard', $tenancy, $tenant);
// Or explicitly specify which resolver's URL format to use
sprout_route('dashboard', resolver: 'subdomain');
Given the significant constraints around parameter-based resolvers, it’s worth asking whether a StackedResolver
provides enough value. The cleanest use cases are:
[header, <parameter-based>] — API fallback[cookie, <parameter-based>] — Tenant persistenceThese could potentially be handled by simpler mechanisms (e.g., a “fallback” option on parameter-based resolvers) without a full stacking abstraction.
Counterargument: The stacking model is conceptually clean and matches StackedOverride. Even if practical use cases are
limited, consistency in the API has value.
Options A, B, and C each have trade-offs:
Recommendation: Option C feels most consistent with how the tenancy already manages this state.
Given the route configuration constraints, should stacking multiple parameter-based resolvers be:
IdentityResolverUsesParameters
implementationsroutes config to designate which resolver handles route
configurationThe third option provides flexibility while making the constraints explicit.
When using multiple parameter-based resolvers, should Sprout provide helpers for registering parallel route groups?
// Hypothetical API
Route::tenantedStack(['subdomain', 'path'], function () {
// Registers routes for both resolvers with appropriate names
Route::get('/dashboard', DashboardController::class)->name('dashboard');
});
// Results in:
// - {tenant}.example.com/dashboard (name: subdomain.dashboard)
// - example.com/{tenant}/dashboard (name: path.dashboard)
This adds complexity but solves the URL generation problem cleanly.
Should resolvers be tried in configured order (precedence), or should there be a specificity system?
For example, if both domain and subdomain could resolve to the same tenant:
Precedence is explicit and predictable. Specificity is implicit and might cause surprises. Recommendation: stick with precedence.
When $tenancy->resolver() returns the matched resolver, calling route() works for the current tenant. But what about
generating routes for a different tenant?
// Current tenant was identified via subdomain
// Generate route for a tenant that has a custom domain
$tenancy->resolver()->route('dashboard', $tenancy, $otherTenant);
// Uses subdomain format, but $otherTenant might prefer domain format
This is arguably correct behaviour (generate URLs in the same format the user arrived with), but might not always be desired.
The biggest drawback is that stacking parameter-based resolvers creates route configuration challenges. Users expecting to freely combine subdomain + path + domain resolution will find that route registration and URL generation don’t work seamlessly. This is a fundamental limitation of how Laravel routing works, not something Sprout can fully abstract away.
Users need to understand which resolver combinations work well together. The “stack anything” mental model is appealing but misleading. Documentation must clearly explain:
Given the constraints, the practical use cases for stacking are narrower than they might appear:
[header, subdomain] — API fallback for subdomain-based apps[cookie, subdomain] — Remember tenant across sessions[domain, subdomain] — Custom domains with subdomain fallback (but requires separate route groups)More exotic combinations may not be worth the complexity.
Users write their own resolver combining strategies. Rejected because:
Store preferred resolution method on the tenant. Rejected because: