philetaylor/ciphersweet-encrypt
A Laravel package for integrating CipherSweet field-level encryption, letting you store sensitive database columns as encrypted values while keeping them searchable through blind indexes. It provides Eloquent-friendly configuration and casts for encrypting and decrypting attributes transparently.
This is a Doctrine DBAL custom type that wraps paragonie/ciphersweet: a column mapped to the type is encrypted on write and decrypted on read, and you get blind indexes as a companion mechanism so you can still do exact-match lookups on encrypted data. It is not an Eloquent feature — that distinction drives most of what follows.
Minimal steps
composer require philetaylor/ciphersweet-encrypt. Note the Packagist name does not match the repo name (doctrine-ciphersweet); don't guess.src/ — specifically the type class's getName() (the string you register under), convertToDatabaseValue(), convertToPHPValue(), and how/where it constructs or receives the CipherSweet engine. That's the whole API surface; it's a small package.CipherSweet, StringProvider, Field, and BlindIndex. You cannot use this package without understanding those.use Doctrine\DBAL\Types\Type;
use ParagonIE\CipherSweet\CipherSweet;
use ParagonIE\CipherSweet\Backend\FIPSCrypto; // verify backend name for your CipherSweet major
use ParagonIE\CipherSweet\KeyProvider\StringProvider;
$engine = new CipherSweet(
new StringProvider(base64_decode(config('ciphersweet.key')))
);
Type::addType('ciphersweet', \PhilETaylor\CipherSweetEncrypt\...\Type::class);
// then set the engine/field factory on the type instance, per the README
In Laravel put this in a service provider's register() (not boot()), and guard it with if (!Type::hasType('ciphersweet')) so Octane/queue workers re-running the provider don't double-register.
First use case: a users.ssn column. Map it to the type, add a plain users.ssn_index string column, and on save compute the blind index so WHERE ssn_index = ? finds the row without decrypting anything. That single vertical slice exercises the type, the index, and the key config — get it working end-to-end before touching a second table.
Realistically in a Laravel app: the DBAL type only fires on Doctrine ORM / DBAL hydration paths. If you're on plain Eloquent, skip the package and write a ~40-line custom cast around CipherSweet directly. Use this package if you run laravel-doctrine/orm, or a Doctrine app with a Laravel console/queue layer.
Entity mapping (Doctrine ORM)
/** @ORM\Column(type="ciphersweet", nullable=true) */
private ?string $ssn = null;
/** @ORM\Column(type="string", length=255) */
private ?string $ssnIndex = null;
Encrypted columns must be TEXT/LONGTEXT. Ciphertext plus nonce plus base64 expansion is roughly 2–3× the plaintext plus fixed overhead; varchar(255) silently truncates and you lose data permanently.
Write path: ciphertext + blind index in one transaction
$db->transactional(function () use ($db, $engine, $user) {
$db->update('users', [
'ssn' => $user->ssn, // type encrypts
'ssn_index' => $engine->getBlindIndex('users.ssn', $user->ssn), // field name per CipherSweet docs
], ['id' => $user->id]);
});
Every write path must set both columns: ORM, seeders, imports, INSERT ... SELECT, raw admin SQL. A stale or missing blind index is the #1 production bug with this approach and is invisible until a lookup misses.
Lookup path
$index = $engine->getBlindIndex('users.ssn', $submittedSsn);
$user = $db->fetchAssociative('SELECT * FROM users WHERE ssn_index = ?', [$index]);
Never WHERE ssn = ?. CipherSweet is non-deterministic; the same plaintext produces different ciphertext each time. That's the point, and it also means no ORDER BY, no LIKE, no ranges, no GROUP BY on the encrypted column. If you need those, denormalize a separate derived column or a search table.
Multi-tenant keys
Use CipherSweet's MultiTenantProvider and construct/select the engine per tenant, not per request. In a queue or CLI context, resolve the tenant explicitly — there's no ambient tenant in a worker, and if the provider silently falls back to a default key you get rows encrypted under the wrong tenant that can never be read again. Assert the tenant is set.
Version and key separation
Blind indexes and encryption keys are separate secrets in CipherSweet. Keep both in env/KMS, both base64 in a config file, neither in git. For rotation, CipherSweet ships key-rotation helpers — verify the API against the major version pinned here (see Gotchas) and back up before running one against production.
Testing
Pin a fixed test key in phpunit.xml/.env.testing. Then:
DB::selectOne('select ssn from users ...')->ssn is not the plaintext;ssn but the same ssn_index (catches "the type never registered" and "the index is salted per-row");WHERE ssn_index = ? round-trips.That trio catches nearly every misconfiguration before it reaches a real database.
FIPSCrypto in particular was reworked in later CipherSweet versions. Pin doctrine/dbal and paragonie/ciphersweet to the majors the README was written against, or fork and port.DB::table('users')->insert(...) and Model::create() go through PDO, not DBAL types, so ciphertext is inserted literally and decrypted on read as garbage (or a TypeError). If most of your app is Eloquent, the DBAL type is the wrong tool — use a cast.Unknown column type "ciphersweet" at boot, or worse, at Schema:: time, means you registered too late or in boot().convertToPHPValue()'s null handling before adding nullable: true; a null round-tripping as "" will quietly corrupt your blind-index comparisons.SELECT * for bulk exports. Decryption is per-value work with a symmetric cipher; a 100k-row report that used to be cheap can become minutes.bin2hex($rawColumn) is the fastest way to confirm ciphertext at rest, and comparing two rows' hex is the fastest way to confirm non-determinism.Field and blind-index names are string identifiers that must line up with what the type constructs internally, or you compute an index over a different field and lookups silently return zero rows. Verify against the type's source, not the README, when they disagree.getName() and inject your engine via a singleton rather than static config; and wrap reads with a decryption-failure catch so a key mismatch surfaces as a loud, logged error instead of a corrupt string flowing into your domain layer.How can I help you explore Laravel packages today?