---
title: "X.509 certificates & chains — Crypto for Laravel | Roundly"
description: "Read certificate facts — names, fingerprints, validity, keys and raw extensions — and prove chain linkage. Trust stays yours."
url: https://roundly-consulting.com/open-source/docs/crypto-for-laravel/x509
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Crypto for Laravel](https://roundly-consulting.com/open-source/docs/crypto-for-laravel.md)

# X.509 certificates & chains

X509\\Certificate parses a certificate eagerly, so a getter can never throw a parse error later. Input is capped at 64 KiB before OpenSSL sees a byte, and it answers the factual questions about a certificate:

```php
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;

$certificate = Crypto::x509()->fromPem($pem);        // also fromDer(), fromBase64() for an x5c entry
$certificate->fingerprint();

$chain = Crypto::x509()->chain()->fromX5c($x5c);     // also fromPems(), fromPemBundle(), fromCertificates()
$chain->isLinked();                                  // the math — the trust call stays yours
$chain->fingerprints(HashAlgorithm::Sha1);

// Shortcuts for the common cases:
Crypto::certificate($pem);
Crypto::chainFromX5c($x5c);
Crypto::chainFromPemBundle($bundle);
```

Or call the classes the facade fronts directly:

```php
use RoundlyConsulting\Crypto\X509\Certificate;

$certificate = Certificate::fromPem($pem);        // also fromDer(), fromBase64() for an x5c entry

$certificate->commonName();                       // 'app.example'
$certificate->dnsNames();                         // ['app.example', '*.api.example'] — SAN dNSNames, read from the DER
$certificate->fingerprint();                      // lower-case sha256 hex, as openssl emits it
$certificate->notAfter();                         // CarbonImmutable
$certificate->publicKey();                        // RsaKey | EcKey — policy-checked
$certificate->isSignedBy($issuer);                // an ALGORITHM question
```

fromDer() and fromBase64() take exactly one certificate: bytes after it are refused, so two different x5c strings can never be the same certificate. fromPem() takes PEM text, never a file:// path. dnsNames() walks the subjectAltName GeneralNames in the DER and returns each dNSName verbatim — never OpenSSL’s comma-joined text, where one name containing “, DNS:victim.example” would read as two.

The input of fromPem(), fromDer() and fromBase64() — and of Chain::fromPems(), fromX5c() and fromPemBundle(), facade shortcuts included — is #\[\\SensitiveParameter\]: a key file or a certificate-and-key bundle passed by mistake stays out of the trace of the exception that refuses it.

## More certificate facts

```php
$certificate->subject()->toString();   // 'CN=leaf.example, O=Acme, C=US'
$certificate->issuer()->commonName;    // DistinguishedName properties
$certificate->serialNumber();          // upper-case hex
$certificate->signatureAlgorithm();    // e.g. 'ecdsa-with-SHA256'
$certificate->version();               // 1, 2 or 3
$certificate->base64();                // the x5c form (standard base64 DER)
$certificate->isSelfSigned();          // the signature math, not a subject == issuer compare
$certificate->equals($other);          // constant-time DER comparison

$extension = $certificate->extension('1.2.840.113635.100.8.2');   // ?Extension
$extension?->critical;                 // bool
$extension?->der;                      // raw, uninterpreted extension bytes
$certificate->extensions();            // every extension, keyed by OID
```

extension() returns the raw DER inside the extension plus its criticality flag — walked from the certificate’s own DER, because openssl\_x509\_parse() pretty-prints unknown extensions into lossy text. Decode those bytes with the ASN.1 / DER decoder.

## Validity dates

Validity is reported as dates, with a symmetric clock-skew leeway you own. The instant defaults to now (honouring Carbon::setTestNow) and accepts any DateTimeInterface, so you can evaluate against a token’s own signed-at time:

```php
$certificate->isValidAt();                                  // now (honours Carbon::setTestNow)
$certificate->isValidAt($token->signedAt, leewaySeconds: 60);
$certificate->isExpiredAt(leewaySeconds: 60);              // distinct from a bad signature
$certificate->isNotYetValidAt();                            // a negative leeway throws
```

A negative leeway throws InvalidLeewayException. Separate isExpiredAt() and isNotYetValidAt() let you raise distinct errors — an expired certificate must never look like a bad signature. Dates gate nothing else: an expired certificate still hands over its public key and facts.

## Chains

X509\\Chain is an ordered leaf → root list of up to 10 certificates (Chain::MAX\_CERTIFICATES). Build it from a JOSE x5c header, a PEM bundle or a list of PEMs:

```php
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;
use RoundlyConsulting\Crypto\X509\Chain;

// A JOSE x5c chain (leaf first), a concatenated PEM bundle, or a list of PEMs:
$chain = Chain::fromX5c($x5c);                    // ≤ 10 certificates, strict base64, typed errors
$chain = Chain::fromPemBundle($bundle);
$chain = Chain::fromPems([$leafPem, $intermediatePem, $rootPem]);

$chain->isLinked();                               // every cert is signed by the next one up
$chain->fingerprints(HashAlgorithm::Sha1);        // leaf → root
$chain->leaf()->publicKey();
$chain->root();                                   // the LAST certificate — not "a trusted root"
$chain->get(1);                                   // by index
count($chain);                                    // Countable; foreach works too
$chain->pemBundle();
```

isLinked() is pure math — every certificate is signed by its successor, and a one-certificate chain is trivially linked. fromPemBundle() counts PEM blocks before parsing any of them, so an oversized bundle costs a regex, not eleven parses.

A chain is a real list. The constructor, fromX5c() and fromPems() check their array before parsing anything: leaf at index 0, no gaps, no string keys — else InvalidChainException::notAList() — and every entry a Certificate (constructor) or a string (fromX5c(), fromPems()) — else invalidEntry(). A JSON object or a nested array in an x5c header fails here as a CryptoException, never later as an undefined index or a TypeError.

## Certificate reference

| Method | Returns |
| --- | --- |
| `fromPem() / fromDer() / fromBase64()` | A Certificate. fromBase64() takes an x5c entry — standard padded base64 of the DER, not base64url. DER input must be exactly one certificate; PEM input is text, never a path. |
| `pem() / der() / base64()` | The PEM, DER and x5c (base64) encodings. |
| `fingerprint($algorithm = Sha256)` | Lower-case hex without colons, byte-identical to openssl\_x509\_fingerprint(). |
| `publicKey()` | An RsaKey or EcKey, policy-checked. Ed25519 certificates are not supported. |
| `subject() / issuer()` | A DistinguishedName (CN, O, OU, C, ST, L) with toString(). |
| `commonName() / dnsNames()` | The subject CN; the dNSName entries of subjectAltName, read from the DER — verbatim (wildcards included), in encoding order. |
| `extension($oid) / extensions()` | ?Extension (oid, critical, der); every extension keyed by OID, in encoding order. |
| `version() / serialNumber() / signatureAlgorithm()` | 1, 2 or 3; upper-case hex; the signature algorithm name. |
| `notBefore() / notAfter()` | CarbonImmutable. |
| `isValidAt() / isExpiredAt() / isNotYetValidAt()` | Date predicates with an optional instant and leeway in seconds. |
| `isSignedBy($issuer) / isSelfSigned()` | The signature math — isSelfSigned() is not a subject == issuer string compare. |
| `equals($other)` | A constant-time comparison of the DER. |

## The trust boundary

Crypto proves the math; deciding what to trust is yours. There is no isTrusted(), no pinning, no root store, no revocation and no hostname matching — by design, and enforced by an architecture test. root() is simply the last certificate, not an authority you have heard of. Pin your own anchors, and decide what an expired certificate means.

## 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/crypto-for-laravel.md)
