DoDomain
Guides

Custom domains for a multi-tenant Laravel app

Resolve the tenant from the request host in Laravel 12 middleware, keep trusted-host and CSRF checks from breaking customer domains, mint the DNS records with DoDomain through the Http client, verify the signed webhook in PHP, and decide where TLS terminates on Forge, Vapor or Octane.

A multi-tenant Laravel app gets three separate problems the day a customer asks for app.acme.com instead of acme.yourproduct.com: the app has to know which tenant a hostname belongs to, the customer's DNS has to point that hostname at you and stay pointed, and something has to hold a certificate for a domain you do not own. This page covers all three in the order they bite, and ends with the two answers that are not obvious — where TLS terminates on a Laravel stack, which differs sharply between Forge, Vapor and Octane, and what to say when a customer wants the bare acme.com.

The examples use Laravel 12, the Http facade for the REST API, and plain PHP for the webhook verifier. Nothing here depends on a particular database or tenancy package.

1. Resolve the tenant from the request host

Route::domain('{account}.yourproduct.com') is the right tool for tenants on your own subdomains — the pattern is known up front. A customer's domain is not, so custom domains are resolved in middleware from $request->getHost() and published through a per-request singleton the rest of the app reads:

app/Http/Middleware/ResolveTenantFromHost.php
<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use App\Tenancy\CurrentTenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenantFromHost
{
    private const PRIMARY_HOST = 'yourproduct.com';

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

        if ($host === self::PRIMARY_HOST || $host === 'www.'.self::PRIMARY_HOST) {
            return $next($request);
        }

        // A tenant on your own subdomain (acme.yourproduct.com) or on a custom
        // domain (app.acme.com). Both come from the same table — one row per
        // hostname a tenant is allowed to serve — and only ACTIVE rows count.
        $tenant = Tenant::findByActiveHostname($host);
        abort_unless($tenant, 404);

        app(CurrentTenant::class)->set($tenant);

        return $next($request);
    }
}
bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        \App\Http\Middleware\ResolveTenantFromHost::class,
    ]);

    // Behind Forge's nginx, Vapor's CloudFront or any load balancer, the
    // customer's hostname arrives in X-Forwarded-Host. getHost() only reads it
    // from a TRUSTED proxy — trust yours, or every tenant resolves to your
    // upstream name.
    $middleware->trustProxies(at: '*');
})

Tenant::findByActiveHostname is a query against a table you own. The rest of this guide is about how rows get into that table honestly: not when a customer types a domain into a form, but when DNS actually resolves to you.

Do not trust the form

A customer typing app.acme.com into your settings page proves nothing. Store the domain as pending, and only mark it active — only let the middleware serve it — when a connection.verified webhook says the records are live. Otherwise a typo in a competitor's hostname routes their traffic to a 404 on your infrastructure.

Two Laravel-specific traps

trustHosts. Laravel answers any Host header by default, so a fresh app serves customer domains fine. The trap is the day someone hardens the app with $middleware->trustHosts(): that middleware allows only your app URL and its subdomains, and every custom domain starts answering 400. If you want the protection, give it the same lookup the tenant middleware uses — trustHosts(at: fn () => Tenant::activeHostnames()) — and keep that list cached and active-only, or a pending domain becomes reachable before DNS proves anything.

CSRF on the webhook route. Laravel's web group validates a CSRF token on every POST, and an inbound webhook carries none. Exclude the webhook path — $middleware->validateCsrfTokens(except: ['webhooks/dodomain']) — or route it through the api group, which has no CSRF middleware.

2. Mint the records with a connect session

Your server mints one session per domain, describing the records your product needs. For a subdomain that is a single CNAME to your ingress hostname; add a TXT if you want an ownership proof you can check yourself. The call is POST /api/v1/sessions with your app's secret key:

app/Http/Controllers/Settings/DomainsController.php
<?php

namespace App\Http\Controllers\Settings;

use App\Http\Controllers\Controller;
use App\Tenancy\CurrentTenant;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class DomainsController extends Controller
{
    public function store(Request $request, CurrentTenant $current): RedirectResponse
    {
        $domain = strtolower($request->string('domain'));
        $tenant = $current->get();

        // Every non-2xx answer is { "error": { "code", "message" } }; throw()
        // turns it into a RequestException whose response still carries the
        // code (quota_exceeded on a 402, invalid_request on a 400, …).
        $session = Http::withToken(config('services.dodomain.secret_key'))
            ->post('https://app.dodomain.io/api/v1/sessions', [
                'domain' => $domain,
                'records' => [
                    ['type' => 'CNAME', 'host' => '@', 'value' => 'cname.yourproduct.com'],
                ],
                'returnUrl' => "https://{$tenant->slug}.yourproduct.com/settings/domains",
            ])
            ->throw()
            ->json();

        // pending until the webhook says otherwise
        $tenant->customDomains()->create([
            'hostname' => $domain,
            'dodomain_session_id' => $session['id'],
        ]);

        return redirect()->away($session['connectUrl']);
    }
}

