kaveraa / data-lifecycle
Keep personal data only as long as needed, for Laravel and Symfony/Doctrine: warn, disable with a grace period, then anonymise or delete
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-30 13:30:42 UTC
README
English - Français
The GDPR asks you not to keep personal data longer than needed (article 5.1.e). In real life almost nobody does it: you would have to find the idle accounts, warn the people, disable without breaking anything, leave a way back, then anonymise or delete. And be able to show it.
This package does that journey, with one declaration per entity, for Laravel and for Symfony / Doctrine.
#[KeepFor('3 years')] // kept 3 years after the last sign of life #[WarnBefore('30 days')] // an email 30 days before the deadline #[DisableFirst('30 days')] // disabled, then 30 days to come back #[ThenAnonymise('email', 'name')] // after that the row stays, but anonymous class User { }
php artisan lifecycle:report # what would happen, without writing anything php artisan lifecycle:run # for real
- The whole journey: warn, disable, leave a grace period, then anonymise or delete. Not only delete.
- Observe mode:
lifecycle:reportsays exactly how many rows would be touched, and which ones, without a single write. This is what you run in production for weeks before daring the rest. - Reversible: while the grace period lasts, a person who comes back finds the account untouched, and the clock restarts.
- No schema is forced: a policy only reads the columns it needs. A simple policy works with a single date column.
- Two frameworks, one package: the cycle is written once, in plain PHP; Laravel and Doctrine are only drivers.
- Testable: the clock can be replaced (
FrozenClock), so three years pass in three lines of test. - Light: two PSR interfaces, nothing else.
Table of contents
- The problem
- Requirements
- Installation
- Write a policy
- The columns to add
- Run the cycle
- Observe mode
- Warn the person
- When the person comes back
- Anonymise
- The activity signal
- Where a row stands
- All the options
- What this package does not do
- Development
The problem
A database keeps everything, forever, by default. Accounts left behind six years ago are still there, with their address, their name, their history. It is a risk if data leaks, and it goes against the GDPR.
The usual answer is a home-made script, run once, that deletes in bulk. It is frightening, so nobody runs it. This package replaces that script with something you dare to run:
last sign of life today
| |
|------------------- 3 years (KeepFor) ------------------| |
| | |
reminder D-30 disabled anonymised
(WarnBefore) (DisableFirst) or deleted
|<- 30 days ->|
to come back
Requirements
- PHP 8.2 or more.
- Laravel 12+, or Symfony 7.2+ with Doctrine ORM 3+.
- One date column per entity: the last sign of life (
last_active_at,last_order_at,sent_at, your choice).
Installation
composer require kaveraa/data-lifecycle
Laravel
php artisan lifecycle:install
The command publishes config/data-lifecycle.php and an example migration. The service provider is found on its own.
Symfony
Add the bundle in config/bundles.php:
return [ // ... Kaveraa\DataLifecycle\Symfony\DataLifecycleBundle::class => ['all' => true], ];
Then write config/packages/data_lifecycle.yaml:
data_lifecycle: discover: - App\Entity\User
Write a policy
Two ways, as you like. Attributes read better, configuration is handier when the rule changes with the environment. If both exist for the same class, configuration wins.
With attributes
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 { }
An entity can have only a start and an end:
#[KeepFor('90 days', since: 'sent_at')] #[ThenDelete] class Invitation { }
Then say where to look for these classes, in discover:
// config/data-lifecycle.php 'discover' => [ App\Models\User::class, App\Models\Invitation::class, ],
With 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, ], ],
Durations are written in plain words: 3 years, 18 months, 30 days, 48 hours. The ISO 8601 form (P30D) works too.
The columns to add
You only add the columns your policy needs.
| Column | When it is needed | Type |
|---|---|---|
last_active_at |
always (it is the starting point) | date, nullable |
lifecycle_warn_stage |
only with #[WarnBefore] |
small integer, default 0 |
lifecycle_warned_at |
only with #[WarnBefore] |
date, nullable |
disabled_at |
only with #[DisableFirst] |
date, nullable |
anonymised_at |
only with #[ThenAnonymise] |
date, nullable |
So a #[KeepFor] + #[ThenDelete] policy needs only the date column. The names can be changed, globally or policy by policy:
'fields' => ['since' => 'seen_at', 'disabled_at' => 'blocked_at'],
Add an index on the date column, and on disabled_at: they carry the queries.
Run the cycle
php artisan lifecycle:run # everything, for real php artisan lifecycle:run --dry-run # without writing anything php artisan lifecycle:run --subject="App\Models\User" php artisan lifecycle:run --step=warn # only the reminders php artisan lifecycle:run --limit=500 # at most 500 rows per step
With Symfony the same commands are bin/console lifecycle:run and bin/console lifecycle:report.
Once a day is enough. Laravel:
// routes/console.php Schedule::command('lifecycle:run')->dailyAt('03:30');
Symfony, with cron:
30 3 * * * /usr/bin/php /var/www/bin/console lifecycle:run
A run is made to be stopped and resumed: --limit bounds every step, and the next run carries on where it stopped.
The first run
On a database that was never cleaned, the whole backlog comes out at once: thousands of rows are already past the deadline. They get their first reminder, then are disabled in the same run, which leaves nobody time to react. Two precautions:
- Run
lifecycle:reportfirst, and look at the numbers. - Catch up gently: play
--step=warnalone for the length of your reminder (30 days if you warn 30 days before), and only then the full command.
php artisan lifecycle:run --step=warn --limit=200 # for 30 days php artisan lifecycle:run # after that
Observe mode
This is the front door of the package. Nothing is written, no event is sent, and the report says what would happen:
php artisan lifecycle:report
Dry run: nothing was written.
Entity Step Rows Examples
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
A row that is very late can show up twice, in warn and in disable: in observe mode nothing is written between the two steps, so the report shows exactly what a real run would do, one step after the other.
Let it run for a few weeks in a scheduled task, watch the numbers settle, then remove --dry-run. Writing can also be blocked from the configuration while you set things up:
DATA_LIFECYCLE_DRY_RUN=true
As long as this setting is true, lifecycle:run stays in observe mode and says so.
Warn the person
The package sends no email: it tells you when to send one, and you write the message. There are five events, listened to like any event of your 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 of the disabling reminder: $event->warnIndex, // 0 for the first reminder, 1 for the next one )); });
| Event | When |
|---|---|
SubjectWarned |
a reminder has to go out |
SubjectDisabled |
the row has just been disabled |
SubjectAnonymised |
the personal data is gone |
SubjectDeleted |
the row has been deleted |
SubjectReactivated |
the person came back |
No event is sent in observe mode.
When the person comes back
This is the whole point of the grace period: disabling is not deleting.
use Kaveraa\DataLifecycle\Lifecycle; public function login(Request $request, Lifecycle $lifecycle) { // ... $lifecycle->reactivate($user); // no more reminder, no more disabling, clock back to zero }
reactivate() returns false on a row that is already anonymised: what is gone does not come back.
Anonymise
Anonymising instead of deleting keeps your counts right (orders, statistics, invoices) while the person disappears.
#[ThenAnonymise('email', 'name')]
Every field gets a strategy. Without one, Strategy::Auto chooses from the name: a field that contains mail gets an address, everything else gets [removed].
| Strategy | Result |
|---|---|
Strategy::Email |
anonymous-42@anonymous.invalid, one per row |
Strategy::Text |
Anonymous |
Strategy::Redact |
[removed] |
Strategy::EmptyText |
an empty string |
Strategy::Nullify |
null (the column must accept it) |
Strategy::Zero |
0 |
Strategy::YearOnly |
keeps the year of a date, sets the 1st of January |
Strategy::Hash |
a fingerprint: the value does not come back, but two equal values stay equal |
Strategy::Hash helps when you need to know that two rows came from the same person, without knowing who. The replacement texts can be changed in the configuration.
The activity signal
Everything rests on a date you can trust. Writing last_active_at on every request costs one write per request: not acceptable. The package ships a guard that writes only once per window (15 minutes by default).
Laravel, in bootstrap/app.php:
$middleware->web(append: [ \Kaveraa\DataLifecycle\Laravel\Middleware\TrackActivity::class, ]);
With Symfony the listener is wired by the bundle. The window is set with activity.throttle (in minutes; 0 turns it off).
Watch out for the trap: an automatic login from a cookie, a monitoring call or a scheduled task that touches the table will reset the clock. An account that looks active because a robot goes through it is not active. Pick a deliberate action of the person as the starting point.
Where a row stands
$lifecycle->stageOf($user); // Stage::Active, Warned, Disabled or Erased $lifecycle->dueAt($user); // date of the coming disabling
With Laravel, the HasLifecycle trait puts the same answers on the model, plus 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();
All the options
| Option | Default | Role |
|---|---|---|
dry_run |
false |
Blocks every write, everywhere |
limit |
1000 |
Rows at most per step and per policy |
fields.since |
last_active_at |
Column of the last sign of life |
fields.warn_stage |
lifecycle_warn_stage |
How many reminders were sent |
fields.warned_at |
lifecycle_warned_at |
Date of the last reminder |
fields.disabled_at |
disabled_at |
Date of the disabling |
fields.anonymised_at |
anonymised_at |
Date of the anonymisation |
anonymiser.email_domain |
anonymous.invalid |
Domain of the replacement addresses |
anonymiser.redacted_text |
[removed] |
Replacement text |
anonymiser.anonymous_name |
Anonymous |
Replacement name |
anonymiser.pepper |
the application key | Salt of Strategy::Hash |
activity.throttle |
15 |
Minutes between two writes of the activity signal |
subjects |
[] |
The policies written in configuration |
discover |
[] |
The classes whose attributes are read |
What this package does not do
- This is not legal advice. The durations are yours: they depend on your activity and your duties (an invoice is kept ten years, a rejected job application two years). The package applies the duration you decide.
- It does not hold your record of processing activities and does not answer access or portability requests.
- It does not touch your backups or your logs: a row anonymised in the database is still readable in yesterday's backup. Think about how long you keep your backups.
- It does not guess your relations: anonymising a user does not empty the linked tables. Write one policy per entity, or clean up in a listener of
SubjectAnonymised.
Development
git clone https://github.com/kaveraa/data-lifecycle.git
cd data-lifecycle
composer install
vendor/bin/phpunit
To suggest a change, read the CONTRIBUTING.md guide. See the CHANGELOG for the history of versions.
To report a vulnerability, open a private security advisory rather than a public issue.
License
MIT. See LICENSE.