a-khalid / cloudflare-saas-domains
Manage the full lifecycle of tenant custom domains on Cloudflare for SaaS (Custom Hostnames) in Laravel.
Package info
github.com/a-khalid/cloudflare-saas-domains
pkg:composer/a-khalid/cloudflare-saas-domains
Requires
- php: ^8.3
- ext-intl: *
- guzzlehttp/guzzle: ^7.8|^8.0
- laravel/framework: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.1
- stancl/tenancy: ^3.9
Suggests
- stancl/tenancy: Initializes tenancy automatically when a tenant is resolved by hostname.
Provides
None
Conflicts
None
Replaces
None
README
Let the merchants on your multi-tenant SaaS connect their own domains through Cloudflare for SaaS (Custom Hostnames, formerly SSL for SaaS).
The problem
Generic Cloudflare API wrappers give you the HTTP calls and leave the rest to you. A custom domain is a long-running state machine: validate the hostname, create it, tell the merchant exactly what to put in DNS, poll until Cloudflare is happy, react to success and failure once each, route requests to the right tenant, and clean up afterwards.
This package manages that whole lifecycle:
- Actions to add, remove, refresh and re-validate a domain
- DNS instructions computed from the domain's current state
- Status polling by command or queued job
- Events fired only on real state transitions
- Middleware that resolves the tenant from the request host, with caching
- A fake so your own tests need no network
- Optional stancl/tenancy integration
Requirements
- PHP 8.3+ with the
intlextension - Laravel 12 or 13 (for Laravel 10 and 11, see below)
- A Cloudflare zone with Cloudflare for SaaS enabled, an active fallback origin, and an API token with
SSL and Certificates Writeon that zone
Installation
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
Tenant IDs default to big integers. If yours are not, set CF_SAAS_TENANT_KEY_TYPE to uuid, ulid or string before migrating (stancl/tenancy uses string by default).
php artisan migrate
Laravel 10 and 11
These have a separate release, tagged v0.0.1. Both versions are past security support, so it gets fixes on a best-effort basis and no new features. It needs PHP 8.3 too.
composer require a-khalid/cloudflare-saas-domains:~0.0.1
Configuration
Everything lives in config/cloudflare-saas-domains.php and reads from the environment.
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,your-other-brand.com
| Variable | Default | Purpose |
|---|---|---|
CF_SAAS_API_TOKEN, CF_SAAS_ZONE_ID |
none | Cloudflare credentials |
CF_SAAS_FALLBACK_ORIGIN |
none | Where Cloudflare sends custom hostname traffic |
CF_SAAS_CNAME_TARGET |
fallback origin | What merchants point their CNAME at |
CF_SAAS_POLLING_INTERVAL |
300 |
Minimum seconds between checks of one domain |
CF_SAAS_TABLE |
tenant_domains |
Table name |
CF_SAAS_DB_CONNECTION |
default at boot | Connection for that table |
CF_SAAS_TENANT_MODEL |
any model | Restrict domain owners to this class |
CF_SAAS_TENANT_KEY_TYPE |
int |
int, uuid, ulid or string, read by the migration |
CF_SAAS_SSL_METHOD |
txt |
txt, http or email |
CF_SAAS_SSL_TYPE |
dv |
Certificate type |
CF_SAAS_BLOCKED_DOMAINS |
none | Comma-separated platform domains. Subdomains are blocked too. |
CF_SAAS_ALLOW_APEX |
false |
Allow apex domains. Needs Apex Proxying or BYOIP. |
CF_SAAS_HTTP_TIMEOUT, CF_SAAS_HTTP_CONNECT_TIMEOUT |
15, 5 |
Seconds, applied to every call |
CF_SAAS_HTTP_RETRIES |
3 |
Extra attempts on 429, 5xx and connection failures |
The published file has the rest: retry delays, sync batch size and queue, cache store and TTLs, extra public suffixes.
Quick start
Add the trait to your tenant model. It gives you a customDomains() relation.
use AKhalid\CloudflareSaasDomains\Concerns\HasCustomDomains; class Merchant extends Model { use HasCustomDomains; }
Then manage domains through the facade:
use AKhalid\CloudflareSaasDomains\Facades\CloudflareSaasDomains; $domain = CloudflareSaasDomains::add($merchant, 'shop.merchant.com'); $instructions = CloudflareSaasDomains::instructions($domain); // what to show the merchant CloudflareSaasDomains::refresh($domain); // check now, for a "Verify" button CloudflareSaasDomains::retryValidation($domain); // ask Cloudflare to re-check now CloudflareSaasDomains::remove($domain); // delete from Cloudflare, soft delete locally
Schedule the sync so pending domains move forward by themselves:
// routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('cf-domains:sync')->everyFiveMinutes()->withoutOverlapping(); // Catches live domains that stop pointing at you, and lapsed certificates. Schedule::command('cf-domains:sync --all')->daily()->withoutOverlapping();
--queue dispatches SyncDomainsJob instead of running inline, and skips it if one is already waiting. The job's timeout is 900 seconds, so give its queue connection a higher retry_after, or lower CF_SAAS_SYNC_BATCH_SIZE. --limit= caps a run. The command exits non-zero if a domain could not be checked.
What add guarantees
- The hostname is normalized: lowercase, IDN to punycode, no trailing dot.
- Invalid hostnames, apex domains and platform domains are rejected before any API call.
- Adding a hostname the tenant already has returns the existing domain, with no API call and no event.
- A hostname held by another tenant throws
DomainAlreadyClaimedException. - If Cloudflare fails, the local claim is released, so you can simply try again.
- A hostname that was removed, or that Cloudflare deleted, is free for any tenant to claim. So is a claim abandoned by a crashed process, after 15 minutes.
- If an earlier attempt reached Cloudflare but its response was lost, the existing hostname on your zone is adopted.
DNS instructions
DnsInstructions reflects the domain's current state, so a merchant only sees what is still needed.
$instructions = CloudflareSaasDomains::instructions($domain); foreach ($instructions->required() as $record) { echo "{$record->type} {$record->name} {$record->value} ({$record->purpose->value})\n"; }
For a new domain:
CNAME shop.merchant.com customers.your-saas.com (routing)
TXT _cf-custom-hostname.shop.merchant.com 5cc07c04-ea62-4a5a-95f0-419334a875a4 (ownership)
TXT _acme-challenge.shop.merchant.com 810b7d5f01154524b961ba0cd578acc2 (ssl_validation)
| State | required() returns |
|---|---|
| New, hostname pending | CNAME, ownership TXT, SSL TXT |
| Hostname active, certificate pending validation | SSL TXT |
| Certificate past validation, or HTTP validation in use | Nothing beyond any record above that is still unmet |
| Live | Empty, and $instructions->complete is true |
A certificate can need two TXT values under the same _acme-challenge name, and required() lists both, so tell merchants to add every one. Cloudflare generates these tokens asynchronously, so they may be missing just after creation. When $instructions->awaitingValidationRecords is true, refresh the domain and ask again. When retryRequired is true, validation failed: once DNS is fixed, call retryValidation(). When hostnameGone is true, Cloudflare no longer has the hostname, so records is empty and the domain has to be added again. optional() lists DCV delegation records when Cloudflare provides them. toArray() and json_encode() both work for API responses.
The CNAME must stay in place for as long as the domain is connected. The ownership TXT can be removed once the hostname is active. Renewals can bring new certificate tokens, which required() shows when a sync sees them.
Events
| Event | Fires when | Properties |
|---|---|---|
DomainAdded |
The hostname is created on Cloudflare for this tenant | domain |
DomainVerified |
The hostname status moves into an active state | domain |
SslActivated |
The certificate moves into a serving state | domain, previousSslStatus, isRenewal |
DomainFailed |
The hostname or certificate moves into a failed state | domain, reason |
DomainRemoved |
The domain is removed | domain |
They live in AKhalid\CloudflareSaasDomains\Events, and fire only on a transition, never on an unchanged poll. A domain that fails, recovers and fails again fires DomainFailed twice. SslActivated also fires when a certificate returns to a serving state after a renewal, with isRenewal set to true.
failure_reason also carries Cloudflare's current message while a domain is merely pending, such as "custom hostname does not CNAME to this zone". That is normal until the merchant finishes DNS and does not fire DomainFailed.
Middleware
Route::middleware('cf-domains.tenant')->group(function () { Route::get('/', function (Request $request) { $tenant = $request->attributes->get('tenant'); // or CloudflareSaasDomains::currentTenant() // and CloudflareSaasDomains::currentDomain() }); });
An unknown or inactive host gets a 404. Use cf-domains.tenant:optional to continue without a tenant, for example on routes shared with your own domain.
A domain resolves when its hostname status is active and its certificate is serving. Results, including misses, are cached and invalidated when a domain's status changes. Use a cache store shared between your servers (CF_SAAS_CACHE_STORE), otherwise the other servers only notice at the TTL.
If you use Laravel's TrustHosts middleware, merchant domains need to pass it.
stancl/tenancy
Nothing to configure. When stancl/tenancy is installed, the middleware calls tenancy()->initialize($tenant) for tenants that implement its Tenant contract. Other tenant models are left alone. The tenant_domains table stays on the connection that was the default at boot, so it keeps working after tenancy switches to a tenant database. Set CF_SAAS_STANCL_TENANCY=false to turn this off.
Testing with fakes
CloudflareSaasDomains::fake() swaps the API client for an in-memory zone, so your flows run for real, with real events and database rows, without network access.
use AKhalid\CloudflareSaasDomains\Facades\CloudflareSaasDomains; it('connects a merchant domain', function () { Event::fake([DomainVerified::class]); $fake = CloudflareSaasDomains::fake(); $domain = CloudflareSaasDomains::add($merchant, 'shop.merchant.com'); $fake->assertCreated('shop.merchant.com'); $fake->activate('shop.merchant.com'); CloudflareSaasDomains::refresh($domain); expect($domain->isLive())->toBeTrue(); Event::assertDispatched(DomainVerified::class); });
| Move a hostname along | Simulate trouble | Assert |
|---|---|---|
verifyHostname($host) |
setStatus($host, $status, $sslStatus, $errors) |
assertCreated, assertNotCreated |
activateSsl($host) |
withoutValidationRecords($host) |
assertCreatedCount, assertNothingCreated |
activate($host) |
forget($host) |
assertDeleted, assertNothingDeleted |
seed($host, id: $id) |
failNextWith($exception) |
assertRevalidated, assertNothingRevalidated |
The fake does not poll by itself: calling verifyHostname changes the zone, and your code sees it on the next refresh or sync. It also lasts for one process, so use seed() if a later process needs to act on an existing domain.
Errors
Everything thrown extends CloudflareSaasDomainsException. API errors extend CloudflareApiException, with statusCode and errors.
| Situation | Exception |
|---|---|
| 401 | AuthenticationFailedException |
| 403 | PermissionDeniedException |
| 404 | ResourceNotFoundException |
| 409, or a "duplicate" message | DuplicateHostnameException |
| 429 after retries | RateLimitedException, with retryAfterSeconds |
| 5xx after retries | ServerErrorException |
Other rejections, including HTTP 200 with success: false |
InvalidRequestException |
| An undocumented response shape | InvalidResponseException |
| Timeouts and connection failures after retries | ConnectionFailedException |
| Bad hostnames | InvalidHostnameException, ApexDomainNotAllowedException, BlockedHostnameException |
During a sync, one failing domain is logged and the run continues, a rate limit ends the run early, and credential errors stop it. A hostname that Cloudflare no longer has is marked deleted and fires DomainFailed.
Limitations
- Only partly verified against the live API. On a Free plan zone, the live smoke tests pass for creating, reading, looking up, re-validating and deleting a hostname, the duplicate error, adopting an existing hostname, and the package's actions. Not yet exercised live: a merchant's DNS taking a domain to active, a certificate being issued, real traffic through the middleware, and renewal. Those parts rest on Cloudflare's documentation.
- Apex domains need Cloudflare's help.
merchant.comcan't use a CNAME in standard DNS, so it needs Apex Proxying or BYOIP, which are limited to certain plans and cost extra. Apex domains are blocked by default.CF_SAAS_ALLOW_APEX=truelets them through, butDnsInstructionsstill shows a CNAME: you need to tell 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. An unlisted suffix can let an apex through as if it were a subdomain. Add suffixes invalidation.public_suffixes. - A brief cache race. A request that read a domain just as it was removed can write its result back after the invalidation. That host then keeps resolving until
CF_SAAS_CACHE_TTL(300 seconds by default). Lower the TTL if that matters to you. - Polling only. Status changes appear on your next sync, not instantly. Live domains are only re-checked with
--all. - Renewal behavior is an assumption. The docs do not describe
ssl.statusduring renewal. So a store doesn't go dark, a domain whose certificate has already served keeps resolving through renewal-looking states while its stored expiry is in the future. See assumption 6. - Duplicate detection is by message. Cloudflare documents no error code for it. See assumption 1.
- Cloudflare-to-Cloudflare hostnames. Cloudflare doesn't support pre-validation when the merchant's own zone is also on Cloudflare. The ownership TXT won't help them, and they need the CNAME in place.
- Not covered yet: wildcard hostnames, custom certificates, per-hostname
custom_origin_server, and custom metadata throughadd()(the client supports metadata). - Retries block. A retried call can wait several seconds in the current process, which matters in a web request. Lower
CF_SAAS_HTTP_RETRIESif needed, or calladdfrom a job. - Laravel 10 and 11 get a separate, best-effort release. See Laravel 10 and 11.
Assumptions
Cloudflare's docs do not settle these, so the package takes the safest reading. Please open an issue if the live API behaves differently.
- Duplicate errors. The docs give no code for this. A response counts as a duplicate when it is HTTP 409 or an error message contains "duplicate" or "already exists". The live API answers 409 with code 1406, "Duplicate custom hostname found." The existing hostname is adopted only on an exact match in your own zone, checked client-side too, and not while Cloudflare is deleting it.
- Re-validation body. Docs say to send an
sslobject with the samemethodandtype, and elsewhere "the same values as your initial POST".RetryValidationreads the hostname and PATCHes only itsmethodandtype, which the live API accepts with HTTP 202. Copying more is unsafe: it answers 400 (code 1459) tocertificate_authoritybelow the Enterprise plan. - Retrying non-idempotent calls. POST is retried on 429/5xx/connection errors. A duplicate from a lost response is handled by adoption (1), and a DELETE retried into 404 counts as already removed.
- Failed states are not documented as such. Treated as failed: hostname
moved, deleted, blocked, pending_blocked, test_blocked, test_failed; SSL*_timed_out, expired, deleted.pending_deletion,inactiveand unknown values are not failures on their own. - Active states. Hostname active means
activeoractive_redeploying.provisionedandtest_*are not mapped by the docs and count as not active. - Certificate renewal. Docs do not say how
ssl.statusbehaves during renewal. To avoid taking a live store down,pending_expirationcounts as serving, and renewal-looking states (pending_validation, pending_issuance, pending_deployment, holding_deployment, backup_issued) still resolve while the storedssl.expires_onis in the future, but only once a certificate has served on that domain, so a first certificate never resolves early. The hostname must be active in every case, and Cloudflare's edge is still what terminates TLS. hostname.exactquery parameter uses the dotted form the List docs name.- A 404 while polling means Cloudflare no longer has the hostname: it is marked
deleted,DomainFailedfires once, and polling stops. It is not raised as an exception, so one lost domain cannot break a sync run. - Ownership TXT is shown as required until the hostname is active. A live CNAME alone can validate it, but adding the TXT is harmless and avoids a second round with the merchant.
- Delegated DCV. The docs describe getting the target from the dashboard, while the API schema exposes
ssl.dcv_delegation_records. They are surfaced only as optional records, when bothcnameandcname_targetare present. - Apex detection uses a label count plus a curated multi-part suffix list (extendable in config), not the full Public Suffix List. It can miss unusual suffixes, which is listed as a limitation.
- HTTP and email DCV produce no DNS records for the merchant, because HTTP DCV needs only the CNAME. Records for them stay readable in
verification_records.
Development
See CONTRIBUTING.md.
License
MIT. See LICENSE.