acmephp/ssl
acmephp/ssl is a PHP library for SSL/TLS certificate handling. It provides key and CSR generation, certificate parsing and signing, and the low-level certificate utilities that power Acme PHP's Let's Encrypt client.
acmephp/ssl is the low-level SSL primitive layer of the Acme PHP stack. It does not talk to Let's Encrypt — it gives you typed objects for keys, CSRs, certificates and a thin, opinionated wrapper over ext-openssl so you never touch raw openssl_* resource juggling.
composer require acmephp/ssl
Requirements: PHP with ext-openssl. There is no Laravel service provider, config file or facade — it's a plain library (AcmePhp\Ssl\*), so you wire it yourself.
Read these four files first (they cover 90% of the surface area):
| File | Why |
|---|---|
src/KeyPair.php / src/Key.php |
Key generation, PEM export, public key extraction |
src/CertificateSigningRequest.php |
Building + exporting a CSR |
src/Signer/OpenSslSigner.php |
Actually signing a CSR |
src/Parser/OpenSslParser.php |
Turning a PEM string into a Certificate/Key/CSR object |
First use case — almost every project starts here: given a certificate PEM you already have on disk, answer "when does it expire, and what domains does it cover?"
use AcmePhp\Ssl\Parser\OpenSslParser;
$parser = new OpenSslParser();
$cert = $parser->parseCertificate($pem);
$cert->getSubjectCommonName(); // string
$cert->getSubjectAlternativeNames(); // array of DNS names
$cert->getValidTo(); // \DateTime (UTC)
$cert->getSerialNumber(); // hex string
Second use case: minting a self-signed certificate for local TLS/mTLS without shelling out to openssl:
use AcmePhp\Ssl\Generator\KeyOption;
use AcmePhp\Ssl\Generator\OpenSslKeyGenerator;
use AcmePhp\Ssl\Signer\OpenSslSigner;
$generator = new OpenSslKeyGenerator();
$keyPair = $generator->generateKeyPair(new KeyOption(KeyOption::KEY_RSA, 4096));
$signer = new OpenSslSigner();
$certificate = $signer->signCertificate(
$csr,
null, // no issuer certificate => self-signed
$keyPair,
new \DateTime('-1 day'),
new \DateTime('+1 year')
);
Register the collaborator objects as singletons. They're stateless and cheap, but you want one place to swap in a fake for tests.
// app/Providers/SslServiceProvider.php
$this->app->singleton(OpenSslParser::class);
$this->app->singleton(OpenSslSigner::class);
$this->app->singleton(OpenSslKeyGenerator::class);
$this->app->alias(OpenSslParser::class, ParserInterface::class);
$this->app->alias(OpenSslSigner::class, SignerInterface::class);
Depend on ParserInterface / SignerInterface in your services, not the concrete classes — those interfaces are the intended extension point.
Store PEMs in a text column, with the private key encrypted. Casts are the natural fit:
protected $casts = [
'certificate_pem' => 'string',
'private_key_pem' => 'encrypted', // Laravel's Crypt under the hood
];
Never let getPEM() output reach logs, ->toArray(), or Horizon payloads. Wrap any Key/KeyPair you pass into a queued job in a DTO that only carries what the job needs, or serialize the PEM inside Crypt::encryptString() first.
Renewal checks as a scheduled command. getValidTo() returns a plain \DateTime in UTC; comparing against now() without normalising the timezone is the classic off-by-hours bug.
$expiresAt = \Carbon\CarbonImmutable::instance($cert->getValidTo());
if ($expiresAt->isBefore(now()->addDays(30))) {
$this->line("Renewing {$cert->getSubjectCommonName()}");
}
A daily Schedule::command('certs:check')->at('03:00') that loops over stored certificates is the whole feature.
Domain coverage check — useful in middleware or an uptime-ish check:
$sans = $cert->getSubjectAlternativeNames(); // ['example.com', 'www.example.com']
$matches = fn (string $host) => in_array($host, $sans, true)
|| collect($sans)->contains(fn ($san) => str_starts_with($san, '*.')
&& str_ends_with($host, substr($san, 1)));
Note wildcards only match one label — *.example.com must not match a.b.example.com. Do that check yourself; the library won't.
Building a CSR for an ACME order. Build the DistinguishedName with the common name, attach the SANs (this is what the CA actually validates — the CN is cosmetic since BR requirements changed), then hand it to the signer:
use AcmePhp\Ssl\DistinguishedName;
use AcmePhp\Ssl\CertificateSigningRequest;
$dn = new DistinguishedName(
$primaryDomain, // common name
'US', // country
'Acme Inc', // organization
'IT', // organizational unit
'[email protected]' // email
);
// SANs: pass as the last constructor arg or via setSubjectAlternativeNames(),
// depending on the version you pin — verify against vendor/ before relying on it.
$csr = new CertificateSigningRequest($dn, $keyPair->getPublicKey());
$csr = $signer->signCertificateSigningRequest($csr, $keyPair->getPrivateKey());
$pem = $csr->getPEM(); // base64url-encode this for the ACME newOrder payload
Pairing with the rest of the stack. acmephp/ssl is the bottom layer — the actual protocol work lives in acmephp/acme-lib (account, order, challenge, finalize). If you're in Laravel and just want certificates issued, use a higher-level client and keep this package for the parsing/signing primitives, or skip it entirely and use openssl_* directly for the trivial cases.
Key choice. RSA 2048/4096 is the boring, universally-supported option; KeyOption::KEY_EC with a P-256 curve produces much smaller certs and faster handshakes, but verify your ext-openssl build actually exposes the curve before shipping — older builds silently fail.
It is unmaintained. Last release January 2019, ~15 stars, zero dependents. Treat it as frozen code you own. The biggest practical consequence: code written against PHP 7-era ext-openssl may assume openssl_pkey_get_* returns a resource. PHP 8 returns an OpenSSLAsymmetricKey object. Any is_resource() guard, strict type hint, or get_resource_type() call in Key/KeyPair internals is a landmine. Test the key-round-trip path on your target PHP version before committing.
getPEM() returns the private key unencrypted, even if you loaded it from a passphrase-protected file. Encryption at rest is entirely your problem (hence the encrypted cast above). There's no "encrypted PEM" object.
getSerialNumber() is a hex string, not an integer. If you're matching against a CA's dashboard serial, normalise case and strip colons. Certificates with serials > PHP_INT_MAX will silently truncate if you cast to int.
SANs come back as bare DNS names from Certificate, but as DNS:foo-prefixed strings if you fall back to raw openssl_x509_parse(). Don't mix the two parsing paths in one codebase — pick the library parser and stay there.
No validation is performed. parseCertificate() will happily parse an expired, self-signed, wrong-host certificate. There's no chain verification, no OCSP/CRL, no issuer trust check. If you need "is this cert actually valid for this host right now", that's openssl_x509_checkpurpose or an HTTP client doing the handshake — this package won't tell you.
Multi-certificate PEMs need manual splitting. A full chain from a CA (leaf + intermediate) is one blob with multiple -----BEGIN CERTIFICATE----- blocks. The parser handles one certificate; split on the BEGIN marker with a regex and parse each block, then keep the last as the issuer for signing.
\DateTime objects are mutable. getValidTo() returns the object the parser built; mutating it corrupts the certificate object's internal state for any later caller. Clone before doing arithmetic, or wrap in CarbonImmutable::instance().
Exceptions are library-specific — AcmePhp\Ssl\Exception\AcmeSslException and friends, not \RuntimeException. Catch AcmeSslException broadly in controllers, but let it bubble in console commands so failures are loud; a silently-swallowed parse failure means an expired cert on production.
Testing. Generate fixtures once (openssl req -x509 -newkey rsa:2048 -nodes ...) and commit the PEMs rather than generating them in setUp(). Key generation is slow (hundreds of ms for RSA 4096), and a suite that regenerates keys per test will drag.
Extension points worth knowing: implement SignerInterface if you need to delegate signing to a KMS/HSM instead of a local private key; implement ParserInterface if you must support a format ext-openssl chokes on. Everything else is final-ish value objects — don't subclass Certificate, construct it.
How can I help you explore Laravel packages today?