<?phpfunction daysLeft(string $path): int{ $info = openssl_x509_parse(file_get_contents($path)); // PEM text if ($info === false) { throw new RuntimeException("$path: not a certificate: " . openssl_error_string()); } $notAfter = new DateTimeImmutable('@' . $info['validTo_time_t']); // a Unix timestamp, UTC $left = intdiv($info['validTo_time_t'] - time(), 86400); echo "$path: notAfter {$notAfter->format(DATE_ATOM)}, $left days left\n"; return $left;} <?phpfunction serverNotAfter(string $host, int $port, ?string $caFile = null): DateTimeImmutable{ $ssl = ['capture_peer_cert' => true, 'peer_name' => $host]; // verify_peer is on by default if ($caFile !== null) { $ssl['cafile'] = $caFile; // a private CA instead of the system bundle } $context = stream_context_create(['ssl' => $ssl]); $warnings = []; // a failed handshake reports through warnings, the first one names the cause set_error_handler(function (int $level, string $message) use (&$warnings): bool { $warnings[] = $message; return true; }); try { $client = stream_socket_client("ssl://$host:$port", $errno, $error, 10, STREAM_CLIENT_CONNECT, $context); } finally { restore_error_handler(); } if ($client === false) { throw new RuntimeException('TLS connection failed: ' . ($warnings[0] ?? $error)); } $cert = stream_context_get_params($client)['options']['ssl']['peer_certificate']; fclose($client); return new DateTimeImmutable('@' . openssl_x509_parse($cert)['validTo_time_t']);} How it works
openssl_x509_parsereturns an array;validTo_time_tis the expiry as a Unix timestamp, whichnew DateTimeImmutable('@' . $ts)turns into a UTC date.stream_socket_client("ssl://host:port")verifies the peer by default (verify_peer,verify_peer_name);cafilereplaces the system bundle andpeer_nameis the name checked and sent as SNI.capture_peer_certkeeps the server's certificate in the stream context, wherestream_context_get_params(...)['options']['ssl']['peer_certificate']returns it foropenssl_x509_parse.- A failed handshake reports through PHP warnings;
serverNotAftercollects them withset_error_handlerand throws with the first one, which names the cause.
Gotchas
validTois the raw ASN.1 time (351231235959Z, two-digit year), not somethingstrtotimeunderstands; usevalidTo_time_t(2082758399).- For an expired server certificate PHP only says
SSL operation failed with code 1. OpenSSL Error messages: error:0A000086:SSL routines::certificate verify failed— not why. Check the date separately (or withopenssl s_client) before concluding the certificate expired. intdivtruncates toward zero: the expired certificate shows-567days where flooring gives-568. Compare timestamps when the boundary matters.