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.

Maintainers

Package info

github.com/webrek/laravel-data-retention

pkg:composer/webrek/laravel-data-retention

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-29 18:44 UTC

This package is auto-updated.

Last update: 2026-07-04 23:16:29 UTC


README

Latest Version on Packagist Total Downloads Tests PHP Version License

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.