RGPD pour développeur PHP/Symfony : ce qu'il faut implémenter concrètement

Le RGPD n'est pas qu'une affaire de juristes : un développeur PHP/Symfony doit implémenter le recueil de consentement (bandeau cookies, TCF), gérer les durées de rétention des logs et données, construire une vraie suppression en cascade avec Doctrine, chiffrer les données sensibles et s'assurer que chaque sous-traitant technique dispose d'un DPA signé. Ce guide détaille ce qu'il faut coder, pas ce qu'il faut plaider.

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 :

  1. Aucun script non essentiel (analytics, publicité, réseaux sociaux) n’est chargé au premier rendu de la page.
  2. Le bandeau de consentement s’affiche et bloque l’exécution.
  3. 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.
  4. 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.

Développeur configurant un bandeau de consentement cookies conforme RGPD sur une interface web, écran de code visible
Le bandeau de consentement doit bloquer réellement le chargement des scripts tiers, pas seulement s'afficher au-dessus d'un tracking déjà actif.

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.

Schéma de suppression en cascade de données utilisateur dans une base relationnelle Doctrine, terminal affichant une commande Symfony
Une suppression en cascade correcte distingue les données à effacer réellement des données à anonymiser pour raison comptable ou légale.

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 .env committé 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 :

  1. Consentement : aucun script tiers non essentiel chargé avant accord explicite, granularité par catégorie, gestion TCF si publicité programmatique.
  2. 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é.
  3. 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.
  4. 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.
  5. 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.

Questions fréquentes

Le RGPD concerne-t-il vraiment le code, ou seulement les mentions légales ?
Les deux, mais la partie technique est souvent négligée. Une politique de confidentialité bien rédigée ne sert à rien si l'application charge Google Analytics avant tout consentement, si les logs applicatifs conservent des adresses IP indéfiniment, ou si une demande de suppression laisse des lignes orphelines dans la base. Le développeur est responsable de l'implémentation : consentement effectif avant chargement des scripts tiers, durées de rétention appliquées en base, suppression en cascade réelle, chiffrement des champs sensibles et DPA vérifié avec chaque sous-traitant technique.
Faut-il chiffrer toutes les données personnelles dans une base Symfony ?
Non, le chiffrement systématique de toute la base est rarement justifié et complique les recherches et les jointures. La bonne pratique consiste à cibler les données réellement sensibles au sens de l'article 9 du RGPD (santé, origine, opinions) ou à fort impact en cas de fuite (IBAN, mot de passe, document d'identité) avec un chiffrement applicatif au niveau du champ, via un type Doctrine personnalisé ou un attribut Doctrine, plutôt que de chiffrer l'intégralité de chaque table.
Un DPA avec l'hébergeur suffit-il si l'envoi d'emails passe par un service tiers ?
Non. Chaque sous-traitant qui traite des données personnelles pour votre compte doit disposer de son propre DPA (Data Processing Agreement), au sens de l'article 28 du RGPD : hébergeur, service d'envoi transactionnel d'emails, service de paiement, outil d'analytics, service de support client. Un DPA signé avec l'hébergeur ne couvre pas le traitement effectué par le service d'emailing utilisé en parallèle. Il faut recenser tous les sous-traitants qui reçoivent des données personnelles et vérifier un DPA distinct pour chacun.