spatie/laravel-ciphersweet
A Laravel wrapper for CipherSweet that brings searchable field-level encryption to Eloquent models. Encrypt sensitive personal data so unauthorized DB access reveals nothing, while blind indexes let you search encrypted values securely.
What you're adopting. This is a thin Eloquent wrapper over Paragon Initiative's CipherSweet: field-level encryption for individual columns plus blind indexes (deterministic hashes in a side table) that make exact-match lookups possible on encrypted data. You get transparent encrypt-on-save / decrypt-on-read, and you pay for it with longer columns, a second table, and a key that is now a hard dependency for your data.
Install and wire up (about ten minutes):
composer require spatie/laravel-ciphersweet
php artisan vendor:publish --tag="ciphersweet-migrations"
php artisan vendor:publish --tag="ciphersweet-config"
php artisan migrate
php artisan ciphersweet:generate-key # paste into .env as CIPHERSWEET_KEY=
Leave backend=nacl and provider=string unless you have a compliance reason (FIPS → fips, hardened/lib Sodium → boring) or a key-management reason (→ file, or a custom provider backed by Vault/KMS).
Where to look first. Not the package source — the upstream CipherSweet PHP docs. This package only gives you a trait, the scopes, a validation rule and two Artisan commands; every design decision (field types, bit sizes, transformations, compound indexes) comes from upstream. Read the blind-index planning page before you pick bit sizes, because those are effectively permanent.
First use case — encrypt users.email and keep login/registration working:
class User extends Model implements CipherSweetEncrypted
{
use UsesCipherSweet;
public static function configureCipherSweet(EncryptedRow $row): void
{
$row->addTextField('email')
->addBlindIndex('email', new BlindIndex('email_index'));
}
}
Migration column must be text (ciphertext is longer than plaintext, and you should not guess headroom). Then the daily-driver win: everything normal still works.
User::create(['email' => '[email protected]']); // stored encrypted
$user->email; // '[email protected]'
User::whereBlind('email', 'email_index', '[email protected]')->first(); // exact lookup
That whereBlind line is the replacement for where('email', ...). Every where you had on that column must be rewritten — see Gotchas.
Model configuration is the contract. Treat configureCipherSweet() as schema. It's static, it's read on every hydrate/persist, and it must produce the identical configuration in every environment (web, queue workers, artisan tinker, tests). Put the field/index declarations inline or in a shared class — never branch on app()->environment().
Field-type choices that map to real columns:
$row->addTextField('name') // strings
->addIntegerField('age') // ints round-trip as ints
->addBooleanField('is_vip')
->addJsonField('settings', $fieldMap) // structured JSON, see upstream field maps
->addOptionalTextField('phone'); // nullable → stores plain NULL, no index row
Use addOptional* for every nullable column. With the default permit_empty=false, a null on a non-optional field throws EmptyFieldException at save time — usually discovered in a seeder at 6pm.
Searchable-by-exact-match is the only searchable. Blind indexes give you = and nothing else. Day-to-day patterns:
whereBlind. This is 80% of real usage.BlindIndex name to multiple fields; CipherSweet folds them into one compound index.BlindIndex. Do it the same way in both places or lookups silently miss.LIKE '%foo%' → not supported, full stop. If a product requirement needs partial search on encrypted data, that's a design conversation, not a code fix. Common escapes: keep an encrypted-plus-tokenised secondary index (n-grams), or leave a non-sensitive derived column unencrypted.Validation. Rule::unique() compares ciphertext and will happily accept every duplicate. Use the bundled rule:
'email' => [Rule::encryptedUnique(User::class, 'email_index')->ignore($user->id)],
Third argument overrides the DB column when your validation attribute is named differently (email_confirmation etc.).
Writing data / backfilling. Fresh rows written through the model are encrypted automatically — factories, seeders, API controllers, queued jobs all just work. The ciphersweet:encrypt command is for (a) pre-existing plaintext rows and (b) key rotation:
php artisan ciphersweet:encrypt "App\Models\User"
It's chunked and restartable, so it's safe to Ctrl-C and resume on a big table. Run it off-peak; it writes two tables per row and will bloat your DB for a while.
Key rotation runbook (the reason you chose this package):
CIPHERSWEET_KEY pointed at the old key — the command needs it to decrypt.php artisan ciphersweet:encrypt "App\Models\User" "<new-key>" for every encrypted model..env to the new key, php artisan config:clear && php artisan config:cache.Testing. Set CIPHERSWEET_PROVIDER=random in phpunit.xml (or a fixed throwaway key via string) so test runs don't depend on a real secret. Then assert on plaintext attributes as normal — the trait is transparent — and add one test that asserts the raw column is not the plaintext ($user->getRawOriginal('email')) so a future refactor that bypasses the trait fails loudly.
Integration tips. Keep the trait's models out of raw query-builder paths. Wrap whereBlind in repository/scope methods (scopeWhereEmail) so callers can't accidentally reach for where(). For tenanted or per-customer keys, implement the custom key provider and have it resolve the current tenant from the container — the package resolves one provider, so it must be context-aware, not per-model.
The big one: where('email', ...) returns nothing and doesn't error. Encrypted columns contain ciphertext; only whereBlind/orWhereBlind work, and only on columns with a registered blind index. Grep your codebase for the column names after rollout. Watch for scopes, Nova/Filament filters, firstOrCreate on encrypted attributes, and package code that builds where clauses from request input.
Query-builder writes bypass everything. DB::table('users')->insert(...), ->update(...), raw SQL imports and some bulk Eloquent mass-updates skip model mutators — you'll write plaintext into an encrypted column and never notice until a whereBlind misses. Audit any ->update([...]) on these models; prefer per-model save().
Blind indexes are deterministic — that's the tradeoff. Identical plaintext yields identical index values, so anyone with DB access can see "these 400 users share an email" and can dictionary-attack low-entropy fields (salaries, birthdates, boolean-ish columns). Use blindIndexSlow (higher cost, harder to brute-force) for low-cardinality data, and don't add a blind index "just in case" — each one is a leak surface.
The index configuration is frozen once data exists. Changing an index's name, bit length, transformations, or fast/slow setting invalidates every existing lookup. Changing a field's type (addTextField → addJsonField) or renaming the field breaks decryption outright, because the field name is bound into the ciphertext's AAD. Plan migrations as: add new column/index → backfill → cut over → drop old.
Same for the backend. Ciphertext produced by nacl is not readable by boring or fips. Switching backends means a full re-encryption, not a config flip.
Key loss is data loss. There is no recovery path. Put CIPHERSWEET_KEY (or the key file / KMS reference) in your secret manager and in your disaster-recovery runbook, separately from DB backups. Conversely: if the key sits in the same .env as the DB credentials on the same box, you've bought much less than you think.
Passing the key on the CLI leaks it into shell history, ps output and CI logs. Prefer the file provider for rotation, or run the command from a restricted shell/session. Also make sure the first-time encryption uses the same key that's in .env, or you'll encrypt with a key your app can't read.
config:cache is in the loop. The string provider reads env('CIPHERSWEET_KEY') at config-build time. After rotating, config:clear && config:cache (and restart queue workers / Octane) or you'll have a split-brain key across processes.
Deletes and orphans. Blind index rows live in a separate table keyed polymorphically. Verify that deleting a model removes its index rows; on bulk deletes (Model::where(...)->delete(), truncate()) they typically won't be cleaned up automatically. Stale index rows cause phantom matches and table growth.
Performance. whereBlind joins against the index table — check that (indexable_type, index, value) (or whatever your migration created) is indexed and that the join is used with a selective filter. Blind index creation on save adds a write per index per save; models with many indexes get noticeably slower in bulk import loops. Consider insert() on decrypted-then-manually-encrypted data, or batch outside the ORM, for large imports.
toArray() / toJson() emit plaintext. That's the point, but it means API resources, logs, Sentry breadcrumbs, Horizon payloads and queue job dumps all contain the decrypted values. Decide deliberately what goes in $hidden and what's redacted, and scrub job payloads that carry whole models.
orderBy, min/max, groupBy, LIKE and DB UNIQUE constraints don't work on encrypted columns. Enforce uniqueness in the app (the rule above) and accept that it's a check-then-insert race — wrap it in a transaction and, if the stakes are high, add a unique constraint on the blind index row for that index name.
Debugging a miss: check three things in order — (1) is the field actually in configureCipherSweet for this class (inheritance/subclasses silently use the parent's config); (2) is the normalised value byte-identical to what was stored (trailing whitespace and casing are the usual culprits); (3) do the row's index entries exist at all (legacy rows that predate the encrypt command don't have them). Comparing getRawOriginal() against a tinker whereBlind is the fastest way to isolate which of the three it is.
Extension points worth knowing exist: backend=custom for a KMS-envelope or HSM-backed BackendInterface, provider=custom for Vault/tenant keys, JSON field maps for structured payloads, transformation objects on blind indexes, and compound indexes for multi-column uniqueness. Reach for these only when the default string/file provider can't satisfy your key-custody story — they add surface area with no day-to-day benefit.
How can I help you explore Laravel packages today?