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.
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 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 Then add the trait to your tenant model:
use AKhalid\CloudflareSaasDomains\Concerns\HasCustomDomains; class Merchant extends Model { use HasCustomDomains; } 2. Add a domain
try { $domain = CloudflareSaasDomains::add($merchant, $request->input('hostname')); } catch (InvalidHostnameException | ApexDomainNotAllowedException | BlockedHostnameException | DomainAlreadyClaimedException $e) { return back()->withErrors(['hostname' => $e->getMessage()]); } 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"; } 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 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(); 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]); }); 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')); }); }); 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(); }); 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.comneeds 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.ukandcom.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 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.