Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Encrypt Bundle Laravel Package

studio201/doctrine-encrypt-bundle

A Symfony bundle that adds transparent field-level encryption to Doctrine entities, letting you mark sensitive properties for automatic encrypt/decrypt on persist and hydrate with configurable ciphers and key management.

View on GitHub
Deep Wiki
Context7

Getting Started

First, calibrate expectations. This is a Symfony bundle, not a Laravel package, and at 0 stars / 0 dependents it is effectively unvetted. It is a continuation of the ambta → michaelzangerle DoctrineEncryptBundle lineage, so most docs you'll find in blog posts apply — but the class namespaces and config keys have moved between forks. Read vendor/studio201/doctrine-encrypt-bundle/src/DependencyInjection/Configuration.php and the subscriber class before you write any config; that file is the ground truth for this version.

Minimal steps in a Symfony app:

composer require studio201/doctrine-encrypt-bundle
  1. Register the bundle manually. There is almost certainly no Flex recipe for a 0-star fork, so nothing is auto-wired. Add it to config/bundles.php:
    Studio201\DoctrineEncryptBundle\DoctrineEncryptBundle::class => ['all' => true],
    
    (Confirm the FQCN from the package's root PHP file — the namespace does not always match the vendor name.)
  2. Create config/packages/doctrine_encrypt.yaml with a dedicated key, not APP_SECRET:
    doctrine_encrypt:
        secret: '%env(DOCTRINE_ENCRYPT_SECRET)%'
        # driver / encryptor_class: check Configuration.php — Defuse and Halite are both supported in this lineage
    
    Generate the key once, store it in your secret manager / .env.local, and back it up separately from your database backups.
  3. Annotate a field and make the column text:
    use ...\Attribute\Encrypted; // check src/Attribute vs src/Configuration for the namespace
    
    #[ORM\Column(type: 'text')]
    #[Encrypted]
    private string $ssn = '';
    
  4. First use case: pick one genuinely sensitive, non-searchable field — a patient SSN, a customer tax ID, an API credential — encrypt it, persist an entity, then bypass the ORM entirely:
    bin/console doctrine:query:sql "SELECT id, ssn FROM customer WHERE id = 1"
    
    You should see an opaque base64 blob. If you see plaintext, the subscriber isn't registered or the attribute isn't being read.

Where to look first, in order: the subscriber/lifecycle listener (it tells you when encryption happens — onFlush vs preFlush vs a DBAL type — which determines every gotcha below), Configuration.php, composer.json (PHP / Doctrine ORM 2 vs 3 constraints), then any shipped console commands.

Laravel note: you cannot use this as-is. If you're on laravel-doctrine/orm, you can lift the subscriber class and register it manually:

// AppServiceProvider::boot()
Doctrine::getManager()->getEventManager()->addEventSubscriber(
    new \App\Doctrine\EncryptSubscriber(/* encryptor */)
);

For greenfield Laravel work, a ~150-line custom DBAL Type wrapping sodium_crypto_secretbox is usually the better investment than adopting a 0-star bundle.

Implementation Patterns

Two viable architectures, pick one deliberately.

Lifecycle subscriber (what this bundle does): transparent, works with entity hydration, zero changes to entity code beyond an attribute. Weakness: it only exists at the ORM layer — raw SQL, DBAL, NativeQuery, and array hydration all bypass it (see Gotchas).

DBAL custom Type (the pattern to prefer for new code): convertToDatabaseValue / convertToPHPValue on a string/text column. Works for every DBAL consumer, survives getArrayResult(), is testable in isolation, and is ~150 lines you fully own. Combine with a blind index for equality lookups.

The blind index pattern is mandatory if you ever need to look a record up. Non-deterministic encryption kills equality queries, so store a deterministic HMAC alongside the ciphertext:

#[ORM\Column(length: 64, unique: true)]
private string $ssnHash;

#[ORM\Column(type: 'text')]
#[Encrypted]
private string $ssn;

public function setSsn(string $ssn): self
{
    $this->ssn = $ssn;
    $this->ssnHash = hash_hmac('sha256', $ssn, $_ENV['BLIND_INDEX_PEPPER']);
    return $this;
}

Repositories then query findOneBy(['ssnHash' => hash_hmac(...)]). Keep the pepper separate from the encryption key so a leaked DB doesn't enable offline dictionary attacks on low-entropy fields like SSNs.

Migrating existing plaintext rows. There is no reliable one-liner; you write a command:

foreach ($this->em->getRepository(Customer::class)->createQueryBuilder('c')
    ->where('c.ssn NOT LIKE :b64')->setParameter('b64', 'def50200%') // or just iterate all
    ->getQuery()->toIterable() as $customer) {
    $customer->setSsn($customer->getSsn()); // force dirty, subscriber encrypts onFlush
    $this->em->flush();
    $this->em->detach($customer);            // keep memory flat
}

Some forks in this lineage ship doctrine:encrypt:database / doctrine:decrypt:database — check src/Command/. Run it on a restored copy first, verify row counts, snapshot the table, and run in a maintenance window.

Key rotation is the same command inverted: decrypt with the old key into memory, flip the config to the new key, re-save. There is no envelope encryption or key versioning column here — you get exactly one key at a time.

Testing. Pin a fixed key in config/packages/test/doctrine_encrypt.yaml so fixtures are deterministic-ish, and write one functional test that asserts the raw row is not plaintext:

$raw = $connection->fetchOne('SELECT ssn FROM customer WHERE id = ?', [$id]);
$this->assertStringNotContainsString('123-45-6789', $raw);
$this->assertSame('123-45-6789', $repo->find($id)->getSsn());

Serialization and API output are transparent — getters return plaintext after postLoad, so API Platform / Symfony Serializer / JSON responses work normally. That's correct and intended, but it also means anything that snapshots entity state (profiler, PSR-3 argument logging, dump(), queue payloads, messenger __sleep serialization) captures plaintext. Exclude encrypted fields from __debugInfo, from jsonSerialize where appropriate, and wrap setters in #[\SensitiveParameter].

Gotchas and Tips

  • The dependency risk is the top gotcha. Zero dependents means zero community pressure on Doctrine ORM 3 / DBAL 4 compatibility. ORM 3 removed LifecycleEventArgs in favour of PostLoadEventArgs, PreFlushEventArgs, etc. — bundles in this lineage frequently still type-hint the old classes and will blow up on upgrade. Pin the exact version, read the subscriber, and consider vendoring the ~300 lines you actually use.
  • Silent zero results are the defining failure mode. findBy(['ssn' => '123-45-6789']) doesn't error — it returns nothing, because the column holds ciphertext and encryption is non-deterministic. Same for ORDER BY, LIKE, GROUP BY, aggregate MIN/MAX, and UNIQUE constraints (which are meaningless on random ciphertext). Add this to code review and, if you can, a PHPStan rule banning those columns in findBy/where.
  • Bypass paths return ciphertext into your app. getArrayResult(), SELECT PARTIAL, createNativeQuery, Connection::fetchAllAssociative, and doctrine:query:sql all skip postLoad. If your code sometimes sees plaintext and sometimes base64, this is why. Assert on types at the boundaries.
  • Column length. Ciphertext is substantially longer than plaintext (base64 overhead + IV + HMAC). VARCHAR(255) truncates silently unless MySQL runs in strict mode. Use TEXT/LONGTEXT for every encrypted column, no exceptions.
  • Don't encrypt non-strings. DateTimes, decimals, booleans, JSON arrays and enums get cast to string by the subscriber and come back mangled or throw on decrypt. Keep encrypted columns as string/text and cast in the getter/setter.
  • null vs '' behave differently. Nulls are typically passed through untouched, empty strings get encrypted. This changes what WHERE col IS NULL matches — verify against your installed subscriber before relying on either.
  • Halite is slow; Defuse is fast. Halite derives a key with Argon2 on every encrypt/decrypt call. Per-field that's tens of milliseconds; across a 100k-row import it's hours. Benchmark your driver before a bulk migration, and prefer the symmetric/Defuse path for high-volume tables.
  • Cache layers. Doctrine's second-level cache and any Redis result cache sit between you and the DB. Decrypted entity state in an in-memory cache is fine (it's the same trust boundary as the app), but a serialized cache written before the subscriber ran will hold ciphertext and re-enter the app undecrypted. Flush caches after enabling encryption or rotating keys.
  • Stale plaintext after manual DB writes. Anything that writes outside the ORM (seeders, doctrine:query:sql, another service) leaves plaintext or wrong-key ciphertext behind. $em->refresh($entity) after such writes; the postLoad decrypt won't help you if the row is bad.
  • Key loss is unrecoverable data loss and there is no key-versioning column. If you back up the DB to the same bucket as the .env, the encryption is decorative. Store the key separately, document the rotation runbook, and test the restore path once.
  • Debugging a decryption failure: bin/console doctrine:query:sql "SELECT ..." to see raw bytes, check whether the key changed, check whether the row was written by the subscriber or by a fixture using raw inserts, and check whether the entity was loaded via array hydration. Nine times out of ten it's one of those three.
  • Extension points: the subscriber is a normal Symfony service, so you can decorate it to inject additional authenticated data (tenant ID, entity class) into the ciphertext, or to support per-tenant keys — neither is supported out of the box. If you need per-row keys, that's a fork, not a config flag.
  • Compliance reality check: this is encryption at rest. The application and anyone with the key can still read everything; it does not satisfy "anonymization" under GDPR, and it does not protect against SQL injection or a compromised app server. It protects stolen backups and stray DB dumps. Size the threat model before you take on the query limitations.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource