Chiffrement transparent d’attributs Eloquent avec un Custom Cast
Chiffrement Eloquent Laravel: cast personnalisé pour crypter/décrypter téléphone et IBAN, sans toucher à la logique métier, recherche via hash auxiliaire
Sommaire
Chiffrement transparent d’attributs Eloquent avec un Custom Cast
Quand tu manipules des données sensibles dans Laravel (numéro de téléphone, IBAN, NIR…), les stocker en clair dans la base est une vraie prise de risque. Ce tutoriel te montre comment chiffrer automatiquement ces attributs “au repos” avec un Custom Cast Eloquent, sans changer ta logique métier. Tu verras aussi comment requêter par égalité via un hash auxiliaire indexable, gérer des tableaux/objets, écrire des tests, et même faire une rotation de clé sans downtime.
Objectif
L’objectif est de créer un Cast Eloquent qui chiffre et déchiffre automatiquement certaines colonnes. Par exemple, en affectant $user->phone = '0600000000', la valeur en clair reste disponible côté PHP, mais c’est un ciphertext qui sera persisté en base. Pour permettre des recherches d’égalité (et éventuellement des contraintes d’unicité) sans exposer la donnée sensible, nous calculerons un hash auxiliaire (SHA-256) indexable.
Objectif et périmètre
Nous allons chiffrer au repos des attributs sensibles sans toucher aux contrôleurs et services existants. Aucun package externe n’est nécessaire : on s’appuie sur Crypt, le chiffrement natif de Laravel. Les capacités de recherche seront limitées à l’égalité stricte grâce à une colonne hash auxiliaire, ce qui est cohérent avec une stratégie de chiffrement robuste au repos. Pas de LIKE, pas de tri ni de recherches partielles sur la donnée chiffrée.
Préparation: migration et configuration
Commence par sécuriser la clé d’application. Dans .env, APP_KEY doit être défini et gardé secret. Si ce n’est pas le cas, génère-en une nouvelle:
php artisan key:generate
Côté schéma, choisis un type de colonne capable d’accueillir un ciphertext (plus long que la donnée claire). Une colonne TEXT est adaptée pour éviter toute troncature. Ajoute à côté une colonne pour le hash indexable, par exemple SHA-256 hexadécimal sur 64 caractères.
Exemple de migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Colonne chiffrée
$table->text('phone')->nullable();
// Hash auxiliaire indexable (et potentiellement unique)
$table->string('phone_hash', 64)->nullable()->index();
// ->unique(); // décommente si tu veux imposer l'unicité
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['phone', 'phone_hash']);
});
}
};
Après migration, ta base est prête à recevoir des ciphertexts sans fuite de données ni troncature.
Créer le Cast Encrypted
Génère le squelette du Cast:
php artisan make:cast Encrypted --implements
Implémente un premier Cast “strict” qui accepte uniquement des scalaires (string/int/float/bool) ou des objets stringables. Il retournera null pour les valeurs null, et utilisera Crypt::encryptString et Crypt::decryptString pour la persistance.
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use InvalidArgumentException;
class Encrypted implements CastsAttributes
{
/**
* @param mixed $value Ciphertext en base
* @return string|null Valeur en clair dans PHP
*/
public function get($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
try {
return Crypt::decryptString($value);
} catch (DecryptException $e) {
// Échec de déchiffrement: on échoue vite pour ne pas masquer le problème
throw $e;
}
}
/**
* @param mixed $value Valeur en clair dans PHP
* @return string|null Ciphertext à persister
*/
public function set($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
return Crypt::encryptString((string) $value);
}
throw new InvalidArgumentException("Encrypted cast accepte uniquement des scalaires/stringables pour {$key}.");
}
}
Ce Cast couvre 90% des cas (ex. téléphone, IBAN, email), avec un comportement prévisible et simple à tester.
Brancher le Cast sur un modèle
Déclare le Cast sur le modèle concerné. Tu peux aussi masquer la propriété pour éviter toute fuite accidentelle dans des réponses JSON.
<?php
namespace App\Models;
use App\Casts\Encrypted;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email', 'phone', 'phone_hash'];
protected $casts = [
'phone' => Encrypted::class,
];
// Optionnel: ne jamais exposer la donnée brute dans les ressources API
protected $hidden = [
// 'phone',
];
}
Dès lors, l’affectation et la lecture sont transparentes:
$user = User::find(1);
$user->phone = '0600000000';
$user->save(); // phone chiffré en base
$clair = $user->phone; // "0600000000" en PHP
Pour vérifier la valeur brute stockée, utilise getRawOriginal:
$ciphertext = $user->getRawOriginal('phone'); // ciphertext illisible
Supporter JSON/objets (cast générique)
Si tu dois chiffrer des tableaux/objets, sérialise-les en JSON avant chiffrement, puis tente de les re-désérialiser à la lecture. L’idée est d’écrire un Cast qui gère à la fois string et “JSON-like”.
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use JsonException;
class EncryptedJsonOrString implements CastsAttributes
{
/**
* @return array|string|null Retourne un array si JSON valide, sinon la string d'origine (ou null)
*/
public function get($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
try {
$plain = Crypt::decryptString($value);
} catch (DecryptException $e) {
throw $e;
}
// Si PHP >= 8.3: json_validate est exact et rapide.
if (function_exists('json_validate') && json_validate($plain)) {
try {
return json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
// En cas d'erreur, on retombe sur la string
return $plain;
}
}
// Fallback raisonnable si PHP < 8.3
if (self::looksLikeJson($plain)) {
$decoded = json_decode($plain, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
return $plain;
}
/**
* @param mixed $value string|array|object|null
* @return string|null
*/
public function set($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
if (is_array($value) || is_object($value)) {
$payload = json_encode($value, JSON_THROW_ON_ERROR);
return Crypt::encryptString($payload);
}
return Crypt::encryptString((string) $value);
}
private static function looksLikeJson(string $s): bool
{
$s = trim($s);
return ($s !== '' && (
($s[0] === '{' && str_ends_with($s, '}')) ||
($s[0] === '[' && str_ends_with($s, ']'))
));
}
}
Documente bien le type retourné par le Cast (array ou string) pour réduire toute ambiguïté côté appelant. Par exemple, si tu castes address en EncryptedJsonOrString, code comme si address pouvait être soit un tableau (décodé), soit une string.
Rechercher/contraintes d’unicité via un hash auxiliaire
Pour requêter sans déchiffrer ni dégrader la sécurité, calcule un hash auxiliaire. Avec SHA-256, tu obtiens une valeur de longueur fixe, indexable, et stable.
Ajoute un hook saving sur le modèle pour maintenir le hash à jour:
<?php
namespace App\Models;
use App\Casts\Encrypted;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email', 'phone', 'phone_hash'];
protected $casts = [
'phone' => Encrypted::class,
];
protected static function booted(): void
{
static::saving(function (self $model) {
$plain = $model->phone; // valeur en clair via le Cast
$model->phone_hash = is_null($plain) ? null : hash('sha256', $plain);
});
}
}
Tu peux alors requêter ou imposer une contrainte d’unicité sur phone_hash:
// Requête d’égalité exacte
$user = User::where('phone_hash', hash('sha256', '0600000000'))->first();
// Contrainte d’unicité (si ajoutée en migration)
// $table->string('phone_hash', 64)->nullable()->unique();
Ce mécanisme n’autorise pas les recherches partielles, ni les tris, ni les LIKE — c’est attendu et cohérent avec un chiffrement robuste.
Validation et sérialisation
Valide la donnée avant persistance, exactement comme tu le fais habituellement, puisque côté PHP tu manipules la valeur en clair.
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserController
{
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['required', 'string', 'min:8'],
]);
$user = User::create($validated);
return response()->json([
'id' => $user->id,
], 201);
}
}
Pour l’exposition via API, tu peux soit masquer complètement le champ, soit présenter une version masquée. Un accessor est pratique pour offrir les deux possibilités.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
// ...
public function getMaskedPhoneAttribute(): ?string
{
$plain = $this->phone;
if ($plain === null) {
return null;
}
$len = strlen($plain);
if ($len <= 4) {
return str_repeat('•', $len);
}
return str_repeat('•', max(0, $len - 4)) . substr($plain, -4);
}
}
Dans une ressource API, retourne masked_phone au lieu de phone pour éviter toute fuite accidentelle.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'phone' => $this->masked_phone, // pas de donnée sensible en clair
];
}
}
Tests rapides
Teste que la valeur n’est pas stockée en clair, que le round-trip fonctionne, et que la recherche via hash est opérationnelle.
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EncryptedAttributesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function phone_is_encrypted_and_searchable_by_hash(): void
{
$user = User::factory()->create([
'phone' => '0600000000',
]);
// La BDD ne doit pas contenir la valeur en clair
$raw = $user->getRawOriginal('phone');
$this->assertIsString($raw);
$this->assertNotSame('0600000000', $raw);
// Round-trip en clair via Eloquent
$this->assertSame('0600000000', $user->fresh()->phone);
// Recherche par hash auxiliaire
$exists = User::where('phone_hash', hash('sha256', '0600000000'))->exists();
$this->assertTrue($exists);
}
}
Ce test valide le comportement attendu du Cast, du hook de hash et de la recherche.
Rotation de clé sans downtime (versionner le ciphertext)
Pour changer de clé de chiffrement en production sans downtime, versionne le ciphertext. Préfixe la valeur chiffrée avec la version, écris avec la nouvelle version, lis les deux, puis réécris progressivement.
Commence par définir des clés versionnées. Génére des clés 32 octets (AES-256-CBC) et base64:
php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
Dans .env:
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_KEY_V1=base64:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
APP_KEY_V2=base64:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
APP_KEY_WRITE=v2
Crée config/encryption.php pour centraliser ces versions:
<?php
return [
'cipher' => config('app.cipher', 'AES-256-CBC'),
'keys' => [
'v1' => env('APP_KEY_V1'),
'v2' => env('APP_KEY_V2'),
],
// Version utilisée pour écrire les nouveaux ciphertexts
'write' => env('APP_KEY_WRITE', 'v2'),
];
Implémente un Cast “versionné” qui sait lire v1 et v2, et écrit en v2:
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use RuntimeException;
class VersionedEncrypted implements CastsAttributes
{
private array $encrypters = [];
private string $writeVersion;
public function __construct()
{
$cipher = config('encryption.cipher', config('app.cipher', 'AES-256-CBC'));
$keys = (array) config('encryption.keys', []);
$this->writeVersion = (string) config('encryption.write', 'v2');
foreach ($keys as $version => $key) {
if (!$key) {
continue;
}
$this->encrypters[$version] = new Encrypter($this->keyToBytes($key), $cipher);
}
}
public function get($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
[$ver, $ciphertext] = $this->parseVersioned($value);
$encrypter = $this->encrypters[$ver] ?? null;
if (!$encrypter) {
throw new RuntimeException("Aucune clé configurée pour la version {$ver}.");
}
return $encrypter->decryptString($ciphertext);
}
public function set($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
$encrypter = $this->encrypters[$this->writeVersion] ?? null;
if (!$encrypter) {
throw new RuntimeException("Aucune clé d'écriture pour la version {$this->writeVersion}.");
}
$payload = is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))
? (string) $value
: json_encode($value, JSON_THROW_ON_ERROR);
$ciphertext = $encrypter->encryptString($payload);
return "{$this->writeVersion}:{$ciphertext}";
}
private function parseVersioned(string $value): array
{
$pos = strpos($value, ':');
// Backward-compat: si pas de préfixe, considère v1
if ($pos === false) {
return ['v1', $value];
}
return [substr($value, 0, $pos), substr($value, $pos + 1)];
}
private function keyToBytes(string $key): string
{
return Str::startsWith($key, 'base64:')
? base64_decode(substr($key, 7))
: $key;
}
}
Processus recommandé:
- Déploie le Cast versionné en écriture v2, mais capable de lire v1 et v2. Les nouveaux enregistrements utilisent v2, les anciens continuent de fonctionner.
- Lance un job de réécriture par lots qui relit et réécrit la valeur pour passer de v1 à v2. Exemple:
use App\Models\User;
use Illuminate\Support\Facades\Log;
User::whereNotNull('phone')->chunkById(100, function ($users) {
foreach ($users as $user) {
try {
// Lecture en clair (quel que soit v1/v2), puis réécriture en v2
$plain = $user->phone;
$user->phone = $plain;
$user->saveQuietly();
} catch (\Throwable $e) {
Log::warning('Rotation échouée pour user', [
'id' => $user->id,
'error' => $e->getMessage(),
]);
}
}
});
- Une fois la migration terminée et observée, retire le support v1 côté code et variables d’environnement.
Surveille particulièrement les exceptions de déchiffrement (DecryptException). Elles signalent soit des données corrompues, soit des enregistrements non encore migrés avec une clé manquante.
Performance et limites
Le déchiffrement ne s’opère que lorsque tu accèdes à l’attribut. Pour éviter du travail inutile, sélectionne explicitement les colonnes nécessaires quand tu n’as pas besoin des champs chiffrés. Par exemple, pour paginer une liste d’utilisateurs, contente-toi des métadonnées non sensibles:
$users = \App\Models\User::select('id', 'name', 'email')->paginate(50);
N’essaie pas d’indexer ou de trier sur la colonne chiffrée: c’est impossible, et c’est précisément à cela que sert la colonne hash auxiliaire (pour des égalités exactes). Le ciphertext étant plus volumineux que la donnée claire, préfère un type TEXT au lieu d’un VARCHAR court, et vérifie la taille maximale des paquets si tu utilises MySQL avec de gros enregistrements.
Enfin, sauvegardes et restaurations: assure-toi que APP_KEY et, en cas de versionnement, les anciennes clés restent disponibles dans l’environnement de restauration. Sans elles, le déchiffrement échouera.
Checklist
Avant de livrer, prends le temps de relire le code du Cast, des modèles et des migrations pour confirmer que les null sont gérés correctement, que les exceptions sont traitées, et que les colonnes ont les bons types. Exécute localement les quelques snippets ci-dessus (artisan make:cast, migration, tests) pour vérifier que tout passe dans ton contexte. Une fois validé, publie en incrémentant progressivement: déploiement du Cast, ajout de la colonne hash, rotation de clé si nécessaire, puis mises à jour de l’API pour exposer des champs masqués plutôt que des valeurs en clair.
Ressources
Pour approfondir, consulte la documentation officielle de Laravel sur le chiffrement (https://laravel.com/docs/encryption), sur les casts personnalisés (https://laravel.com/docs/eloquent-mutators#custom-casts), sur les migrations (https://laravel.com/docs/migrations), et sur la validation (https://laravel.com/docs/validation). La fonction json_validate introduite en PHP 8.3 est documentée ici: https://www.php.net/manual/fr/function.json-validate.php.
Conclusion
Avec un Custom Cast, tu obtiens un chiffrement transparent des colonnes sensibles sans perturber ta logique métier. La combinaison colonne chiffrée + hash auxiliaire couvre les besoins courants: sécurité au repos, égalité indexable et validation standard côté application. En ajoutant une stratégie de rotation de clé versionnée, tu peux faire évoluer tes secrets sans downtime ni coupes franches. Il ne te reste qu’à intégrer progressivement ces patterns dans tes modèles cibles et à monitorer tes jobs de migration.