Migrating a Laravel application’s encrypted configurations between servers often leads to unexpected failures, even when following standard backup and restore procedures. The root cause isn’t always obvious—it stems from how Laravel handles encryption keys and caching during the process. A recent update to the laravel-config-backup package addressed these challenges, but the journey to the fix revealed a subtle yet critical issue that could affect any Laravel deployment involving encrypted data.
Why Laravel’s APP_KEY breaks naive config migrations
Laravel encrypts sensitive data using the APP_KEY defined in the .env file. This key secures everything from Eloquent model casts to cookies and sessions. When you attempt to migrate an application’s database or configuration to a new server, the encrypted values stored in the original environment cannot be decrypted on the destination server if the APP_KEY differs. For example, a MySQL dump containing encrypted columns will produce ciphertext that appears corrupted when imported into a server with a different key.
The laravel-config-backup package circumvents this problem by exporting the database in a decrypted state. During export, encrypted columns are processed through their model casts, converting them into plaintext values stored within a password-protected ZIP archive. When restoring, the package imports the data back through the model layer, re-encrypting it with the destination server’s APP_KEY. This approach ensures that encrypted values remain functional regardless of the key change.
Think of this process as disassembling furniture before moving it through a narrow doorway. You wouldn’t try to carry a fully assembled wardrobe through a space it doesn’t fit; instead, you flatten it, transport it, and reassemble it at the destination using the tools available there.
The correct restore sequence to preserve encrypted data
A successful restore operation must follow a strict order to avoid data corruption. The laravel-config-backup package implements this sequence programmatically. Here’s how it works:
public function restore(string $absZipPath, string $password, array $sections, int|string|null $userId = null): array
{
// 1. Create a safety backup of the current configuration before making any changes.
$safety = $this->create(
ConfigBackupSection::values(),
$password,
'Automatic pre-restore safety backup',
$userId,
isSafety: true,
);
$zip = $this->openArchive($absZipPath, $password); // Validates the password.
$appKeyChanged = false;
// 2. Restore the .env file FIRST.
// If the APP_KEY changes, update the active encrypter so subsequent database re-encryption uses the new key.
if ($this->wants($sections, ConfigBackupSection::ENV) && /* conditions */) {
$oldKey = (string) config('app.key');
File::put(base_path('.env'), $newEnv);
$newKey = Env::parse($newEnv)['APP_KEY'] ?? $oldKey;
if ($newKey !== '' && $newKey !== $oldKey) {
$this->useEncryptionKey($newKey);
$appKeyChanged = true;
}
}
// 3. Restore database settings — now re-encrypted with the active key.
// ...
ConfigRestored::dispatch($restored, $databaseSummary, $appKeyChanged, $safety->uuid);
return [
'safety_backup' => $safety->uuid,
'restored' => $restored,
'database' => $databaseSummary,
'app_key_changed' => $appKeyChanged,
];
}The .env file must be restored before the database. Restoring the database first would re-encrypt the data using the old key, and then changing the key afterward would leave the data unreadable. This sequence ensures that the final APP_KEY is active when the data is written back to the database.
The hidden bug: stale encryption cache
Despite following the correct restore order, tests initially failed because of an undetected caching issue. The problem arose during the creation of the safety backup in step one. When backing up encrypted rows, Laravel resolves the encrypter to read the plaintext values. Laravel caches this resolved encrypter instance, which means it holds a reference to the original APP_KEY.
Even after updating the APP_KEY in step two, the cached encrypter continued to use the old key. When the database was restored in step three, the model casts attempted to re-encrypt the data using a stale encrypter instance, resulting in ciphertext that could not be decrypted with the new key. The configuration repository was updated, but the singleton instance of the encrypter remained unchanged.
This scenario illustrates a classic caching pitfall: the value you modified and the value being used are no longer in sync. Updating the configuration via config(['app.key' => ...]) changes the repository, but the already-resolved encrypter singleton remains oblivious to that change.
The fix required not only updating the key but also clearing the resolved facade instance. This forces the next use of the encrypter to resolve it anew with the updated key:
protected function useEncryptionKey(string $appKey): void
{
$key = $this->parseKey($appKey); // Strip base64: prefix and decode.
$cipher = config('app.cipher', 'AES-256-CBC');
Config::set('app.key', $appKey);
app()->instance('encrypter', new Encrypter($key, $cipher));
Crypt::clearResolvedInstance('encrypter'); // Force re-resolution on next use.
}Without clearing the resolved instance, the "portable" backup would silently fail, producing data that appeared correct but was encrypted with the wrong key.
Designing tests that expose the failure
This class of bug is notoriously difficult to detect with casual testing. A simple test that restores data and immediately reads it back might pass because the encrypter cache is still warm. The failure only becomes apparent when something reads encrypted data before the key is updated—exactly what the safety backup does in production.
The regression test must replicate this exact sequence: write a value under the original key, create a backup, restore an archive containing a different key, and then verify that the restored data decrypts correctly with the new key and fails with the old one.
it('re-encrypts database settings under the restored APP_KEY', function () {
// Store a value encrypted with the original key.
Setting::create(['key' => 'smtp.password', 'value' => 'Portable']);
$path = ConfigBackup::backup(['env', 'database'], 'secret-pass');
// Archive contains a different APP_KEY in its .env.
$result = ConfigBackup::restore($path, 'secret-pass', ['env', 'database']);
expect($result['app_key_changed'])->toBeTrue();
// The raw column must decrypt with the new key and fail with the old one.
$raw = DB::table('settings')->where('key', 'smtp.password')->value('value');
expect(Crypt::decryptString($raw))->toBe('Portable');This ensures that the restore process not only updates the configuration but also correctly re-encrypts the data for the new environment.
Moving forward, developers should exercise caution when handling encrypted configurations during migrations. Always validate the restore process under conditions that simulate production—especially when the APP_KEY changes. The lessons from this bug extend beyond Laravel, highlighting the importance of managing stateful dependencies in cryptographic operations.
AI summary
Laravel projelerinizdeki verileri farklı APP_KEY’lere sahip sunucular arasında nasıl güvenle yedekleyip restore edebilirsiniz? Detaylı adımlar ve en iyi uygulamalar.