host: "@" means "the domain itself" — the domain you passed is app.acme.com, so the CNAME lands on app.acme.com, not on acme.com. Read records[].fqdn in the response if you want to confirm what will be verified. Send the customer to connectUrl. On Cloudflare-hosted zones the record is written with one OAuth consent; on a provider that has enabled Domain Connect for DoDomain it is one click; everywhere else the hosted page shows that provider's exact panel steps and verifies each record live. You do not branch on any of that.

3. Activate the tenant from the webhook, not the redirect

The customer coming back to returnUrl means they closed the flow, not that DNS is live. The signal you act on is the signed connection.verified event. Every delivery carries an x-dodomain-signature header of the form t=<unix ms>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + rawBody keyed with the endpoint's whsec_... secret (the full scheme). The verifier needs nothing outside PHP itself:

app/Support/DodomainWebhookSignature.php
<?php

namespace App\Support;

final class DodomainWebhookSignature
{
    public const SIGNATURE_HEADER = 'x-dodomain-signature';
    public const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;

    /**
     * Verifies the exact bytes DoDomain sent. Never pass a re-encoded array —
     * the signature covers the raw body.
     */
    public static function verify(
        string $secret,
        string $rawBody,
        string $header,
        int $toleranceMs = self::DEFAULT_TOLERANCE_MS,
        ?int $nowMs = null,
    ): bool {
        $parts = [];
        foreach (explode(',', $header) as $pair) {
            [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
            $parts[trim($key)] = trim($value);
        }

        $t = (int) ($parts['t'] ?? 0);
        $nowMs ??= (int) floor(microtime(true) * 1000);
        if ($t <= 0 || abs($nowMs - $t) > $toleranceMs) {
            return false;
        }

        // A malformed signature is a failed verification, not an exception:
        // shape-gate before the constant-time compare.
        $got = $parts['v1'] ?? '';
        if (preg_match('/^[0-9a-f]{64}$/', $got) !== 1) {
            return false;
        }

        $expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);

        return hash_equals($expected, $got);
    }
}
app/Http/Controllers/Webhooks/DodomainController.php
<?php

namespace App\Http\Controllers\Webhooks;

use App\Http\Controllers\Controller;
use App\Models\CustomDomain;
use App\Models\WebhookReceipt;
use App\Support\DodomainWebhookSignature;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class DodomainController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $rawBody = $request->getContent();
        $signature = (string) $request->header(DodomainWebhookSignature::SIGNATURE_HEADER, '');

        if (! DodomainWebhookSignature::verify(config('services.dodomain.webhook_secret'), $rawBody, $signature)) {
            return response('bad signature', 401);
        }

        $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

        // Dedupe on the event id — retries reuse it. Store it before acting.
        if (WebhookReceipt::where('event_id', $event['id'])->exists()) {
            return response()->noContent();
        }
        WebhookReceipt::create(['event_id' => $event['id']]);

        $domain = $event['data']['domain'];
        match ($event['type']) {
            // First verification, or recovery after a drift (data.recovered === true).
            'connection.verified' => CustomDomain::activate($domain),
            // scope "connection": a verified domain drifted and stayed wrong for
            // three consecutive checks. scope "session": an attempt failed
            // mid-flow — the row is still pending, nothing to deactivate.
            'connection.failed' => ($event['data']['scope'] ?? null) === 'connection'
                ? CustomDomain::deactivate($domain)
                : null,
            'connection.disconnected' => CustomDomain::deactivate($domain),
            default => null,
        };

        return response()->noContent();
    }
}

The controller reads $request->getContent(), never $request->all(), because the signature covers the bytes on the wire. Route it with Route::post('webhooks/dodomain', DodomainController::class) (CSRF excluded as above) and register the URL as an endpoint in the dashboard, which gives you the whsec_... secret. The webhooks page has the full event table and the retry schedule.

CustomDomain::activate is what flips the row the middleware reads. Once active, the domain stays watched: DoDomain re-checks every connection's records against authoritative DNS on every plan, at no charge — every 10 minutes for the first day, hourly for the first week, every 6 hours after that. A customer who deletes the CNAME months later produces a connection.failed, which your handler turns into a deactivated row instead of a tenant silently serving a 404 on their own domain. Details in Automatic DNS drift monitoring.

4. Where TLS terminates on a Laravel stack

This is the part multi-tenant guides gloss over. DoDomain is a DNS layer and is never in your request path, so it holds no certificate for app.acme.com — the certificate is issued wherever your Laravel app terminates TLS, and connection.verified is the moment issuance can succeed, because DNS finally resolves to that host. The three common Laravel deployments answer this very differently.

