nowo-tech/doctrine-encrypt-bundle
A Symfony bundle for transparent field-level encryption in Doctrine entities. Protect sensitive database columns with encrypted DBAL types, PHP attributes, and configurable key management—ideal for GDPR compliance and secure application data.
composer require nowo-tech/doctrine-encrypt-bundle. There is no Flex recipe, so add the bundle manually to config/bundles.php (NowoTech\DoctrineEncryptBundle\DoctrineEncryptBundle::class => ['all' => true]) and create config/packages/doctrine_encrypt.yaml. Confirm the exact root key and option names against src/DependencyInjection/Configuration.php — for this family of bundles it is typically doctrine_encrypt: with secret/key and encryptor (one of halite, defuse, mysql_aes).%env(DOCTRINE_ENCRYPT_KEY)%). Never commit a key; a leaked key plus a leaked dump equals plaintext.@Encrypted / #[Encrypted]) to the entity property you want protected. Keep the property typed as string and change the Doctrine column to TEXT/LONGTEXT before the first write — ciphertext is Base64-wrapped and far longer than the plaintext.flush(), clear(), reload, and assert the property is plaintext. Then bypass the ORM (SELECT via dbal or mysql CLI) and assert the stored value is opaque. That second check is the one people skip and regret.national_id, iban, date_of_birth, medical notes — not your whole model. Encrypt one field, ship it, then expand. Where to look first in the code: the encryptor implementations under src/, the interface they implement (your extension point), and the Doctrine event subscriber that does the work on postLoad/prePersist/preUpdate.Attribute-per-field, no accessor changes. Encryption is transparent at hydration time, so getters, setters, form types, and serializers keep working with plaintext. Keep entity properties as plain PHP strings; do not add custom Doctrine types or convertToDatabaseValue logic unless you have a specific reason.
Pick the encryptor against your query budget, not your taste. Halite is authenticated, randomized, and the sane default for data you never filter on. Defuse is the compatibility path for databases already holding Defuse ciphertext. mysql_aes pushes crypto into MySQL/MariaDB (AES_ENCRYPT/AES_DECRYPT) and is the only one of the three that lets you compare ciphertext in SQL — at the cost of vendor lock-in and the key travelling in queries.
Searchable fields = blind index, always. Add a second, unencrypted column holding hash_hmac('sha256', $normalizedValue, $indexKey) and query on that. Index it, put unique constraints on it, and never WHERE the encrypted column. This is the single pattern that decides whether the bundle is pleasant or painful on day three.
Key rotation as an explicit runbook.
EntityManager::clear() every N rows — large tables will exhaust memory otherwise).Multi-key config is the safety net. Keep historical keys in the config for at least as long as your oldest restorable backup. A restore plus a dropped key is unrecoverable data loss.
Incremental migration on live tables. Add secret_v2 as a nullable encrypted column, backfill asynchronously, dual-read (prefer v2, fall back to v1), then drop v1. Avoid rewriting a populated column in place during a deploy.
Custom encryptor as the extension point. Implement the bundle's encryptor interface, register it as a service, and reference the service id in encryptor:. Useful when you need KMS/HSM-backed keys rather than a raw string in config.
Test fixtures. Set a fixed test key in .env.test, seed fixtures with plaintext (the subscriber handles the rest), and never assert on exact ciphertext — randomized encryptors produce different bytes every run. Assert decrypt-equality instead.
Forms and API payloads. Nothing changes for the happy path, but add explicit tests for an encrypted field inside a CollectionType / nested DTO, since partial-hydration bugs surface there first.
VARCHAR(255) silently truncates long ciphertext on MySQL (or throws in strict mode). Use TEXT/LONGTEXT from day one; migration later means a full table rewrite.SELECT PARTIAL, array hydration, scalar queries, and NEW Dto(...) DQL all skip postLoad. Only full object hydration decrypts. This is the number one "why is my API returning garbage" bug.createNativeQuery or raw connection read gives you Base64. Route those through the encryptor service manually or don't use them on encrypted columns.LIKE, joins, aggregates, or unique constraints on encrypted columns. Design the schema around the blind index before writing repository code.mysql_aes (and any deterministic setup) reveals equal values across rows. Only acceptable for low-cardinality, low-sensitivity lookups with a hashed blind index; otherwise use a randomized encryptor.TEXT blobs on wide result sets is measurable. Benchmark a 1k-row list before encrypting a field the UI renders in bulk, and consider keeping bulk-displayed content out of scope.bin/console cache:clear, then bin/console debug:config doctrine_encrypt to confirm the live key set — a stale config cache pointing at the wrong key looks exactly like corrupted data.null stays null while '' encrypts; check your entity semantics and be consistent, especially if a NOT NULL column is involved.dump() in tests, and error-tracking payloads all capture raw parameters — and after decryption in memory, plaintext PII can leak into exception traces.mysql_aes. It requires MySQL/MariaDB and a session key variable; it will not survive a move to PostgreSQL. Choose the encryptor with your hosting roadmap in mind.How can I help you explore Laravel packages today?