paragonie/sodium_compat
Pure PHP polyfill for libsodium (Sodium crypto), transparently using the PHP extension when installed. Supports PHP 5.2.4+ and 32-bit integers in v1.x, with v2.x targeting PHP 8.1+.
Know what this actually is before you install it. paragonie/sodium_compat is a pure-PHP reimplementation of ext/sodium. Since PHP 7.2 the extension ships with every PHP build, so on a modern Laravel app (PHP 8.1+) this package does nothing but sit in vendor/ and define nothing, because every sodium_* function already exists. Its real job is:
So your day-to-day relationship with it is usually: it arrives transitively (JWT libs), you don't remove it, and you occasionally reach for the ParagonIE_Sodium_Compat:: static class when you need a primitive Laravel doesn't expose.
Verify what you're running before writing a line of code:
composer show paragonie/sodium_compat # is it there, and which major?
php -m | grep -i sodium # is the native extension loaded?
php -r 'var_dump(PHP_INT_SIZE);' # 8 = 64-bit, safe for v2.x
If extension_loaded('sodium') is true (it will be on php:8.x Docker images), you are paying for the polyfill without using it — that's fine, but it means benchmarks you take are native libsodium, not pure PHP.
First real use case: encrypting to a public key without sharing a secret.
The one thing Laravel's Crypt cannot do is asymmetric encryption — Crypt::encrypt() is symmetric with a single APP_KEY. crypto_box_seal lets anyone encrypt to your server's public key, and only the holder of the secret key can decrypt. That's the canonical "third party sends us sensitive data" shape.
use ParagonIE_Sodium_Compat as Sodium;
// One-time: generate and store the keypair (public key can be published)
$keypair = Sodium::crypto_box_keypair();
$public = Sodium::crypto_box_publickey($keypair); // 32 bytes, publish this
$secret = Sodium::crypto_box_secretkey($keypair); // 32 bytes, secrets manager only
// Anywhere (no secret required, nonce handled for you)
$ciphertext = Sodium::crypto_box_seal($plaintext, $public);
// Only on the server holding the secret key
$plaintext = Sodium::crypto_box_seal_open($ciphertext, $keypair);
Where to look first: the ParagonIE_Sodium_Compat class itself (vendor/paragonie/sodium_compat/src/ParagonIE_Sodium_Compat.php) — its docblocks are the real API reference. Then the libsodium Quick Reference (linked in the README) for "which primitive do I use for X". Skip the NaCl-era names (crypto_box, crypto_secretbox) until you've read why XChaCha20 variants exist.
Version pinning: for an application on PHP 8.1+ with 64-bit only, require ^2.0. For a package or anything that might land on PHP 7.4 or 32-bit ARM, ^1.21 (or >=1 if you're the library). Note v2.0 dropped PHP < 8.1 and 32-bit support entirely — a composer update can yank you across that boundary if you're loose.
Never read .env at call sites, and never hold raw key bytes in a class property that gets serialized.
// config/services.php
'sodium' => [
'app_key' => env('SODIUM_APP_KEY'), // base64url, 32 bytes
'signing_sk' => env('SODIUM_SIGNING_SK'),
'signing_pk' => env('SODIUM_SIGNING_PK'),
],
final class SodiumKeys
{
private static ?string $appKey = null;
public static function appKey(): string
{
return self::$appKey ??= ParagonIE_Sodium_Compat::base642bin(
config('services.sodium.app_key'),
ParagonIE_Sodium_Compat::BASE64_VARIANT_URLSAFE_NO_PADDING
);
}
public static function signingSecret(): string
{
return ParagonIE_Sodium_Compat::base642bin(
config('services.sodium.signing_sk'),
ParagonIE_Sodium_Compat::BASE64_VARIANT_URLSAFE_NO_PADDING
);
}
}
Store keys byte-identically across environments — base64 in .env, decoded in code. Generate them with php artisan tinker and a one-liner that prints base64; never paste raw binary into a .env file.
Multi-tenant SaaS often needs per-tenant encryption keys. Storing one key per tenant is a nightmare to rotate. crypto_kdf_derive_from_key gives you deterministic subkeys from one master key:
$master = SodiumKeys::appKey(); // 32 bytes
$context = 'tenant__'; // EXACTLY 8 bytes, required
$subkey = ParagonIE_Sodium_Compat::crypto_kdf_derive_from_key(
32, // subkey length
$tenant->id, // subkey id (int)
$context,
$master
);
Then crypto_secretbox with $subkey gives you tenant-isolated ciphertext with one secret to protect. Rotating the master means re-encrypting everything — plan for it, or store a key_version column next to the ciphertext.
HMAC with a shared secret is the Laravel default (Stripe-style), but it requires both sides to hold the same secret. Ed25519 lets you publish a verification key while keeping the signing key on one server — much better for license servers, plugin/package signing, or outbound webhooks to customers.
// Signer
$canonical = $method."\n".$path."\n".$timestamp."\n".hash('sha256', $body);
$signature = ParagonIE_Sodium_Compat::crypto_sign_detached($canonical, $secretKey);
// Verifier (in middleware)
abort_unless(
hash_equals($expectedSig, $providedSig) &&
ParagonIE_Sodium_Compat::crypto_sign_verify_detached($sig, $canonical, $publicKey),
401, 'Bad signature'
);
Signature is 64 bytes, public key 32 bytes — perfectly fine as base64 headers (X-Signature, X-Key-Id). Include a key id so you can rotate.
polyfill_is_fast()Pure-PHP crypto is 100–1000× slower than the extension. On PHP 8.1 with the extension this never fires, but on a legacy host it's the difference between a 20ms request and a 20-second one.
if (ParagonIE_Sodium_Compat::polyfill_is_fast()) {
$ciphertext = Sodium::crypto_secretbox($payload, $nonce, $key);
} else {
EncryptPayloadJob::dispatch($payload)->onQueue('crypto');
}
Good hook: log a warning in a service provider when ! ParagonIE_Sodium_Compat::polyfill_is_fast() in production, so you notice a host that silently lost the extension.
crypto_secretbox requires the whole message in memory. For S3 uploads or large exports, use the push/pull state machine:
[$state, $header] = ParagonIE_Sodium_Compat::crypto_secretstream_xchacha20poly1305_init_push($key);
$out = $header;
foreach ($chunks as $i => $chunk) {
$tag = $i === array_key_last($chunks)
? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
: SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE;
$out .= ParagonIE_Sodium_Compat::crypto_secretstream_xchacha20poly1305_push($state, $chunk, '', $tag);
}
Store the header (24 bytes) as a separate column, not concatenated — you need it before decryption. The TAG_FINAL marker is your truncation defence; verify it on pull or an attacker can silently drop the tail.
Binary payloads belong in binary columns, not text. On MySQL use MEDIUMBLOB/LONGBLOB — $table->binary() is a 64 KB BLOB and will truncate large ciphertexts silently on some configurations. If you must go into a JSON column or a query string, hex or base64 it and decode on read.
$table->binary('payload')->nullable(); // MySQL: fine for small; consider raw MEDIUMBLOB
A cast is only necessary if you're storing as text:
protected function payload(): Attribute
{
return Attribute::make(
get: fn (?string $v) => $v === null ? null
: ParagonIE_Sodium_Compat::hex2bin($v),
set: fn (?string $v) => $v === null ? null
: ParagonIE_Sodium_Compat::bin2hex($v),
);
}
| Need | Use |
|---|---|
| Encrypt a session cookie, model attribute, small blob | Laravel Crypt / encrypt() — key rotation, MAC included |
| Password hashing | Hash::make() (argon2id driver) — not crypto_pwhash |
| API token / MAC, same secret both sides | hash_equals + HMAC, or crypto_auth |
| Asymmetric signing, cross-language verification | crypto_sign_detached |
| Encrypt to a public key, no shared secret | crypto_box_seal |
| Symmetric at-rest data you'll decrypt in another language | crypto_secretbox (XChaCha20/XSalsa20) |
| Fast content hashing / keyed hashing | crypto_generichash (BLAKE2b) |
Cross-language interop is the strongest argument for sodium over Crypt: every language has libsodium bindings, and crypto_secretbox in PHP decrypts with nacl.secret.SecretBox in Python with no format negotiation.
Use fixed test vectors (key + nonce + expected ciphertext) so the test proves interop, not just round-tripping. Run your CI matrix twice — once with the extension, once on a PHP build without it — because the polyfill path is the one that's never exercised in dev.
Decrypt failures don't behave the same way on both paths. Historically, sodium_compat throws SodiumException from some *_open/*_decrypt calls where ext/sodium returns false. Write defensive code regardless of which is loaded:
try {
$plaintext = ParagonIE_Sodium_Compat::crypto_secretbox_open($ciphertext, $nonce, $key);
} catch (\SodiumException $e) {
$plaintext = false;
}
if ($plaintext === false) { /* tampered or wrong key */ }
Also note \SodiumException only exists if either the extension or the polyfill is loaded. If you reference it in a catch without the package installed on a PHP 7.1 host, you get a fatal error at catch time, not at throw time.
Nonce reuse is the one mistake that destroys everything. 24-byte nonces for crypto_secretbox / XChaCha20, 12-byte for the IETF ChaCha20 AEAD variants. Always random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) and always store the nonce alongside the ciphertext. XChaCha20 (24-byte nonce) is safe with random nonces at any volume; the 12-byte IETF variants have a birthday-bound problem at high message counts — if you're encrypting millions of records, use the X-variants. If you want to never think about nonces, crypto_box_seal handles it internally.
Never enable $fastMult to "make it faster." Setting ParagonIE_Sodium_Compat::$fastMult = true removes the constant-time multiplication workaround. The README is explicit: only do this if you have verified your specific CPU implements constant-time multiplication (BearSSL's list). On any CPU where that's false, you leak key material through timing. Leave it at false.
Composer autoload.files means it always loads. The polyfill's autoload.php runs on every request and conditionally requires its function files behind function_exists guards. The overhead is negligible, but it also means you cannot "just remove it from composer.json" if something transitive needs it — you'll get Call to undefined function sodium_crypto_... only on hosts without the extension, i.e. exactly your lowest-quality environment, i.e. the one you never test. Lock the transitive constraint rather than removing.
config:cache bakes your keys into bootstrap/cache/config.php. That file is gitignored by default in Laravel, but it lands on disk in plaintext and is often copied around in deploy artifacts. Prefer reading key material from the secrets manager/.env at boot via a dedicated config file that reads env() and is not cached, or fetch from Vault/KMS at container start and write it into a runtime-only config. Rotate immediately if a config cache ever escapes.
32-bit PHP plus v2.x is a silent trap. v2.0 dropped PHP_INT_SIZE == 4 support. Raspberry Pi deployments, some cheap ARM VPSes, and certain Docker cross-builds are 32-bit. If you get weird arithmetic failures in signing, check PHP_INT_SIZE first and pin ^1.21.
Long-running workers and Octane. The polyfill is stateless, and $fastMult / the lazily-decoded static key are per-process — setting them in a service provider is fine. But if you memoize derived per-tenant subkeys in a static array inside a singleton, that survives across Octane requests. Scope key material to the request/tenant, or use an explicit bounded cache with eviction.
Don't confuse it with a password hasher. crypto_pwhash (Argon2id) is exposed, but Laravel's Hash driver already wraps argon2id with sane defaults and a verification API. Pure-PHP Argon2id via the polyfill in a request cycle is a denial-of-service waiting to happen — the params that make it secure make it take seconds in PHP.
base642bin/bin2base64 are not base64_encode. sodium_compat's versions take a variant constant (ParagonIE_Sodium_Compat::BASE64_VARIANT_URLSAFE_NO_PADDING etc.) and decode in constant time. Pick one variant per format and stick to it — mixing URL-safe and standard base64 across producers and consumers is the single most common interop bug with libsodium-adjacent code.
Compliance note. The README states plainly that this library has not been formally audited. If you're under PCI-DSS, HIPAA, or a customer security questionnaire, you need a sentence ready. The practical answer: on PHP 7.2+ you're calling libsodium through the native extension, which is audited; the polyfill only matters on legacy hosts. Document which path each environment takes.
Debugging tip: when a signature or decryption fails only in staging, log extension_loaded('sodium'), ParagonIE_Sodium_Compat::polyfill_is_fast(), the byte length of every key/nonce/ciphertext (32/24/48-byte expectations catch most bugs immediately), and the base64 variant in use. Nine times out of ten the "crypto bug" is a base64 variant mismatch or a truncated BLOB column, not the algorithm.
How can I help you explore Laravel packages today?