NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

There is no Crypto::fake(), and nothing needs one: the facade makes no database, queue, event, mail or HTTP call. Apart from randomness and the key loaders, every call is a pure function of its arguments, so run it for real — and for random output, assert the shape rather than the value.

The key loaders read your config or a disk, and fromStorageOrGenerate() writes on first boot. That goes through Laravel’s Storage and config(), so Storage::fake('local') and config([...]) already cover fromStorageOrGenerate() and the *FromConfig loaders in your tests.

Test keys and OTP vectors

The Testing namespace gives your suite ready-made key material and known OTP vectors instead of hand-rolled ones. It ships in src/ but imports no PHPUnit or Pest symbol:

use RoundlyConsulting\Crypto\Testing\TestKeys;
use RoundlyConsulting\Crypto\Testing\TestOtp;

$secret = TestKeys::hmacSecret();      // a fixed, valid 64-byte secret (every HS tier)
$rsa    = TestKeys::rsa();             // ephemeral 2048-bit private key
$ec     = TestKeys::ec('P-384');       // ephemeral EC private key

if (TestKeys::supportsEd25519()) {
    $okp = TestKeys::ed25519();        // guard on hosts without ext-sodium
}

$code = TestOtp::codeAt(time());       // a valid TOTP code for TestOtp::SECRET

TestKeys::hmacSecret() is a fixed, valid 64-byte secret — long enough for every HS tier, HS512 included — and TestOtp::SECRET a fixed base32 secret; the RSA, EC and Ed25519 factories mint fresh ephemeral keys on each call. Never use any of them in production.

Throwaway certificate chains

TestCertificates mints genuinely linked chains — writing its own temporary openssl.cnf with the sections it needs — so no suite has to hand-roll CSRs and CA extensions. CA keys are EC P-256; the leaf may be EC or RSA:

use RoundlyConsulting\Crypto\Testing\TestCertificates;

$ca = TestCertificates::chain();                  // leaf → intermediate → root, genuinely linked
$ca->leafKey;                                     // the leaf's PRIVATE key — sign your test token with it
$ca->x5c();                                       // ready to drop into a JWS `x5c` header
$ca->pinnedFingerprints();                        // the [intermediate, root] slice a pinning verifier compares
$ca->pemBundle();

$rogue = TestCertificates::rogueLeaf($ca);        // same subject, a different CA — breaks isLinked()
$self  = TestCertificates::selfSigned(['app.test']);

ext-openssl always stamps notBefore at signing time, so produce expired or not-yet-valid scenarios by evaluating at another instant rather than backdating a certificate:

$leaf = $ca->leaf();

$leaf->isExpiredAt($leaf->notAfter()->addDay());        // expired
$leaf->isNotYetValidAt($leaf->notBefore()->subDay());   // not yet valid
CarbonImmutable::setTestNow($leaf->notAfter()->addYear());

Leaves that carry extensions

TestLeafOptions decorates the leaf with raw extensions, extended key usage OIDs, a directoryName SAN, an empty subject or an OU — so you can test your own reading of an extension through Certificate::extension() and DerDecoder:

use RoundlyConsulting\Crypto\Testing\TestCertificates;
use RoundlyConsulting\Crypto\Testing\TestLeafOptions;

$nonce = random_bytes(32);

$chain = TestCertificates::chain(length: 2, leafOptions: new TestLeafOptions(
    rawExtensions: ['1.2.840.113635.100.8.2' => "\x30\x24\xA1\x22\x04\x20".$nonce],
    subjectOrganizationalUnit: 'Authenticator Attestation',
));

$chain->leaf()->extension('1.2.840.113635.100.8.2')->der;   // exactly the bytes above

Opt-in Pest expectations

Five Pest expectations ship in a non-autoloaded file. Require it from your own tests/Pest.php — it is guarded by function_exists('expect'), so it never loads at runtime:

// tests/Pest.php
require dirname(__DIR__).'/vendor/roundly-consulting/crypto-for-laravel/src/Testing/pest-expectations.php';

expect($token)->toBeValidJws($verifier, Algorithm::RS256);
expect($code)->toBeValidTotp($secret);
expect($ca->chain)->toBeLinked();                     // the math, not trust
expect($ca->leaf())->toBeSignedBy($ca->chain->get(1));  // the intermediate signed the leaf
expect($ca->chain->get(1))->toBeSignedBy($ca->root());
expect($jwk)->toHaveThumbprint('NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs');

A complete test

use RoundlyConsulting\Crypto\Jose\Jws;
use RoundlyConsulting\Crypto\Signature\Algorithm;
use RoundlyConsulting\Crypto\Signature\Es;
use RoundlyConsulting\Crypto\Signature\Key\EcKey;
use RoundlyConsulting\Crypto\Testing\TestKeys;
use RoundlyConsulting\Crypto\Testing\TestOtp;

it('issues a verifiable ES256 token', function () {
    $key = TestKeys::ec();                                  // ephemeral P-256 private key
    $token = (new Jws)->sign([], ['sub' => 'alice'], new Es($key));

    expect($token)->toBeValidJws(new Es(EcKey::public($key->publicPem())), Algorithm::ES256);
});

it('accepts the current TOTP code', function () {
    expect(TestOtp::codeAt(time()))->toBeValidTotp(TestOtp::SECRET);
});

Pinning the clock

Totp and Claims::assertTemporal() read the current time via CarbonImmutable::now(), so pin it with Laravel’s helper:

use Illuminate\Support\Carbon;

Carbon::setTestNow('2026-07-10 12:00:00');
// … assert TOTP codes / token expiry deterministically …
Carbon::setTestNow();   // clear

The package’s own suite

The package pins its behaviour with committed RFC test vectors, Project Wycheproof corpora and fuzz targets on every attacker-facing input. To run it from a clone of the repository:

composer test
CRYPTO_FUZZ_ITERATIONS=100000 composer test   # a longer fuzz run

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

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