A PHP 8.3+ ACME v2 client for issuing, renewing, and revoking TLS certificates. Works with Let's Encrypt, ZeroSSL, Google Trust Services, SSL.com, Buypass, and any RFC 8555-compliant CA. Fluent API, no framework dependencies, solid test coverage.
ACME (Automatic Certificate Management Environment) is the protocol behind free, automated TLS certificates. Yes, same name as the cartoon supply company. We leaned into it. CoyoteCert covers the whole thing: account management, order lifecycle, HTTP-01, DNS-01, and TLS-ALPN-01 challenges, certificate issuance, ARI smart renewal, and revocation. One composer require blendbyte/coyotecert and you're off. No cliff. No 🪨.
- Why CoyoteCert
- Requirements
- Installation
- Laravel
- Quick start
- Full example: nginx + automatic renewal
- CLI
- Providers
- Challenge handlers
- DNS-01 providers
- Storage backends
- Issuing certificates
- Event callbacks
- CAA pre-check
- Error handling
- Wildcard and multi-domain certificates
- IP address certificates
- Automatic renewal
- ARI: CA-guided renewal windows
- ACME profiles
- Preferred chain selection
- Key types
- Certificate revocation
- PSR-18 HTTP client
- HTTP timeout
- Logging
- Inspecting StoredCertificate
- Builder reference
- Low-level API
- Testing with Pebble
Built-in providers for Let's Encrypt, ZeroSSL, Google Trust Services, SSL.com, and Buypass. Full EAB support included; ZeroSSL auto-provisions credentials from your API key, no token copy-pasting. Need something more exotic? CustomProvider handles any RFC 8555-compliant CA.
coyote issue and coyote status come in the box. Issue a certificate with one command, inspect it with another. Drop it anywhere certbot or acme.sh would go in a PHP stack: same providers, same key types, same storage paths, cron-friendly exit codes.
Filesystem with file locking, PDO for MySQL/PostgreSQL/SQLite, and in-memory for tests, all sharing the same interface. Switching backends never touches your issuance code.
Cloudflare, Hetzner DNS, DigitalOcean, ClouDNS, AWS Route53, and shell/exec, all with automatic zone detection, post-deploy propagation checking, and fluent timeout controls. Route53 handles SigV4 signing itself; no AWS SDK required. Wildcards need DNS-01, and CoyoteCert has the providers covered.
CoyoteCert checks CAA DNS records for every domain before touching the CA. If a record blocks your chosen CA, you get a CaaException immediately, not after burning a rate-limit attempt. Same pre-flight logic verifies your HTTP token or DNS TXT record locally before the CA comes knocking. Unlike a certain cartoon coyote, we check for obstacles before ordering supplies.
RateLimitException carries the CA's Retry-After seconds so your retry logic is precise. AuthException means bad credentials, not a transient blip. AcmeException::getSubproblems() tells you exactly which domain in a multi-domain order was rejected and why.
Let's Encrypt's shortlived profile gives you 6-day certs with no OCSP or CRL overhead. CoyoteCert passes the profile through and quietly ignores it on CAs that haven't caught up yet. Call ->profile() unconditionally.
Proper nonce handling with automatic retry on badNonce, JWS signing for every request, EAB for CAs that require it, and ARI (RFC 9773) so renewal windows are set by the CA rather than a fixed calendar guess.
No default CA, no hidden opinions
CoyoteCert has no default CA. Every call requires an explicit provider. Trust store coverage, rate limits, certificate lifetime, EAB requirements, data residency. Those trade-offs are yours, not ours.
ECDSA-first: keys default to EC P-256; EC P-384, RSA-2048, and RSA-4096 are all there.
IP address certificates (RFC 8738): pass an IP to ->identifiers() and it works. type: ip on the order, IP: SANs in the CSR, no extra setup.
PSR-18 HTTP client: the built-in curl client needs no extra dependencies; swap it for any PSR-18 client with one builder call.
94%+ test coverage: unit tests with mocked responses plus a live Pebble integration suite across PHP 8.3, 8.4, and 8.5. No mock-only false confidence.
Modern PHP: strict types, backed enums, readonly constructor promotion. No magic methods, no global state.
Truly independent: no CA affiliation, not maintained or financed by one.
PHP ^8.3 with ext-curl, ext-json, ext-mbstring, and ext-openssl.
composer require blendbyte/coyotecertFirst-party Laravel integration is available as a separate package: blendbyte/coyotecert-laravel.
Adds a service provider, config file, Artisan commands (cert:issue, cert:renew, cert:status, cert:revoke), HTTP-01 challenge served through your app via the cache store (no web server changes, works behind load balancers), Laravel Events, queue job support for DNS-01, and a daily scheduled renewal task. No boilerplate beyond publishing the config.
HTTP-01 write a token to your web root:
use CoyoteCert\CoyoteCert;
use CoyoteCert\Challenge\Http01Handler;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
$cert = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com')
->email('admin@example.com')
->challenge(new Http01Handler('/var/www/html'))
->issueOrRenew();DNS-01 deploy a TXT record via a DNS provider (required for wildcards):
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();Both return the same value object:
echo $cert->certificate; // PEM leaf certificate
echo $cert->privateKey; // PEM private key
echo $cert->fullchain; // PEM leaf + intermediates
echo $cert->caBundle; // PEM intermediate chainA complete production setup: certificate issuance, PEM files on disk, nginx pointed at them, automatic reload on renewal, and a daily cron job.
/usr/local/bin/renew-certs.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use CoyoteCert\CoyoteCert;
use CoyoteCert\Challenge\Http01Handler;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/etc/certs'))
->identifiers(['example.com', 'www.example.com'])
->email('admin@example.com')
->challenge(new Http01Handler('/var/www/html'))
->onRenewed(fn() => exec('systemctl reload nginx'))
->issueOrRenew();On first run this creates the ACME account and issues the certificate. On subsequent runs it does nothing until renewal is due (30 days before expiry by default), then issues a new certificate and reloads nginx automatically.
Files written to /etc/certs/:
account-letsencrypt.pem
account-letsencrypt.json
example.com.EC_P256.cert.json
example.com.EC_P256.certificate.pem
example.com.EC_P256.fullchain.pem ← point nginx here
example.com.EC_P256.ca.pem
example.com.EC_P256.private_key.pem ← and here
/etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com www.example.com;
# Required for HTTP-01 challenge validation
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/certs/example.com.EC_P256.fullchain.pem;
ssl_certificate_key /etc/certs/example.com.EC_P256.private_key.pem;
# ... the rest of your config
}Cron, daily at 03:00, idempotent:
0 3 * * * php /usr/local/bin/renew-certs.php
CoyoteCert ships with a coyote CLI for issuing and inspecting certificates without writing PHP. It wraps the same builder API as the library.
composer global require blendbyte/coyotecertMake sure ~/.composer/vendor/bin (or ~/.config/composer/vendor/bin on Linux) is on your PATH.
Issue or renew a certificate using HTTP-01 or DNS-01 challenge validation.
HTTP-01:
coyote issue \
--identifier example.com \
--identifier www.example.com \
--webroot /var/www/html \
--email admin@example.com \
--provider letsencrypt \
--storage /etc/certsDNS-01 (required for wildcards). Set the provider's credentials as environment variables, then pass --dns:
export CLOUDFLARE_API_TOKEN=your-token
coyote issue \
--identifier example.com \
--identifier '*.example.com' \
--dns cloudflare \
--email admin@example.com \
--provider letsencrypt \
--storage /etc/certsIf a valid certificate already exists and expiry is more than --days away, the command exits cleanly with no network requests. Pass --force to issue regardless.
Options
| Option | Short | Default | Description |
|---|---|---|---|
--identifier |
-i |
Identifier to include on the certificate (domain name or wildcard). Repeat for SANs: --identifier example.com --identifier www.example.com |
|
--email |
-e |
Contact email registered with the ACME account | |
--webroot |
-w |
Webroot path for HTTP-01. CoyoteCert writes tokens under .well-known/acme-challenge/ |
|
--dns |
DNS provider for DNS-01 challenge. See DNS providers table below. Mutually exclusive with --webroot |
||
--dns-propagation-timeout |
60 |
Seconds to wait for the TXT record to appear in DNS before submitting the challenge to the CA | |
--dns-propagation-delay |
0 |
Fixed delay in seconds after the propagation check, for providers with slow secondary sync | |
--dns-skip-propagation |
Skip the post-deploy DNS propagation check entirely (split-horizon or internal DNS) | ||
--provider |
-p |
CA to use. See provider table below. Required | |
--storage |
-s |
./certs |
Directory to read/write certificates and account keys |
--days |
30 |
Renew when fewer than this many days remain before expiry | |
--key-type |
ec256 |
Certificate key type: ec256, ec384, rsa2048, rsa4096 |
|
--force |
-f |
Issue a fresh certificate even if the current one is still valid | |
--skip-caa |
Skip CAA DNS pre-check | ||
--skip-local-test |
Skip the HTTP pre-flight self-test | ||
--zerossl-key |
ZeroSSL API key for automatic EAB provisioning | ||
--eab-kid |
EAB key ID (Google Trust Services, SSL.com, or pre-provisioned ZeroSSL) | ||
--eab-hmac |
EAB HMAC key |
Providers
--provider value |
CA |
|---|---|
letsencrypt, le |
Let's Encrypt (production) |
letsencrypt-staging, le-staging, staging |
Let's Encrypt (staging) |
zerossl |
ZeroSSL (use --zerossl-key or --eab-kid/--eab-hmac) |
google, gts |
Google Trust Services (requires --eab-kid and --eab-hmac) |
buypass |
Buypass Go SSL (production) |
buypass-staging |
Buypass Go SSL (staging) |
sslcom, ssl.com |
SSL.com (requires --eab-kid and --eab-hmac) |
DNS providers
--dns value |
Required env vars | Optional zone override |
|---|---|---|
cloudflare |
CLOUDFLARE_API_TOKEN |
CLOUDFLARE_ZONE_ID |
hetzner |
HETZNER_API_TOKEN |
HETZNER_ZONE_ID |
digitalocean, do |
DO_API_TOKEN |
DO_ZONE |
cloudns |
CLOUDNS_AUTH_ID, CLOUDNS_AUTH_PASSWORD |
CLOUDNS_ZONE |
route53 |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
AWS_ROUTE53_ZONE_ID |
exec, shell |
DNS_DEPLOY_CMD |
DNS_CLEANUP_CMD |
Zone is auto-detected from the domain for all providers that support it. Supply the zone override to skip the detection API call or to disambiguate when the same domain appears in multiple zones.
Inspect a stored certificate.
coyote status --identifier example.com --storage /etc/certs| Option | Short | Default | Description |
|---|---|---|---|
--identifier |
-i |
Primary identifier of the certificate to inspect | |
--storage |
-s |
./certs |
Directory where certificates are stored |
--key-type |
ec256 |
Key type to look up: ec256, ec384, rsa2048, rsa4096 |
The status line reflects time to expiry:
| Status | Condition |
|---|---|
Valid |
More than 30 days remaining |
Renewal due |
7–30 days remaining |
Expiring soon |
Fewer than 7 days remaining |
Expired |
Certificate has passed its expiry date |
0 3 * * * coyote issue --identifier example.com --webroot /var/www/html --storage /etc/certs --email admin@example.com
The command is idempotent: it does nothing until fewer than --days (default 30) remain, so running it daily is safe. Set it and forget it. Unlike certain Road Runner traps, this actually works unattended.
coyote --help # list available commands
coyote --version # show version
coyote issue --help # full option reference for issue
coyote status --help # full option reference for statusCoyoteCert ships with built-in providers for every major public ACME CA. Pick one and go.
| Provider class | CA | EAB | Profiles | CAA identifier |
|---|---|---|---|---|
LetsEncrypt |
Let's Encrypt (production) | No | Yes | letsencrypt.org |
LetsEncryptStaging |
Let's Encrypt (staging) | No | Yes | letsencrypt.org |
ZeroSSL |
ZeroSSL | Yes | No | sectigo.com, comodoca.com |
BuypassGo |
Buypass Go SSL (production) | No | No | buypass.com |
BuypassGoStaging |
Buypass Go SSL (staging) | No | No | buypass.com |
GoogleTrustServices |
Google Trust Services | Yes | No | pki.goog |
SslCom |
SSL.com | Yes | No | ssl.com |
CustomProvider |
Any RFC 8555-compliant CA | Optional | Optional | configurable (default: skip) |
Production for real certs, staging for development. No rate limits on staging, but staging certificates aren't browser-trusted.
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Provider\LetsEncryptStaging;
CoyoteCert::with(new LetsEncrypt())
CoyoteCert::with(new LetsEncryptStaging())Requires EAB credentials. CoyoteCert can provision them automatically from your API key, or accept pre-provisioned credentials directly.
use CoyoteCert\Provider\ZeroSSL;
// Automatic provisioning: CoyoteCert fetches EAB credentials from the ZeroSSL API
CoyoteCert::with(new ZeroSSL(apiKey: 'your-zerossl-api-key'))
->email('admin@example.com') // required for auto-provisioning
// Manual credentials: skip the API call
CoyoteCert::with(new ZeroSSL(eabKid: 'kid', eabHmac: 'hmac'))Obtain EAB credentials from the Google Cloud Console.
use CoyoteCert\Provider\GoogleTrustServices;
CoyoteCert::with(new GoogleTrustServices(eabKid: 'kid', eabHmac: 'hmac'))Exposes separate endpoints for RSA and ECC certificates.
use CoyoteCert\Provider\SslCom;
// RSA endpoint (default)
CoyoteCert::with(new SslCom(eabKid: 'kid', eabHmac: 'hmac'))
// ECC endpoint
CoyoteCert::with(new SslCom(eabKid: 'kid', eabHmac: 'hmac', ecc: true))use CoyoteCert\Provider\BuypassGo;
use CoyoteCert\Provider\BuypassGoStaging;
CoyoteCert::with(new BuypassGo())
CoyoteCert::with(new BuypassGoStaging())Point CoyoteCert at any ACME-compliant directory URL: internal CAs, private PKI, whatever you're running.
use CoyoteCert\Provider\CustomProvider;
use CoyoteCert\Enums\EabAlgorithm;
CoyoteCert::with(new CustomProvider(
directoryUrl: 'https://acme.example.com/directory',
displayName: 'My Internal CA',
eabKid: 'kid', // omit if EAB not required
eabHmac: 'hmac',
verifyTls: true,
profilesSupported: false,
eabAlgorithm: EabAlgorithm::HS256, // HS256 (default), HS384, or HS512
caaIdentifiers: ['myca.com'], // CAA values that permit this CA; omit to skip CAA check
))The storage namespace slug is derived automatically from the directory URL's hostname: https://acme.example.com/directory → acme-example-com. Call $provider->getSlug() to inspect it. This matters when using a custom storage backend alongside CustomProvider, since account keys are keyed by slug.
ACME requires domain ownership proof via a challenge. CoyoteCert ships with handlers for http-01, dns-01, and tls-alpn-01.
CoyoteCert writes a token file to your web root; the CA fetches it over HTTP to confirm domain control.
use CoyoteCert\Challenge\Http01Handler;
->challenge(new Http01Handler('/var/www/html'))The file lands at {webroot}/.well-known/acme-challenge/{token} and is removed automatically after validation. Your server must serve it as plain text with no authentication in the way.
Deploy a TXT record at _acme-challenge.{domain} and remove it after validation. DNS-01 is the only challenge type that supports wildcard certificates.
CoyoteCert has built-in handlers for Cloudflare, Hetzner DNS, DigitalOcean, ClouDNS, AWS Route53, and shell scripts. See DNS-01 providers for full details.
Need something custom? Implement ChallengeHandlerInterface:
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);
}
}->challenge(new MyDns01Handler())Defined in RFC 8737. The CA opens a TLS connection to port 443, negotiates acme-tls/1, and expects a self-signed certificate with a critical id-pe-acmeIdentifier extension containing the SHA-256 digest of the key authorization. No port 80 required.
Extend TlsAlpn01Handler, implement deploy() and cleanup(), and call generateAcmeCertificate() to get the RFC 8737-encoded cert and key. No manual DER encoding needed.
use CoyoteCert\Challenge\TlsAlpn01Handler;
class MyTlsAlpn01Handler extends TlsAlpn01Handler
{
public function deploy(string $domain, string $token, string $keyAuthorization): void
{
['cert' => $certPem, 'key' => $keyPem] =
$this->generateAcmeCertificate($domain, $keyAuthorization);
MyServer::loadAcmeCert($domain, $certPem, $keyPem);
}
public function cleanup(string $domain, string $token): void
{
MyServer::removeAcmeCert($domain);
}
}->challenge(new MyTlsAlpn01Handler())Note: TLS-ALPN-01 runs on port 443 only and doesn't touch port 80. It works with Caddy, nginx (ACME plugin), and HAProxy. Wildcards aren't supported; use DNS-01 for those.
Six built-in DNS-01 handlers, all extending AbstractDns01Handler, which runs a post-deploy propagation check by default. Three fluent controls tune the behaviour:
// All return a new immutable instance.
$handler->propagationTimeout(120) // seconds to poll for the TXT record (default: 60)
$handler->propagationDelay(10) // fixed pause after the check, for slow secondaries (default: 0)
$handler->skipPropagationCheck() // skip polling entirely (split-horizon / internal DNS)Zone detection is automatic: the handler walks public-suffix candidates (sub.example.com → example.com) until it finds a match in the API. Supply an explicit zone to skip the detection call entirely.
API token with Zone.DNS:Edit permission.
use CoyoteCert\Challenge\Dns\CloudflareDns01Handler;
$handler = new CloudflareDns01Handler(apiToken: 'your-api-token');
// With explicit zone ID (skips zone detection)
$handler = new CloudflareDns01Handler(apiToken: 'your-api-token', zoneId: 'zone-id');->challenge($handler->propagationTimeout(90))API token from the Hetzner DNS Console.
use CoyoteCert\Challenge\Dns\HetznerDns01Handler;
$handler = new HetznerDns01Handler(apiToken: 'your-api-token');
// With explicit zone ID
$handler = new HetznerDns01Handler(apiToken: 'your-api-token', zoneId: 'zone-id');Personal access token with write access to domains.
use CoyoteCert\Challenge\Dns\DigitalOceanDns01Handler;
$handler = new DigitalOceanDns01Handler(apiToken: 'your-api-token');
// With explicit zone name
$handler = new DigitalOceanDns01Handler(apiToken: 'your-api-token', zone: 'example.com');Auth-id and auth-password from your ClouDNS account panel.
use CoyoteCert\Challenge\Dns\ClouDnsDns01Handler;
$handler = new ClouDnsDns01Handler(authId: '12345', authPassword: 'secret');
// With explicit zone name
$handler = new ClouDnsDns01Handler(authId: '12345', authPassword: 'secret', zone: 'example.com');No AWS SDK required; SigV4 request signing is implemented directly with hash_hmac() and hash(). Needs an IAM user or role with route53:ChangeResourceRecordSets and route53:ListHostedZonesByName permissions.
use CoyoteCert\Challenge\Dns\Route53Dns01Handler;
$handler = new Route53Dns01Handler(
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
);
// With explicit zone ID (with or without the /hostedzone/ prefix)
$handler = new Route53Dns01Handler(
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
zoneId: 'Z1D633PJN98FT9',
);Delegates to any command-line tool: nsupdate, acme.sh hook scripts, a custom DNS CLI, whatever. Use {domain} and {keyauth} as placeholders; values are also injected as ACME_DOMAIN and ACME_KEYAUTH environment variables for scripts that prefer the environment.
use CoyoteCert\Challenge\Dns\ShellDns01Handler;
// Single command for deploy; no cleanup
$handler = new ShellDns01Handler('/usr/local/bin/dns-hook {domain} {keyauth}');
// Separate deploy and cleanup commands
$handler = new ShellDns01Handler(
deployCommand: '/usr/local/bin/dns-hook add {domain} {keyauth}',
cleanupCommand: '/usr/local/bin/dns-hook del {domain}',
);A non-zero exit code throws ChallengeException.
Storage persists the ACME account key and issued certificates between runs. Without it, CoyoteCert issues a fresh certificate and creates a new ACME account every time. Probably not what you want.
use CoyoteCert\Storage\FilesystemStorage;
->storage(new FilesystemStorage('/var/certs'))Files written per certificate:
| File | Mode | Contents |
|---|---|---|
/var/certs/account-{provider}.pem |
0600 | ACME account private key, e.g. account-letsencrypt.pem |
/var/certs/account-{provider}.json |
0600 | Account key type metadata |
/var/certs/{domain}.{KeyType}.cert.json |
0600 | Serialised StoredCertificate (e.g. example.com.EC_P256.cert.json) |
/var/certs/{domain}.{KeyType}.certificate.pem |
0644 | Leaf certificate |
/var/certs/{domain}.{KeyType}.private_key.pem |
0600 | Private key |
/var/certs/{domain}.{KeyType}.fullchain.pem |
0644 | Leaf + intermediate chain |
/var/certs/{domain}.{KeyType}.ca.pem |
0644 | Intermediate chain only |
PEM files are written on every saveCertificate() call alongside the JSON, so they're always in sync. Point your web server straight at them:
ssl_certificate /var/certs/example.com.EC_P256.fullchain.pem;
ssl_certificate_key /var/certs/example.com.EC_P256.private_key.pem;The directory is created automatically (mode 0700). Reads use shared locks, writes use exclusive locks, safe for concurrent processes.
Everything in a single key-value table. MySQL/MariaDB, PostgreSQL, and SQLite out of the box.
use CoyoteCert\Storage\DatabaseStorage;
$pdo = new PDO('mysql:host=localhost;dbname=myapp', $user, $pass);
$storage = new DatabaseStorage($pdo);
// Or with a custom table name
$storage = new DatabaseStorage($pdo, table: 'ssl_storage');Run this once to create the table:
$pdo->exec(DatabaseStorage::createTableSql());
// Or with a custom name:
$pdo->exec(DatabaseStorage::createTableSql('ssl_storage'));The generated schema (MySQL):
CREATE TABLE IF NOT EXISTS `coyote_cert_storage` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`store_key` VARCHAR(255) NOT NULL,
`value` MEDIUMTEXT NOT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_store_key` (`store_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Upserts are dialect-aware: INSERT OR REPLACE (SQLite), ON CONFLICT DO UPDATE (PostgreSQL), ON DUPLICATE KEY UPDATE (MySQL/MariaDB).
Non-persistent: data is gone when the process exits. Great for tests and one-shot scripts.
use CoyoteCert\Storage\InMemoryStorage;
->storage(new InMemoryStorage())If you don't call ->storage() at all, issue() still works and returns a StoredCertificate. Nothing is persisted. Useful when your application manages its own persistence (database ORM, secret manager, etc.) and you only want the cert in-hand:
$cert = CoyoteCert::with(new LetsEncrypt())
->identifiers('example.com')
->email('admin@example.com')
->challenge(new Http01Handler('/var/www/html'))
->issue();
// Store wherever you like
$myVault->put('example.com', $cert->fullchain, $cert->privateKey);Note: without storage, needsRenewal() always returns true and issueOrRenew() will issue on every call. Manage renewal yourself, or use a custom storage backend.
Implement StorageInterface with eight methods:
use CoyoteCert\Enums\KeyType;
use CoyoteCert\Storage\StorageInterface;
use CoyoteCert\Storage\StoredCertificate;
class RedisStorage implements StorageInterface
{
public function __construct(private \Redis $redis) {}
public function hasAccountKey(string $providerSlug): bool
{
return (bool) $this->redis->exists("acme:{$providerSlug}:account:pem");
}
public function getAccountKey(string $providerSlug): string
{
return $this->redis->get("acme:{$providerSlug}:account:pem");
}
public function getAccountKeyType(string $providerSlug): KeyType
{
return KeyType::from($this->redis->get("acme:{$providerSlug}:account:type"));
}
public function saveAccountKey(string $providerSlug, string $pem, KeyType $type): void
{
$this->redis->set("acme:{$providerSlug}:account:pem", $pem);
$this->redis->set("acme:{$providerSlug}:account:type", $type->value);
}
public function hasCertificate(string $domain, KeyType $keyType): bool
{
return (bool) $this->redis->exists("acme:cert:{$domain}:{$keyType->value}");
}
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate
{
$json = $this->redis->get("acme:cert:{$domain}:{$keyType->value}");
return $json ? StoredCertificate::fromArray(json_decode($json, true)) : null;
}
public function saveCertificate(string $domain, StoredCertificate $cert): void
{
$this->redis->set("acme:cert:{$domain}:{$cert->keyType->value}", json_encode($cert->toArray()));
}
public function deleteCertificate(string $domain, KeyType $keyType): void
{
$this->redis->del("acme:cert:{$domain}:{$keyType->value}");
}
}The provider slug is passed automatically by CoyoteCert on every account key operation, so multiple CAs never share the same account key. No extra wiring needed.
Built-in providers return fixed slugs (letsencrypt, zerossl, etc.). CustomProvider derives its slug from the directory URL hostname (acme.example.com → acme-example-com). If you implement AcmeProviderInterface directly, getSlug() must return a string matching [a-z0-9][a-z0-9-]*[a-z0-9] — lowercase, no leading or trailing hyphens. Extending AbstractProvider gives you assertValidSlug() as a convenience guard.
Always requests a new certificate from the CA, regardless of what's in storage.
$cert = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com')
->email('admin@example.com')
->challenge(new Http01Handler('/var/www/html'))
->issue();The one you want in production. Returns the existing certificate if it's still valid; issues a new one when it's getting close to expiry. Accepts an optional $daysBeforeExpiry threshold (default 30). Safe to call as often as you like. It does nothing when the certificate is still healthy.
$cert = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com')
->email('admin@example.com')
->challenge(new Http01Handler('/var/www/html'))
->issueOrRenew(daysBeforeExpiry: 30);Check whether a renewal is needed without triggering one.
$coyote = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com');
if ($coyote->needsRenewal(30)) {
// issue or alert
}Returns true when:
- no storage is configured
- no certificate is stored for the primary domain
- the stored certificate expires within
$daysBeforeExpirydays - an ARI renewal window is open (see ARI)
React to certificate lifecycle events without subclassing or parsing log output. Handy for reloading a web server, pushing secrets to a vault, or firing off a Slack notification.
Fires after every successful certificate issuance, first-time or renewal.
CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->onIssued(function (StoredCertificate $cert): void {
SecretsManager::push('tls/example.com', [
'cert' => $cert->certificate,
'key' => $cert->privateKey,
'fullchain'=> $cert->fullchain,
]);
})
->issueOrRenew();Fires only when an existing certificate is replaced (storage already held a cert before the new one was issued). Fires after onIssued callbacks.
CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->onIssued(fn($cert) => SecretsManager::push('tls/example.com', $cert->toArray()))
->onRenewed(fn($cert) => Nginx::reload())
->issueOrRenew();Both methods accept any callable and can be called multiple times. Callbacks run in registration order, after the certificate has been saved to storage.
CAA (Certification Authority Authorization) records let domain owners restrict which CAs can issue for them. If example.com has CAA 0 issue "digicert.com", Let's Encrypt will reject the order, but only after you've burned a rate-limit attempt and sat through the full ACME workflow.
CoyoteCert checks CAA before talking to the CA. Unlike a certain cartoon coyote, we look before we order from the ACME Corporation:
use CoyoteCert\Exceptions\CaaException;
try {
$cert = CoyoteCert::with(new LetsEncrypt())
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->issue();
} catch (CaaException $e) {
echo $e->getMessage();
}- For each domain in
->identifiers(), CoyoteCert queries CAA records at the exact name. - If nothing is found, it walks up one label at a time (
sub.example.com→example.com) until records appear or the second-level domain is exhausted. - No records anywhere in the tree means an open policy; any CA may issue.
- For wildcards (
*.example.com),issuewildrecords are checked first, falling back toissuerecords if none exist. - Parameter extensions after a semicolon (
letsencrypt.org; validationmethods=http-01) are stripped before comparison.
CaaException extends AcmeException, so existing catch blocks for the base type keep working. IP address identifiers are excluded. CAA records apply to domain names only.
Skip the CAA check when DNS is internal, split-horizon, or not reachable from the issuing host:
CoyoteCert::with(new LetsEncrypt())
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->skipCaaCheck()
->issue();Pebble and CustomProvider (without explicit caaIdentifiers) skip the check automatically.
All exceptions extend AcmeException, so a single catch (AcmeException $e) covers everything. Catch the narrower types when you need to respond differently to specific failure modes.
use CoyoteCert\Exceptions\RateLimitException;
try {
$cert = CoyoteCert::with(new LetsEncrypt())
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->issue();
} catch (RateLimitException $e) {
$wait = $e->getRetryAfter(); // int seconds from Retry-After header, or null
echo "Rate limited. Retry" . ($wait ? " in {$wait}s." : " later.");
}getRetryAfter() returns the value from the CA's Retry-After header when present, or null when the header is absent.
use CoyoteCert\Exceptions\AuthException;
try {
$api->account()->get();
} catch (AuthException $e) {
// 401 / 403: account key rejected or credentials revoked
echo $e->getMessage();
}AuthException is thrown on 401 and 403 responses, distinct from a rate limit or transient error, so you can alert or re-provision credentials rather than retrying blindly.
When an order covering multiple domains is rejected, the CA may return a subproblems array with a separate error for each failing domain:
use CoyoteCert\Exceptions\AcmeException;
try {
$cert = CoyoteCert::with(new LetsEncrypt())
->identifiers(['example.com', 'bad.example.com'])
->challenge(new Http01Handler('/var/www/html'))
->issue();
} catch (AcmeException $e) {
foreach ($e->getSubproblems() as $sub) {
echo $sub['identifier']['value'] . ': ' . $sub['detail'] . PHP_EOL;
}
}getSubproblems() returns an empty array when the server returned a single top-level error with no per-identifier breakdown.
AcmeException - base; always safe to catch
├── AuthException - 401/403 (bad credentials, revoked account)
├── RateLimitException - 429 (too many requests); carries getRetryAfter()
├── CaaException - CAA DNS record blocks issuance
├── ChallengeException - challenge validation failed
├── CryptoException - local key or certificate operation failed
├── DomainValidationException - pre-flight HTTP/DNS self-check failed
├── OrderNotFoundException - order ID not found on the CA
└── StorageException - storage backend error
Pass an array of domains to ->identifiers(). Wildcards need dns-01.
// Multi-domain (SAN) certificate via HTTP-01
CoyoteCert::with(new LetsEncrypt())
->identifiers(['example.com', 'www.example.com', 'api.example.com'])
->challenge(new Http01Handler('/var/www/html'))
->issueOrRenew();
// Wildcard certificate via DNS-01
CoyoteCert::with(new LetsEncrypt())
->identifiers(['example.com', '*.example.com'])
->challenge(new CloudflareDns01Handler())
->issueOrRenew();*.example.com covers one label deep (sub.example.com) but not the apex (example.com). Include both if you need both.
->identifiers() validates every entry against RFC-compliant hostname syntax (or as an IP address) and throws immediately for malformed input, before any CA communication starts.
->identifiers() accepts IPv4 and IPv6 addresses alongside hostnames. CoyoteCert automatically sets type: ip on ACME identifiers and IP: SAN entries in the CSR. Nothing extra required.
// IPv4-only certificate (e.g. with Let's Encrypt shortlived profile)
CoyoteCert::with(new LetsEncrypt())
->identifiers('192.0.2.1')
->profile('shortlived')
->challenge(new Http01Handler('/var/www/html'))
->issueOrRenew();
// Mixed hostname + IP certificate
CoyoteCert::with(new LetsEncrypt())
->identifiers(['example.com', '192.0.2.1', '2001:db8::1'])
->challenge(new Http01Handler('/var/www/html'))
->issueOrRenew();IP SANs are validated via HTTP-01 (the CA connects to the IP directly). Wildcards can't be combined with IP identifiers. Not all CAs support IP SANs, so check yours. Let's Encrypt supports them on both classic and shortlived profiles.
The recommended setup: a daily cron job calling issueOrRenew(). Here's the full script with PSR-3 logging:
// /usr/local/bin/renew-certs.php
require __DIR__ . '/vendor/autoload.php';
use CoyoteCert\CoyoteCert;
use CoyoteCert\Challenge\Http01Handler;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('certs');
$logger->pushHandler(new StreamHandler('/var/log/certs.log'));
$cert = CoyoteCert::with(new LetsEncrypt())
->storage(new FilesystemStorage('/var/certs'))
->email('ops@example.com')
->identifiers(['example.com', 'www.example.com'])
->challenge(new Http01Handler('/var/www/html'))
->logger($logger)
->onRenewed(fn($cert) => exec('systemctl reload nginx'))
->issueOrRenew(daysBeforeExpiry: 30);Add to crontab. Daily is fine; issueOrRenew() does nothing until renewal is actually due:
0 3 * * * php /usr/local/bin/renew-certs.php
RFC 9773 lets a CA tell you exactly when it wants you to renew: a specific window, not just "X days before expiry." CoyoteCert checks the ARI endpoint automatically whenever needsRenewal() or issueOrRenew() is called.
- If the CA exposes a
renewalInfoURL and the window is open,needsRenewal()returnstrueeven if the certificate has more than$daysBeforeExpirydays left. - If the ARI request fails, CoyoteCert falls back to the
$daysBeforeExpirythreshold silently. - If the CA doesn't support ARI, the threshold is used exclusively.
No configuration needed. It just works.
Profiles let you request a specific certificate type from the CA. Let's Encrypt currently supports two:
->profile('shortlived') // 6-day certificate, no OCSP/CRL infrastructure needed
->profile('classic') // 90-day certificate (default if no profile is set)Short-lived certificates renew more often but eliminate the need for OCSP stapling, CRL checks, and revocation infrastructure. Simpler to operate.
Profiles are forwarded to the CA only if the provider reports supportsProfiles() === true. For CAs that don't support profiles, the setting is silently ignored. Call ->profile() unconditionally if you want.
Some CAs offer multiple certificate chains via Link: rel="alternate" headers (RFC 8555 §7.4.2). Let's Encrypt uses this to serve both the ISRG Root X1 chain and older cross-signed chains.
Use ->preferredChain() to request a chain by matching against the Common Name or Organisation of the intermediate certificates. The match is a case-insensitive substring, so partial names work fine. If no alternate chain matches, CoyoteCert falls back to the default chain, always safe to include.
CoyoteCert::with(new LetsEncrypt())
->identifiers('example.com')
->challenge(new Http01Handler('/var/www/html'))
->preferredChain('ISRG Root X1')
->issueOrRenew();When using the low-level API directly, pass the preference as a second argument to getBundle():
$bundle = $api->certificate()->getBundle($order, 'ISRG Root X1');use CoyoteCert\Enums\KeyType;
// Certificate key type (default: EC_P256)
->keyType(KeyType::EC_P256) // ECDSA P-256: fast, compact, widely supported
->keyType(KeyType::EC_P384) // ECDSA P-384: higher security margin
->keyType(KeyType::RSA_2048) // RSA 2048-bit
->keyType(KeyType::RSA_4096) // RSA 4096-bit: maximum compatibility
// ACME account key type (default: EC_P256)
->accountKeyType(KeyType::RSA_2048)EC P-256 is the default for both the certificate and the account key. Smaller keys, faster TLS handshakes, accepted by every major CA and browser.
Revoke a stored certificate with an optional RFC 5280 reason code.
use CoyoteCert\CoyoteCert;
use CoyoteCert\Enums\KeyType;
use CoyoteCert\Enums\RevocationReason;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
$storage = new FilesystemStorage('/var/certs');
$coyote = CoyoteCert::with(new LetsEncrypt())->storage($storage);
$cert = $storage->getCertificate('example.com', KeyType::EC_P256);
$coyote->revoke($cert); // Unspecified (default)
$coyote->revoke($cert, RevocationReason::KeyCompromise);
$coyote->revoke($cert, RevocationReason::CaCompromise);
$coyote->revoke($cert, RevocationReason::AffiliationChanged);
$coyote->revoke($cert, RevocationReason::Superseded);
$coyote->revoke($cert, RevocationReason::CessationOfOperation);
$coyote->revoke($cert, RevocationReason::CertificateHold);
$coyote->revoke($cert, RevocationReason::PrivilegeWithdrawn);
$coyote->revoke($cert, RevocationReason::AaCompromise);Throws AcmeException if the CA rejects the request.
After revoking, delete the stored certificate so issueOrRenew() requests a fresh one:
$storage->deleteCertificate('example.com', KeyType::EC_P256);CoyoteCert ships with a built-in curl client that needs no extra dependencies. To use a custom HTTP client, pass any PSR-18 ClientInterface:
// Symfony HttpClient: implements all three interfaces itself
->httpClient(new \Symfony\Component\HttpClient\Psr18Client())
// Guzzle: pass request and stream factories separately
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
->httpClient(
new Client(),
new HttpFactory(), // RequestFactoryInterface
new HttpFactory(), // StreamFactoryInterface: same object works for both
)
// Nyholm PSR-7 + any client
use Nyholm\Psr7\Factory\Psr17Factory;
$factory = new Psr17Factory();
->httpClient($myClient, $factory, $factory)If the PSR-18 client also implements RequestFactoryInterface and StreamFactoryInterface, the factory arguments are optional and detected automatically.
Tune the built-in curl client's timeout without replacing the whole client:
->withHttpTimeout(30) // secondsHas no effect when a custom PSR-18 client is configured; configure timeout there instead.
Pass any PSR-3 logger to get debug and info messages throughout the certificate lifecycle:
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('acme');
$logger->pushHandler(new StreamHandler('php://stdout'));
CoyoteCert::with(new LetsEncrypt())
->logger($logger)
->...Log messages cover directory fetches, nonce acquisition, challenge deployment, validation polling, and order finalisation. Nothing is logged when no logger is configured.
StoredCertificate is the value object returned by issue() and issueOrRenew(). It holds all certificate data and exposes a handful of inspection helpers.
$cert->certificate // string: PEM leaf certificate
$cert->privateKey // string: PEM private key
$cert->fullchain // string: PEM leaf + intermediate chain
$cert->caBundle // string: PEM intermediate chain only
$cert->issuedAt // DateTimeImmutable
$cert->expiresAt // DateTimeImmutable
$cert->domains // string[]: domains as recorded at issuance time// Quick expiry checks
$cert->isExpired(); // bool: true if the cert is past its expiry
$cert->expiresWithin(30); // bool: true if expiry is ≤ 30 days away
// Days until expiry (0 if already expired)
$cert->remainingDays();
// Ceiling of days until expiry (negative if expired)
$cert->daysUntilExpiry();
// Whether the certificate covers all the given domains (wildcard-aware)
$cert->isValidForDomains(['example.com', 'www.example.com']); // bool
// DNS SANs from the actual certificate
$cert->sans(); // ['example.com', 'www.example.com']
// Lowercase hex serial number
$cert->serialNumber(); // 'a1b2c3...'
// Authority Key Identifier (colon-separated uppercase hex, or null if absent)
$cert->authorityKeyId(); // 'A1:B2:C3:...'
// Issuer DN fields
$cert->issuer(); // ['CN' => "Let's Encrypt R11", 'O' => "Let's Encrypt", 'C' => 'US']StoredCertificate round-trips through JSON cleanly:
$array = $cert->toArray();
$cert = StoredCertificate::fromArray($array);CoyoteCert::with(AcmeProviderInterface $provider) // factory: pick your CA| Method | Type | Default | Description |
|---|---|---|---|
->email(string) |
fluent | '' |
Contact email; required for ZeroSSL auto-provisioning |
->identifiers(string|array) |
fluent | Domain(s) and/or IP(s) to certify; first entry is the primary | |
->challenge(ChallengeHandlerInterface) |
fluent | Challenge handler | |
->storage(StorageInterface) |
fluent | none | Storage backend |
->keyType(KeyType) |
fluent | EC_P256 |
Certificate key algorithm |
->accountKeyType(KeyType) |
fluent | EC_P256 |
ACME account key algorithm |
->profile(string) |
fluent | '' |
ACME profile (shortlived, classic) |
->httpClient(ClientInterface, ...) |
fluent | built-in curl | PSR-18 HTTP client |
->withHttpTimeout(int) |
fluent | 10 |
Curl timeout in seconds |
->logger(LoggerInterface) |
fluent | none | PSR-3 logger |
->preferredChain(string) |
fluent | '' |
Preferred chain issuer CN/O (RFC 8555 §7.4.2); falls back to default if no match |
->pollAttempts(int) |
fluent | 10 |
Maximum challenge validation poll attempts |
->skipLocalTest() |
fluent | off | Disable pre-flight HTTP/DNS self-check |
->skipCaaCheck() |
fluent | off | Disable CAA DNS pre-check |
->onIssued(callable) |
fluent | none | Callback fired after every successful issuance; receives StoredCertificate |
->onRenewed(callable) |
fluent | none | Callback fired when an existing cert is replaced; receives StoredCertificate |
->issue() |
terminal | Issue unconditionally; returns StoredCertificate |
|
->issueOrRenew(int $days = 30) |
terminal | Issue only when needed; returns StoredCertificate |
|
->needsRenewal(int $days = 30) |
query | true if renewal is needed |
|
->revoke(StoredCertificate, RevocationReason) |
terminal | Revoke a certificate |
For advanced use cases like custom account management, manual order orchestration, and scripted key rollovers, the Api class exposes every ACME endpoint directly.
use CoyoteCert\Api;
use CoyoteCert\Enums\KeyType;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
$api = new Api(
provider: new LetsEncrypt(),
storage: new FilesystemStorage('/var/certs'),
);
// Account management
$account = $api->account()->create('admin@example.com');
$account = $api->account()->get();
$account = $api->account()->update($account, ['mailto:new@example.com']);
$account = $api->account()->deactivate($account);
$account = $api->account()->keyRollover($account); // rotate account key in place
// Order lifecycle
$order = $api->order()->new($account, ['example.com', 'www.example.com']);
$order = $api->order()->refresh($order);
$order = $api->order()->waitUntilValid($order);
$order = $api->order()->finalize($order, $csrPem);
// Domain validation
$statuses = $api->domainValidation()->status($order);
$data = $api->domainValidation()->getValidationData($statuses, $challengeType);
$api->domainValidation()->start($account, $status, $challengeType, localTest: true);
$api->domainValidation()->allChallengesPassed($order); // polls with retry
// Certificate
$bundle = $api->certificate()->getBundle($order);
$api->certificate()->revoke($certPem, reason: 1);
// ARI
$window = $api->renewalInfo()->get($certPem, $issuerPem);
$certId = $api->renewalInfo()->certId($certPem, $issuerPem);
// Directory
$all = $api->directory()->all();
$newAcc = $api->directory()->newAccount();
$ariUrl = $api->directory()->renewalInfo(); // null if not supportedPebble is a small, RFC-compliant ACME test server from the Let's Encrypt team. Run end-to-end tests locally without touching real CA rate limits.
use CoyoteCert\Provider\Pebble;
// Default: connects to localhost:14000
CoyoteCert::with(new Pebble())
// Pebble uses a self-signed CA, disable TLS verification explicitly
CoyoteCert::with(new Pebble(verifyTls: false))
// Custom URL
CoyoteCert::with(new Pebble(url: 'https://pebble.internal:14000/dir', verifyTls: false))
// With EAB (if Pebble is configured for it)
CoyoteCert::with(new Pebble(verifyTls: false, eab: true, eabKid: 'kid', eabHmac: 'hmac'))Docker Compose setup for local development:
services:
pebble:
image: ghcr.io/letsencrypt/pebble:latest
ports:
- "14000:14000"
- "15000:15000"
environment:
PEBBLE_VA_NOSLEEP: "1"
PEBBLE_VA_ALWAYS_VALID: "1"
Blendbyte builds cloud infrastructure, web apps, and developer tools.
We've been shipping software to production for 20+ years.
This package runs in our own stack, which is why we keep it maintained.
Issues and PRs get read. Good ones get merged.