Forge. Forge manages certificates per domain: each custom domain attached to a site gets its own nginx configuration and its own Let's Encrypt certificate, and the server's 000-catch-all config answers 444 to any hostname you have not configured. So a customer domain is a Forge domain: on connection.verified, add the hostname to the site through the Forge API and request its certificate. Let's Encrypt's HTTP-01 challenge needs the domain to resolve to the server and port 80 open — which is exactly what the webhook has just confirmed. Forge's recommended DNS-01 path instead needs a CNAME at the customer's DNS pointing at a verify-….ssl.on-forge.com target; if you go that way, put that second CNAME in the same connect session so it is written, verified and monitored with the first one. The session's CNAME points at the Forge server.

Vapor. Domains and certificates are attached per environment in vapor.yml and provisioned on deploy, through AWS Certificate Manager and CloudFront — and Vapor's own docs say a new custom domain takes 30–45 minutes to become active. That fits your product's domains; it does not fit a domain a customer adds from a settings page. On Vapor, terminate the customer's TLS somewhere built for unknown hostnames — Cloudflare for SaaS, or a proxy in front of the API Gateway — and point the session's CNAME at that layer, not at Vapor.

Octane behind nginx, or Octane on FrankenPHP. Octane (Swoole, RoadRunner or FrankenPHP) is a long-running app server; behind nginx the certificate story is nginx's — certbot or acme.sh per customer domain with HTTP-01, triggered by the webhook, then a reload. FrankenPHP embeds Caddy, so it can also use Caddy's on_demand_tls: a certificate is issued the first time a new hostname arrives, after Caddy's ask endpoint has confirmed with you. Point ask at a tiny route that answers 200 only for active custom domains — the same table the tenant middleware reads — and no certificate is ever minted for a hostname that has not verified.

Cloudflare for SaaS. Create a custom hostname for the domain and have the session's CNAME point at your zone's fallback origin. Cloudflare validates through DNS and issues the edge certificate; your origin sees X-Forwarded-Host, which getHost() honours once the proxy is trusted.

The sequencing rule that holds for every host — add the domain on the platform, mint the session pointing at it, treat connection.verified as "issuance can proceed" — and the Render and Fly.io notes are on the SSL page.

5. Apex survival: what to say to "can I use acme.com itself?"

DNS forbids a CNAME at a zone's apex, so acme.com → cname.yourproduct.com is not a record any provider will accept, and DoDomain cannot create the provider-specific ALIAS/ANAME/flattened substitute for your customer. Decide the policy before the first customer asks, and enforce it in your form request, not in the connect flow:

  1. Ask for a subdomain (app.acme.com, links.acme.com). One CNAME, every provider, every one-click path, and the middleware needs no change. POST /api/v1/domains/check returns both the domain and the zone that owns its records; when the two are equal the customer gave you an apex, and that is the moment to fail validation with a message that says what to do.
  2. Serve the apex from A/AAAA records if — and only if — your ingress has stable, documented IPs (an anycast edge, a load balancer with reserved addresses). Request them at "@" on acme.com. Those addresses become a contract with every connected customer — and on Vapor they do not exist at all.
  3. www plus a redirect at the provider. The customer points www.acme.com at you with a CNAME (verified and monitored), and sets apex-to-www forwarding in their DNS panel. Be explicit that the redirect is theirs: DoDomain neither creates it nor watches it.

The reasoning behind each, the CNAME-flattening providers, and the warnings a session returns for an apex request are on the Apex domains page.

Checklist

  • The tenant middleware resolves from $request->getHost() behind a trusted proxy and serves only active rows.
  • trustHosts, if you use it, is fed the same active-hostname list; the webhook route is excluded from CSRF.
  • Domains enter as pending; only connection.verified activates them; connection.failed (scope connection) and connection.disconnected deactivate them.
  • The webhook controller verifies $request->getContent() with the verifier above and dedupes on id.
  • Certificates come from where TLS terminates (Forge per-domain certificates, Caddy inside FrankenPHP, nginx + certbot, Cloudflare for SaaS — and never Vapor itself for customer domains) — never from DoDomain.
  • The apex policy is decided in your form: subdomain by default, A/AAAA only with stable IPs, www + provider redirect as the fallback.

See also

  • The connect flow — what your customer sees on the hosted page, provider by provider.
  • DNS provider setup guides — the same panel steps the hosted flow shows, one page per provider, for your own support docs.
  • Free tools — the provider detector and the authoritative-vs-public DNS lookup, for debugging a customer's zone without a session.
  • DoDomain with Cloudflare for SaaS — the seam between the DNS half and the TLS half, the natural pairing on Vapor.

On this page