ACME & Let’s Encrypt
The acme driver is a native ACME v2 (RFC 8555) client. The transport is Laravel’s HTTP client, the CSR comes from ext-openssl and every signature, JWK and X.509 parse comes from crypto-for-laravel — no third-party ACME or crypto SDK. Point it at a CA directory, give it a contact address and issue:
CERTIFICATES_DRIVER=acme
CERTIFICATES_ACME_DIRECTORY=https://acme-staging-v02.api.letsencrypt.org/directory
CERTIFICATES_ACME_CONTACT=[email protected]
CERTIFICATES_ACME_HTTP_DISK=local
CERTIFICATES_ACME_STORE_DISK=local// config/certificates.php → 'default' => 'acme', or:
Certificates::for('app.example.com')->using('acme')->issue();Use the Let’s Encrypt staging directory while testing — it is noted in the published config — and switch to production once issuance works.
Driver keys
| Key | Env | Default | Purpose |
|---|---|---|---|
directory | CERTIFICATES_ACME_DIRECTORY | Let’s Encrypt production | ACME v2 directory URL of the CA. |
contact | CERTIFICATES_ACME_CONTACT | null | E-mail sent to the CA as a mailto: contact on registration. |
account.key_type | CERTIFICATES_ACME_KEY_TYPE | EC | Generated account key: EC (P-256) or RSA (2048-bit) — exactly, anything else throws. |
account.disk | CERTIFICATES_ACME_ACCOUNT_DISK | local | Disk holding the account key and account records. |
account.key_path | CERTIFICATES_ACME_ACCOUNT_KEY | acme/account.pem | Account key path on that disk. |
account.auto_register | CERTIFICATES_ACME_AUTO_REGISTER | true | Generate the key on first use; false requires one on disk. |
solver | CERTIFICATES_ACME_SOLVER | null | AcmeChallengeSolver class name; null or blank uses the HTTP-01 solver, a class that is not an AcmeChallengeSolver throws. The solver’s type() picks the challenge answered. |
http.disk | CERTIFICATES_ACME_HTTP_DISK | local | Disk the HTTP-01 token files are written to. |
http.path | CERTIFICATES_ACME_HTTP_PATH | acme-challenge | Directory for the token files. |
store.disk | CERTIFICATES_ACME_STORE_DISK | local | Disk for issued material. |
store.path | CERTIFICATES_ACME_STORE_PATH | certificates | Directory for issued material. |
poll.attempts | CERTIFICATES_ACME_POLL_ATTEMPTS | 30 | Maximum polls for validation and finalization. |
poll.seconds | CERTIFICATES_ACME_POLL_SECONDS | 2 | Seconds between polls. |
verify | CERTIFICATES_ACME_VERIFY | true | TLS verification to the CA: a CA bundle path, true or null (1, on, yes) for the system bundle, or false to disable it. An array or a float throws. |
How an order runs
- The account is registered, or reused, at the configured directory.
- A new order is placed for every domain. For each authorization the challenge of the solver’s own type() is picked, the solver publishes it, the CA is told to validate it and the authorization is polled (poll.attempts × poll.seconds). The solver’s cleanup runs afterwards, even on failure.
- A fresh 2048-bit RSA certificate key and a CSR covering all domains are generated; the order is finalized and polled until valid.
- The certificate is downloaded, split into the leaf and the chain, and stored with its private key.
- Any protocol failure throws AcmeException — directory, nonce, account, order, challenge, finalize or download.
HTTP-01
The default solver writes the key authorization to a file named after the token under http.path on http.disk. Your app must serve that file’s contents at /.well-known/acme-challenge/{token} on every domain being validated. The file is deleted once validation finishes.
The account key
On first use the account key is generated and saved to account.key_path on account.disk (account.auto_register): EC mints a P-256 key, RSA a 2048-bit one. A key you place there yourself is used as-is — EC P-256 and P-384 are both accepted, and each signs under its own algorithm (ES256 / ES384). With auto_register off and no key on disk, issuance throws AcmeException.
The account itself is recorded per CA directory, next to the key ({key_path}.{sha256(directory)}.kid), and bound to the key it was registered with. Switching from staging to production registers a production account on the next issue instead of replaying the staging account, switching back reuses the staging one, and a replaced key re-registers rather than signing under its predecessor’s account. Re-registering an already-known key is safe — the CA answers with the existing account.
verify controls TLS verification of the CA connection: a CA bundle path, true or null for the system bundle, or false to disable it (not recommended). Any other string is read as a bundle path, so a typo fails the handshake instead of disabling verification.
Where the certificate lands
Issued material is written to store.path on store.disk, one directory per certificate name:
certificates/ path on the disk
└── generated-tls-app-example-com/ Certificates::certificateName($domain)
├── certificate.pem leaf certificate
├── private.key private key
└── chain.pem intermediate chain, when presentThe container binds the CertificateStore contract to that same store, so you can read the PEMs back — for example to hand them to a web server — and parse them with CertificateMapper:
use RoundlyConsulting\Certificates\Contracts\CertificateStore;
use RoundlyConsulting\Certificates\Facades\Certificates;
$material = app(CertificateStore::class)->get(Certificates::certificateName('app.example.com'));
$material?->certificatePem; // leaf certificate
$material?->privateKeyPem; // private key
$material?->chainPem; // intermediates, or null
$material?->fullChainPem(); // leaf followed by the chainuse RoundlyConsulting\Certificates\Support\CertificateMapper;
$parsed = app(CertificateMapper::class)->parse($material->certificatePem);
$parsed->commonName; // 'app.example.com'
$parsed->subjectAltNames; // list<string>
$parsed->notBefore; // CarbonImmutable
$parsed->notAfter; // CarbonImmutable
$parsed->issuer; // issuer CN, or its organization
$parsed->serial; // upper-case hex
$parsed->fingerprint; // upper-case SHA-256 hex
$parsed->isExpired(); // boolDNS-01
DNS-01 is an extension point — the package ships no DNS provider SDK. Extend DnsChallengeSolver and implement publishRecord() and removeRecord() against your DNS provider’s API; they receive the TXT record name and value:
use RoundlyConsulting\Certificates\ChallengeSolvers\DnsChallengeSolver;
final class Route53Solver extends DnsChallengeSolver
{
protected function publishRecord(string $name, string $value): void { /* upsert TXT */ }
protected function removeRecord(string $name, string $value): void { /* delete TXT */ }
}Then register the solver’s class name in drivers.acme.solver (CERTIFICATES_ACME_SOLVER). That one key is all it takes: the solver’s own type() picks the challenge answered — dns-01 for a DnsChallengeSolver, http-01 for the shipped default — so there is no separate challenge-type setting to keep in step. The solver is resolved from the container, so constructor injection works:
// config/certificates.php — the solver's own type() picks the challenge (dns-01 here)
'acme' => [
// ...
'solver' => App\Acme\Route53Solver::class,
],ACME CAs such as Let’s Encrypt never offer HTTP-01 for a *. name, so wildcard orders need a DNS solver; with the default HTTP-01 solver a wildcard order fails with an AcmeException that says so. For full control implement the AcmeChallengeSolver contract directly — solve() and cleanup() each receive an AcmeChallenge:
namespace RoundlyConsulting\Certificates\Contracts;
interface AcmeChallengeSolver
{
public function type(): string; // 'http-01' or 'dns-01'
public function solve(AcmeChallenge $challenge): void; // publish the challenge
public function cleanup(AcmeChallenge $challenge): void; // remove it afterwards
}$challenge->type; // 'http-01' or 'dns-01'
$challenge->domain; // 'app.example.com'
$challenge->token;
$challenge->keyAuthorization; // token + '.' + account key thumbprint
$challenge->httpPath(); // '/.well-known/acme-challenge/{token}'
$challenge->dnsRecordName(); // '_acme-challenge.app.example.com'
$challenge->dnsRecordValue(); // base64url(SHA-256(key authorization))Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.