ywh/doctrine-encryption-extension
ywh/doctrine-encryption-extension adds transparent field-level encryption to Doctrine entities, with configurable DBAL types and event subscribers for securely storing sensitive data in PHP applications.
This is a small, low-traffic package (no stars, no dependents), so treat the source as the documentation of record. Budget 20 minutes to read the two or three files that matter before wiring it in — the failure modes of field-level encryption are mostly about when the crypto runs, not about the cipher itself.
Install and vendor it.
composer require ywh/doctrine-encryption-extension
ls vendor/ywh/doctrine-encryption-extension/src
Open the README.md and src/ first. You are looking for exactly two things:
Doctrine\DBAL\Types\Type, or a Doctrine event subscriber on postLoad/prePersist/preUpdate), andDecide the encryption contract before touching entities. Which is it?
#[ORM\Column(type: 'encrypted_string')]. Transparent at the persistence layer; works for any entity using that column type.Everything downstream (querying, migrations, testing) depends on this answer.
Provide the key from the environment, never from code. In Symfony:
# config/services.yaml
parameters:
ywh_encryption.key: '%env(base64:APP_ENCRYPTION_KEY)%'
Generate once, store in your secret manager: php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'. Losing this key means losing the data; there is no recovery path.
First use case — one field, one round-trip test. Pick a low-blast-radius PII column you are not yet querying (e.g. customer.phone). Change the column type, write a functional test that persists, clears the EntityManager, reloads, and asserts equality — and a second assertion that raw SQL returns ciphertext:
$raw = $connection->fetchOne('SELECT phone FROM customer WHERE id = ?', [$id]);
self::assertNotSame('+33612345678', $raw);
That one assertion is what tells you whether the extension is actually in the hydration path. Ship that test before you touch a second column.
Registering the type (DBAL-type flavour). DBAL types must exist before the connection is opened, so register them in configuration, not in a service constructor:
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
encrypted_string: YWH\DoctrineEncryptionExtension\DBAL\Types\EncryptedStringType
If the package predates dbal.types support, you need a compiler pass that calls Type::addType() in preBoot — doing it from a controller or a listener is a classic "Unknown column type" bug.
Encrypting structured data. Encrypting a JSON blob is usually better than encrypting five columns: one ciphertext, one key operation, no partial-hydration surprises.
#[ORM\Column(type: 'encrypted_text')]
private ?string $medicalRecordJson = null; // json_encode() before set, json_decode() on read
Blind indexes for lookups. With a random IV, the same plaintext encrypts to different ciphertext each time — so WHERE email = :email and unique constraints stop working. Keep a deterministic companion column:
#[ORM\Column(length: 64, unique: true)]
private string $emailBlindIndex; // hash_hmac('sha256', mb_strtolower($email), $hmacKey)
public function setEmail(string $email): void
{
$this->email = $email; // encrypted column
$this->emailBlindIndex = $this->blindIndex($email); // searchable column
}
Use a different key for HMAC than for encryption, and remember the blind index leaks equality (frequency analysis) — it is not a substitute for encryption, it is a lookup aid.
Embeddables and value objects. If the extension is a DBAL type, it composes cleanly: put the encrypted type on the embeddable's fields and the VO's fromString/toString stays crypto-agnostic. If it is a subscriber, embedding usually does not work — the subscriber walks entity properties, not embeddable internals. Verify with a test either way.
Backfilling existing plaintext. Never let doctrine:schema:update or a naive ALTER TABLE rewrite a populated column without a plan. Sequence: add the new encrypted column → dual-write in code → background job re-encrypts row by row in batches → flip reads → drop the old column in a later deploy. Keep the migration down() honest (it can only restore plaintext if you kept a backup).
Testing pattern. Test against the real driver (SQLite in-memory is fine for the round-trip; it is not fine for asserting column lengths). Wrap crypto assertions in a small helper that bypasses the ORM:
$raw = $em->getConnection()->fetchOne('SELECT secret FROM vault WHERE id = ?', [$id]);
If you rely on getArrayResult() or ->select('partial v.{id, secret}') anywhere, add a test for that path specifically — array hydration is the usual place field-level crypto silently does nothing.
Key rotation extension point. Design for a keyring on day one even if the package only supports one key: prefix the ciphertext with a short key id (v2: or a one-byte header) at the application level or by decorating the type. Rotation then becomes re-encrypt-on-write plus a background sweep, instead of an emergency.
VARCHAR(255) holding a 200-char string will truncate or throw. Size as ceil(len * 1.4) + 64, or use TEXT/BLOB. Verify against the actual driver — MySQL in strict mode errors, SQLite silently accepts.LIKE, ORDER BY, and range queries are dead. Encrypted columns only support exact match via a blind index. Any LIKE '%foo%' or ORDER BY on an encrypted column must move to a dedicated search index; catch this in code review, because it fails as "returns nothing", not as an exception.ResultSetMapping, DQL NEW, and partial hydration can hand you ciphertext. The Symfony profiler is the fastest way to spot it: the value in the "Request" panel will look like base64 noise.var_dump($entity) all leak plaintext or ciphertext. Disable doctrine.dbal.logging in prod, add #[SensitiveParameter] to setters taking secrets, and consider __debugInfo() on entities holding decrypted PII.Unknown column type "encrypted_string" — always caused by Type::addType() running after the connection is booted, or by the type being registered only in test config. If it works in dev and fails in cache:clear-warmed prod, this is why.salary from another employee). If that threat is in your model, decorate the type to bind table + column + primary key as AAD using sodium_crypto_aead_xchacha20poly1305_ietf_*, and accept that the value must be re-encrypted if the PK changes.findBy() on an encrypted field silently returns empty. No exception, no warning. Add a static-analysis rule or a grep check in CI for findBy([<encrypted field> — it is the highest-value 10-line guardrail you can add.openssl_encrypt? libsodium? IV generation?) before production use; if it uses openssl_encrypt with a non-random IV or ECB, wrap or replace it rather than adopting it. The cleanest mitigation is to isolate it behind your own EncryptorInterface so swapping to paragonie/halite or libsodium later is a one-class change.How can I help you explore Laravel packages today?