← Back to blog

How to Add Custom Domains to a Laravel Multi-Tenant SaaS (with Code)

Daniel Sternlicht·
tutoriallaravelphpmulti-tenantcustom domainssaas
How to Add Custom Domains to a Laravel Multi-Tenant SaaS (with Code)

To add customer custom domains to a multi-tenant Laravel app you need four pieces: a custom_domains table mapping hostnames to tenants, trusted-proxy config so Laravel sees the customer's hostname, middleware that resolves the tenant from it, and an edge that terminates TLS per hostname. This guide builds all four, with working code.

Laravel multi-tenancy tutorials almost all stop at subdomains. You wire up a package or a route group, get acme.yourapp.com working, and ship. Then a customer asks for app.acme.com and you discover that the framework parts are easy and the parts nobody wrote down are where the week goes.

Laravel's specific twist is that its defaults are the opposite of Rails'. Rails blocks unknown hosts and you have to open it up. Laravel does the reverse: it responds to every Host header it receives and happily builds absolute URLs from whatever the visitor sent. So the failure mode isn't a 403 on day one. It's password-reset emails that quietly point at the wrong domain three weeks later.

This is the Laravel sibling of our Next.js multi-tenant guide and the Rails one. Same architecture, Laravel idioms.

Architecture

[ visitor: app.acme.com ]
        |
        v  TLS handshake (cert for app.acme.com)
[ edge layer: terminates TLS, proxies to origin ]
        |
        v  X-Forwarded-Host: app.acme.com
        |  X-Forwarded-Proto: https
[ your Laravel origin (Forge / Vapor / Cloud / VPS) ]
        |
        v  TrustProxies rewrites the request, middleware reads the host
[ tenant lookup -> bound into the container for the request ]

The edge is whatever answers the TLS handshake for customer hostnames: a Caddy server you run, your platform's certificate manager, or a custom domains API. The Laravel side is identical in all three, so we build that first.

Step 1: model custom domains per tenant

Give domains their own table. Not a custom_domain string column on tenants, because one tenant will eventually want two domains, and every domain needs its own lifecycle state while DNS and the certificate catch up.

// database/migrations/xxxx_create_custom_domains_table.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('custom_domains', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->string('hostname', 253)->unique();
            $table->string('status')->default('pending'); // pending -> verifying -> active
            $table->timestamps();
        });
    }
};
// app/Models/CustomDomain.php
class CustomDomain extends Model
{
    protected $fillable = ['tenant_id', 'hostname', 'status'];

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

    protected function hostname(): Attribute
    {
        return Attribute::set(fn (string $value) => strtolower(trim($value)));
    }

    public function scopeActive(Builder $query): void
    {
        $query->where('status', 'active');
    }
}

The unique index is the important line. Without it two tenants can claim the same hostname and you will route one tenant's traffic to the other. That's a security bug, not a UX bug.

DNS is case-insensitive and MySQL's default collation is too, but Postgres is not, so normalise on write with the accessor above and normalise again on lookup. Don't rely on your database's collation to save you.

Step 2: trust the proxy, or none of this works

This is the step to do before the interesting one, because skipping it makes step 3 look broken in a confusing way.

Your edge terminates TLS and proxies to your origin over plain HTTP, passing the real hostname in X-Forwarded-Host. Laravel ignores that header until you tell it which proxies to trust. Until you do, $request->getHost() returns your origin's hostname, every tenant lookup misses, and url() generates http:// links because the origin only ever saw port 80. That last symptom is the one people chase for an afternoon.

In bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    // Behind a cloud load balancer whose IPs you don't control:
    $middleware->trustProxies(at: '*');

    // Or, if you know them:
    // $middleware->trustProxies(at: ['10.0.0.0/8']);

    $middleware->trustProxies(headers: Request::HEADER_X_FORWARDED_FOR |
        Request::HEADER_X_FORWARDED_HOST |
        Request::HEADER_X_FORWARDED_PORT |
        Request::HEADER_X_FORWARDED_PROTO
    );
})

