CoyoteCert

Modern ACME for Modern PHP

Look, you need TLS certs. You don't need another weekend reading RFC 8555. CoyoteCert is a PHP 8.3+ client that talks ACME to Let's Encrypt, ZeroSSL, Buypass, and anyone else with a compliant directory. It issues. It renews. It revokes. It does not explode at 3am.

View on GitHub
RFC 8555 · ACME v2
PHP 8.3+ · strict types · PSR-4
License: MIT
A Blendbyte open-source project
CoyoteCert mascot holding a sealed certificate
CERTIFIEDRFC 8555
APPROVEDQUALITY SEAL

The Coyote tested it. So you don't have to.

No global state. No shelling out to certbot and praying. No 600-line configs. Just a PHP library that speaks ACME to any compliant CA and then gets out of your hair.

№ 01 · HTTP + DNS

HTTP-01 and DNS-01

HTTP challenge? Built in. DNS-01 for wildcards? Cloudflare, Route 53, DigitalOcean, ClouDNS, Hetzner already packed in. Running something weirder? Three methods to implement — supports, deploy, cleanup — and you're certified.

№ 02 · AUTO

Renewal on autopilot

Comes with a CLI for issuing and renewing. Not a PHP person? Still fine. Drop vendor/bin/coyote issue in cron. Idempotent, atomic, skips what isn't due. There is no separate renew command — issue handles both. Run it every hour. The CA won't even notice.

№ 03 · § 7.4

RFC 8555, by the book

Accounts, orders, authorizations, finalization. Nonces, replay protection, JWS signing. All the boring plumbing happens inside the library. You just ask for a cert.

№ 04 · TYPED

Types, not mystery arrays

PHP 8.3 readonly classes. Enums for every state. Return types everywhere. Your IDE knows what it's doing, and so will you.

№ 05 · KEYS

ECDSA or RSA, your call

P-256, P-384, P-521 or honest 2048 to 4096 bit RSA. Store them on disk, in Vault, in KMS, in a cigar box. One interface, one method.

№ 06 · OBSERVE

PSR-3 logs, zero mystery

Every request, challenge poll, and retry goes through a logger you hand us. When something goes sideways you get receipts.

One chain. One cert. Zero anvils.

Point it at a CA. Hand it a domain and a solver. Get back a fullchain and a private key. That's the whole show. The paperwork is hiding in the basement where it belongs.

  • ✓Hit Let's Encrypt staging first. Then prod. Same code, different URL.
  • ✓Swap CAs by changing one string. ZeroSSL, Buypass, SSL.com, step-ca.
  • ✓Get a real Certificate object. Not a sack of PEM you have to shake out.
  • ✓Typed exceptions for rate limits, bad nonces, and other CA tantrums.
IssueCertificate.php PHP · 8.3
use CoyoteCert\CoyoteCert;
use CoyoteCert\Provider\LetsEncrypt;
use CoyoteCert\Storage\FilesystemStorage;
use CoyoteCert\Challenge\Dns\CloudflareDns01Handler;

$cert = CoyoteCert::with(new LetsEncrypt())
    ->storage(new FilesystemStorage('/var/certs'))
    ->identifiers(['example.com', '*.example.com'])
    ->email('[email protected]')
    ->challenge(new CloudflareDns01Handler(apiToken: 'your-api-token'))
    ->issueOrRenew();

Any CA worth its salt.

If it speaks ACME v2, CoyoteCert will chat. Swap the directory URL. Keep your code. Life's short.

Let's EncryptStaging + Prod
ZeroSSLFree 90-day
BuypassGo SSL
SSL.comEnterprise
Google TrustServices
Bring your ownAny ACME v2 CA

Wildcards without the yak-shaving.

Cloudflare, Route 53, DigitalOcean are already in there. Running something else? Implement supports, deploy, and cleanup and ship it.

CloudflareBuilt-in
AWS Route 53Built-in
DigitalOceanBuilt-in
ClouDNSBuilt-in
HetznerBuilt-in
Bring your ownThree methods. Done.
8555
RFC compliant
8.3+
PHP required
0
Shell calls
MIT
Open source

Laravel? Already handled.

First-party package. Facade, Artisan commands, HTTP-01 over the cache store (no nginx changes needed), and a daily scheduled renewal task. Zero boilerplate beyond publishing the config.

coyotecert-laravel on GitHub →
anywhere.php Laravel
use Blendbyte\CoyoteCertLaravel\Facades\Cert;

Cert::for('example.com')->issueOrRenew();

Every other crate has a catch.

We ordered from every supplier so you don't have to. Here's what actually shipped.

