odandb/doctrine-ciphersweet-encryption-bundle
A Doctrine bundle that adds CipherSweet field-level encryption to your entities, letting you store sensitive data encrypted while keeping it searchable through blind indexes. Attribute-based config makes setup simple and secure.
Know what this is first. odandb/doctrine-ciphersweet-encryption-bundle is a Symfony bundle, not a Laravel package. It wires spatie/ciphersweet into Doctrine ORM entities via PHP attributes and custom DBAL types. It ships a Bundle class, config/packages/*.yaml config, and a Symfony DI extension — there is no ServiceProvider, no config/ publish tag, no facade.
If your app is Eloquent-based, stop here and use spatie/laravel-ciphersweet instead. Use this package only when:
laravel-doctrine/orm or a hand-rolled EntityManager binding), orInstall and wire (Doctrine-in-Laravel path):
composer require odandb/doctrine-ciphersweet-encryption-bundle
Then, because there is no Laravel auto-discovery, you must:
symfony/dependency-injection) — easiest is to register the bundle's extension config by hand and let it build the cipher service.CIPHERSWEET_KEY (or a KeyProvider) in your config/ and pass it into the container parameters.'types' => [...] in config/database.php under your Doctrine connection, or via Type::addType() in a service provider before the EntityManager is resolved. Order matters — types registered after the metadata driver boots will silently produce unencrypted writes.Where to look first:
#[Encrypted] on the entity class, #[EncryptedField] on properties, blind-index attributes.DependencyInjection/Configuration.php — every config key with its default and expected type.First use case: a single high-value column you never query by range — e.g. Patient.ssn or Integration.secret. Encrypt it, add a blind index only if you need exact-match lookup. Do not start by encrypting ten columns; start with one and prove the round-trip plus a re-encryption of existing rows in staging.
Attribute-driven entity mapping. Encryption is declared on the model, not in config:
#[Encrypted]
class Patient
{
#[EncryptedField]
private string $ssn;
#[BlindIndex('ssn_idx')]
private string $ssnIndex;
}
The bundle derives the index from the plaintext on persist and stores the hash in a separate column. You only ever read the hash column for lookups.
Query through the index, never the ciphertext. Encrypted columns are non-deterministic. WHERE ssn = ? returns nothing — always WHERE ssnIndex = ? with the index value computed by the bundle's transformer service. Wrap that in a small repository method so callers can't get it wrong:
public function findBySsn(string $ssn): ?Patient
{
return $this->em->getRepository(Patient::class)
->findOneBy(['ssnIndex' => $this->blindIndexer->compute($ssn, 'ssn_idx')]);
}
DBAL type pattern for legacy tables. If you can't annotate an entity (third-party or generated model), map the column to the package's encrypted DBAL type in config/database.php. This is the seam that lets Symfony-style encryption work under a Laravel Doctrine connection without touching the model.
Migration pattern. Adding encryption to a populated column is a two-phase migration, not one:
chunkById raw pass with the cipher service).Never do both in one migration — you lose the rollback path.
Testing. In-memory SQLite is fine, but you must register the DBAL types on the test connection before the schema tool runs, or SchemaTool will emit TEXT where you expect ciphertext and your assertions pass for the wrong reason. Assert on ciphertext inequality (value in DB ≠ plaintext) rather than on a fixed hash.
Extension points worth knowing. The KeyProvider and the blind-index transformer are the two interfaces to implement — the first for KMS/rotation, the second if you need a deterministic index over a normalized value (lowercased email, E.164 phone).
0.13.0 swapped symfony/property-info for symfony/type-info. symfony/type-info was introduced in Symfony 7.1 and requires PHP 8.2+. Consequences for a Laravel shop:
symfony/type-info that fights your locked symfony/* versions. Verify with composer why symfony/type-info before upgrading; if you're on Laravel 10, pin 0.12.1.composer update resolves differently in CI than locally. Commit your composer.lock bump as its own PR.0.13.0 removed ORM deprecations. That means it targets a newer doctrine/orm and drops calls that older ORM versions still expected. Check your doctrine/orm constraint — doctrine/orm 2.x users on older minors should treat 0.13.0 as breaking and pin 0.12.1. This is a 0.x package, so semver gives you nothing here.
No Laravel integration surface. Two recurring failures:
Class "Odandb\...\Bundle" not found at boot — someone added the bundle to config/app.php's provider list. It's a Symfony Bundle, not a ServiceProvider. Remove it.Unknown column type "encrypted" — the DBAL type was never registered, or was registered on a different connection than the one the EntityManager uses. Register per-connection, and in phpunit.xml too.Blind indexes are deterministic — that's the whole tradeoff. Same plaintext → same hash, forever, across all rows. That means:
status enums) are trivially dictionary-attacked. Never add a blind index to a low-cardinality column.Key rotation silently breaks indexes. Rotating the encryption key without recomputing every blind-index column leaves you unable to find existing rows. Rotation is a data migration: decrypt, re-encrypt, re-index, in a transaction or a resumable batch job that records position.
Decryption failures are almost never the cipher. Ranked causes:
.env after a copy-paste).SELECT LENGTH(col) — real ciphertext is much longer.Debugging visibility. Query logs, Telescope, and Laravel's query listener will all render ciphertext blobs and can blow up log storage. Exclude encrypted columns from DB::listen payloads. Conversely, dd() on an entity gives you the decrypted value — that's the leak you actually have to guard against in error trackers and toArray() paths.
0.x versioning is the meta-gotcha. This package is pre-1.0 with a two-release-old API surface history; treat every minor as potentially breaking and read the changelog. Pin exactly ("0.13.0", not "^0.13") and upgrade deliberately.
How can I help you explore Laravel packages today?