blendbyte / coyotecert
Full-featured ACME v2 (RFC 8555) PHP 8.3+ client for issuing, renewing, and revoking TLS certificates from Let's Encrypt, ZeroSSL, Google Trust Services, SSL.com, Buypass, or any standards-compliant CA โ with ARI smart renewal, ECDSA/RSA key support, EAB, and PSR-18 HTTP client integration.
Requires
- php: ^8.3
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- nunomaduro/termwind: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/log: ^3.0
- spatie/dns: ^2.7
- symfony/console: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- nyholm/psr7: ^1.8
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.5
- dev-main
- v1.0.1
- v1.0.0
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-fix-namespace-helpers
- dev-release/v1.0.0
- dev-dns-provider-adapters
- dev-add-cli-tool
- dev-preferred-chain-selection
- dev-rate-limit-typed-exceptions
- dev-dual-algo-certs
- dev-event-hooks-callbacks
- dev-fix-remove-from-crl-reason
- dev-security-audit
- dev-feat/unit-test-coverage
This package is auto-updated.
Last update: 2026-04-20 13:28:53 UTC
README
CoyoteCert
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 ๐ชจ.
Contents
- 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
Why CoyoteCert
Every major CA, out of the box
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.
A CLI that ships with the package
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.
Storage that fits wherever you are
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.
Six DNS-01 providers, no extra SDK needed
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.
๐ชจ Fails fast, before it costs you
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.
Typed exceptions that tell you what actually went wrong
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.
Short-lived certificates and ACME profiles
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.
RFC 8555 + RFC 9773, done right
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.
Also worth knowing
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.
Requirements
PHP ^8.3 with ext-curl, ext-json, ext-mbstring, and ext-openssl.
Installation
composer require blendbyte/coyotecert
Laravel
First-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.
Quick start
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 chain
Full example: nginx + automatic renewal
A 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
CLI
CoyoteCert ships with a coyote CLI for issuing and inspecting certificates without writing PHP. It wraps the same builder API as the library.
Install globally
composer global require blendbyte/coyotecert
Make sure ~/.composer/vendor/bin (or ~/.config/composer/vendor/bin on Linux) is on your PATH.
coyote issue
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/certs
DNS-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/certs
If 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.
coyote status
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 |
Cron renewal
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.
Help and version
coyote --help # list available commands coyote --version # show version coyote issue --help # full option reference for issue coyote status --help # full option reference for status
Providers
CoyoteCert 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) |
Let's Encrypt
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())
ZeroSSL
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'))
Google Trust Services
Obtain EAB credentials from the Google Cloud Console.
use CoyoteCert\Provider\GoogleTrustServices; CoyoteCert::with(new GoogleTrustServices(eabKid: 'kid', eabHmac: 'hmac'))
SSL.com
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))
Buypass Go SSL
use CoyoteCert\Provider\BuypassGo; use CoyoteCert\Provider\BuypassGoStaging; CoyoteCert::with(new BuypassGo()) CoyoteCert::with(new BuypassGoStaging())
Custom CA
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.
Challenge handlers
ACME requires domain ownership proof via a challenge. CoyoteCert ships with handlers for http-01, dns-01, and tls-alpn-01.
http-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.
dns-01
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())
tls-alpn-01
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.
DNS-01 providers
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.
Cloudflare
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))
Hetzner DNS
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');
DigitalOcean
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');
ClouDNS
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');
AWS Route53
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', );
Shell / exec
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 backends
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.
Filesystem
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.
Database (PDO)
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).
In-memory
Non-persistent: data is gone when the process exits. Great for tests and one-shot scripts.
use CoyoteCert\Storage\InMemoryStorage; ->storage(new InMemoryStorage())
No storage
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.
Custom storage
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.
Issuing certificates
issue()
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();
issueOrRenew()
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);
needsRenewal()
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)
Event callbacks
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.
onIssued
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();
onRenewed
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 pre-check
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(); }
How the check works
- 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.
Opting out
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.
Error handling
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.
Rate limits, Retry-After included
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.
Authentication failures
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.
Per-identifier subproblems (RFC 8555 ยง6.7)
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.
Exception hierarchy
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
Wildcard and multi-domain certificates
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.
IP address certificates (RFC 8738)
->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.
Automatic renewal
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
ARI: CA-guided renewal windows
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.
ACME profiles
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.
Preferred chain selection
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');
Key types
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.
Certificate revocation
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);
PSR-18 HTTP client
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.
HTTP timeout
Tune the built-in curl client's timeout without replacing the whole client:
->withHttpTimeout(30) // seconds
Has no effect when a custom PSR-18 client is configured; configure timeout there instead.
Logging
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.
Inspecting StoredCertificate
StoredCertificate is the value object returned by issue() and issueOrRenew(). It holds all certificate data and exposes a handful of inspection helpers.
Properties
$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
Methods
// 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']
Serialisation
StoredCertificate round-trips through JSON cleanly:
$array = $cert->toArray(); $cert = StoredCertificate::fromArray($array);
Builder reference
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 |
Low-level API
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 supported
Testing with Pebble
Pebble 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"
Maintained by Blendbyte
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.