studio201/doctrine-encrypt-bundle
A Symfony bundle that adds transparent field-level encryption to Doctrine entities, letting you mark sensitive properties for automatic encrypt/decrypt on persist and hydrate with configurable ciphers and key management.
First, calibrate expectations. This is a Symfony bundle, not a Laravel package, and at 0 stars / 0 dependents it is effectively unvetted. It is a continuation of the ambta → michaelzangerle DoctrineEncryptBundle lineage, so most docs you'll find in blog posts apply — but the class namespaces and config keys have moved between forks. Read vendor/studio201/doctrine-encrypt-bundle/src/DependencyInjection/Configuration.php and the subscriber class before you write any config; that file is the ground truth for this version.
Minimal steps in a Symfony app:
composer require studio201/doctrine-encrypt-bundle
config/bundles.php:
Studio201\DoctrineEncryptBundle\DoctrineEncryptBundle::class => ['all' => true],
(Confirm the FQCN from the package's root PHP file — the namespace does not always match the vendor name.)config/packages/doctrine_encrypt.yaml with a dedicated key, not APP_SECRET:
doctrine_encrypt:
secret: '%env(DOCTRINE_ENCRYPT_SECRET)%'
# driver / encryptor_class: check Configuration.php — Defuse and Halite are both supported in this lineage
Generate the key once, store it in your secret manager / .env.local, and back it up separately from your database backups.text:
use ...\Attribute\Encrypted; // check src/Attribute vs src/Configuration for the namespace
#[ORM\Column(type: 'text')]
#[Encrypted]
private string $ssn = '';
bin/console doctrine:query:sql "SELECT id, ssn FROM customer WHERE id = 1"
You should see an opaque base64 blob. If you see plaintext, the subscriber isn't registered or the attribute isn't being read.Where to look first, in order: the subscriber/lifecycle listener (it tells you when encryption happens — onFlush vs preFlush vs a DBAL type — which determines every gotcha below), Configuration.php, composer.json (PHP / Doctrine ORM 2 vs 3 constraints), then any shipped console commands.
Laravel note: you cannot use this as-is. If you're on laravel-doctrine/orm, you can lift the subscriber class and register it manually:
// AppServiceProvider::boot()
Doctrine::getManager()->getEventManager()->addEventSubscriber(
new \App\Doctrine\EncryptSubscriber(/* encryptor */)
);
For greenfield Laravel work, a ~150-line custom DBAL Type wrapping sodium_crypto_secretbox is usually the better investment than adopting a 0-star bundle.
Two viable architectures, pick one deliberately.
Lifecycle subscriber (what this bundle does): transparent, works with entity hydration, zero changes to entity code beyond an attribute. Weakness: it only exists at the ORM layer — raw SQL, DBAL, NativeQuery, and array hydration all bypass it (see Gotchas).
DBAL custom Type (the pattern to prefer for new code): convertToDatabaseValue / convertToPHPValue on a string/text column. Works for every DBAL consumer, survives getArrayResult(), is testable in isolation, and is ~150 lines you fully own. Combine with a blind index for equality lookups.
The blind index pattern is mandatory if you ever need to look a record up. Non-deterministic encryption kills equality queries, so store a deterministic HMAC alongside the ciphertext:
#[ORM\Column(length: 64, unique: true)]
private string $ssnHash;
#[ORM\Column(type: 'text')]
#[Encrypted]
private string $ssn;
public function setSsn(string $ssn): self
{
$this->ssn = $ssn;
$this->ssnHash = hash_hmac('sha256', $ssn, $_ENV['BLIND_INDEX_PEPPER']);
return $this;
}
Repositories then query findOneBy(['ssnHash' => hash_hmac(...)]). Keep the pepper separate from the encryption key so a leaked DB doesn't enable offline dictionary attacks on low-entropy fields like SSNs.
Migrating existing plaintext rows. There is no reliable one-liner; you write a command:
foreach ($this->em->getRepository(Customer::class)->createQueryBuilder('c')
->where('c.ssn NOT LIKE :b64')->setParameter('b64', 'def50200%') // or just iterate all
->getQuery()->toIterable() as $customer) {
$customer->setSsn($customer->getSsn()); // force dirty, subscriber encrypts onFlush
$this->em->flush();
$this->em->detach($customer); // keep memory flat
}
Some forks in this lineage ship doctrine:encrypt:database / doctrine:decrypt:database — check src/Command/. Run it on a restored copy first, verify row counts, snapshot the table, and run in a maintenance window.
Key rotation is the same command inverted: decrypt with the old key into memory, flip the config to the new key, re-save. There is no envelope encryption or key versioning column here — you get exactly one key at a time.
Testing. Pin a fixed key in config/packages/test/doctrine_encrypt.yaml so fixtures are deterministic-ish, and write one functional test that asserts the raw row is not plaintext:
$raw = $connection->fetchOne('SELECT ssn FROM customer WHERE id = ?', [$id]);
$this->assertStringNotContainsString('123-45-6789', $raw);
$this->assertSame('123-45-6789', $repo->find($id)->getSsn());
Serialization and API output are transparent — getters return plaintext after postLoad, so API Platform / Symfony Serializer / JSON responses work normally. That's correct and intended, but it also means anything that snapshots entity state (profiler, PSR-3 argument logging, dump(), queue payloads, messenger __sleep serialization) captures plaintext. Exclude encrypted fields from __debugInfo, from jsonSerialize where appropriate, and wrap setters in #[\SensitiveParameter].
LifecycleEventArgs in favour of PostLoadEventArgs, PreFlushEventArgs, etc. — bundles in this lineage frequently still type-hint the old classes and will blow up on upgrade. Pin the exact version, read the subscriber, and consider vendoring the ~300 lines you actually use.findBy(['ssn' => '123-45-6789']) doesn't error — it returns nothing, because the column holds ciphertext and encryption is non-deterministic. Same for ORDER BY, LIKE, GROUP BY, aggregate MIN/MAX, and UNIQUE constraints (which are meaningless on random ciphertext). Add this to code review and, if you can, a PHPStan rule banning those columns in findBy/where.getArrayResult(), SELECT PARTIAL, createNativeQuery, Connection::fetchAllAssociative, and doctrine:query:sql all skip postLoad. If your code sometimes sees plaintext and sometimes base64, this is why. Assert on types at the boundaries.VARCHAR(255) truncates silently unless MySQL runs in strict mode. Use TEXT/LONGTEXT for every encrypted column, no exceptions.string/text and cast in the getter/setter.null vs '' behave differently. Nulls are typically passed through untouched, empty strings get encrypted. This changes what WHERE col IS NULL matches — verify against your installed subscriber before relying on either.doctrine:query:sql, another service) leaves plaintext or wrong-key ciphertext behind. $em->refresh($entity) after such writes; the postLoad decrypt won't help you if the row is bad..env, the encryption is decorative. Store the key separately, document the rotation runbook, and test the restore path once.bin/console doctrine:query:sql "SELECT ..." to see raw bytes, check whether the key changed, check whether the row was written by the subscriber or by a fixture using raw inserts, and check whether the entity was loaded via array hydration. Nine times out of ten it's one of those three.How can I help you explore Laravel packages today?