Multi-tenant platforms where each tenant gets their own domain or subdomain — franchise platforms, agency white-labels, creator storefronts — have one shared architectural question that needs answering before you write a single controller: how do I know which tenant this request is for?

There are tenant packages that'll do this for you (stancl/tenancy is excellent). But for teams that want to understand the moving parts, or who have requirements that don't quite fit an off-the-shelf package, writing your own domain resolution middleware is a worthwhile exercise. It's also genuinely not that much code.

This post covers the pattern I use, the caching strategy that keeps it fast under load, and the three mistakes that turn this from "middleware" into "timebomb".

The shape of the problem

Your platform has a tenants table:

Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('primary_domain')->unique()->nullable();
    $table->timestamps();
});

Each tenant can be reached via:

  1. A platform subdomain: acme.myplatform.com
  2. A primary custom domain: store.acme.com
  3. Any number of additional aliases: www.acme.com, shop.acme.com

For every request, you need to:

  1. Figure out which tenant (if any) this domain belongs to
  2. Make that tenant available to the rest of the application (controllers, jobs, Blade templates)
  3. Constrain all database queries to that tenant's data
  4. Do it all in under 5ms so you're not adding latency to every request

The tenant domains table

Rather than shoving every domain form into the tenants table, use a dedicated tenant_domains table:

Schema::create('tenant_domains', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('domain')->unique();
    $table->boolean('is_primary')->default(false);
    $table->timestamp('verified_at')->nullable();
    $table->timestamps();

    $table->index(['tenant_id', 'is_primary']);
});

Each tenant can have many domains. One is marked primary (used when generating canonical URLs). Domains not yet verified via DNS don't resolve.

The middleware

Here's the core:

namespace App\Http\Middleware;

use App\Models\Tenant;
use App\Models\TenantDomain;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $host = strtolower($request->getHost());

        $tenant = $this->resolveTenant($host);

        if (! $tenant) {
            abort(404, 'No tenant registered for this domain.');
        }

        app()->instance('tenant', $tenant);

        return $next($request);
    }

    protected function resolveTenant(string $host): ?Tenant
    {
        $cacheKey = "tenant:domain:{$host}";

        return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($host) {
            return TenantDomain::query()
                ->where('domain', $host)
                ->whereNotNull('verified_at')
                ->with('tenant')
                ->first()
                ?->tenant;
        });
    }
}

Register it in bootstrap/app.php (Laravel 11) or Kernel.php (Laravel 10 and below) so it runs on every web request before routing:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(prepend: [
        \App\Http\Middleware\ResolveTenant::class,
    ]);
})

That's the happy path. Now the problems.

Mistake 1: Querying the database on every request

The naive version of this middleware runs a SQL query for every single request. At scale, this is crippling — especially for asset requests, health checks, and anything else that doesn't actually need the tenant.

The cache wrapper above handles it, but be careful about invalidation. When a tenant adds or removes a domain, or when verification status changes, flush the cache:

// In your TenantDomain model
protected static function booted(): void
{
    static::saved(function (TenantDomain $domain) {
        Cache::forget("tenant:domain:{$domain->domain}");
    });

    static::deleted(function (TenantDomain $domain) {
        Cache::forget("tenant:domain:{$domain->domain}");
    });
}

Five minutes of cache TTL on domain resolution is fine because you're flushing on every write. If your domain table changes thousands of times per day you might want shorter, but for most platforms this is comfortable.

Mistake 2: Not scoping model queries

Resolving the tenant and sticking it in the container is only useful if your application actually enforces tenant isolation. If a controller can do Product::all() and return every tenant's products, you have not solved the problem — you've just added a lookup.

The standard Laravel pattern is a global scope:

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('tenant')) {
            return;
        }

        $builder->where($model->getTable() . '.tenant_id', app('tenant')->id);
    }
}

Apply to every tenant-scoped model via a trait:

namespace App\Models\Traits;

use App\Models\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function ($model) {
            if (app()->bound('tenant') && ! $model->tenant_id) {
                $model->tenant_id = app('tenant')->id;
            }
        });
    }

    public function tenant()
    {
        return $this->belongsTo(\App\Models\Tenant::class);
    }
}

Now Product::all() only returns the current tenant's products, and Product::create([...]) automatically stamps the current tenant's ID. Bypass the scope only when you genuinely need to (admin dashboards, system jobs) via withoutGlobalScope(TenantScope::class).

Mistake 3: Forgetting about queued jobs

The tenant is set on the request. Jobs don't have a request. If you dispatch a job from a tenant's context and forget to pass the tenant forward, the job runs with no tenant bound, the global scope does nothing, and your job silently operates on... whichever tenant happens to be next?

Actually, worse: the job runs with no tenant scope applied, meaning it sees all tenants' data. In a reporting job, this means tenant A's scheduled export silently grows to include tenant B's rows.

The fix is to bind the tenant into the queued job and re-bind it on the worker side:

class ProcessTenantExport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Tenant $tenant,
        public Export $export,
    ) {}

    public function handle(): void
    {
        app()->instance('tenant', $this->tenant);

        // Now any tenant-scoped queries inside this job will work correctly
        $products = Product::where('active', true)->get();

        // ... rest of the job
    }
}

For bonus safety, add a middleware to your queue worker that validates a tenant is bound before running any tenant-scoped job, and explodes loudly if not. Loud failures are better than silent cross-tenant leaks.

Handling the "no tenant" case

Not every request should 404 if there's no tenant. Some routes are platform-level:

  • The marketing homepage at myplatform.com
  • The tenant signup flow
  • Status pages, documentation, support

Two ways to handle this:

Option A: Whitelist platform domains in the middleware and skip resolution:

protected array $platformDomains = [
    'myplatform.com',
    'www.myplatform.com',
    'admin.myplatform.com',
];

public function handle(Request $request, Closure $next): Response
{
    $host = strtolower($request->getHost());

    if (in_array($host, $this->platformDomains, true)) {
        return $next($request);
    }

    // ... tenant resolution as before
}

Option B: Use route groups and only apply the middleware to tenant routes. The platform routes go in a separate route file (routes/platform.php) without the middleware. This is cleaner architecturally but requires discipline about which routes go where.

I use option B for new projects. Option A is a fine retrofit for existing apps where you don't want to restructure routing.

Custom domains: the DNS verification dance

Supporting custom tenant domains (store.acme.com pointing to your platform) requires a verification flow so tenants can't claim domains they don't own:

  1. Tenant enters the domain they want to use
  2. You generate a random verification token and store it on the tenant_domains row
  3. You instruct the tenant to add a TXT record at _verify.{their-domain} with the token, plus a CNAME at the domain itself pointing to your platform
  4. A scheduled job polls the TXT record; when the value matches, set verified_at = now()
  5. Only verified domains resolve in the middleware

The CNAME needs to resolve to a platform hostname that can serve SSL for arbitrary domains. In practice this means Cloudflare SaaS, AWS ACM with multiple SANs, or Caddy with on-demand TLS. Each has tradeoffs — that's a post for another day.

The takeaway

Domain-based tenant resolution is one of those features that looks simple until you touch production. The middleware itself is maybe 30 lines. Making it fast, safe, and cache-correct is where the real work is.

The short version of everything above: cache aggressively, invalidate on writes, scope every query, carry the tenant into queued jobs, and handle "no tenant" as a first-class case, not an edge case.

Get those right and the rest is just wiring.