gracious/doctrine-encryption-bundle
A Symfony bundle that adds transparent encryption for Doctrine entity fields, helping protect sensitive data at rest with minimal changes to existing mappings and persistence code.
Reality check first: this is a Symfony bundle, not a Laravel package. There is no service provider, no config/doctrine-encryption.php, and Laravel's container will never boot the Bundle class. The value you actually get is the Doctrine DBAL custom type in src/ — one small class that converts a string to ciphertext on write and back on read. The Symfony DI wiring around it is the part you rewrite.
Version 5.0.0 status: the only change in this release is Symfony 7 compatibility — no crypto changes, no new features, no deprecations. That makes it a packaging release, not a security release. Two consequences: (a) if you're installing the bundle rather than copying the type, the floor is now PHP 8.2 and symfony/framework-bundle ^7.0; (b) if you were waiting for the major bump to fix the cipher, it didn't. Re-audit the type on its merits. Given 5 stars and 0 dependents, budget 30 minutes to read the whole thing before you trust it with PII.
Two paths:
laravel-doctrine/orm → install the package, reuse the type class, skip the bundle.encrypted cast, or write a ~40-line custom cast modeled on this bundle's type. Nothing here is Eloquent-aware — and 5.0.0 did nothing to change that.Minimal steps (path 1):
composer require gracious/doctrine-encryption-bundle:^5.0
If Composer refuses (PHP < 8.2, or a Symfony 6 pin in the tree), you're not blocked — ^4.x behaves identically for our purposes, and copying the type class sidesteps the whole question.
Then look at, in order:
src/Types/* — the DBAL type. This is the whole product, and it is byte-identical in spirit to 4.x. Note the cipher, the IV handling, and whether it authenticates.src/DependencyInjection/* — tells you what the type needs injected (a key, an encrypter service) and how the bundle expects to supply it. Expect the 5.0.0 diff to be concentrated here and in composer.json.src/Resources/config/* — the default type name and key config key.Register it in a service provider's register() (not boot() — metadata must be resolvable before Doctrine builds it):
use Doctrine\DBAL\Types\Type;
public function register(): void
{
if (! Type::hasType('encrypted')) {
Type::addType('encrypted', \Gracious\DoctrineEncryptionBundle\Types\EncryptedType::class);
}
}
You'll also need the platform mapping so schema tooling understands the type:
DB::connection()->getDoctrineConnection()
->getDatabasePlatform()
->registerDoctrineTypeMapping('encrypted', 'string');
Key handling: put a dedicated key in config/database.php (read from env('DB_ENCRYPTION_KEY')), never APP_KEY, and never call env() inside the type — once config:cache runs, env() returns null.
First use case: one column. Start with api_tokens.secret or users.totp_secret — a secret nobody filters, sorts, or joins on. Map it as #[Column(type: 'encrypted', length: 512)]. Base64 ciphertext is ~1.4–2× plaintext, so always set an explicit length or use TEXT; the default VARCHAR(255) truncates silently on MySQL in non-strict mode.
Injectable encrypter via static setter. DBAL instantiates type objects itself, so constructor injection doesn't work. The bundle (like most of its kind) either uses a static setter or a container-aware lookup. In Laravel, bind a singleton in a provider and hand it over once:
public function boot(): void
{
EncryptedType::setEncrypter(app(FieldEncrypter::class));
}
Versioned ciphertext for rotation. Still not provided by 5.0.0 — you build it. Don't let the type emit raw base64. Prefix it:
// write: "v2:" . base64_encode($nonce . $tag . $cipher)
// read: match (substr($raw, 0, 3)) { 'v1:' => $this->oldKey->decrypt(...), ... }
This is the single most important extension point — the moment you have ciphertext in production, the key is immutable unless the format is self-describing.
Blind index for equality lookups. Default encryption is non-deterministic (random IV per write), so WHERE encrypted_col = ? returns nothing, forever, with no error. Add a sibling column:
#[Column(type: 'string', length: 64, unique: true)]
private string $emailIndex; // hash_hmac('sha256', strtolower($email), $indexKey)
#[Column(type: 'encrypted', length: 512)]
private string $email;
Set both in one place (a setter or a Doctrine lifecycle callback) so they can't drift. Use a different key for the index than for the cipher — otherwise a key leak lets an attacker brute-force low-entropy values offline.
Dual-read / write-new for backfill. Changing a column's type does not convert existing rows; readers will hit "decrypt failed" on the first plaintext row. Sequence it: (1) add the type and make the reader tolerant of undecryptable values, (2) deploy the writing path, (3) backfill with ->chunkById(500, ...) re-saving each row, (4) flip the reader to strict and drop the fallback.
Repository discipline. Encrypted columns are write-only from SQL's perspective. No ORDER BY, no LIKE, no GROUP BY, no SUM, no range predicates, no unique constraints on the ciphertext column. If a query needs any of that, it needs a blind index or a plaintext-derived column — decide that at design time, not after the migration.
Testing. Pin a fixed key in phpunit.xml, then assert against the raw table, not the entity:
$raw = DB::table('users')->where('id', $id)->value('totp_secret');
$this->assertNotSame('ABCDEF', $raw);
$this->assertSame('ABCDEF', $repo->find($id)->getTotpSecret());
openssl_encrypt with aes-256-gcm (good) or bare CBC with no MAC (malleable — an attacker can flip bits in ciphertext and you'll decrypt garbage without detection). If it's unauthenticated, wrap it yourself or choose a different package. Also confirm the IV/nonce is random per write, not derived from the value. A major version number is not a security signal.symfony/framework-bundle ^7.0 implies PHP >= 8.2. On Laravel 11 (which already ships Symfony 7 components) the install is smooth; on Laravel 10 with pinned symfony/* 6.x it will refuse to resolve, and downgrading to ^4.x is the pragmatic answer because the type class is unchanged. Don't let a Symfony version fight force you into upgrading PHP for one encryption class.convertToDatabaseValue(null) returns null and not base64(encrypt('')). Nullable encrypted columns are the most common silent breakage — you get '' back instead of null and whereNotNull logic inverts.string-mapped column. Keep the type emitting base64/hex strings; raw bytes on a VARCHAR column behave differently across MySQL/Postgres and break json_encode on entities.Type::addType is process-global static state. Under Octane it survives between requests — fine, but guard with Type::hasType() to avoid double-registration exceptions, and never stash request-scoped data (tenant keys!) on the type. If you need per-tenant keys, resolve the key from the entity, a context object, or a Doctrine event listener — not from the type's own state.Type::getType() succeeding does not mean Doctrine is using it. Same trap applies after bumping to 5.0.0 — clear it on deploy.composer conflicts, now sharper. Pulling a Symfony bundle into a Laravel app drags in symfony/framework-bundle and friends' version constraints; 5.0.0 pins them to 7.x. If composer require fights your existing tree, copy the type class (with attribution, MIT) instead of installing — and pin the copy to the commit you audited, since future major bumps are evidently framework-driven, not crypto-driven.'' becomes non-empty ciphertext and comes back '' — but strlen() in the DB, "not blank" validators, and IS NULL-vs-'' assumptions all change. Normalize to null in the setter.dd($entity), toArray(), Telescope, or a Log::info($payload). Encrypted-at-rest is not redacted-in-logs; add those separately.APP_KEY. Losing it is unrecoverable-by-design — write that in the runbook, and rehearse a rotation with the versioned format before you need it.unique on an encrypted VARCHAR(765) fails at migration time. Blind index, always.How can I help you explore Laravel packages today?