Search by

kyzegs / doctrine-encryption-bundle

Kyzegs

Doctrine field encryption and blind indexes for Symfony applications.

Package info

github.com/Kyzegs/doctrine-encryption-bundle

Homepage

Type:symfony-bundle

pkg:composer/kyzegs/doctrine-encryption-bundle

Fund package maintenance!

kyzegs

Statistics

Installs: 488

Dependents: 0

Suggesters: 0

Stars: 8

Open Issues: 0

v2.0.0 2026-10-08 14:51 UTC

README

Doctrine Encryption Bundle banner

Doctrine Encryption Bundle

Field-level authenticated encryption and blind indexes for Doctrine entities in Symfony applications.

Maintained fork published as kyzegs/doctrine-encryption-bundle, replacing the unmaintained original package.

Requirements

  • PHP 8.2 or newer
  • Symfony 6.4, 7.4, or 8.x
  • Doctrine ORM 2.20 or 3.x
  • OpenSSL with AES-256-GCM support

Installation

composer require kyzegs/doctrine-encryption-bundle

Register the bundle if Symfony Flex has not already done so:

// config/bundles.php
return [
    Kyzegs\DoctrineEncryptionBundle\DoctrineEncryptionBundle::class => ['all' => true],
];

Generate a 256-bit key:

bin/console encrypt:genkey

Store the result in a secret manager or an uncommitted environment file:

DOCTRINE_ENCRYPTION_ENCRYPT_KEY=base64-encoded-key

Configure the bundle:

# config/packages/doctrine_encryption.yaml
doctrine_encryption:
    encrypt_key: '%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY)%'
    key_id: '2026-01'

Never commit the key. Blind indexes need a second key, which only applications using that feature have to configure.

Encrypting fields

Use the PHP attribute on a Doctrine string field. Allow room for the versioned ciphertext envelope; TEXT is the least surprising choice.

use Doctrine\ORM\Mapping as ORM;
use Kyzegs\DoctrineEncryptionBundle\Attribute\Encrypted;

#[ORM\Column(type: 'text', nullable: true)]
#[Encrypted]
private ?string $personalNumber = null;

New values are written with AES-256-GCM in a versioned, authenticated envelope. The entity contains plaintext while it is in memory; Doctrine stores ciphertext. Existing unversioned AES-CBC and AES-GCM values from older bundle versions remain readable.

External XML, YAML, or PHP Doctrine mappings can opt in without modifying the entity:

<field name="personalNumber" type="text">
    <options>
        <option name="encrypted">true</option>
    </options>
</field>

The equivalent field option is encrypted: true.

Encrypting JSON arrays

Doctrine json fields can encrypt array values without exposing serialization or ciphertext wrappers in entities. Select JSON format explicitly:

#[ORM\Column(type: 'json', nullable: true)]
#[Encrypted(format: Encrypted::FORMAT_JSON)]
private ?array $metadata = null;

External XML, YAML, or PHP mappings use encrypted: json instead of encrypted: true:

<field name="metadata" type="json">
    <options>
        <option name="encrypted">json</option>
    </options>
</field>

Only arrays and null are accepted. Objects, resources, and scalar JSON values are rejected so hydration always preserves the declared array contract. Plaintext arrays already stored in a JSON column remain readable and are encrypted when changed or when processed by encrypt:database encrypt.

Encrypted JSON is stored as a versioned wrapper:

{
  "__doctrine_encrypted": {
    "version": 1,
    "ciphertext": "versioned-ciphertext-envelope<ENC>"
  }
}

Wrapper detection requires exactly these keys, supported version 1, and the <ENC> ciphertext marker. Other arrays containing __doctrine_encrypted remain plaintext. The exact wrapper shape is reserved and must not be used as application data.

Encrypted scalar properties inside Doctrine embeddables are supported. Mark the embeddable property normally; bundle uses Doctrine's nested field path for encryption, decryption, change tracking, and database maintenance:

#[ORM\Embeddable]
final class ContactDetails
{
    #[ORM\Column(type: 'text')]
    #[Encrypted]
    public string $privateNote;
}

Encrypting through the DBAL type

encrypted_text is an alternative to the attribute. It converts values on the way to and from the driver rather than through entity lifecycle events, so it also covers reads that never construct a managed entity:

use Kyzegs\DoctrineEncryptionBundle\Types\EncryptedTextType;

#[ORM\Column(type: EncryptedTextType::NAME, nullable: true)]
private ?string $personalNumber = null;

The type is registered automatically; no doctrine.dbal.types entry is needed. An application declaring its own encrypted_text type keeps its own.

Use the type or #[Encrypted] on a field, never both.

#[Encrypted] encrypted_text
Managed entities yes yes
Array and partial hydration no yes
Blind indexes yes no
Encrypted JSON arrays yes no
encrypt:database maintenance yes no
Ciphertext bound to its field yes no

Two limits are worth stating plainly. The DBAL API gives a type no column context, so values written this way carry no field binding and can be moved between encrypted_text columns. And getScalarResult() and getSingleScalarResult() return the raw column: the ORM deliberately skips type conversion for a scalar mapping, a documented quirk it keeps for backward compatibility. Select the entity, or use getArrayResult(), when a query has to return plaintext.

