dwgebler/encrypted-fields-bundle
Symfony bundle that encrypts and decrypts Doctrine entity fields via a simple attribute, using a master key to generate secure per-row encryption keys stored in a separate table. Supports key rotation, UUID and composite primary keys.
First, confront the framing. dwgebler/encrypted-fields-bundle is a Symfony bundle built around Doctrine ORM, not a Laravel/Eloquent package. There is no service provider, no Artisan command, no Eloquent cast, and no Flex recipe that applies to Laravel. If your app is Eloquent-only, stop here — the effort of adopting laravel-doctrine/orm is an architecture change, not an integration task. If you already run laravel-doctrine/orm (or are willing to), this package is a plausible fit.
Minimal steps for a Laravel + Doctrine setup:
openssl extension enabled; Doctrine ORM wired into Laravel via laravel-doctrine/orm. This determines whether the rest is viable at all.composer require dwgebler/encrypted-fields-bundle
php -r "file_put_contents('master.key', bin2hex(random_bytes(32)));"
Store the file (or its value) in a secrets manager — not committed, not a plain .env in production.config/packages/gebler_encrypted_fields.yaml). In Laravel you must translate this into a config/encrypted_fields.php and pass the values into the bundle's config when you register it. You will not get the Flex recipe — expect to hand-wire this.laravel-doctrine/orm, that means registering the listener/subscriber on the entity manager's EventManager in a service provider. This is the step that most often trips people up; verify listeners are actually attached before assuming encryption is happening.encryption_key table. The README's make:migration generates the schema against the bundle's mapping. In Laravel you will hand-write the migration — the table stores per-row encrypted data keys. Run it before persisting any encrypted entity.#[EncryptedField], persist a row, then inspect the raw column in the database to confirm ciphertext, and reload the entity to confirm the plaintext round-trips. Only then expand.Declarative field encryption. Annotate entity properties and let the persistence layer handle the rest:
use Gebler\EncryptedFieldsBundle\Attribute\EncryptedField;
class Customer
{
#[EncryptedField]
private string $email;
#[EncryptedField(elements: ['ssn', 'dob'])]
private array $profile;
#[EncryptedField(useMasterKey: true)]
private string $internalToken;
#[EncryptedField(key: 'tenant_a_pii')]
private string $address;
}
You then use the entity through normal Doctrine repositories — no encryption calls in your code. Encryption fires on prePersist/preUpdate, decryption on hydration.
Event registration in Laravel. In a service provider, resolve the Doctrine EntityManager and attach the bundle's listener/subscriber to its EventManager. Keep the listener classes resolved through the container so the master key and cipher config are injected correctly.
Searchable data pattern (blind index). Because GCM ciphertext is non-deterministic, you cannot WHERE, ORDER BY, or LIKE an encrypted column. For anything you need to filter on, store a separate deterministic hash column (e.g. hash('sha256', $normalisedValue) with a per-tenant salt) alongside the ciphertext, and query that. Plan this into your migrations before you encrypt the column, because retrofitting it means decrypting the whole table.
Key rotation. The README documents gebler:encryption:rotate-key with --generate-new-key, --database-key, and --database-key-file. This is a Symfony console command; under Laravel you will invoke it via the Symfony bin/console kernel or port the logic into an Artisan command. Treat rotation as a scheduled, audited operation with a backup taken immediately beforehand. Version 2.0 fixed rotation for useMasterKey and custom key: fields — do not attempt rotation on 1.x.
Multi-tenant scoping. Use the key: option to isolate per-tenant keys where a tenant should not share a wrapping key with others, and ensure the encryption_key table rows are access-controlled per tenant if you do.
Testing. Pin a known test master key in phpunit.xml/test config, and include at least one assertion that reads the raw DB column and asserts it is not the plaintext. Round-trip tests alone will not catch a listener that silently failed to attach.
WHERE, ORDER BY, LIKE, GROUP BY, or unique constraints on encrypted columns. A TPM/architect should scope which fields can realistically be encrypted, because reworking every filter and report that touches those fields is often 5–10x the effort of the encryption work.preUpdate bug in 1.x caused silent data loss. Upgrade to 2.0 and read UPGRADE-2.0.md before doing anything else.UPDATE/DELETE, raw SQL, DBAL-level writes, and some partial-hydration paths bypass the listener and silently write plaintext into a column you believe is encrypted. Audit your write paths; for bulk operations, either avoid them on encrypted tables or encrypt explicitly before executing.VARCHAR columns and migrate typed columns (int, bool, date) to TEXT/BLOB — the ciphertext is not type-preserving. Do this migration before enabling encryption on existing data.encryption_key table access, and log rotation events. There is no HSM or FIPS 140-2 validation here.EntityManager; (3) confirm the attribute is on the real class, not a mapped superclass the listener doesn't resolve; (4) confirm the encryption_key table exists and the row was inserted; (5) check the cipher length matches what you configured.preUpdate flush with no field change will not produce new ciphertext — if you were relying on that side effect, update your expectations.cipher is configurable; if you move off aes-256-gcm, you re-validate the key length and lose the authenticated-encryption property unless your replacement provides it. Keep GCM unless you have a strong reason.How can I help you explore Laravel packages today?