DEV Community

Ahmed Khalid
Ahmed Khalid

Posted on

How to let your SaaS customers connect their own domains in Laravel, with automatic SSL

Cloudflare for SaaS handles the certificates. Here is everything it leaves for your app to do, and an open-source package that does it.

Sooner or later, a customer on your multi-tenant SaaS asks the same question: "Can my store live on shop.mybrand.com instead of mybrand.yoursaas.com?"

I run a multi-tenant SaaS where merchants connect their own domains through Cloudflare for SaaS. [One or two sentences from your own experience: what the first version looked like, or what went wrong with it.]

Cloudflare's API makes the certificate part easy. Creating a custom hostname takes one request. The hard part is everything around that request, so I extracted it into a Laravel package: a-khalid/cloudflare-saas-domains.

This article walks through why the problem is harder than it looks, then how to solve it in a few lines of Laravel.

Why it's harder than one API call

A custom domain isn't a request. It's a state machine that runs for days and depends on a person editing DNS somewhere else. Five things catch you out.

There are two validations, not one. Hostname ownership and certificate issuance are separate, with separate tokens and separate statuses. A domain is ready only when both are active and DNS points at you.

A domain is ready only when both validations pass: the ownership TXT sets status to active, the certificate TXT sets ssl.status to active)

The records you need may not exist yet. Cloudflare generates certificate validation tokens asynchronously, so the create response can come back without them. Your UI has to handle "not ready yet, ask again".

Polling makes events hard to get right. If you check a domain every five minutes, "send the merchant a congratulations email" must fire once, on the real transition, not on every poll. Two workers polling at once must not fire it twice.

Failure is often normal. A "does not CNAME to this zone" error is expected until the merchant finishes DNS. A hostname that Cloudflare has deleted after its validation window is a real problem. Your code has to tell them apart.

Requests have to find the right tenant. Every request now arrives with a hostname your app has never heard of. That lookup runs on every request, so it needs caching, and the cache has to clear the moment a domain's status changes.

The whole flow in Laravel

The package needs PHP 8.3+ and Laravel 12 or 13. A separate release covers Laravel 10 and 11. On the Cloudflare side, you need a zone with Cloudflare for SaaS enabled, an active fallback origin, and an API token with SSL and Certificates Write.

1. Install and configure

composer require a-khalid/cloudflare-saas-domains
php artisan vendor:publish --tag=cloudflare-saas-domains-config
php artisan vendor:publish --tag=cloudflare-saas-domains-migrations
php artisan migrate
Enter fullscreen mode Exit fullscreen mode
CF_SAAS_API_TOKEN=your-token
CF_SAAS_ZONE_ID=your-zone-id
CF_SAAS_FALLBACK_ORIGIN=fallback.your-saas.com
CF_SAAS_CNAME_TARGET=customers.your-saas.com
CF_SAAS_BLOCKED_DOMAINS=your-saas.com
Enter fullscreen mode Exit fullscreen mode

Then add the trait to your tenant model:

use AKhalid\CloudflareSaasDomains\Concerns\HasCustomDomains;

class Merchant extends Model
{
    use HasCustomDomains;
}
Enter fullscreen mode Exit fullscreen mode

2. Add a domain

try {
    $domain = CloudflareSaasDomains::add($merchant, $request->input('hostname'));
} catch (InvalidHostnameException
    | ApexDomainNotAllowedException
    | BlockedHostnameException
    | DomainAlreadyClaimedException $e) {
    return back()->withErrors(['hostname' => $e->getMessage()]);
}
Enter fullscreen mode Exit fullscreen mode

The hostname is normalized and validated before any API call, including converting international domains to punycode. These four exceptions are safe to show the merchant. Leave API errors to your exception handler, because their messages include internal details.

Calling add twice is safe. If the merchant already has that hostname, you get the existing domain back, with no API call and no event.

3. Tell the merchant what to do

$instructions = CloudflareSaasDomains::instructions($domain);

foreach ($instructions->required() as $record) {
    echo "{$record->type} {$record->name} {$record->value}\n";
}
Enter fullscreen mode Exit fullscreen mode

