ywh/doctrine-encryption-bundle
A Symfony bundle that adds transparent field-level encryption to Doctrine entities, encrypting sensitive columns at rest via Doctrine DBAL types and event listeners. Configure keys and ciphers to protect data with minimal changes to existing mappings.
composer require ywh/doctrine-encryption-bundle. Confirm the constraint resolves against your Symfony/Doctrine versions — this is a Symfony2-era bundle (no Flex recipe, no symfony/* v4+ constraints in most tags), so on modern stacks you'll typically need composer require ... --ignore-platform-reqs and expect to port service definitions yourself.app/AppKernel.php: new Ywh\DoctrineEncryptionBundle\YwhDoctrineEncryptionBundle(). (Check src/ for the exact class name — namespaces differ between early tags.)DependencyInjection/Configuration.php in the bundle to see the real option names. Expect something like:
ywh_doctrine_encryption:
key: "%encryption_key%" # 32 bytes, base64 or hex depending on the encryptor
# algorithm / cipher options, if exposed
Put the key in parameters.yml locally and an env var in prod; never commit it.EncryptedString, EncryptedText, or similar — again, verify in the extension package's src/):
doctrine:
dbal:
types:
encrypted_string: Ywh\DoctrineEncryption\Types\EncryptedString
/** @ORM\Column(type="encrypted_string") */
private $ssn;
or the @Encrypted annotation if your version uses the event-subscriber approach instead of a DBAL type.User entity with a ssn, taxId, or apiToken column. Persist, flush, then doctrine:query:sql "SELECT ssn FROM user" — you should see base64 ciphertext in the DB while $user->getSsn() returns plaintext after hydration.Where to look first, in order: the bundle's Resources/config/services.xml (what it actually wires), DependencyInjection/Configuration.php (real option names), then the extension package's Encryptor class (key format and cipher) and its Type/subscriber classes (when encryption fires).
Pick the right mapping mechanism. Older tags use a Doctrine event subscriber (postLoad/prePersist/preUpdate) keyed off an annotation; newer ones use a DBAL custom type. DBAL types are more reliable because they survive find(), getReference(), partial hydration, and native queries through the connection; subscribers miss bulk DQL and native SQL. Prefer the type-based path if both exist in your checkout.
Blind index for lookups. You cannot WHERE ssn = :x on randomized ciphertext. Add a deterministic companion column:
/** @ORM\Column(type="encrypted_string") */
private $ssn;
/** @ORM\Column(type="string", length=64, unique=true) */
private $ssnIndex;
public function setSsn(string $ssn): void {
$this->ssn = $ssn;
$this->ssnIndex = hash_hmac('sha256', $ssn, $this->indexKey);
}
Keep the HMAC key separate from the encryption key and normalize (trim/lowercase) before hashing, or lookups will silently miss.
Deterministic vs randomized. If the package lets you choose, use randomized (IV-per-value) for data at rest and always pair it with a blind index. Deterministic encryption leaks equality across rows — acceptable only when the column is already low-entropy and you accept that tradeoff.
Keep the encryptor out of entities where possible. Inject the bundle's encryptor service into a dedicated EncryptionService or Doctrine listener rather than calling it from getters/setters, so you can swap in a sodium_crypto_secretbox implementation when the bundled one is unmaintained.
Migration pattern for existing plaintext. Never run an in-place UPDATE. Add nullable ssn_enc and ssn_idx columns, backfill with a one-off command iterating in batches (->iterate() on a Query with ->setMaxResults() chunks — avoid loading 100k entities at once), then enforce NOT NULL, swap the property mapping, and drop the old column in a follow-up deploy.
Test environment. Point the key at a fixed test value and use SQLite in-memory. Assert on the raw connection value to prove encryption happened:
$raw = $this->em->getConnection()->fetchOne('SELECT ssn FROM user WHERE id = ?', [$id]);
$this->assertNotSame('123-45-6789', $raw);
That one assertion catches "I forgot to register the DBAL type" immediately.
Read-only projections. If a query returns scalar rows (e.g. SELECT ssn FROM ... via getArrayResult() or a custom repository), the DBAL type applies — but raw Connection::fetchAll() bypasses it. Route every read of a sensitive column through the ORM or explicitly decrypt.
composer.json for PHP/Symfony constraints before assuming it installs; on Symfony 5+/PHP 8 you'll be hand-porting services.xml to services.yaml and replacing @service references with proper DI.VARCHAR(255) holding a 200-char value will truncate or throw. Use TEXT/BLOB, or size columns at ~1.5× the plaintext max.UPDATE User u SET u.ssn = :x via DQL, or $conn->executeUpdate(), writes plaintext. The DBAL-type approach also bypasses on DQL UPDATE because it goes through the type only for parameter binding — verify explicitly, and prefer flushing entities for sensitive writes.json_encode($user) and Symfony's serializer will emit decrypted plaintext. Add @Groups/#[Ignore] on encrypted properties and audit every API resource that exposes the entity.doctrine:query:sql show ciphertext (that's your proof it works). dump($user) in a controller shows plaintext (that's your proof hydration works). If you see plaintext in the DB, the type isn't registered under the exact name used in @ORM\Column(type=...) — typos here fail silently as string.parameters.yml ends up in the repo in most legacy projects. Move to %env(...)% or a mounted secret, and make sure the key isn't dumped by config:dump-reference or a stray debug:container --env-vars in CI logs.openssl_* signature changes, mcrypt if the old tag used it). Pin to sodium_crypto_secretbox via a custom encryptor implementation — the bundle's service definition usually makes the encryptor a swappable service, which is the cleanest extension point available here.How can I help you explore Laravel packages today?