spatie/ssl-certificate
A PHP package from Spatie that makes it easy to query SSL certificate properties. Fetch a certificate from a hostname, file, or string, then check validity, expiration dates, issuer, and key details.
Install with Composer; there is no service provider, config file or facade to publish — it's a plain PHP class, so it just works inside Laravel:
composer require spatie/ssl-certificate
Requirements: PHP 8.0+ (v2.x). The only entry point you need to know is Spatie\SslCertificate\SslCertificate. Start by reading that class, then Spatie\SslCertificate\Downloader (the fluent builder) and Spatie\SslCertificate\Exceptions\*.
Three ways to get a certificate object:
use Spatie\SslCertificate\SslCertificate;
$certificate = SslCertificate::createForHostName('spatie.be'); // live download, 30s timeout
$certificate = SslCertificate::createFromFile(storage_path('cert.pem')); // PEM or DER
$certificate = SslCertificate::createFromString($rawCertificateData); // e.g. from a proxy header
First use case — an expiry check in tinker/artisan:
$cert = SslCertificate::createForHostName('example.com', timeout: 10);
$cert->isValid(); // date range only
$cert->daysUntilExpirationDate();
$cert->getAdditionalDomains(); // ['example.com', 'www.example.com']
That's the 90% case: a scheduled command that reports which of your domains expires within n days. Everything else in the package is a property accessor on top of that.
A command that checks a configured list of hosts and notifies when a threshold is crossed:
// app/Console/Commands/CheckSslCertificates.php
class CheckSslCertificates extends Command
{
protected $signature = 'ssl:check {--days=14}';
public function handle(): int
{
$expiring = [];
foreach (config('monitoring.domains') as $host) {
try {
$cert = SslCertificate::createForHostName($host, timeout: 10);
} catch (CouldNotDownloadCertificate|InvalidUrl $e) {
report($e);
$expiring[] = "{$host}: could not download ({$e->getMessage()})";
continue;
}
if ($cert->isExpired() || $cert->daysUntilExpirationDate() <= $this->option('days')) {
$expiring[] = "{$host}: expires {$cert->expirationDate()->toDateString()}";
}
}
if ($expiring) {
Notification::route('mail', config('monitoring.alert_email'))
->notify(new SslCertificatesExpiring($expiring));
}
return self::SUCCESS;
}
}
Schedule it daily (not hourly — you're hitting external network + other people's servers):
$schedule->command('ssl:check')->dailyAt('07:00')->onOneServer();
toArray() / createFromArray() round-trip means you can persist certificate state without touching the network again — ideal for a dashboard or for comparing today's cert to yesterday's (issuer changed? key size changed? fingerprint rotated?):
$cert = SslCertificate::createFromArray($record->certificate_data);
$cert->getFingerprintSha256();
$cert->getPublicKeySize(); // e.g. 2048 — flag anything < 2048
$cert->isSelfSigned();
$cert->usesSha1Hash(); // flag legacy signing
Store it as a json column and cast in the model; the array contains plain scalars and date strings only.
createForHostName('host:8443') handles custom ports, but for anything more involved use the fluent downloader:
$cert = SslCertificate::download()
->usingPort(443)
->fromIpAddress('10.0.0.42') // hit one node behind the LB
->forHost('shop.example.com') // keep SNI/Host correct
->getCertificates(); // chain: Collection<SslCertificate>
This is the pattern for verifying that every node in a cluster serves the same certificate and that the intermediate chain is present (->withFullChain()), rather than trusting whatever the LB's DNS resolution happens to return.
Expose a route that returns JSON built from toArray() plus a computed status — useful for external uptime monitors that only speak HTTP:
return response()->json([
'expires_in_days' => $cert->daysUntilExpirationDate(),
'valid' => $cert->isValid(),
'issuer' => $cert->getIssuer(),
]);
Keep the route behind a cache (e.g. Cache::remember('ssl:example.com', now()->addHours(6), ...)) so monitoring pings don't cause a TLS handshake per request.
Beyond "is it valid", the package is handy for asserting a certificate actually covers what you think after a migration:
$cert->isValid('www.example.com'); // date range + domain match
$cert->getDomains(); // primary + additional
$cert->isValidUntil(now()->addDays(7));
Use isValid($domain) in a feature test or a deploy-time check when you switch from example.com to www.example.com or move a subdomain to a new cert, instead of waiting for user reports.
Network calls to 50 hosts synchronously inside a request or a single worker will be slow and flaky. Dispatch one job per host onto a low-concurrency queue, with retries and retryUntil, and write results to a table. That gives you history (when did the cert last rotate?) for free.
The class is Macroable. If your team keeps re-implementing the same check (e.g. "expires within X and issued by Let's Encrypt"), register it once in a service provider:
SslCertificate::macro('needsRenewalSoon', function (int $days = 21): bool {
return $this->daysUntilExpirationDate() <= $days;
});
isValid() does NOT validate the trust chain. This is the single biggest trap and the README calls it out explicitly. It returns true if the current time is between validFromDate() and expirationDate(). A self-signed or wrong-CA certificate will pass. If you need real trust validation, keep the normal TLS handshake as the source of truth (Guzzle/Http::get() with verification on) and use this package for metadata (expiry, issuer, SANs, key size). isSelfSigned() and usesSha1Hash() help, but they aren't a CA check.
Wildcards don't cover the root domain. Since 2.6.10, a cert for *.example.com does not match example.com. So isValid('example.com') on a wildcard cert returns false. If your check iterates over apex domains, either add the apex as an additional SAN or account for this in your comparison logic — otherwise you'll get false alarms.
verifyCertificate: false is how you inspect broken certs. The third argument of createForHostName($host, $timeoutInSeconds, false) lets you download expired/invalid certs. This is exactly what you want for a "why is this host broken?" diagnostic command, not for your happy-path monitor.
Exceptions are part of the contract. CouldNotDownloadCertificate, InvalidUrl, and InvalidIpAddress — catch them per-host. One misconfigured host string must not abort the whole scan. Note InvalidUrl vs CouldNotDownloadCertificate are distinct: the former is a bad input, the latter is a network/TLS failure, and the message includes the connecting port (since 2.5.0), which speeds up debugging.
Default timeout is 30 seconds. For a 40-host scan that's a potential 20-minute worst case on a wedged network. Always pass an explicit short timeout (5–10) in batch jobs.
daysUntilExpirationDate() can go negative on expired certificates. Compare with <= thresholds rather than assuming a positive number, and prefer isExpired() when you specifically mean "already expired".
Organization fields are empty for DV certificates. Let's Encrypt certs have no organization, so getOrganization() / getSubjectOrganization() return null/empty. Don't build logic or UI that assumes these are populated — and if you display them, guard for null.
Carbon and tests. The date methods return Carbon instances, so Carbon::setTestNow() (or $this->travelTo()) fully controls isValid() / daysUntilExpirationDate() in tests. Combine that with createFromString() on a fixture PEM to write deterministic tests with zero network I/O — that's the right way to test your monitoring logic. Don't let CI reach out to real hosts.
Socket context options for odd setups. ->withSocketContextOptions([...]) and ->withVerifyPeer() / ->withVerifyPeerName() exist for the cases where you need SNI, a specific crypto setting, or a bypass — treat them as escape hatches, not routine configuration.
IPv6 is supported but validate input yourself. fromIpAddress('2a00:1450:4001:80e::200e') works; fromIpAddress('not-an-ip') throws InvalidIpAddress. If the address comes from user input, wrap it.
createFromFile() accepts DER too (since 2.4.0). Useful when a certificate lands in your app as raw bytes from a proxy header or an upload rather than a PEM bundle.
Serialization caveats. toArray()/createFromArray() has had edge-case fixes (2.0.1), so pin a recent version if you persist arrays to the database. Also remember the persisted array is a snapshot — a stored cert that said "valid" last week proves nothing now.
Not a rate-limit concern, but be a good citizen. Checking a host means a real TCP+TLS handshake against someone's infrastructure. Daily per host is fine; per-request is not. Cache aggressively and stagger your schedule.
How can I help you explore Laravel packages today?