For a new domain, that prints something like this:

CNAME shop.mybrand.com                     customers.your-saas.com
TXT   _cf-custom-hostname.shop.mybrand.com 9e65b562-ab7c-479c-9c9a-9e6514632c5e
TXT   _acme-challenge.shop.mybrand.com     PzbB9BB8zprTZlQ3deWWg2ad5o7j8f1s
Enter fullscreen mode Exit fullscreen mode

A certificate can need two TXT values under the same _acme-challenge name, so tell merchants to add every record listed. The list follows the domain's state, so the merchant only sees what's still missing. Three flags cover the awkward cases: awaitingValidationRecords means the tokens aren't ready yet, retryRequired means validation failed, and hostnameGone means Cloudflare no longer has the hostname.

4. Let the status update itself

// routes/console.php
Schedule::command('cf-domains:sync')->everyFiveMinutes()->withoutOverlapping();
Schedule::command('cf-domains:sync --all')->daily()->withoutOverlapping();
Enter fullscreen mode Exit fullscreen mode

The first line checks pending domains. The second also checks live ones, which catches a hostname that stopped pointing at you or a certificate that lapsed. For a "Verify now" button, call CloudflareSaasDomains::retryValidation($domain).

5. React once, not on every poll

Event::listen(SslActivated::class, function (SslActivated $event) {
    if ($event->domain->isLive() && ! $event->isRenewal) {
        $event->domain->tenant->notify(new DomainIsLive($event->domain));
    }
});

Event::listen(DomainFailed::class, function (DomainFailed $event) {
    Log::warning($event->reason, ['host' => $event->domain->hostname]);
});
Enter fullscreen mode Exit fullscreen mode

There are five events: DomainAdded, DomainVerified, SslActivated, DomainFailed and DomainRemoved. Each fires only on a real state change, and the row is locked while the change is applied, so two concurrent polls can't both fire it.

6. Resolve the tenant on every request

Route::middleware('cf-domains.tenant')->group(function () {
    Route::get('/', function (Request $request) {
        $merchant = $request->attributes->get('tenant');

        return view('store.home', compact('merchant'));
    });
});
Enter fullscreen mode Exit fullscreen mode

Only active domains resolve, and any other host gets a 404. Results are cached and cleared when a domain's status changes. If stancl/tenancy is installed, tenancy is initialized automatically.

7. Test without a network

it('connects a merchant domain', function () {
    $fake = CloudflareSaasDomains::fake();

    $domain = CloudflareSaasDomains::add($merchant, 'shop.mybrand.com');
    $fake->assertCreated('shop.mybrand.com');

    $fake->activate('shop.mybrand.com');
    CloudflareSaasDomains::refresh($domain);

    expect($domain->isLive())->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

The fake is an in-memory Cloudflare zone. Your flows run for real, with real events and database rows, and no token or network.

What it doesn't do yet

A few honest limits, so you can decide if it fits:

  • Only partly verified against the live Cloudflare API. Live tests pass for creating, looking up, re-validating and deleting hostnames, and for the duplicate error. A merchant's DNS taking a domain to active, certificate issuance, real traffic and renewal haven't been exercised live yet.
  • Apex domains need Cloudflare's help. mybrand.com needs Apex Proxying or BYOIP from Cloudflare, so apex domains are blocked by default. You can allow them, but you'll have to give those merchants your A records yourself.
  • Apex detection is approximate. It uses a built-in list of common suffixes such as co.uk and com.sa, not the full Public Suffix List. You can add your own.
  • Status changes are polled, not pushed. They appear on your next sync.
  • Not covered yet: wildcard hostnames, custom certificates and per-hostname origin servers.

Try it

composer require a-khalid/cloudflare-saas-domains
Enter fullscreen mode Exit fullscreen mode

The code, docs and full list of assumptions are on GitHub, and it's MIT licensed. If Cloudflare's API behaves differently from what the package assumes, please open an issue. That's the feedback I most want.

Top comments (0)