51systems/doctrine-encrypt
Transparent field-level encryption for Doctrine entities in Laravel. Custom DBAL types and event subscribers encrypt sensitive columns at rest, keeping normal ORM reads and writes seamless while protecting stored data.
This package is a small Doctrine DBAL type extension: you register an "encrypted" column type, point it at a key/cipher, and then mark entity fields with that type. Doctrine handles encryption on write and decryption on read transparently.
Minimal setup:
composer require 51systems/doctrine-encrypt
src/ for the class extending Doctrine\DBAL\Types\Type. That is the whole package: convertToDatabaseValue() encrypts, convertToPHPValue() decrypts. Read those two methods before trusting anything else — with a package this small and this old, the source is the documentation.Type::addType() in a service provider):
doctrine:
dbal:
types:
encrypted: App\Doctrine\EncryptedType
APP_ENCRYPTION_KEY=base64:...
First use case: pick exactly one sensitive, low-query column — a notes or api_token field — and change its @Column(type="encrypted"). Persist something, read it back, then check the raw row in the database to confirm ciphertext on disk and plaintext in PHP. That round-trip is your smoke test.
Column-level, not entity-level. Apply the type to the smallest set of fields that need it: tokens, PII, free-text notes. Each encrypted column is a read/write cost and a query liability, so be deliberate.
Transparent to the ORM layer. Once the type is registered, your normal entityManager->persist() / find() flow just works. The cipher is applied in the DBAL layer during parameter binding and result hydration, so nothing in your controllers or repositories changes.
Key sourcing pattern. Read the key once at container boot from env or a secrets manager and inject it into the type. Keep per-environment keys separate; a shared key between local and prod is a footgun. If the package allows it, derive per-column keys from the master key so a single leaked column key doesn't expose the rest.
Blind index for lookups. Encrypted columns can't be WHERE'd on directly. When you need equality lookup (e.g., "find user by SSN"), add a companion ssn_hash column populated in a lifecycle callback or setter with hash_hmac('sha256', $value, $macKey) and query on that. Never use a plain hash for low-entropy values like SSNs — always HMAC with a secret.
Migration from plaintext. Do it in three deploys: (1) add the encrypted column and dual-write, (2) backfill via a console command that re-saves each entity, (3) drop the plaintext column with a Doctrine migration. Decrypt-on-read code can bridge the gap temporarily.
Testing. Put a throwaway key in phpunit.xml or .env.test. Fixtures written through the entity manager are automatically encrypted; fixtures loaded with raw SQL are not — keep those two paths separate or your round-trip tests will lie.
Serialization. Decrypted values flow into API responses and logs by default. Wrap sensitive fields in your serializer's groups and be explicit about what leaves the app.
Archived since 2017 — treat it as source to vendor, not a dependency to trust. Pin your PHP and Doctrine DBAL versions, or better, copy the type class into your own namespace, audit it (it's tiny), and own it. DBAL type signatures changed across 2.x/3.x; an unmaintained type may simply fail to register or hydrate on modern Doctrine.
Crypto review is mandatory. Check: which cipher (AES-CBC/CTR vs. AES-ECB — ECB is broken), how the IV is generated and stored, whether a MAC/authenticated mode is used, and whether mcrypt is used anywhere (removed in PHP 7.2). If it's plain CBC without a MAC, you have malleability issues and should wrap it in an authenticated scheme yourself.
Deterministic vs. randomized. If the implementation uses a fixed IV for a given plaintext, identical values produce identical ciphertext — that's what makes lookups possible but leaks equality. Know which mode you're in and document it.
Key rotation is not built in. Changing the key means decrypting with the old one and re-encrypting with the new across every row. Plan a rotation command and dual-key read path early, because retrofitting one after the data grows is painful.
Query and schema limits. No ORDER BY, GROUP BY, LIKE, or joins on encrypted columns. Range queries are impossible. Type maps to TEXT/BLOB under the hood — set the column length on the plaintext expectation, not the ciphertext, or you'll get silent truncation depending on the implementation.
Nulls and empty strings. Verify what convertToDatabaseValue(null) does — some types happily encrypt '' into a non-empty blob, breaking your IS NULL assumptions. Test both explicitly.
Debugging checklist when reads return garbage or throw:
Extension points. The type class is the natural seam: subclass it to change the cipher, add a MAC, or swap the key provider to a Vault/KMS-backed reader. Keep the key provider injectable so you can test encryption without real secrets.
How can I help you explore Laravel packages today?