# Custom domains in multi-tenant Laravel (https://dodomain.io/docs/guides/custom-domains-multi-tenant-laravel)

Custom domains in multi-tenant Laravel 12: resolve the tenant from the host, handle trustHosts, exempt webhooks from CSRF, place TLS on Forge or Vapor.

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](https://dodomain.io/docs/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 [#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:

```php title="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);
    }
}
```

```php title="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.

<Callout title="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.
</Callout>

### Two Laravel-specific traps [#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 [#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:

```php title="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 [#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](https://dodomain.io/docs/webhooks#verifying-signatures)). The verifier needs nothing outside PHP itself:

```php title="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);
    }
}
```

```php title="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 with the `add_webhook_endpoint` tool or `POST /api/v1/webhook-endpoints`, which returns the `whsec_...` secret. The [webhooks page](https://dodomain.io/docs/webhooks) 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](https://dodomain.io/docs/webhooks#automatic-dns-drift-monitoring).

## 4. Where TLS terminates on a Laravel stack [#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](https://forge.laravel.com/api-documentation) 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](https://dodomain.io/docs/ssl).

## 5. Apex survival: what to say to "can I use acme.com itself?" [#5-apex-survival-what-to-say-to-can-i-use-acmecom-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](https://dodomain.io/docs/apex-domains) page.

## Checklist [#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 [#see-also]

- [The connect flow](https://dodomain.io/docs/connect-flow): what your customer sees on the hosted page, provider by provider.
- [DNS provider setup guides](https://dodomain.io/docs/guides/dns-providers): the same panel steps the hosted flow shows, one page per provider, for your own support docs.
- [Free tools](https://dodomain.io/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](https://dodomain.io/compare/cloudflare-for-saas): the seam between the DNS half and the TLS half, the natural pairing on Vapor.
