philetaylor/doctrine-encrypt
Transparent field-level encryption for Doctrine entities in Laravel, letting you store sensitive columns encrypted at rest and decrypt them automatically through a custom DBAL type.
Read the README banner before anything else: it says WIP DO NOT USE, and the package's own metadata backs that up — 1 star, 0 dependents, and no release since 2022-01-03. Treat this repository as a reading/reference artifact, not a dependency you composer require into a Laravel app. If you're evaluating it, that is the first use case: audit the source to understand how annotation-driven Doctrine field encryption is attempted, then decide whether to borrow the pattern rather than the package.
Minimal steps to explore it:
composer.json first — check PHP and doctrine/orm / doctrine/dbal constraints. A 2022 package will likely pin Doctrine versions incompatible with a current laravel-doctrine/orm install, so expect conflict errors before any code runs.doctrine.types), an event subscriber (postLoad, prePersist, onFlush), or both. This tells you the whole design and whether encrypted columns are queryable.openssl_encrypt, sodium_crypto, aes-256, config. Note whether the key comes from a config file, env, or is hardcoded in tests.First realistic use case (in a throwaway branch, never main): encrypt one non-searchable PII column — ssn, national_id, a stored API secret — on a single Doctrine entity, verify the raw DB row is ciphertext, and verify a round-trip through the EntityManager. Do not put it in an auth or token path.
If you actually need this capability in production, start from Laravel's built-in Crypt with a custom Eloquent cast, or ambta/doctrine-encrypt-bundle for Doctrine — both maintained and audited. Use this repo to understand the tradeoffs, not to ship.
Because you should not run this on a real app, the useful pattern is extracting the idea into your own code.
Application-layer encryption with a cast (recommended). Encrypt in a mutator/cast before save(), decrypt on attribute access. Keeps the ciphertext out of the query layer entirely, so WHERE, LIKE, and ORDER BY on those columns are simply impossible by design — which is the correct mental model anyway.
// app/Casts/Encrypted.php (sketch)
protected function set($model, $key, $value, $attributes) {
return [$key => $value === null ? null : Crypt::encryptString(serialize($value))];
}
protected function get($model, $key, $value, $attributes) {
return $value === null ? null : unserialize(Crypt::decryptString($value));
}
Split write and read paths. Encrypt immediately before persist; decrypt immediately after hydrate. Never leave a plaintext attribute sitting on the model longer than the request needs it.
Key rotation from day one. Store a key_id alongside the ciphertext (a second column), so rotation is "decrypt with old, re-encrypt with new, write both" rather than a full-table guess. Packages of this vintage almost never ship rotation.
Dual-write migration pattern (if replacing this package or moving from plaintext): add column_enc → dual-write plaintext and encrypted on every write → backfill existing rows in batches → switch reads to encrypted with plaintext fallback → drop plaintext once metrics confirm zero fallback hits. This is the only safe way to move a live PII column.
Testing pattern. Pin a fixed key in phpunit.xml, then assert two things per test: (a) the model attribute returns the plaintext, and (b) DB::table('users')->where('id',$id)->value('ssn') is not the plaintext. If your test only checks (a), you can pass with a broken cast that silently stores plaintext.
Blind indexes for lookup. If you must query an encrypted column (e.g., find by email), don't reach for deterministic encryption alone — store a keyed HMAC (hash_hmac('sha256', strtolower($email), $indexKey)) in a separate email_index column with a unique constraint, and keep the ciphertext non-deterministic. This is the pattern any serious implementation converges on (CipherSweet does exactly this).
openssl_encrypt with OPENSSL_RAW_DATA/OPENSSL_ZERO_PADDING, for ECB mode, for a reused IV/nonce, for missing authentication tags, and for base64_encode-everywhere without integrity. Older packages routinely omit the auth tag, which makes ciphertext malleable.findBy, LIKE, ORDER BY, and joins on that column operate on ciphertext and will return nonsense. Uniqueness constraints also compare ciphertext — with a random IV, "unique" silently stops enforcing anything.postLoad listeners mark entities dirty. Decrypting in an event subscriber mutates the entity after hydration; Doctrine's change tracker may see a change and re-encrypt/re-persist on the next flush. Decrypt in a getter or type, not a listener — or your ciphertext grows every save.VARCHAR(255) plaintext overflows a VARCHAR(255) column. Use TEXT/BLOB for ciphertext and validate against the encrypted length, not the plaintext length.toArray(), JSON responses, Log::info($model), queue payloads, and Telescope. Add $hidden and scrub job payloads, or you've encrypted at rest and decrypted into your logs.env('ENCRYPT_KEY') at runtime, it breaks under php artisan config:cache. Move the key into config/*.php via env() there, and keep the raw key out of version control.composer require philetaylor/doctrine-encrypt --dry-run before anything else; a 2022 doctrine/orm constraint will almost certainly clash with current Laravel Doctrine integrations.DB::table(...)->value(...) is the only reliable check that something is actually encrypted. The model will happily show plaintext because a listener decrypted it.encrypt(string): string / decrypt(string): string), add the auth tag yourself (Sodium crypto_secretbox/aead or Crypt), write round-trip and tamper-detection tests before changing logic. Then you have your own code with a known provenance, not a vendored WIP.Crypt + an encrypted cast (native, audited, rotation-friendly), ambta/doctrine-encrypt-bundle (maintained Doctrine equivalent), ParagonIE CipherSweet (blind indexes done right), or Symfony's Sodium bundles if you're cross-framework.How can I help you explore Laravel packages today?