CoyoteCert ACMECert acmephp kelunik/acme yaac
ARI (RFC 9773) ✓ ✓ ✗ ✗ ✗
EAB auto-provisioning 1 ✓ manual manual ✗ ✗
IP SANs (RFC 8738) ✓ ✓ ✗ ✗ ✗
TLS-ALPN-01 ✓ ✓ ✗ ✗ ✗
Built-in DNS providers 6 none 3 none none
CLI shipped with package ✓ ✗ ✓ ✗ ✗
Laravel integration ✓ ✗ ✗ ✗ ✗

¹ CoyoteCert fetches EAB credentials directly from your ZeroSSL API key (no copy-pasting tokens). "manual" means EAB is supported but credential wrangling is on you.

Cells verified from each library's public repository, May 2026. Wrong? Open a PR. We check before merging.

Install. Issue. Forget.

01

Grab the package

Composer, the way nature intended. No weird PECL extensions. Just OpenSSL.

$ composer require blendbyte/coyotecert
02

Catch your first cert

Test on staging. Flip to prod when it stops crashing. (It won't.)

CoyoteCert::with(new LetsEncrypt())->issueOrRenew();
03

Set it, forget it

Skip the PHP script. Slap the CLI in cron. Done. There is no renew command — issue handles both.

0 * * * * vendor/bin/coyote issue --identifier example.com --webroot /var/www/html --provider letsencrypt --storage /etc/certs --email [email protected]

The manual the CA forgot to write.

Answers to the questions that send people to GitHub issues at 2am. We've been there. Here's what actually fixes it.

My HTTP-01 validation fails with a 404 but the file is right there.

nginx found it first. nginx always finds it first, and not in a helpful way.

Your location / block swallows the request before it reaches .well-known/acme-challenge/. Add a dedicated block above everything else:

location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
    default_type text/plain;
}

The ^~ prefix stops nginx from even considering regex locations once this prefix matches. Apache users: check that AllowOverride None is not blocking the directory, and that no .htaccess rule is redirecting HTTP to HTTPS before the token can be served.

I'm behind Cloudflare's proxy. Does HTTP-01 still work?

Sometimes. If Always Use HTTPS is enabled, the 80-to-443 redirect fires before the token is ever served and the CA gets a redirect instead of a token.

Easiest fix: switch to DNS-01. Cloudflare is a built-in DNS provider, so it is a one-line handler swap and the orange cloud stays orange.

Committed to HTTP-01? Grey-cloud the record (DNS only) during issuance, then flip it back. Cloudflare's proxy can be asked to step aside briefly. It doesn't like it, but it cooperates.

issueOrRenew() issues a fresh certificate on every single cron run.

Missing storage, or the storage path changes between runs. Without a storage backend, needsRenewal() always returns true because there is nowhere to check what you already have. Like a dog who has never seen a ball before. Every single time.

->storage(new FilesystemStorage('/etc/coyotecert'))

Same path, every run. If you are using DatabaseStorage, confirm the connection points at the same database as last time and not your staging environment.

Can I get a wildcard certificate?

Yes, but DNS-01 only. RFC 8555 forbids wildcard validation over HTTP-01 or TLS-ALPN-01. That is not a CoyoteCert limitation. It is a rule carved into the RFC the same way the ACME Corporation name was stamped into every box of defective rocket skates.

->identifiers(['*.example.com', 'example.com'])
->challenge(new CloudflareDns01Handler(...))

*.example.com covers subdomains but not the apex itself, so include both if you need the root. And *.*.example.com is not a thing. The exception message will remind you of this fact.

How do I test without burning Let's Encrypt rate limits?

Use LetsEncryptStaging. The certificates are not browser-trusted but the full ACME handshake is byte-for-byte identical to production. Hammer it as many times as you need.

$coyote = CoyoteCert::with(new LetsEncryptStaging())
    // ... rest of your config

When everything looks good, swap to LetsEncrypt and issue once for real. Do not skip staging and go straight to production. Someone does this every time, and they always end up filing a rate limit issue on GitHub.

I run on multiple servers. Can they share one account?

Yes. Use DatabaseStorage with a shared database, or FilesystemStorage on a shared volume. File locking is safe across concurrent processes, so two servers racing to issueOrRenew() at the same time will not corrupt anything. They end up holding the same certificate, which is the civilised outcome.

->storage(new DatabaseStorage($sharedPdo))

One account key per CA, shared by however many servers you run.

Stamp it.
Ship it. Sleep on it.

MIT licensed. Barely any dependencies. Ready whenever your next deploy is.