kaveraa / data-lifecycle
Conservation et cycle de vie des données personnelles pour Laravel et Symfony/Doctrine : durée de conservation, rappel avant échéance, désactivation avec période de grâce, puis anonymisation ou suppression.
Requires
- php: ^8.2
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
Requires (Dev)
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.3
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
- symfony/config: ^7.2 || ^8.0
- symfony/console: ^7.2 || ^8.0
- symfony/dependency-injection: ^7.2 || ^8.0
- symfony/doctrine-bridge: ^7.2 || ^8.0
- symfony/framework-bundle: ^7.2 || ^8.0
- symfony/http-kernel: ^7.2 || ^8.0
Suggests
- doctrine/orm: Pour le pilote Doctrine (ORM 3+)
- laravel/framework: Pour le fournisseur de services, les commandes artisan et le pilote Eloquent (Laravel 12+)
- symfony/framework-bundle: Pour le bundle Symfony et les commandes console
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-28 21:09:24 UTC
README
Français - English
Le RGPD demande de ne pas garder les données personnelles plus longtemps que nécessaire (article 5.1.e). Dans la vraie vie, presque personne ne le fait : il faudrait repérer les comptes inactifs, prévenir les personnes, désactiver sans tout casser, laisser une chance de revenir, puis anonymiser ou supprimer. Et pouvoir le montrer.
Ce paquet fait ce parcours, en une déclaration par entité, pour Laravel et pour Symfony / Doctrine.
#[KeepFor('3 years')] // on garde 3 ans apres le dernier signe de vie #[WarnBefore('30 days')] // un e-mail 30 jours avant l'echeance #[DisableFirst('30 days')] // desactivation, puis 30 jours pour revenir #[ThenAnonymise('email', 'name')] // ensuite, la ligne reste mais elle est anonyme class User { }
php artisan lifecycle:report # ce qui se passerait, sans rien ecrire php artisan lifecycle:run # pour de vrai
- Le parcours entier : prévenir, désactiver, laisser une période de grâce, puis anonymiser ou supprimer. Pas seulement supprimer.
- Mode observation :
lifecycle:reportdit exactement combien de lignes seraient touchées, et lesquelles, sans écrire une seule fois. C'est ce qu'on lance en production pendant des semaines avant d'oser le reste. - Réversible : tant que la période de grâce dure, la personne qui revient retrouve son compte intact, et le compteur repart de zéro.
- Aucun schéma imposé : une règle ne lit que les colonnes dont elle a besoin. Une règle simple fonctionne avec une seule colonne de date.
- Deux frameworks, un seul paquet : le cycle est écrit une fois, en PHP pur ; Laravel et Doctrine ne sont que des pilotes.
- Testable : une horloge se remplace (
FrozenClock), donc trois ans passent en trois lignes de test. - Léger : deux interfaces PSR, rien d'autre.
Sommaire
- Le problème
- Prérequis
- Installation
- Déclarer une règle
- Les colonnes à ajouter
- Lancer le cycle
- Le mode observation
- Prévenir la personne
- Quand la personne revient
- Anonymiser
- Le signal d'activité
- Savoir où en est une ligne
- Toutes les options
- Ce que ce paquet ne fait pas
- Développement
Le problème
Une base de données garde tout, pour toujours, par défaut. Les comptes abandonnés depuis six ans sont encore là, avec leur adresse, leur nom, leur historique. C'est un risque en cas de fuite, et c'est contraire au RGPD.
La réponse habituelle est un script maison, lancé une fois, qui supprime en masse. Il fait peur, donc personne ne le lance. Ce paquet remplace ce script par quelque chose qu'on ose exécuter :
dernier signe de vie aujourd'hui
| |
|------------------- 3 ans (KeepFor) --------------------| |
| | |
rappel J-30 desactivation anonymisation
(WarnBefore) (DisableFirst) ou suppression
|<- 30 jours ->|
pour revenir
Prérequis
- PHP 8.2 ou plus.
- Laravel 12+, ou Symfony 7.2+ avec Doctrine ORM 3+.
- Une colonne de date par entité concernée : le dernier signe de vie (
last_active_at,last_order_at,sent_at, à vous de choisir).
Installation
composer require kaveraa/data-lifecycle
Laravel
php artisan lifecycle:install
La commande publie config/data-lifecycle.php et une migration d'exemple. Le fournisseur de services est découvert tout seul.
Symfony
Ajoutez le bundle dans config/bundles.php :
return [ // ... Kaveraa\DataLifecycle\Symfony\DataLifecycleBundle::class => ['all' => true], ];
Puis créez config/packages/data_lifecycle.yaml :
data_lifecycle: discover: - App\Entity\User
Déclarer une règle
Deux façons, au choix. Les attributs sont plus lisibles, la configuration est plus pratique quand la règle change selon l'environnement. Si les deux existent pour une même classe, la configuration gagne.
Avec des attributs
use Kaveraa\DataLifecycle\Attribute\DisableFirst; use Kaveraa\DataLifecycle\Attribute\KeepFor; use Kaveraa\DataLifecycle\Attribute\ThenAnonymise; use Kaveraa\DataLifecycle\Attribute\ThenDelete; use Kaveraa\DataLifecycle\Attribute\WarnBefore; use Kaveraa\DataLifecycle\Strategy; #[KeepFor('3 years')] #[WarnBefore('30 days')] #[WarnBefore('7 days')] #[DisableFirst('30 days')] #[ThenAnonymise('email', 'name', ['birth_date' => Strategy::YearOnly])] class User extends Authenticatable { }
Une entité peut n'avoir qu'un début et une fin :
#[KeepFor('90 days', since: 'sent_at')] #[ThenDelete] class Invitation { }
Il faut ensuite dire où chercher ces classes, dans discover :
// config/data-lifecycle.php 'discover' => [ App\Models\User::class, App\Models\Invitation::class, ],
Avec la configuration
// config/data-lifecycle.php 'subjects' => [ App\Models\User::class => [ 'keep_for' => '3 years', 'warn_before' => ['30 days', '7 days'], 'grace' => '30 days', 'anonymise' => ['email' => 'email', 'name' => 'text'], ], App\Models\Invitation::class => [ 'keep_for' => '90 days', 'fields' => ['since' => 'sent_at'], 'delete' => true, ], ],
Les durées s'écrivent en toutes lettres : 3 years, 18 months, 30 days, 48 hours. La forme ISO 8601 (P30D) est acceptée aussi.
Les colonnes à ajouter
Vous n'ajoutez que les colonnes dont votre règle a besoin.
| Colonne | Quand elle est nécessaire | Type |
|---|---|---|
last_active_at |
toujours (c'est le point de départ) | date, nullable |
lifecycle_warn_stage |
seulement avec #[WarnBefore] |
petit entier, défaut 0 |
lifecycle_warned_at |
seulement avec #[WarnBefore] |
date, nullable |
disabled_at |
seulement avec #[DisableFirst] |
date, nullable |
anonymised_at |
seulement avec #[ThenAnonymise] |
date, nullable |
Une règle #[KeepFor] + #[ThenDelete] n'a donc besoin que de la colonne de date. Les noms se changent, globalement ou règle par règle :
'fields' => ['since' => 'derniere_activite', 'disabled_at' => 'desactive_le'],
Pensez à un index sur la colonne de date, et sur disabled_at : ce sont elles qui portent les requêtes.
Lancer le cycle
php artisan lifecycle:run # tout, pour de vrai php artisan lifecycle:run --dry-run # sans rien ecrire php artisan lifecycle:run --subject="App\Models\User" php artisan lifecycle:run --step=warn # seulement les rappels php artisan lifecycle:run --limit=500 # au maximum 500 lignes par etape
Sous Symfony, les mêmes commandes s'appellent bin/console lifecycle:run et bin/console lifecycle:report.
Une fois par jour suffit. Laravel :
// routes/console.php Schedule::command('lifecycle:run')->dailyAt('03:30');
Symfony, avec cron :
30 3 * * * /usr/bin/php /var/www/bin/console lifecycle:run
L'exécution est faite pour être coupée et reprise : --limit borne chaque étape, et la commande suivante reprendra là où elle en était.
La première exécution
Sur une base qui n'a jamais été nettoyée, tout le retard sort d'un coup : des milliers de lignes sont déjà au-delà de l'échéance. Elles reçoivent leur premier rappel, puis sont désactivées dans la même exécution, ce qui ne laisse à personne le temps de réagir. Deux précautions :
- Lancez
lifecycle:reportd'abord, et regardez les nombres. - Rattrapez le retard en douceur : jouez
--step=warnseul pendant la durée de votre rappel (30 jours si vous prévenez 30 jours avant), puis seulement ensuite la commande complète.
php artisan lifecycle:run --step=warn --limit=200 # pendant 30 jours php artisan lifecycle:run # ensuite
Le mode observation
C'est la porte d'entrée du paquet. Rien n'est écrit, aucun événement n'est émis, et le rapport dit ce qui se passerait :
php artisan lifecycle:report
Essai a blanc : rien n'a ete ecrit.
Entite Etape Lignes Exemples
User warn 412 18, 45, 61, 88, 90
User disable 73 7, 12, 30, 44, 51
User erase 19 3, 9, 14, 21, 25
Invitation erase 1 204 2, 4, 5, 6, 8
Une ligne très en retard peut apparaître deux fois, dans warn et dans disable : en observation rien n'est écrit entre les deux étapes, donc le rapport montre bien ce qu'une vraie exécution ferait, l'une après l'autre.
Laissez-le tourner quelques semaines dans une tâche planifiée, regardez les nombres se stabiliser, puis enlevez --dry-run. On peut aussi bloquer toute écriture depuis la configuration, le temps de la mise en place :
DATA_LIFECYCLE_DRY_RUN=true
Tant que ce réglage est vrai, lifecycle:run reste en observation et le dit.
Prévenir la personne
Le paquet n'envoie aucun e-mail : il vous dit quand le faire, et vous écrivez le message. Cinq événements existent, écoutables comme n'importe quel événement de votre framework.
use Kaveraa\DataLifecycle\Event\SubjectWarned; Event::listen(function (SubjectWarned $event): void { $user = $event->entity(); Mail::to($user)->send(new AccountExpiring( dueAt: $event->dueAt, // date de la desactivation reminder: $event->warnIndex, // 0 pour le premier rappel, 1 pour le suivant )); });
| Événement | Quand |
|---|---|
SubjectWarned |
un rappel doit partir |
SubjectDisabled |
la ligne vient d'être désactivée |
SubjectAnonymised |
les données personnelles sont parties |
SubjectDeleted |
la ligne a été supprimée |
SubjectReactivated |
la personne est revenue |
Aucun événement n'est émis en mode observation.
Quand la personne revient
C'est tout l'intérêt de la période de grâce : la désactivation n'est pas une suppression.
use Kaveraa\DataLifecycle\Lifecycle; public function login(Request $request, Lifecycle $lifecycle) { // ... $lifecycle->reactivate($user); // plus de rappel, plus de desactivation, compteur remis a zero }
reactivate() renvoie false sur une ligne déjà anonymisée : ce qui est parti ne revient pas.
Anonymiser
Anonymiser plutôt que supprimer garde vos compteurs justes (commandes, statistiques, factures) tout en faisant disparaître la personne.
#[ThenAnonymise('email', 'name')]
Chaque champ reçoit une stratégie. Sans précision, Strategy::Auto choisit d'après le nom : un champ qui contient mail reçoit une adresse, tout le reste reçoit [removed].
| Stratégie | Résultat |
|---|---|
Strategy::Email |
anonymous-42@anonymous.invalid, unique par ligne |
Strategy::Text |
Anonymous |
Strategy::Redact |
[removed] |
Strategy::EmptyText |
une chaîne vide |
Strategy::Nullify |
null (la colonne doit l'accepter) |
Strategy::Zero |
0 |
Strategy::YearOnly |
garde l'année d'une date, met le 1er janvier |
Strategy::Hash |
une empreinte : la valeur ne revient pas, mais deux valeurs égales le restent |
Strategy::Hash sert quand vous avez besoin de savoir que deux lignes venaient de la même personne, sans savoir qui. Les textes de remplacement se changent dans la configuration.
Le signal d'activité
Tout repose sur une date fiable. Écrire last_active_at à chaque requête coûte une écriture par requête : inacceptable. Le paquet fournit un garde-fou qui n'écrit qu'une fois par fenêtre (15 minutes par défaut).
Laravel, dans bootstrap/app.php :
$middleware->web(append: [ \Kaveraa\DataLifecycle\Laravel\Middleware\TrackActivity::class, ]);
Sous Symfony, l'abonné est branché tout seul par le bundle. La fenêtre se règle avec activity.throttle (en minutes ; 0 désactive complètement).
Attention au piège : une connexion automatique par cookie, un appel d'API de supervision ou une tâche planifiée qui touche la table remettent le compteur à zéro. Un compte "actif" parce qu'un robot passe dessus n'est pas actif. Choisissez comme point de départ une action volontaire de la personne.
Savoir où en est une ligne
$lifecycle->stageOf($user); // Stage::Active, Warned, Disabled ou Erased $lifecycle->dueAt($user); // date de la desactivation a venir
Avec Laravel, le trait HasLifecycle ajoute les mêmes réponses sur le modèle, et des scopes :
use Kaveraa\DataLifecycle\Laravel\Concerns\HasLifecycle; class User extends Authenticatable { use HasLifecycle; } User::active()->count(); User::disabled()->get(); $user->lifecycleStage(); $user->lifecycleDueAt(); $user->reactivate();
Toutes les options
| Option | Défaut | Rôle |
|---|---|---|
dry_run |
false |
Bloque toute écriture, partout |
limit |
1000 |
Lignes maximum par étape et par règle |
fields.since |
last_active_at |
Colonne du dernier signe de vie |
fields.warn_stage |
lifecycle_warn_stage |
Nombre de rappels déjà envoyés |
fields.warned_at |
lifecycle_warned_at |
Date du dernier rappel |
fields.disabled_at |
disabled_at |
Date de désactivation |
fields.anonymised_at |
anonymised_at |
Date d'anonymisation |
anonymiser.email_domain |
anonymous.invalid |
Domaine des adresses de remplacement |
anonymiser.redacted_text |
[removed] |
Texte de remplacement |
anonymiser.anonymous_name |
Anonymous |
Nom de remplacement |
anonymiser.pepper |
la clé de l'application | Sel de Strategy::Hash |
activity.throttle |
15 |
Minutes entre deux écritures du signal d'activité |
subjects |
[] |
Les règles écrites en configuration |
discover |
[] |
Les classes dont on lit les attributs |
Ce que ce paquet ne fait pas
- Ce n'est pas un conseil juridique. Les durées sont les vôtres : elles dépendent de votre activité et de vos obligations (une facture se garde dix ans, un CV non retenu deux ans). Le paquet applique la durée que vous décidez.
- Il ne tient pas votre registre des traitements et ne répond pas aux demandes d'accès ou de portabilité.
- Il ne touche pas à vos sauvegardes ni à vos journaux : une ligne anonymisée en base reste lisible dans une sauvegarde d'hier. Pensez à la durée de conservation de vos sauvegardes.
- Il ne devine pas vos relations : anonymiser un utilisateur ne vide pas les tables liées. Déclarez une règle par entité, ou faites le ménage dans un écouteur de
SubjectAnonymised.
Développement
git clone https://github.com/kaveraa/data-lifecycle.git
cd data-lifecycle
composer install
vendor/bin/phpunit
Pour proposer une modification, lisez le guide CONTRIBUTING.md. Voir le CHANGELOG pour l'historique des versions.
Pour signaler une faille, ouvrez une alerte de sécurité privée plutôt qu'une issue publique.
Licence
MIT. Voir LICENSE.