webrek / laravel-data-retention
Declarative data retention for Laravel: keep records for a window, then delete or anonymize them automatically — with a compliance audit log.
Requires
- php: ^8.2
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- infection/infection: ^0.29
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
README
Declare how long a model's rows are kept and what happens when they expire. A scheduled command then purges or anonymizes the rows that have outlived their window, recording every one it touches in an audit log.
Keeping personal data longer than necessary is a risk under the LFPDPPP, the GDPR, and most privacy regimes. This package turns "delete inactive customers after a year" or "anonymize closed tickets after 90 days" from a recurring manual chore into a declaration that lives next to the model and runs itself.
use Illuminate\Database\Eloquent\Model; use Webrek\DataRetention\Concerns\HasRetention; use Webrek\DataRetention\RetentionPolicy; class Customer extends Model { use HasRetention; public function retentionPolicy(RetentionPolicy $policy): RetentionPolicy { return $policy ->since('last_seen_at') // measure age from this column ->keepFor(365) // keep for a year, then… ->where(fn ($q) => $q->where('legal_hold', false)) ->anonymize([ // …scrub the PII, keep the row 'name' => '[redacted]', 'email' => fn (Customer $c) => "anon+{$c->id}@example.test", 'phone' => null, ], markColumn: 'anonymized_at'); } }
Installation
composer require webrek/laravel-data-retention
Publish and run the migration for the audit log:
php artisan vendor:publish --tag=data-retention-migrations php artisan migrate
Optionally publish the configuration:
php artisan vendor:publish --tag=data-retention-config
Declaring a policy
Add the HasRetention trait to a model, implement retentionPolicy(), and list
the model under data-retention.models:
// config/data-retention.php 'models' => [ App\Models\Customer::class, App\Models\EventLog::class, ],
A policy is two decisions: how long to keep a row and what to do when it expires.
How long
$policy ->since('created_at') // the anchor column; defaults to created_at ->keepFor(30); // an integer means days…
use Carbon\CarbonInterval; $policy->keepFor(CarbonInterval::months(18)); // …or any CarbonInterval
Rows whose anchor column is null are never eligible: data the package
cannot date is data it will not touch.
What happens
| Action | Effect |
|---|---|
->delete() |
Deletes the row. Models using soft deletes are soft-deleted; everything else is hard-deleted. Model events fire, so observers and cascades run. |
->forceDelete() |
Permanently deletes the row, bypassing soft deletes. |
->anonymize([...]) |
Keeps the row but overwrites the given columns. |
anonymize() takes a map of column => value. Each value is a literal or a
closure that receives the model:
$policy->anonymize([ 'name' => '[redacted]', 'email' => fn ($model) => 'anon+' . $model->id . '@example.test', 'ip' => null, ], markColumn: 'anonymized_at');
Pass a markColumn (a nullable timestamp) and the runner stamps it, then
skips already-anonymized rows on later runs, so the job stays cheap and
idempotent. Without one, anonymization simply reapplies the same values on every
run.
Legal holds and scoping
where() adds constraints to the eligibility query. Use it to exempt records
under a litigation legal hold, or to scope a policy to part of the table:
$policy ->keepFor(365) ->where(fn ($q) => $q->where('legal_hold', false)) ->where(fn ($q) => $q->where('region', 'MX')) ->delete();
Purging soft-deleted rows
A common need is to permanently clean up records some time after they were sent
to the trash. Anchor on deleted_at, include the trashed rows, and force
delete:
$policy ->since('deleted_at') ->keepFor(90) ->includeTrashed() ->forceDelete();
Models you can't edit
For a vendor or framework model you can't add the trait to, register a policy from a service provider:
use Webrek\DataRetention\Facades\DataRetention; DataRetention::register(\Spatie\Activitylog\Models\Activity::class, fn ($policy) => $policy->keepFor(90)->delete() );
Running it
php artisan retention:run # run every configured policy php artisan retention:run --dry-run # report what would change, without changing anything php artisan retention:run --model="App\Models\Customer" php artisan retention:list # show the configured policies
Schedule it however you schedule the rest of your maintenance. Daily and off-peak is typical:
// routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('retention:run')->dailyAt('03:00');
The runner paginates eligible rows by primary key, so an interrupted run simply resumes on the next pass instead of starting over or skipping rows.
The audit log
Every row a policy touches is written to data_retention_log: the policy name,
the action, the model and key, the affected columns (for anonymization), and
when it happened. That is the evidence a data protection review expects: proof
that the retention rules ran and what they did.
Each policy run also fires a Webrek\DataRetention\Events\RecordsRetained event
carrying a RetentionResult, so you can forward the results to your own metrics
or alerts.
Disable the log in the configuration if you keep that evidence elsewhere:
'logging' => ['enabled' => false],
Configuration
return [ 'connection' => env('DATA_RETENTION_CONNECTION'), // audit log connection 'models' => [/* models with a HasRetention policy */], 'chunk' => 500, // rows per batch 'logging' => [ 'enabled' => true, 'table' => 'data_retention_log', 'connection' => null, ], ];
Testing
composer test
Contributing
See CONTRIBUTING.md. Run make check before opening a PR.
Security
Please report vulnerabilities through the security advisory form, not as public issues. See SECURITY.md.
License
The MIT License (MIT). See LICENSE.