spaze/encryption
spaze/encryption is a lightweight PHP library for symmetric encryption, offering a simple API to encrypt and decrypt strings and application data using secure cryptographic primitives, with key handling that fits easily into existing PHP projects.
This package wraps libsodium/Halite in a Laravel-friendly API, so day-to-day you mostly touch three things: the config file, the facade/service, and (for models) a cast.
composer require <vendor>/<package> # requires paragonie/halite ^5.1
php artisan vendor:publish --tag=crypto-config
.env. Keys are 32-byte hex strings; sodium_bin2hex() output works, and lowercase or uppercase are both valid.
// one-off, e.g. in tinker
$hex = sodium_bin2hex(sodium_crypto_secretbox_keygen());
CRYPTO_PREFIX_HEX=...
config/crypto.php first. The prefix_hex key is the default symmetric key; additional keys live under the same map with their own ids. Use the tagged form for anything asymmetric (prefix_secret_hex / prefix_public_hex) — it will refuse to boot the object if a key is in the wrong slot.use ...\Facades\Crypto;
$ciphertext = Crypto::useKey('app')->encrypt($user->ssn);
$plaintext = Crypto::useKey('app')->decrypt($ciphertext);
Then immediately reach for a cast so the rest of the app never sees ciphertext (see below).Eloquent cast for at-rest fields. The highest-leverage pattern: a CryptoCast on ssn, iban, notes, etc. Keep the ciphertext column as TEXT/LONGTEXT — v3 values are longer ($keyId$SymV1$... prefix plus base64), so an old VARCHAR(255) will start silently truncating and produce UnknownFormatMarkerException on read.
Row-bound encryption with additional data. When a field must not be swappable between rows, encrypt with the row's identity:
$ciphertext = Crypto::useKey('app')->encryptWithAd($value, $row->uuid);
This is the strongest pattern for anything with legal weight (KYC, audit trails), but it couples the ciphertext to the AD — remember this in every re-encryption path.
Key rotation as a queued sweep. Add the new key alongside the old, keep both configured, and run a chunked job/command:
Model::query()->whereNotNull('ssn')
->chunkById(500, function ($rows) {
foreach ($rows as $row) {
if (Crypto::needsReEncrypt($row->ssn)) {
$row->ssn = Crypto::useKey('app_v2')->encrypt($row->ssn);
$row->saveQuietly();
}
}
});
Since v3, needsReEncrypt() returns true for every pre-v3 value, so your first run after upgrading is a full-table rewrite — throttle it, run it off-peak, and expect backup/WAL pressure.
Two-party service integration (AuthenticatedPublicKeyEncryption). Configure each side as one key id holding our secret + their public. Both directions encrypt/decrypt, and decryption proves origin. Good fit for: signed-ish webhook payloads, machine-to-machine API bodies where you also need a producer identity, multi-service deployments sharing a queue.
Write-only producers (AnonymousPublicKeyEncryption). The pattern that unlocks new deployments: a log shipper, telemetry collector, or CI job holds only recipients' public keys and physically cannot read back what it wrote. Use it when a compromise of the producer must not expose historical data. Note the trade-off: no AD slot and no origin proof — don't model authenticity on it.
Per-tenant / per-environment key ids. Multiple ids in config/crypto.php (one per partner or tenant) and select at runtime with useKey($id). Keep the id in a column or config, never derive it from the ciphertext alone — the marker makes the id visible, but you still need policy about which id is legal for which record.
Testing. Pin keys in phpunit.xml rather than .env, and build factories that write plaintext through the cast so tests exercise the round trip. Add one test asserting a fixed ciphertext decrypts, to catch accidental key rotation in CI.
FormatMarkerMismatchException / UnknownFormatMarkerException, not Halite's InvalidMessage or InvalidNumberOfComponentsException. Any catch written against the old types is now dead code — grep for them after upgrading, and prefer catching the package's own base exception.$keyId$SymV1$...) values. In a multi-service fleet, writing from an upgraded node while an old node reads is a data-visible outage, not a graceful degradation.encryptWithAd() rows must be re-encrypted with the same AD. A generic sweep that calls plain encrypt() on those columns will leave you with rows that decrypt fine but fail AD verification — i.e. corrupt from the application's point of view. Special-case those models.prefix_secret_hex / prefix_public_hex fail at object construction instead of producing undecryptable ciphertext — the difference between a deploy-time error and a data-loss incident.^5.0 strictly, resolve it before deploying; check composer why paragonie/halite.decryptWithAd semantics apply symmetrically across the new classes.UnknownFormatMarkerException ⇒ truncated column, wrong column, or not from this package), then the key id (⇒ wrong useKey() or a key dropped from config), then the method/class (⇒ fed to the wrong API). Only then suspect tampering.How can I help you explore Laravel packages today?