Both mechanisms write the same envelope format. A field can move from #[Encrypted] to encrypted_text without re-encrypting. Moving the other way needs a rotation, because values written by the type are not bound to the field and the attribute refuses them: set verify_associated_data: false, switch the mapping, run encrypt:database rotate, then restore the setting.

Searching encrypted values

Randomized encryption cannot be queried by plaintext and must not carry a meaningful unique constraint. Add a blind-index column instead.

Blind indexes are hashed with their own key, which must differ from encrypt_key. Configure it before mapping the first blind index; without it, writing one fails with a message saying so.

DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY=a-different-secret
doctrine_encryption:
    blind_index_key: '%env(DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY)%'
use Kyzegs\DoctrineEncryptionBundle\Attribute\BlindIndex;
use Kyzegs\DoctrineEncryptionBundle\Attribute\Encrypted;

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

#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
#[BlindIndex(sourceField: 'email', normalizer: BlindIndex::NORMALIZE_LOWERCASE)]
private ?string $emailLookupHash = null;

Query through BlindIndexQueryHelper, which reads the normalizer from the mapping:

use Kyzegs\DoctrineEncryptionBundle\BlindIndex\BlindIndexQueryHelper;

// Name the encrypted field being searched; the helper returns criteria for its blind index.
$criteria = $blindIndexQueryHelper->criteria(User::class, 'email', $email);
$user = $repository->findOneBy($criteria);

For a query builder, hash a single blind-index field directly:

$hash = $blindIndexQueryHelper->hash(User::class, 'emailLookupHash', $email);

$qb->andWhere('u.emailLookupHash = :hash')->setParameter('hash', $hash);

Both throw when the named field is not a blind index, so a renamed or removed index fails loudly instead of returning no rows. Hashing by hand with BlindIndexHasherInterface still works, but the normalizer passed at the call site has to keep matching the attribute; when the two drift, the query silently finds nothing.

Available normalizers are none, trim, lowercase, and uppercase. Blind indexes reveal equality patterns; use them only for fields that genuinely need lookups.

Rebuild indexes in bounded batches:

bin/console encrypt:blind-index --batch-size=500 --dry-run
bin/console encrypt:blind-index --batch-size=500

Key rotation

Change the current key and key ID, then retain old keys under decryption_keys:

doctrine_encryption:
    encrypt_key: '%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY_2026)%'
    key_id: '2026'
    decryption_keys:
        '2025': '%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY_2025)%'

Back up the database, inspect the operation, and rotate in batches:

bin/console encrypt:database rotate --dry-run
bin/console encrypt:database rotate --batch-size=250

Remove a retired key only after every value using its key ID has been rotated and verified.

Reading legacy AES-CBC values

Unauthenticated AES-CBC ciphertext is refused by default, because accepting it lets anyone who can write to the database strip authentication from a value. Opt in only for the length of the migration:

doctrine_encryption:
    allow_legacy_cbc: true

Rotate every affected table with encrypt:database rotate, then remove the setting.

Renaming an encrypted field

A ciphertext records the field it was written to and, by default, may only be read back from that field. Renaming a mapped field therefore needs one of two migrations: rotate the affected table straight after the rename, or relax verification while the old values are still in place.

doctrine_encryption:
    verify_associated_data: false

Rotate, then restore the default. Leaving verification off allows a ciphertext to be moved between columns by anyone who can write to the database.

Database maintenance

The maintenance command supports scalar and encrypted JSON fields with encrypt, decrypt, and rotate, custom and composite scalar identifiers, quoted identifiers, transactions, batches, confirmation, and dry runs:

bin/console encrypt:database encrypt --dry-run
bin/console encrypt:database decrypt --manager=tenant --batch-size=100

--dry-run performs every decryption and encryption in memory without writing, so it reports unreadable ciphertext before a real run changes anything. Association identifiers are rejected because they cannot be updated safely by the low-level command. Always take a verified backup before a write operation.

Multiple Doctrine connections

doctrine_encryption:
    encrypt_key: '%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY)%'
    connections: ['default', 'tenant']

Optional Twig filter

Install Twig and leave enable_twig enabled to expose value|decrypt:

composer require symfony/twig-bundle

Decrypting in templates increases the number of places where plaintext exists. Prefer decrypting only at the application boundary that needs it.

Custom integrations

KeyProviderInterface is the extension point for Vault, KMS, HSM, or another secret source. It returns binary 32-byte keys and supports lookup by ciphertext key ID. Register the implementation as a service and configure it directly:

doctrine_encryption:
    key_provider_service: 'App\\Encryption\\KmsKeyProvider'
    blind_index_key: '%env(DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY)%'

Likewise, encryptor_service accepts any registered EncryptorInterface service with arbitrary injected dependencies. encryptor_class remains available as a deprecated compatibility path for classes using the historical event-dispatcher constructor.

Security notes

  • Encryption does not replace access control, TLS, backups, audit logging, or database hardening.
  • Losing an encryption key permanently loses the corresponding data.
  • Application compromise can expose plaintext and keys while the process is running.
  • Blind indexes permit equality analysis and require their own high-entropy secret, distinct from the encryption key.
  • is_disabled: true turns the attribute off completely: encrypted fields are neither encrypted on write nor decrypted on read, so entities hold whatever the column holds. It does not affect encrypted_text columns.
  • Test restoration and rotation on a copy of production data before operating on production.

See UPGRADE.md before upgrading an existing installation and SECURITY.md for vulnerability reporting.