HEADER_X_FORWARDED_HOST is the one that matters here, and it's worth setting the header list explicitly rather than inheriting a default you haven't read. If you're behind AWS Elastic Load Balancing, Laravel's docs say the value should be Request::HEADER_X_FORWARDED_AWS_ELB instead.

at: '*' means "believe whatever forwarded headers arrive". That's correct behind a load balancer that is the only path to your origin, and dangerous if your origin is also reachable directly from the internet, because then anyone can spoof a hostname. If your origin has a public IP, firewall it to the edge before you use the wildcard.

Step 3: resolve the tenant from the hostname

With proxies trusted, $request->getHost() is the customer's hostname. Resolve once, in middleware, and bind the result so the rest of the app can ask for it.

// app/Http/Middleware/ResolveTenant.php
class ResolveTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $host = strtolower($request->getHost());

        $tenant = Cache::remember("tenant:host:{$host}", now()->addMinutes(5),
            function () use ($host) {
                $domain = CustomDomain::active()->with('tenant')
                    ->firstWhere('hostname', $host);

                return $domain?->tenant
                    ?? Tenant::firstWhere('slug', Str::before($host, '.'));
            }
        );

        abort_if($tenant === null, 404);

        app()->instance('tenant', $tenant);
        URL::forceRootUrl($request->schemeAndHttpHost());

        return $next($request);
    }
}

Register it in bootstrap/app.php, in the web group, and make sure it runs before anything that needs the tenant:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [\App\Http\Middleware\ResolveTenant::class]);
})

Two notes. Cache the lookup: this query runs on every request including every asset request that goes through PHP, and it's the first thing to show up in your slow query log under real traffic. And remember to bust the cache key when a domain's status changes, or a newly activated domain stays 404 for five minutes.

The URL::forceRootUrl() line is doing quiet work. It pins URL generation to the hostname the visitor actually used for the rest of the request, which matters once you start queueing jobs from a controller.

Step 4: the URL generation trap

Here's the Laravel-specific one, and it's the reason this guide exists.

Laravel's docs are explicit that url() "will automatically use the scheme (HTTP or HTTPS) and host from the current request being handled by the application". Inside a request, everything just works. Links on app.acme.com point at app.acme.com. You test the flow, it's fine, you ship.

Then someone on a custom domain requests a password reset. The mail is built in a queued job. There is no current request in a queue worker, so there is no host to read, and Laravel falls back to APP_URL. Your customer's user gets an email pointing at yourapp.com for an account they only know as acme.com. Depending on your auth setup they either get a confusing branding break or a link that doesn't work at all.

Everything that runs outside the request has this problem: queued mail and notifications, scheduled commands, anything building a URL in a job. The fix is to carry the host with the work instead of hoping it's ambient.

// app/Notifications/ResetPassword.php
class ResetPassword extends Notification implements ShouldQueue
{
    public function __construct(
        public string $token,
        public string $host,   // captured at dispatch, from the request
    ) {}

    public function toMail(object $notifiable): MailMessage
    {
        // Pin URL generation to the tenant's host for this notification.
        URL::forceRootUrl("https://{$this->host}");

        return (new MailMessage)
            ->line('Reset your password.')
            ->action('Reset Password', url("/reset-password/{$this->token}"));
    }
}

Dispatch it with new ResetPassword($token, $request->getHost()). The general rule: any job that will generate a user-facing URL takes the hostname as a constructor argument. It's tedious and it is much less tedious than debugging why one customer's users can't log in.

Signed URLs have a sharper version of this. URL::signedRoute() includes the domain in the signature hash by default, so a link minted on yourapp.com fails validation when it's opened on app.acme.com, with a 403 and no useful message. If a signed URL might be generated on one host and opened on another, sign it relative:

URL::signedRoute('unsubscribe', ['user' => $user->id], absolute: false);

and validate with the matching middleware, ->middleware('signed:relative').

And check SESSION_DOMAIN before you launch. Laravel ships it unset, and the config comment says the cookie is then "available to the root domain without subdomains", which is what you want, because the cookie gets scoped to whatever host served the request and custom domains work by default. But if you previously set SESSION_DOMAIN=.yourapp.com to share sessions across your own subdomains, every custom domain silently fails to keep a session: the browser refuses to store a cookie scoped to a domain it isn't on, so users log in and land back on the login page. There's no error to grep for. If you need subdomain SSO and custom domains, they can't share one cookie domain, and you need a token handoff between the two.

What you don't need to worry about: Laravel's TrustHosts middleware, unless you turned it on. Laravel responds to all hosts by default. If you did enable it, take the closure form so customer domains aren't a deploy away: $middleware->trustHosts(at: fn () => CustomDomain::active()->pluck('hostname')->all()).

Step 5: terminate TLS for customer hostnames

Everything above is plain Laravel. The hard part of custom domains is that https://app.acme.com needs a valid certificate for app.acme.com, issued automatically when the customer connects it and renewed forever after. (The mechanics, ACME challenges, SNI and wildcards, are in how SSL provisioning works for custom domains.)

Three realistic routes.

Option A: run Caddy with on-demand TLS

Caddy obtains a certificate on the first TLS handshake for a new hostname if you enable on_demand_tls. The critical piece is the ask endpoint: before issuing, Caddy calls your Laravel app to confirm the hostname belongs to a real tenant, so strangers can't burn your rate limits by pointing junk domains at your IP.

{
  on_demand_tls {
    ask http://127.0.0.1:8000/internal/domain-check
  }
}

https:// {
  tls {
    on_demand
  }
  reverse_proxy 127.0.0.1:8000 {
    header_up X-Forwarded-Host {host}
  }
}
// routes/web.php - bind to localhost only, this must not be public
Route::get('/internal/domain-check', function (Request $request) {
    return CustomDomain::whereIn('status', ['verifying', 'active'])
        ->where('hostname', strtolower($request->query('domain', '')))
        ->exists()
            ? response('', 200)
            : response('', 404);
})->withoutMiddleware([\App\Http\Middleware\ResolveTenant::class]);

Note the withoutMiddleware: your tenant resolver would 404 this request before it ever ran, because Caddy calls it on localhost.

This works and it's the cheapest option in server bills. What you're signing up for is the operational side: Let's Encrypt allows 50 new certificates per registered domain per week, failed validations lock a hostname out for an hour after five attempts, and you now own renewal monitoring and the 3am page when a certificate expires. Our debugging runbook for customer domains exists because of this route.

Option B: let your platform do it

If you're on Laravel Cloud, custom domains are a first-party feature: the docs say Cloud will "guide you through DNS configuration, verify domain ownership, and issue an SSL certificate automatically". Worth reading their domain limits per plan before you assume it scales to a customer fleet, since this is designed around your own domains rather than thousands of your customers'. Laravel Forge provisions Let's Encrypt certificates per site, a per-hostname operation you'd drive through the Forge API on customer signup. Vapor issues through AWS ACM, which means CloudFront distributions and ACM's own per-account quotas.

Platform certificates are genuinely fine at small scale. The limits are the platform's quotas, issuance latency you don't control, and lock-in: your onboarding flow becomes a series of platform API calls that don't move with you.

Option C: use a custom domains API

Domainee is a custom domains API for SaaS with a native MCP server, 50 domains and 100 GB free. I build it, so weigh that as you read this section; the pricing math is public and you can check it yourself.

The model: your customer points a CNAME at the Domainee edge, the edge handles validation, issuance, renewal and serving, and proxies to your Laravel origin with X-Forwarded-Host set, which is exactly what step 2 already configured. Your app makes two API calls.

// app/Services/Domainee.php
class Domainee
{
    public function __construct(private string $key) {}

    public function createDomain(string $hostname, string $originUrl): array
    {
        return Http::withToken($this->key)
            ->post('https://api.domainee.dev/v1/domains', [
                'hostname' => $hostname,
                'originUrl' => $originUrl,
            ])->throw()->json();
    }

    public function checkDomain(string $id): array
    {
        return Http::withToken($this->key)
            ->post("https://api.domainee.dev/v1/domains/{$id}/check")
            ->throw()->json();
    }
}

Create the domain when the customer submits the form, show them the CNAME target from the response, and take a webhook so active flips your row the moment DNS and TLS are live:

