Subdomains are the easy version of multi-tenancy: a wildcard certificate covers *.yourapp.com, you read the tenant from the host header, and you are done. The day a customer asks to use app.theirbrand.com instead, all of that comfort disappears — because now you need a valid TLS certificate for a domain you do not own.
I have run this in production, and Cloudflare for SaaS is the piece that makes it manageable. It issues and renews certificates for your customers' domains so you never touch a private key.
The shape of the problem
Three things have to line up for a custom domain to work:
- The customer points their domain at you with a DNS record.
- A TLS certificate exists for that exact hostname.
- Your app maps the incoming hostname to the right tenant.
Cloudflare for SaaS owns the first two through custom hostnames. Your Laravel app owns the third. Keeping that line crisp is what stops this from becoming a support nightmare.
How the request actually flows
The customer adds a CNAME from app.theirbrand.com to a hostname you give them (Cloudflare calls it the fallback origin). Cloudflare sees the traffic, validates ownership, provisions a certificate for app.theirbrand.com, terminates TLS at its edge, and forwards the request to your origin with the original Host header intact.
That last detail is the whole trick: your server receives Host: app.theirbrand.com, and that header is your tenant key.
// A middleware that resolves the tenant from whatever host arrived
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$host = $request->getHost();
$tenant = Tenant::where('primary_domain', $host)
->orWhere('custom_domain', $host)
->firstOrFail();
app()->instance('tenant', $tenant);
return $next($request);
}
}
Provisioning a domain without a human in the loop
When a customer saves a custom domain in their dashboard, I do not email anyone. I call Cloudflare's custom hostnames API, store the verification records it returns, and show them to the customer to add to their DNS.
$response = Http::withToken(config('services.cloudflare.token'))
->post("https://api.cloudflare.com/client/v4/zones/{$zone}/custom_hostnames", [
'hostname' => $tenant->custom_domain,
'ssl' => ['method' => 'http', 'type' => 'dv'],
]);
$tenant->update([
'cf_hostname_id' => $response->json('result.id'),
'domain_status' => 'pending',
]);
Cloudflare then works through validation and certificate issuance on its own. I listen for the status to flip to active — either by polling that hostname id on a schedule or via a webhook — and only then mark the domain live in the dashboard. The customer sees "pending → active" and never learns that certificates were involved at all.
The details that bite you in production
A few lessons that cost me time so they do not have to cost you:
- The fallback origin must be set up before any custom hostname will serve traffic. It is a one-time configuration, and forgetting it makes every domain look broken for reasons that are not in your app logs.
- Cache the host → tenant lookup. It runs on every single request; a cold database hit per request adds up fast. A short-lived cache keyed by hostname pays for itself immediately.
- Treat the domain lifecycle as a state machine, not a boolean.
pending,active,failed,deleted— each needs its own handling, and "is it verified yet?" is a question you will answer thousands of times. - Plan for removal. When a customer leaves, delete the custom hostname through the API too, or you leave orphaned certificates and a hostname that still resolves to you.
Done well, the customer's experience is almost anticlimactic: they paste their domain, add one DNS record, wait a minute, and their brand is live on your platform over HTTPS. All the hard parts — ownership validation, certificate issuance, renewal forever — happen where they belong, and your application stays a clean multi-tenant Laravel app that simply reads a host header.