wemxo/encryption-bundle
wemxo/encryption-bundle provides simple, secure application-level encryption for PHP projects, helping protect sensitive data at rest with configurable keys and a clean service-based API for encrypting and decrypting values.
This release (v1.1.0) adds deterministic encryption as an option alongside the existing randomised encryption, so encrypted columns can now be matched with plain WHERE email = ? equality queries without a separate blind index. That is the headline change; everything below assumes the package is your encryption layer for Eloquent attributes.
Minimal path from zero to working:
Install and publish config
composer require <vendor>/laravel-ciphersweet
php artisan vendor:publish --tag="ciphersweet-config"
Generate keys before writing any data. Encryption and blind-index keys are separate, must be 32 bytes, and are usually base64-encoded in .env:
php artisan ciphersweet:generate-key
CIPHERSWEET_KEY=base64:...
CIPHERSWEET_BLIND_INDEX_KEY=base64:...
Committing a key or reusing one across environments is the single most common mistake — treat these like APP_KEY but with a proper rotation plan (see Gotchas).
Cast an attribute on a model
use Vendor\CipherSweet\Casts\Encrypted;
protected $casts = [
'ssn' => Encrypted::class,
];
Read/write code stays unchanged: $user->ssn = '123-45-6789'; $user->save(); The column stores ciphertext; the cast handles encrypt on set and decrypt on get.
First real use case — searchable PII. Pick a field you actually query on (email, national ID, external reference). If you only need equality lookups, prefer a blind index (randomised ciphertext + HMAC index column). Reach for the new deterministic mode when you want to avoid the extra column and accept that equal plaintexts produce equal ciphertexts.
Look first at: the published config (providers, keys, default driver), the cast/field class list (to see whether deterministic is a separate cast, a cast parameter, or a model-level flag in this release), and the package's migration/backfill helpers if present.
Randomised ciphertext + blind index (default, safest). Keep the column non-deterministic and add a companion index column populated on save. Queries go through the index column, never the ciphertext:
protected $casts = [
'email' => Encrypted::class,
'email_index' => BlindIndex::class, // or a HasBlindIndexes trait on the model
];
User::where('email_index', User::blindIndex('email', $email))->first();
This keeps full semantic security on the stored value while still supporting exact-match lookups and unique constraints.
Deterministic mode (new in v1.1.0). Enable it per attribute so the same plaintext under the same key always yields the same ciphertext:
protected $casts = [
'external_ref' => Encrypted::class.':deterministic',
];
// now a plain equality lookup works:
User::where('external_ref', $ref)->first();
Use it only where the leak is acceptable: opaque IDs, tenant-scoped reference numbers, de-duplication keys, join keys between two tables. Do not use it for free-text, dates of birth, names, or anything with low entropy that an attacker could enumerate — determinism turns the ciphertext into an oracle for "does this value exist in the table".
Choosing between the two
| Need | Use |
|---|---|
| Encrypt and never search | Randomised cast |
| Exact-match lookup, strong secrecy | Randomised + blind index |
| Exact-match lookup, want one column / no index column | Deterministic (accepting equality leak) |
LIKE, prefix, range, sort |
Neither — store a derived, non-sensitive search column |
Backfilling existing rows. Changing a column from randomised to deterministic (a one-way change — the ciphertexts differ) requires decrypt-then-re-encrypt. Do it in a chunked job with the old and new configs both available, and never in a single UPDATE on a large table:
User::query()->chunkById(500, function ($users) {
foreach ($users as $user) {
$plain = $user->getRawOriginal('external_ref'); // still ciphertext here
$user->external_ref = decrypt_old($plain);
$user->saveQuietly(); // avoid observers that touch other encrypted fields
}
});
Run the backfill in a transaction-free, idempotent command so a failure mid-run is resumable, and keep the old key in the provider config until the job is verified complete.
Multi-tenant / per-tenant keys. If a provider is resolved per tenant, deterministic ciphertexts are only comparable within that tenant — the same email under two tenant keys produces two different digests. Design unique constraints and cross-tenant admin search around that, and make sure queue jobs and console commands boot the correct tenant provider before touching encrypted attributes.
Testing. Encrypted attributes are transparent to factories and assertions, but assertDatabaseHas on the raw column will fail. Assert via the model, or compute the expected ciphertext/blind index through the package so the test isn't coupled to the KDF output.
LIKE, no range queries, no ORDER BY on the raw column — sorting by ciphertext sorts by random bytes.where() on the encrypted column is the only thing that works. where('email', 'like', '%foo%'), whereBetween, and orderBy all silently compare ciphertexts and return wrong-but-plausible results. Code review should treat any raw query touching an encrypted column as suspect; the cast does not apply to query builder calls.base64: prefix, or a key generated with the wrong driver, fails at first write rather than at boot. Validate keys in a health-check command that runs in CI.first() lookups.DecryptException on a single row usually means a rotated key, a row written under a different tenant provider, or a value that was written before encryption was enabled. Log the model key and column, not the ciphertext, then re-run with DB::listen to find the offending write.Model::toArray(), and Log::info($user) all go through the cast and will expose plaintext. Add the encrypted attributes to $hidden and audit any ->toJson() in API resources.update() with an array bypasses nothing but saveQuietly bypasses events — if your blind index or deterministic re-index lives in a model observer, mass updates and saveQuietly will leave stale indexes. Prefer computing indexes inside the cast/acessor so they cannot be skipped.How can I help you explore Laravel packages today?