This is the first article in a series showcasing the most important new features introduced by Symfony 8.2, which will be released at the end of November 2026.


Florent Morselli
Contributed by Florent Morselli in #64052

Many applications need to encrypt their own data: database columns with personal information, uploaded documents, API tokens, etc. It's common for organizations to keep the keys for that in a managed Key Management System (KMS) such as AWS KMS, Azure Key Vault, Google Cloud KMS or HashiCorp Vault. In those systems, the master key never leaves the provider and every use of it is audited. The downside is that each provider ships its own SDK and encryption process.

Symfony 8.2 introduces the new KeyManagement component to abstract all of them behind a single API. The core package (symfony/key-management) provides the interfaces, the envelope encryption and local backends based on libsodium and OpenSSL for development and tests. Each remote provider lives in its own bridge package: symfony/aws-key-management, symfony/azure-keyvault-key-management, symfony/google-cloud-key-management and symfony/hashicorp-vault-key-management. The component is experimental, so its API may still change in minor versions.

Basic Usage

A KMS client encrypts, decrypts and generates data keys, and you can use it in two ways. Direct mode sends the payload to the KMS and gets the ciphertext back, so it's limited to small values (4 KB on AWS KMS) such as tokens. Envelope mode asks the KMS for a fresh data key, encrypts the payload locally with AES-256-GCM and stores that data key, wrapped by the master key, inside the resulting Envelope. The data never leaves your application and it can be of any size:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Symfony\Component\KeyManagement\Envelope;
use Symfony\Component\KeyManagement\EnvelopeEncrypter;
use Symfony\Component\KeyManagement\KeyLoader\InMemoryKeyLoader;
use Symfony\Component\KeyManagement\Local\SodiumKms;

// a local libsodium backend; in production, use one of the cloud bridges
$kms = new SodiumKms(new InMemoryKeyLoader([
    'app-key' => sodium_crypto_aead_xchacha20poly1305_ietf_keygen(),
]));

// direct mode: short payloads, the KMS sees the plaintext
$ciphertext = $kms->encrypt('app-key', $apiToken);
$apiToken = $kms->decrypt($ciphertext);

// envelope mode: any size, the KMS only sees the wrapped data key
$encrypter = new EnvelopeEncrypter($kms);
$envelope = $encrypter->encrypt('app-key', $fileContents);
file_put_contents($path, $envelope);

$fileContents = $encrypter->decrypt(Envelope::fromBytes(file_get_contents($path)));

Your code never handles the master key itself, only its identifier (an alias, an ARN, a key URL, etc.) and the same envelope decrypts identically no matter which backend produced it, as long as that master key is reachable.

Symfony Integration

In Symfony applications, define one or more clients with DSNs under the new key_management configuration option:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/packages/key_management.yaml
key_management:
    clients:
        # e.g. aws-kms://default?region=eu-west-1
        aws: '%env(AWS_KMS_DSN)%'
        # e.g. hashicorp-vault-transit://TOKEN@vault.example.com:8200
        vault: '%env(VAULT_KMS_DSN)%'
    default_client: aws

when@dev:
    # keys are inlined, base64url-encoded, so no external service is needed
    key_management:
        clients:
            aws: 'sodium://?keys[app-key]=%env(DEV_KMS_KEY)%'
            vault: 'sodium://?keys[app-key]=%env(DEV_KMS_KEY)%'

Then, inject the EnvelopeEncrypterInterface (or EncrypterInterface and DecrypterInterface for the direct mode) in your services. Use the #[Target] attribute to pick a client other than the default one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\KeyManagement\EnvelopeEncrypterInterface;

class DocumentStorage
{
    public function __construct(
        private EnvelopeEncrypterInterface $encrypter,
        #[Target('vault')]
        private EnvelopeEncrypterInterface $vaultEncrypter,
    ) {
    }

    // ...
}

The component also adds four console commands:

1
2
3
4
$ php bin/console key-management:encrypt
$ php bin/console key-management:decrypt
$ php bin/console key-management:generate-data-key
$ php bin/console key-management:rewrap-data-keys

The first two read STDIN and write to STDOUT, so you can pipe them to re-encrypt an envelope under another key. In the dev environment, a new panel in the web debug toolbar shows the calls made to each KMS client.

Encrypting Doctrine Entity Contents

Two extra bridges integrate the component with Doctrine: symfony/doctrine-dbal-key-management provides an EncryptedType that decorates any Doctrine type with column-level envelope encryption, and symfony/doctrine-orm-key-management adds a #[BlindIndexed] attribute to make those columns searchable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\BlindIndex\Email;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\BlindIndexed;

#[ORM\Entity]
class User
{
    #[ORM\Column(type: 'encrypted_string')]
    private string $email = '';

    #[ORM\Column(length: 64)]
    #[BlindIndexed('email', Email::class)]
    private string $emailIndex = '';

    // ...
}

// in a repository, look the row up by the digest of the value
$user = $this->findOneBy(['emailIndex' => $this->emailBlindIndex->of($email)]);

The blind index is needed because encryption is randomized: two rows with the same value store different ciphertexts, so WHERE email = ? never matches. The index column stores a keyed digest of the value instead, and the ORM bridge fills it automatically on every flush. The Email index is a service that needs a KMS client and a wrapped key created with key-management:generate-data-key.

The encrypted_string type name is yours to declare: list the type names in an EncryptedTypes service (each one mapped to the Doctrine type it wraps and to the key it encrypts under) and call its register() method from the kernel's boot() method, so the types exist before the first query.

The DBAL bridge can also keep the data keys in a database table (see the key_management.store option) so each encrypted row carries a 16-byte reference instead of a full wrapped key, and the KMS is called once per data key instead of once per row. This is what makes the key-management:rewrap-data-keys command possible: moving your data to another master key or even another provider only rewraps the stored keys, without reading or rewriting a single encrypted row.

Read the KeyManagement documentation to learn about the available DSNs, the key loaders, data key rotation and the other options of this new component.

Published in #Living on the edge