Looking to hire Laravel developers? Try LaraJobs

laravel-privacy-retention maintained by contoweb

Description
Retention-based anonymization for Eloquent models — interface, trait, strategy enum and scheduler command.
Last update
2026/10/13 15:42 (dev-development)
License
Links
Downloads
1

Comments
comments powered by Disqus

Laravel Privacy Retention

Retention-based anonymization for Eloquent models. Implements the GDPR/DSGVO pattern of stamping anonymized_at once a record's personal-data fields have been masked.

Install

composer require contoweb/laravel-privacy-retention
php artisan vendor:publish --tag=privacy-retention-config

Requires PHP 8.2+ and Laravel 10, 11, 12, or 13.

Usage

1. Add the column

php artisan retention:make-migration --model="App\\Models\\User"
# or
php artisan retention:make-migration --table=users

The column defaults to anonymized_at. Pass --column=masked_at to override, or — when using --model — the command picks up whatever your model returns from getAnonymizedAtColumn() (see Custom column).

Then php artisan migrate.

2. Mark the model

use Contoweb\PrivacyRetention\Concerns\HasRetentionPolicy;
use Contoweb\PrivacyRetention\Contracts\Anonymizable;
use Contoweb\PrivacyRetention\Enums\AnonymizationStrategy;
use Illuminate\Database\Eloquent\Builder;

class User extends Model implements Anonymizable
{
    use HasRetentionPolicy;

    public function anonymizableFields(): array
    {
        return [
            'name'   => AnonymizationStrategy::String,
            'email'  => AnonymizationStrategy::Email,
            'notes'  => AnonymizationStrategy::Nullify,
            'status' => AnonymizationStrategy::Fixed,
            'phone'  => fn (?string $value): string => 'hash-'.md5((string) $value),
        ];
    }

    public static function anonymizableQuery(): Builder
    {
        return self::query()
            ->where('active', false)
            ->where('deactivated_at', '<=', now()->subYear());
    }
}

Already-anonymized records are filtered out automatically via the notAnonymized() scope — no need to add whereNull('anonymized_at') yourself. Use anonymized() for the inverse (records that have been anonymized). Calling anonymize() on an already-anonymized record is a no-op (idempotent), and the field updates plus anonymized_at stamp are wrapped in a single DB transaction.

3. Register & schedule

config/privacy-retention.php:

'models' => [
    \App\Models\User::class,
],

The retention:anonymize command is meant to run on a schedule. Eligible records are processed in chunks of 1000 (chunkById), so it is safe to point it at large tables.

app/Console/Kernel.php:

$schedule->command('retention:anonymize')->daily()->at('23:00');

Strategies

Strategy Result
String First+last char preserved, middle masked
Email anonymized-<uuid>@anonymized.invalid
Nullify null
Fixed Literal "anonymized"

You can pass a closure instead of a strategy for custom logic. Closures receive the current field value (which may be null) and must return the anonymized value. Anything that isn't an AnonymizationStrategy or callable will throw an InvalidArgumentException at runtime.

Events

The trait fires two Eloquent model events around anonymize():

  • anonymizing — before fields are mutated. Return false from a listener to cancel.
  • anonymized — after the record is saved.

Hook in via the static helpers:

User::anonymizing(function (User $user) {
    // ...
});

User::anonymized(function (User $user) {
    // ...
});

Or via an observer with matching anonymizing / anonymized methods:

namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function anonymizing(User $user): void
    {
        // ...
    }

    public function anonymized(User $user): void
    {
        // ...
    }
}

Register it on the model:

#[ObservedBy(UserObserver::class)]
class User extends Model implements Anonymizable
{
    // ...
}

Save events

The save that stamps anonymized_at also fires the usual Eloquent saving / saved / updating / updated events by default. Flip the package-wide default in config/privacy-retention.php:

'events' => false, // persist anonymization via saveQuietly()

Or override per model:

public function shouldFireAnonymizationEvents(): bool
{
    return false;
}

When this returns false, the trait uses saveQuietly() so listeners on the surrounding save events are skipped. The dedicated anonymizing / anonymized events still fire either way.

Custom column

Override getAnonymizedAtColumn() on the model:

public function getAnonymizedAtColumn(): string
{
    return 'masked_at';
}

The migration command picks this up automatically when called with --model.