Le RGPD n’est pas qu’une affaire de juristes. Une politique de confidentialité impeccable ne protège personne si le code charge un pixel de tracking avant tout consentement, si les logs applicatifs conservent des adresses IP pendant des années, ou si un DELETE sur un utilisateur laisse des dizaines de lignes orphelines dans les tables liées. Ce guide s’adresse aux développeurs PHP/Symfony et couvre cinq chantiers concrets : consentement, durées de conservation, droit à l’oubli avec Doctrine, chiffrement des données sensibles et DPA avec les sous-traitants techniques.
Le consentement n’est valide que s’il bloque réellement le chargement des scripts
L’erreur la plus fréquente consiste à afficher un bandeau cookies qui ne bloque rien : les scripts Google Analytics, Facebook Pixel ou un chat tiers sont déjà chargés dans le <head> avant que l’utilisateur n’ait cliqué sur quoi que ce soit. Juridiquement, cela ne constitue pas un consentement préalable — le traitement a déjà commencé. Le développeur doit implémenter un blocage réel, pas un simple bandeau cosmétique.
Le principe technique correct :
- Aucun script non essentiel (analytics, publicité, réseaux sociaux) n’est chargé au premier rendu de la page.
- Le bandeau de consentement s’affiche et bloque l’exécution.
- Seul un clic explicite sur « Accepter » (catégorie par catégorie, pas un bouton unique tout-ou-rien) déclenche le chargement dynamique du script correspondant.
- Le choix est stocké côté client (cookie ou
localStorage) avec une date, et redemandé après une durée définie (généralement 6 à 13 mois selon les recommandations de la CNIL).
Dans une application Symfony, cela se traduit généralement par un service de rendu conditionnel plutôt qu’un simple <script> statique dans le layout Twig :
{# base.html.twig #}
{% if consent_manager.hasConsent('analytics') %}
<script src="https://www.googletagmanager.com/gtag/js?id=..."></script>
{% endif %}
<div id="cookie-consent-banner" data-controller="consent"></div>
Le service ConsentManager lit le cookie de consentement côté serveur (utile pour le rendu initial et pour ne jamais injecter le script tiers dans le HTML si le refus est déjà connu) :
final class ConsentManager
{
public function __construct(private readonly RequestStack $requestStack) {}
public function hasConsent(string $category): bool
{
$request = $this->requestStack->getCurrentRequest();
$raw = $request?->cookies->get('consent_categories');
if ($raw === null) {
return false; // pas de consentement connu = refus par défaut
}
$categories = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
return in_array($category, $categories, true);
}
}
Pour les sites qui vendent de l’espace publicitaire ou passent par des SSP/DSP programmatiques, une gestion manuelle ne suffit généralement plus : le standard TCF (Transparency and Consent Framework) de l’IAB Europe normalise la façon dont le consentement est recueilli, encodé dans une chaîne de caractères (TC String) et transmis à l’ensemble des fournisseurs publicitaires partenaires. Intégrer le TCF dans une application Symfony revient le plus souvent à déléguer l’interface de collecte à une CMP (Consent Management Platform) certifiée IAB, puis à lire côté serveur la TC String déposée en cookie pour conditionner le rendu de certains blocs publicitaires ou le déclenchement d’appels API vers des DSP. Ne réimplémentez pas vous-même le parsing du TC String : la spécification est complexe et évolue régulièrement, mieux vaut s’appuyer sur une CMP existante ou une bibliothèque maintenue.
Logs applicatifs et durées de conservation : ce qui doit expirer, et quand
Le RGPD impose une durée de conservation proportionnée à la finalité du traitement — pas une conservation indéfinie « au cas où ». Concrètement, cela concerne au minimum trois catégories de données dans une application Symfony : les logs applicatifs, les données de compte utilisateur inactif, et les journaux de connexion — un sujet à couvrir aussi par des tests end-to-end automatisés pour vérifier que les purges planifiées s’exécutent réellement en production.
| Type de donnée | Durée usuelle recommandée | Mécanisme technique |
|---|---|---|
| Logs applicatifs (Monolog) | 30 à 90 jours | Rotation automatique du handler, purge planifiée |
| Adresses IP dans les logs d’accès | 6 à 13 mois | Anonymisation partielle ou purge programmée |
| Compte utilisateur inactif | Défini contractuellement (souvent 2 à 3 ans) | Commande planifiée de détection + notification puis suppression |
| Données de panier abandonné | Quelques mois | Purge planifiée, pas de conservation indéfinie |
| Logs de connexion / audit sécurité | Généralement 1 an, selon obligations sectorielles | Archivage séparé, accès restreint |
Pour les logs Monolog, la rotation par taille ou par nombre de fichiers ne suffit pas à garantir une durée de conservation en jours. Utilisez un handler avec rotation temporelle explicite, ou déclenchez une purge par une commande planifiée :
#[AsCommand(name: 'app:gdpr:purge-logs')]
final class PurgeOldLogsCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$cutoff = new \DateTimeImmutable('-90 days');
$finder = (new Finder())->files()
->in('%kernel.logs_dir%')
->date('until ' . $cutoff->format('Y-m-d'));
foreach ($finder as $file) {
unlink($file->getRealPath());
}
$output->writeln(sprintf('Logs antérieurs au %s purgés.', $cutoff->format('Y-m-d')));
return Command::SUCCESS;
}
}
Cette commande est ensuite planifiée via cron ou le Scheduler de Symfony, exécutée quotidiennement. La même logique s’applique aux données métier : une entité User inactive depuis longtemps doit déclencher une notification préalable, puis une anonymisation ou une suppression, selon la politique de rétention définie avec le métier — un point qui doit être arbitré en amont, pas décidé seul par le développeur au moment du code.
Attention également aux logs d’erreur : une stack trace mal filtrée peut contenir un mot de passe en clair envoyé dans un formulaire, un token de session ou un email complet dans un message d’exception. Configurez le PSR-3 processor de Monolog pour masquer les champs sensibles avant écriture, plutôt que de les nettoyer a posteriori.
Droit à l’oubli : la suppression en cascade Doctrine ne se limite pas à $em->remove()
C’est le point le plus souvent bâclé techniquement. Un utilisateur demande la suppression de son compte, le développeur exécute $entityManager->remove($user); $entityManager->flush();, et considère la demande traitée. En réalité, si les relations Doctrine ne sont pas correctement configurées, cette opération peut :
- échouer avec une violation de contrainte de clé étrangère si aucune stratégie de cascade n’est définie ;
- réussir silencieusement en laissant des lignes orphelines dans des tables sans relation ORM explicite (tables de log, tables créées par une migration brute, tables d’un module tiers) ;
- supprimer des données qui ne devraient pas l’être immédiatement (factures, données comptables soumises à une obligation de conservation légale distincte du RGPD).
La bonne pratique consiste à définir explicitement le comportement de cascade sur chaque relation Doctrine concernée, plutôt que de compter sur un comportement par défaut :
#[ORM\Entity]
class User
{
#[ORM\OneToMany(
mappedBy: 'user',
targetEntity: Order::class,
cascade: ['persist'],
orphanRemoval: false // les commandes restent pour l'obligation comptable
)]
private Collection $orders;
#[ORM\OneToMany(
mappedBy: 'user',
targetEntity: UserPreference::class,
cascade: ['persist', 'remove'],
orphanRemoval: true // supprimé avec l'utilisateur
)]
private Collection $preferences;
#[ORM\OneToMany(
mappedBy: 'user',
targetEntity: SessionLog::class,
cascade: ['remove'],
orphanRemoval: true
)]
private Collection $sessionLogs;
}
Pour les données qui doivent légalement survivre (factures, écritures comptables), la solution n’est pas la suppression mais l’anonymisation : conserver la ligne de facturation avec son montant et sa date, mais remplacer les champs identifiants (nom, email, adresse) par des valeurs neutres. C’est une opération distincte de la suppression, à modéliser explicitement dans un service dédié plutôt que de la mélanger avec la cascade Doctrine standard :
final class GdprAnonymizationService
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function forgetUser(User $user): void
{
// 1. Suppression réelle des données sans obligation de conservation
$this->em->remove($user); // cascade Doctrine gère préférences, logs, tokens
// 2. Anonymisation des enregistrements soumis à obligation légale
foreach ($user->getOrders() as $order) {
$order->anonymizeCustomerData(); // méthode métier sur l'entité Order
}
$this->em->flush();
}
}
Un point souvent oublié : les tables créées par des migrations brutes (ALTER TABLE manuel, table de staging, table héritée d’un ancien système) échappent souvent au mapping Doctrine et donc à toute cascade. Un audit ponctuel des tables de la base, comparé aux entités mappées, permet de repérer les tables orphelines qui contiennent potentiellement des données personnelles non couvertes par le processus de suppression.
le guide d’air-web.fr pour qu’un client non technique sache quoi vérifier chez son prestataire web est une bonne référence à transmettre à un client qui pose des questions RGPD sans avoir de compétence technique : il explique côté client ce que ce guide détaille côté implémentation. Utile notamment quand une agence ou un client final veut vérifier qu’un prestataire respecte réellement ces obligations avant de signer un contrat.
Chiffrement des données sensibles : cibler, pas généraliser
Chiffrer l’intégralité d’une base de données n’est ni réaliste ni toujours pertinent : cela complique les recherches, les jointures et les index, pour un bénéfice de sécurité souvent marginal si le chiffrement au niveau du disque (chiffrement au repos assuré par l’hébergeur) est déjà en place. La bonne approche consiste à cibler le chiffrement applicatif — au niveau du champ — sur les données réellement sensibles : données de santé, IBAN, documents d’identité, ou tout champ dont la fuite aurait un impact disproportionné.
Avec Doctrine, un type personnalisé permet de chiffrer/déchiffrer de manière transparente à la lecture et à l’écriture :
final class EncryptedStringType extends StringType
{
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
return EncryptionService::encrypt($value);
}
public function convertToPHPValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
return EncryptionService::decrypt($value);
}
}
#[ORM\Entity]
class BankAccount
{
#[ORM\Column(type: EncryptedStringType::NAME)]
private string $iban;
}
Le service de chiffrement lui-même doit s’appuyer sur une bibliothèque éprouvée et auditée plutôt qu’une implémentation maison : sodium_crypto_secretbox (extension Sodium, intégrée nativement à PHP depuis PHP 7.2) ou la bibliothèque defuse/php-encryption sont des choix raisonnables pour un chiffrement symétrique AEAD.
final class EncryptionService
{
public static function encrypt(string $plaintext): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, self::getKey());
return base64_encode($nonce . $ciphertext);
}
private static function getKey(): string
{
// clé issue d'une variable d'environnement, JAMAIS committée dans le code
return base64_decode($_ENV['APP_ENCRYPTION_KEY']);
}
}
Deux points de vigilance fréquemment négligés :
- La clé de chiffrement ne doit jamais résider dans le même environnement que les données chiffrées sans séparation. Un coffre-fort de secrets (Vault, AWS KMS, ou a minima des variables d’environnement injectées hors dépôt Git) est indispensable — la même rigueur s’applique aux secrets utilisés pour sécuriser une API Symfony en JWT ou OAuth2. Une clé stockée dans
.envcommitté annule tout le bénéfice du chiffrement. - Le chiffrement d’un champ casse la recherche exacte et le tri en base. Si une recherche par IBAN ou par numéro de sécurité sociale est nécessaire, prévoyez un champ de hash déterministe séparé (HMAC) pour l’indexation, distinct du champ chiffré utilisé pour la restitution de la valeur en clair.
DPA avec les sous-traitants : hébergeur, mailing, paiement — chacun a le sien
L’article 28 du RGPD impose qu’un responsable de traitement encadre par contrat chaque sous-traitant qui manipule des données personnelles pour son compte. Ce contrat s’appelle un DPA (Data Processing Agreement, ou accord de traitement des données). Une erreur fréquente côté développeur consiste à considérer que le DPA signé avec l’hébergeur couvre l’ensemble de la chaîne technique — ce n’est jamais le cas.
Chaque service tiers qui reçoit ou stocke des données personnelles a besoin de son propre DPA, ou au minimum d’une clause de traitement des données intégrée à ses conditions générales (le cas pour la plupart des grands fournisseurs SaaS) :
| Sous-traitant technique | Données personnelles concernées | DPA à vérifier |
|---|---|---|
| Hébergeur (Cloudflare, OVH, AWS…) | Toutes les données de l’application | DPA hébergeur, localisation des serveurs |
| Service d’envoi d’emails transactionnels | Adresse email, contenu des emails | DPA distinct, souvent dans les CGU du service |
| Prestataire de paiement | Données de paiement, parfois nom/adresse | DPA + conformité PCI-DSS |
| Outil d’analytics | Comportement de navigation, IP | DPA + éventuel transfert hors UE à vérifier |
| Service de support client (chat, ticketing) | Contenu des échanges, identité | DPA distinct |
| CDN ou service de stockage d’images | Fichiers uploadés par les utilisateurs | DPA + localisation du stockage |
Concrètement, le développeur n’a pas à rédiger ces contrats — c’est une responsabilité du responsable de traitement, souvent le client ou l’entreprise — mais il est le mieux placé pour dresser la liste réelle des sous-traitants techniques utilisés par l’application, ce que le juridique ne peut pas deviner depuis un cahier des charges. Un audit simple consiste à lister chaque appel réseau sortant de l’application vers un service tiers (API externe, SDK, webhook) et à vérifier qu’un DPA existe pour chacun. Cette liste doit être tenue à jour à chaque nouvelle intégration : ajouter un SDK de tracking ou un nouveau service d’emailing sans vérifier son DPA est une des façons les plus rapides de créer une non-conformité invisible.
Pour les transferts de données hors Union européenne (un service d’emailing hébergé aux États-Unis, par exemple), vérifiez également l’existence de clauses contractuelles types (CCT) ou d’un mécanisme de transfert reconnu par la Commission européenne — un point à signaler explicitement au responsable juridique du projet si le service choisi n’en dispose pas.
Une checklist technique à garder sous la main
En synthèse, cinq vérifications à intégrer dans la routine de tout projet Symfony traitant des données personnelles :
- Consentement : aucun script tiers non essentiel chargé avant accord explicite, granularité par catégorie, gestion TCF si publicité programmatique.
- Rétention : commande planifiée de purge des logs et des données inactives, durées documentées et appliquées, pas seulement écrites dans une politique de confidentialité.
- Suppression : cascade Doctrine explicite (
cascade,orphanRemoval) sur chaque relation, distinction claire entre suppression réelle et anonymisation pour obligation légale, audit des tables non mappées. - Chiffrement : ciblé sur les champs réellement sensibles, clé séparée du code et des données, hash déterministe distinct si une recherche exacte est nécessaire.
- DPA : liste à jour de tous les sous-traitants techniques recevant des données personnelles, vérification individuelle — jamais une supposition que le DPA hébergeur suffit pour tout le reste.
Sur le plan sécurité plus large, ces sujets recoupent directement les bonnes pratiques décrites dans notre entretien avec un architecte sécurité Symfony sur les failles courantes en production — le RGPD et la sécurité applicative partagent la même exigence de base : savoir précisément quelles données transitent où, combien de temps, et qui peut y accéder.
Aucun de ces cinq chantiers n’est optionnel une fois qu’une application traite des données personnelles réelles, et aucun ne se résout par une simple mention dans les conditions générales. Le code doit refléter la politique, pas l’inverse.