// routes/web.php
Route::post('/webhooks/domainee', function (Request $request) {
    if ($request->input('type') === 'domain.verified') {
        $hostname = $request->input('data.hostname');

        CustomDomain::where('hostname', $hostname)->update(['status' => 'active']);
        Cache::forget("tenant:host:{$hostname}");
    }

    return response('', 200);
})->withoutMiddleware([
    \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
    \App\Http\Middleware\ResolveTenant::class,
]);

Verify the signature against the signing secret you got at endpoint-creation time before trusting the payload; the snippet above is trimmed for length, not a template for production. Note the Cache::forget, which is the cache-busting from step 3 landing in the one place that always knows a domain went live.

No certificate code in your repo, no rate-limit arithmetic, and the origin stays on Forge, Vapor, Cloud or a VPS unchanged. The tradeoff is a vendor in your request path, which is the same tradeoff as Option B with a different vendor.

Which one should you pick?

DIY CaddyForgeVaporDomainee
Setup effortHigh (server + ask endpoint + monitoring)Medium (Forge API per hostname)Medium (ACM + CloudFront)Low (CNAME + 2 API calls)
IssuanceSeconds, on first handshakeMinutes, per siteMinutes to tens of minutesMinutes, webhook on completion
Rate limits you manageLet's Encrypt, 50/weekLet's Encrypt, 50/weekACM + CloudFront quotasDomainee's problem
Apex domainsYes, you run the IPsYesYesYes, CNAME flattening or ALIAS
Cost at 100 domainsServer + your ops time$0 on top of the serverAWS usage$10/mo (50 free, then $0.20)
Portable across hostsYesNoNoYes

Honest summary: under 10 domains, whatever your platform gives you is free and fine. Past 50, or with onboarding spikes that can put 50 new hostnames in a week, the rate-limit math plus renewal monitoring starts costing real engineering time, which is the entire argument for the API route.

Step 6: the onboarding UX

Whatever edge you chose, the customer-facing flow is the same:

  1. Customer types app.acme.com into your settings page.
  2. You create the CustomDomain row (and the API call, if using one) and show exactly one instruction: "Add a CNAME record pointing app.acme.com to edge.yourapp.com".
  3. Poll or receive a webhook until DNS resolves and the certificate is live, then flip the badge to Active.

Don't make the customer click "verify" and interpret DNS errors themselves. Check it for them on a loop and show plain-language status. A CNAME lookup on your side answers "has their DNS change landed" before you ever attempt issuance, and propagation can lag by minutes to hours depending on the old record's TTL.

FAQ

Can one Laravel app serve multiple custom domains?

Yes. Laravel responds to every Host header by default and one app can serve thousands of hostnames. The work is trusting the proxy (step 2), resolving the tenant per request (step 3), keeping URL generation correct outside the request (step 4), and terminating TLS per hostname (step 5).

Why does my Laravel app generate the wrong domain in emails?

Because there's no request to read the host from. url() and route() use the current request's host, but queued jobs, notifications and scheduled commands run without one, so Laravel falls back to APP_URL. Pass the hostname into the job and call URL::forceRootUrl() before generating the link.

Why do users on custom domains keep getting logged out?

Almost always SESSION_DOMAIN. If it's set to .yourapp.com, the browser won't store the session cookie on app.acme.com, so every request starts a fresh session. Laravel leaves it unset by default, which is the setting custom domains need.

Do I need TrustHosts for custom domains in Laravel?

No, and enabling it naively will break them. Laravel responds to all hosts by default. If you've enabled trustHosts, pass a closure that reads your active hostnames from the database so runtime-added domains are included.

Do I need a wildcard certificate for a multi-tenant Laravel app?

Only for your own preview subdomains (*.yourapp.com). Customer domains can't share a wildcard certificate because each is on a different registered domain; every customer hostname needs its own.

How much does it cost to offer custom domains in a Laravel SaaS?

On a platform, certificates are usually included until quotas bite. DIY costs a small server plus your operations time. Domainee is free for 50 domains and 100 GB bandwidth, then $0.20 per domain per month, so 100 customer domains is $10/mo. Run your own numbers in the cost calculator.

Add Custom Domains to a Laravel App (Multi-Tenant Guide) | Domainee