gdbots/crypto-bundle
Symfony bundle that integrates Gdbots crypto services into your PHP application, providing configuration and dependency injection for encryption, signing, and key management. Simplifies wiring secure crypto components and related services.
Be clear-eyed about what this is: gdbots/crypto-bundle is a Symfony bundle, not a Laravel package. It exists to register defuse/php-encryption as a DI service (Keyring, key-loading, usually a CryptoManager wrapper and a key-generation console command) inside a Symfony kernel. You cannot $this->app->register() it — Laravel has no Bundle, no ContainerBagInterface, no Extension.
The v5.0.0 release changed nothing about that story, and made it slightly worse. The only change is a platform bump: Symfony 8.x and PHP 8.5+. That is a Symfony-LTS-tracking cadence, not a Laravel one, and it means the package now demands a PHP version most Laravel apps haven't adopted. Treat the bump as confirmation of the porting advice below, not as a reason to reconsider.
So the honest "getting started" is a port, and it takes about an hour:
src/DependencyInjection/*Extension.php (config shape — key paths, key ids, cipher settings), Resources/config/services.* (service ids and constructor args), the manager/wrapper class, then the README. That is the entire spec for how gdbots expects keys to be laid out on disk. You are copying conventions, not code.composer require defuse/php-encryption
As of v5.0.0 do not add gdbots/crypto-bundle to require-dev "just to keep the source around." Its constraints (php >=8.5, symfony/* ^8.0) are solved alongside your app's, and require-dev packages participate in that resolution. On a normal Laravel app you'll get a solver conflict at best and a forced PHP upgrade at worst. Clone the repo into a scratch directory or a separate reference project instead — you only need to read it.// app/Providers/CryptoServiceProvider.php
use Defuse\Crypto\Key;
use Defuse\Crypto\Keyring;
public function register(): void
{
$this->app->singleton(Keyring::class, function () {
$ring = new Keyring();
// first key added is the primary (used for encryption)
$ring->addKey(Key::loadFromAsciiSafeString(trim(config('crypto.keys.primary'))));
foreach (config('crypto.keys.previous', []) as $ascii) {
$ring->addKey(Key::loadFromAsciiSafeString(trim($ascii)));
}
return $ring;
});
}
Pin defuse/php-encryption to ^2.0 in composer.json — the bundle's major version and defuse's major version move independently, and the v5 bump says nothing about the crypto API you're actually calling.First use case: you have a sibling Symfony/CLI service (or a legacy gdbots job) writing defuse-encrypted blobs into a shared table or queue, and a Laravel app has to read them. That — decryption interop — is the only scenario where this package's conventions are worth copying. If you just need to encrypt a column of your own, Laravel's Crypt already does it and you should stop reading here.
Wrap the keyring in a single application service. Never let Crypto::encrypt() calls scatter across controllers. Add an envelope prefix so you can version and rotate:
final class Cipher
{
public function __construct(private Keyring $keyring) {}
public function encrypt(string $plaintext): string
{
return 'v1:'.base64_encode(Crypto::encrypt($plaintext, $this->keyring));
}
public function decrypt(string $payload): string
{
[$v, $body] = array_pad(explode(':', $payload, 2), 2, '');
$raw = base64_decode($body, true);
if ($raw === false) {
throw new DecryptException('Malformed ciphertext envelope.');
}
return Crypto::decrypt($raw, $this->keyring);
}
}
Eloquent cast for encrypted columns — encrypt on set, decrypt on get, and store the base64 envelope, never the raw binary:
public function set($model, $key, $value, $attributes)
{
return [$key => $value === null ? null : app(Cipher::class)->encrypt((string) $value)];
}
Rotation. defuse's Keyring adds new keys at the front and tries every key on decrypt, but it gives you no way to ask "which key encrypted this?" So put the key id in the envelope yourself (v2:k3:...) if you want cheap lazy re-encryption on read. Otherwise, rotate by writing new data with the new primary key and running a batched chunkById job that decrypts and re-encrypts old rows.
Blind indexes for lookups. defuse is non-deterministic — the same plaintext yields different ciphertext every time, so you cannot WHERE email_encrypted = ?. If you need to search, store a separate hash_hmac('sha256', mb_strtolower(trim($v)), $indexKey) column and query that. Keep the index key distinct from the encryption key.
Migrating off Laravel Crypt. Dual-read during rollout: try Cipher::decrypt(), catch the exception, fall back to Crypt::decryptString() with the legacy APP_KEY, and re-encrypt on the next write. Only drop the fallback after a full backfill.
Where Symfony's convenience lives, replace it. The bundle's console command for generating keys becomes an Artisan command:
$this->line(Key::createNewRandomKey()->saveToAsciiSafeString());
Print it once to a secret manager, never to a file in the repo.
^5 will drag PHP 8.5 into your build matrix the moment CI resolves fresh.Crypto::encrypt() returns raw bytes. Piping that into MySQL utf8mb4 or JSON silently corrupts it and you get WrongKeyOrModifiedCiphertextException later with no clue why. Always base64 or hex at the storage boundary — Crypto::binToBase64() exists for this.TEXT/LONGTEXT, and never truncate the envelope.WrongKeyOrModifiedCiphertextException extends CryptoException — log with row id and table, return 422/null. Do not let it bubble into an error page that leaks the payload.encryptWithPassword() for application data. PBKDF2 is deliberately slow (fine once per login, disastrous per-row in a query loop) and a password is a single shared secret you can't rotate per key. Use Key/Keyring.trim() the env value (see the provider above — a trailing newline in a .env heredoc is the classic offender), or better, load the ASCII string from a mounted secret file with 0600 permissions outside the webroot and chmod-check at boot. Fail fast in the provider via Key::loadFromAsciiSafeString() — a malformed key throws CryptoException at container resolution, which is exactly when you want to find out.assertSame($plain, $cipher->decrypt($cipher->encrypt($plain))), and write one test that decrypts a fixture blob produced by the real Symfony service — that is your interop regression test. Check that fixture in; it's the only thing that proves the two sides still agree after either of you upgrades.composer.json should tell you how much of your attention the project commands.How can I help you explore Laravel packages today?