The moment a product hands each customer their own subdomain, you need a wildcard certificate, and the moment you need a wildcard certificate, you have left the comfortable world of HTTP-01 behind. We issue wildcards for our own products and for a number of client projects, and the thing we most wish someone had told us at the start is not about any particular library. It is that example.com and *.example.com validate against the same DNS record name, and a surprising number of automation setups get that wrong in a way that only fails intermittently.
This post covers issuing wildcards from PHP with CoyoteCert, which we built, so treat that as the disclosure it is. It also covers the propagation problem properly, because that part applies whatever tool you use.
Why wildcards force you onto DNS-01
ACME offers three ways to prove you control a name. HTTP-01 puts a token on a well-known URL, TLS-ALPN-01 answers a special TLS handshake, and DNS-01 publishes a TXT record at _acme-challenge.<domain>. The first two can prove you control a specific host. Neither can prove you control every possible host under a name, which is what a wildcard asserts.
A small correction to something you will read in a lot of places, including, until recently, our own documentation: RFC 8555 does not itself restrict wildcards to DNS-01. It defines wildcard identifiers and flags the resulting authorizations, and then leaves the choice of challenge to the CA. The restriction comes from CA policy instead. Let's Encrypt only validates wildcards through DNS-01, and the CA/Browser Forum's Baseline Requirements confine wildcard validation to methods that demonstrate control of the DNS zone, so every public CA you can actually use behaves the same way. The practical effect is identical. The attribution is not, and it is worth getting right.
That single constraint reshapes the whole automation problem. HTTP-01 needs a web server. DNS-01 needs API credentials for your DNS provider, code that knows how to create and delete TXT records there, and some way of knowing when the record has actually propagated far enough for the CA to see it. That last part is where things go wrong.
The certbot route, and where it runs out
Certbot handles this with DNS plugins, one per provider, plus manual hooks for anything unsupported. For a single server with a few wildcard domains and a sysadmin who can run cron, that is a perfectly reasonable setup and we would not talk anyone out of it.
It becomes awkward when certificate issuance is part of your application rather than part of your server. A multi-tenant platform where a tenant adds a custom domain from a settings page cannot shell out to certbot from a PHP request handler and hope. A provisioning pipeline running in a container with no persistent shell has nowhere to put certbot's state. And an application that needs to issue against several DNS providers depending on which customer is asking ends up wrapping certbot in enough glue that the glue becomes the project.
At that point you want the ACME client inside the application, speaking the protocol directly, with DNS providers as pluggable handlers.
Issuing a wildcard from PHP
The library needs PHP 8.3 or later with curl, json, mbstring and openssl, which is a standard build.
composer require blendbyte/coyotecert
A wildcard for example.com, validated through Cloudflare, looks like this. Note that both the apex and the wildcard are listed, because *.example.com covers one label deep and does not include the bare domain.
use CoyoteCert\CoyoteCert;
use CoyoteCert\Challenge\Dns\CloudflareDns01Handler;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
$cert = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers(['example.com', '*.example.com'])
->email('admin@example.com')
->challenge(new CloudflareDns01Handler(apiToken: 'your-api-token'))
->issueOrRenew();
echo $cert->fullchain; // PEM leaf + intermediates
echo $cert->privateKey; // PEM private key
The Cloudflare token needs Zone.DNS:Edit. Zone detection is automatic, walking from sub.example.com up to example.com until the API returns a match, and you can pass zoneId: explicitly to skip that lookup. The return is a typed object rather than a bag of PEM strings, so your IDE knows what $cert->caBundle is without you printing it.
Test against staging first. Swap new LetsEncrypt() for new LetsEncryptStaging() and the handshake is byte-for-byte identical, without touching production rate limits. Everyone who skips this step ends up filing a rate limit issue somewhere.
The TXT record race
Here is the part that matters regardless of which client you use.
When you request a certificate for example.com and *.example.com, the CA issues two separate authorizations, and both of them require a TXT record at the same name: _acme-challenge.example.com. Two different values, one record name.
The naive implementation, and we have read several, deploys the first TXT value, waits for the CA to validate it, deletes it, deploys the second, and validates that. Slightly less naive implementations deploy both but check propagation by resolving the name once and seeing that a record exists. Both approaches fail intermittently rather than reliably, which is the worst kind of failure, because the CA's validator may hit an authoritative nameserver that has one value but not the other, or a caching resolver that still holds the previous answer.
CoyoteCert handles this in the way we eventually concluded was the only correct one. Every TXT record for the entire order is written before any propagation check begins, so the wildcard and its base name are never published as a half-complete record set. The check then queries every authoritative nameserver for the zone directly, not a resolver, and requires all of that name's values to be present on every one of them. Only after that passes does a settle delay run, which exists to let caching resolvers in front of the CA drop any pre-update answer they might be holding.
Both stages are tunable, and each call returns a new immutable handler:
$handler = (new CloudflareDns01Handler(apiToken: 'your-api-token'))
->propagationTimeout(120) // seconds to poll authoritative NS (default: 60)
->propagationDelay(300); // settle pause for resolver caches (default: 60)
The settle delay should be at least the TTL of the _acme-challenge record, and the library logs a warning if it observes a TTL longer than the delay you configured. The built-in handlers already request the lowest TTL each provider accepts, which is 10 seconds on Route 53, 30 on DigitalOcean and 60 elsewhere. If you write your own handler, set a short TTL yourself, because otherwise the record inherits the zone default and your settle delay is quietly wrong.
There are two more behaviours worth knowing. Before deploying, each handler deletes any stale _acme-challenge records already sitting at that name, which prevents last month's failed attempt from confusing this month's validation. If you genuinely need multiple certificates for the same domain to coexist, keepExistingRecords() disables that. And if you run split-horizon or internal DNS where the authoritative check cannot work, skipPropagationCheck() turns it off, at which point you are trusting your delay alone.
Swapping DNS providers
The handler is the only line that changes. Hetzner, for instance:
use CoyoteCert\Challenge\Dns\HetznerDns01Handler;
->challenge(new HetznerDns01Handler(apiToken: 'your-api-token'))
Route 53, which is worth a note because it does not pull in the AWS SDK. SigV4 request signing is implemented directly with hash_hmac() and hash(), so a Laravel app that has no other reason to depend on the SDK does not acquire one for the sake of a TXT record. The IAM principal needs route53:ChangeResourceRecordSets and route53:ListHostedZonesByName.
use CoyoteCert\Challenge\Dns\Route53Dns01Handler;
->challenge(new Route53Dns01Handler(
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
))
DigitalOcean and ClouDNS follow the same shape. The sixth built-in handler is ShellDns01Handler, which is the escape hatch for anything else: it runs a command you supply with {domain} and {keyauth} substituted, and separately a cleanup command, which means an existing hook script written for certbot's manual mode can usually be reused unchanged.
use CoyoteCert\Challenge\Dns\ShellDns01Handler;
->challenge(new ShellDns01Handler(
deployCommand: '/usr/local/bin/dns-hook add {domain} {keyauth}',
cleanupCommand: '/usr/local/bin/dns-hook del {domain}',
))
A non-zero exit throws, so a broken hook fails the order loudly rather than producing a certificate with a missing name.
Writing your own handler
For a provider with no built-in and no shell hook, implement ChallengeHandlerInterface. It has three methods, and the whole thing is short enough that we would rather show it than describe it.
use CoyoteCert\Enums\AuthorizationChallengeEnum;
use CoyoteCert\Interfaces\ChallengeHandlerInterface;
class MyDns01Handler implements ChallengeHandlerInterface
{
public function supports(AuthorizationChallengeEnum $type): bool
{
return $type === AuthorizationChallengeEnum::DNS;
}
public function deploy(string $domain, string $token, string $keyAuthorization): void
{
// $keyAuthorization is the value to put in the TXT record
MyDns::setTxtRecord('_acme-challenge.' . $domain, $keyAuthorization);
}
public function cleanup(string $domain, string $token): void
{
MyDns::deleteTxtRecord('_acme-challenge.' . $domain);
}
}
Remember the TTL point from earlier. A custom handler is responsible for requesting a short TTL, and if it does not, the propagation behaviour described above becomes a guess.
The CAA record that silently blocks you
Before contacting the CA at all, the library checks CAA records for every identifier and throws a CaaException if the CA you are about to use is not permitted. For wildcards it checks issuewild records first and falls back to issue records if none exist, which is the RFC 8659 behaviour and also the thing most people forget when they add a CAA record for the apex and then wonder why the wildcard fails.
The gotcha that catches people: the CAA identifier is not always the CA's brand name. Let's Encrypt is letsencrypt.org, but ZeroSSL's is sectigo.com (or comodoca.com), and Google Trust Services is pki.goog. If your CAA record says zerossl.com, ZeroSSL will refuse to issue, and the error message from the CA will not be as helpful as the one CoyoteCert throws before you get that far. skipCaaCheck() exists if you need to opt out, but a check that fails fast with the actual reason is usually worth keeping.
Renewal, and reloading the thing that uses the certificate
issueOrRenew() is idempotent. If a valid certificate exists in storage and is not due, it returns the stored one without touching the network. Due means either within thirty days of expiry (configurable) or, more interestingly, when the CA says so.
That second condition is ACME Renewal Information, RFC 9773. A supporting CA exposes a renewalInfo endpoint that tells you a specific renewal window, and CoyoteCert checks it automatically on every needsRenewal() or issueOrRenew() call. If the window is open, renewal happens even if the certificate has fifty days left. If the ARI request fails, it falls back to the day threshold silently. If the CA does not support ARI, the threshold is used exclusively. There is nothing to configure, which is how it should be.
The piece that turns a certificate into a working site is the callback:
CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers(['example.com', '*.example.com'])
->email('admin@example.com')
->challenge(new CloudflareDns01Handler(apiToken: 'your-api-token'))
->onRenewed(fn() => exec('systemctl reload nginx'))
->issueOrRenew();
onRenewed fires only when an existing certificate is replaced, so a cron run that finds nothing to do does not reload nginx for no reason. onIssued fires on every successful issuance including the first.
On multi-server setups, put storage somewhere shared. DatabaseStorage takes a PDO handle and works with MySQL, MariaDB, PostgreSQL and SQLite, and file locking on FilesystemStorage is safe across concurrent processes on a shared volume. Two servers racing to issueOrRenew() at the same moment end up holding the same certificate, which is the civilised outcome.
Doing it from cron instead
If the application does not need to own the lifecycle, the CLI does the same job with no PHP written. The binary is coyote, and there is deliberately no separate renew command: issue handles both first issuance and renewal, exiting cleanly with no network requests if the stored certificate is not yet due.
export CLOUDFLARE_API_TOKEN=your-token
coyote issue \
--identifier example.com \
--identifier '*.example.com' \
--dns cloudflare \
--email admin@example.com \
--provider letsencrypt \
--storage /etc/certs
In cron, randomise the start so that everyone's renewal does not land on the CA at the same minute:
0 3 * * * sleep $((RANDOM % 3600)) && coyote issue --identifier example.com --identifier '*.example.com' --dns cloudflare --email admin@example.com --provider letsencrypt --storage /etc/certs
The DNS credentials come from environment variables per provider (CLOUDFLARE_API_TOKEN, HETZNER_API_TOKEN, AWS_ACCESS_KEY_ID and so on), and --dns-propagation-delay maps to the settle delay discussed above. coyote status will tell you where a stored certificate stands.
When certbot is still the right answer
If you have one server, a handful of domains, shell access and a cron job, certbot with its DNS plugin is fine and has a decade of testing behind it. This post is not an argument against it. The PHP route earns its keep when issuance is application logic, when there is no shell, when you need several providers in one codebase, or when you want the certificate lifecycle version-controlled alongside the code that depends on it.
For the wildcard-specific problem, though, the TXT record race applies to everyone. Whichever tool you use, check that it publishes all values before validating any, and that its propagation check looks at authoritative nameservers rather than whatever resolver the box happens to have.
Building something with customer subdomains?
Certificate provisioning for multi-tenant platforms, custom-domain features and provisioning pipelines is work we do regularly, both for our own products and for clients. If you are designing that and would like someone to look at the DNS and renewal side before it becomes an incident, talk to us. The first thirty minutes are free.
CoyoteCert itself is MIT licensed and on GitHub. If it does something wrong, the issue tracker is the fastest way to reach the people who wrote it.