ginkelsoft / laravel-data-retention
A Laravel package that enforces GDPR storage-limitation rules per Eloquent model by deleting or anonymizing expired records and writing a tamper-evident audit log.
Package info
github.com/ginkelsoft-development/laravel-data-retention
pkg:composer/ginkelsoft/laravel-data-retention
Requires
- php: ^8.2
- illuminate/console: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
- nesbot/carbon: ^2.62 || ^3.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^2.30 || ^3.0
- phpstan/phpstan: ^1.10 || ^2.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
This package is auto-updated.
Last update: 2026-05-28 17:13:14 UTC
README
Overview
The GDPR — and its Dutch implementation, the AVG — requires that personal data be kept for no longer than is necessary for the purposes for which it is processed (art. 5(1)(e), the storage-limitation principle). Knowing this rule is one thing; proving that it is being applied consistently across an Eloquent codebase is another.
Laravel Data Retention lets you declare a retention policy on every Eloquent model, sweeps expired records on a schedule, and writes a tamper-evident audit log that lets you demonstrate, after the fact, that the data really was retired — when, how, and under which policy.
This is the storage-limitation member of the GinkelSoft compliance family. The other AVG controls (right to be forgotten, subject access, consent, breach registry) live in sibling packages — install the hub to get the whole family in one go. See The family below.
Upgrading from v1.x? The v1.x release of this package bundled five controls in one. v2.x reduces it to storage limitation only; the other four are now separate packages. Hash chains in existing
retention_logtables remain byte-identical and continue to verify. See UPGRADE.md in the core repo for the migration steps.
The family
| Package | GDPR Article(s) | Role |
|---|---|---|
laravel-compliance-core |
art. 5(2) | Shared primitives (hash chain, strategies, subject hash) |
laravel-data-retention |
art. 5(1)(e) | Storage limitation — this package |
laravel-data-right-to-be-forgotten |
art. 17 | Subject-driven erasure |
laravel-data-subject-access |
art. 15 + 20 | Read-only subject export |
laravel-data-consent |
art. 6(1)(a) + 7 | Consent registry |
laravel-data-breach-registry |
art. 33 + 34 | Personal-data breach register |
laravel-compliance-hub |
art. 5(2) | Umbrella: installs the whole family, verifies every chain |
How it works
1. Declare a policy on the model
Attribute form — best for "just delete after N years":
use Ginkelsoft\DataRetention\Attributes\Retention; use Ginkelsoft\DataRetention\Concerns\HasRetention; #[Retention(period: '2 years', from: 'created_at', action: 'delete')] class AuditEntry extends Model { use HasRetention; }
Property form — required for anonymize, because each field gets its own
strategy:
use Ginkelsoft\DataRetention\Concerns\HasRetention; class Client extends Model { use HasRetention; protected array $retention = [ 'period' => '5 years', 'from' => 'ended_at', 'action' => 'anonymize', 'anonymize' => [ 'first_name' => 'placeholder', // [REDACTED] 'last_name' => 'placeholder', 'bsn' => 'hash', // one-way SHA-256, contextual 'phone' => 'null', // overwrite with NULL 'notes' => fn ($value, $field, $model) => 'anon-' . $model->id, ], ]; }
2. Register the models
config/data-retention.php:
return [ 'models' => [ \App\Models\AuditEntry::class, \App\Models\Client::class, ], 'include_soft_deleted' => true, 'chunk_size' => 500, ];
Only listed models are touched. Forgetting to list a model is the safe failure mode.
3. Schedule the sweep
// app/Console/Kernel.php $schedule->command('retention:run')->dailyAt('02:00');
Or run it manually:
php artisan retention:run --dry-run # safe preview php artisan retention:run # actually retire data php artisan retention:run --model="App\\Models\\Client" php artisan retention:run --chunk=1000
4. Read the audit log
Every action produces one row in the retention_log table:
| model_type | model_id | action | retention_period | retention_field | expired_at | performed_at | previous_hash | hash |
|---|---|---|---|---|---|---|---|---|
| App\Models\Client | 01HXYZ... | anonymized | 5 years | ended_at | 2020-05-26 00:00:00 | 2026-05-26 02:00:01 | (prev sha256) | sha256… |
Verify the entire chain at any time, using the shared core HashChain:
use Ginkelsoft\ComplianceCore\Config\LogSecret; use Ginkelsoft\ComplianceCore\Support\HashChain; use Illuminate\Support\Facades\DB; $entries = DB::table('retention_log')->orderBy('id')->get() ->map(fn ($row) => (array) $row) ->all(); $intact = HashChain::verify($entries, LogSecret::value());
If the chain ever fails to verify, somebody touched the log table. That
is exactly what auditors want to be able to detect. (Or run
php artisan compliance:verify from the
hub to
verify every chain in the family in one go.)
Security model
| Threat | Mitigation |
|---|---|
| Retroactive edit of a log row | Every row's hash depends on the previous row's hash + a shared secret. Editing a row invalidates every later row. |
Forged log row inserted by an attacker without the log_secret |
The forged row cannot produce a hash that chains with both its neighbors. |
| Application code accidentally mutating the log | RetentionLogEntry throws on update() / delete(). Bypassing it via raw queries still breaks the chain. |
| Leaking PII via the log itself | Only class + primary key + policy metadata are logged. No field values, ever. |
| Soft-deleted rows hiding personal data forever | retention:run calls forceDelete() on soft-deleted models so the storage-limitation principle is actually satisfied. |
| Operator confidence before first run | --dry-run reports every action that would occur, writes nothing. |
The shared compliance.log_secret (with BC fallback to
data-retention.log_secret) plays the role of a HMAC key: it lives in .env,
not the database, so an attacker with read/write access to the DB cannot
forge a consistent chain unless they also exfiltrate the secret.
Compliance notes
- GDPR art. 5(1)(e) / AVG art. 5 lid 1 sub e — Storage limitation. This package gives you a documented, automated mechanism to retire data and a tamper-evident record that demonstrates compliance to the supervisory authority.
- GDPR art. 5(2) — Accountability. The hash chain (provided by core) is the evidence.
- ISO 27001:2022 A.5.34 / A.8.10 — Information deletion. The audit log produces the "records of erasure" that this control requires.
- NEN 7510 / NEN 7513 (Dutch healthcare) — Append-only logging of data lifecycle events is compatible with NEN 7513 requirements; the log itself stores no patient data.
This package is not legal advice. Retention periods must be set by your DPO based on your processing purposes.
Installation
composer require ginkelsoft/laravel-data-retention php artisan vendor:publish --tag=compliance-config php artisan vendor:publish --tag=data-retention-config php artisan vendor:publish --tag=data-retention-migrations php artisan migrate
Then add a secret to .env:
COMPLIANCE_LOG_SECRET="$(openssl rand -base64 32)"
Existing installations upgrading from v1.x can keep their
DATA_RETENTION_LOG_SECRET — core's LogSecret helper reads it as a
fallback so existing hash chains keep verifying after the upgrade.
Configuration reference
| Option | Default | Description |
|---|---|---|
models |
[] |
Classes processed by retention:run. Anything not listed is never touched. |
include_soft_deleted |
true |
Whether soft-deleted rows also count for retention. Recommended true (they still hold PII). |
chunk_size |
500 |
Records per chunk when iterating large tables. |
The signing secret and anonymize placeholders are in the shared
compliance config
provided by ginkelsoft/laravel-compliance-core.
Anonymize strategies
The three built-in strategies live in
laravel-compliance-core
and are shared with laravel-data-right-to-be-forgotten:
| Strategy id | Class | Output |
|---|---|---|
'null' |
Ginkelsoft\ComplianceCore\Strategies\NullStrategy |
Sets the field to null. |
'hash' |
Ginkelsoft\ComplianceCore\Strategies\HashStrategy |
SHA-256 of `{model} |
'placeholder' |
Ginkelsoft\ComplianceCore\Strategies\PlaceholderStrategy |
The configured placeholder string ([REDACTED] by default). |
Closure |
(resolved inline) | function (mixed $value, string $field, Model $model): mixed |
Implement Ginkelsoft\ComplianceCore\Contracts\AnonymizeStrategy if you
need a reusable custom strategy.
Trying it out (demo seeder)
php artisan db:seed --class="Ginkelsoft\\DataRetention\\Database\\Seeders\\RetentionDemoSeeder"
php artisan retention:run --dry-run
php artisan retention:run
After the run:
- 10 of the 20 audit entries are deleted (the expired ones).
- 5 of the 15 clients are anonymized in place.
- 15 audit-log rows are written, chained, and verifiable.
Framework compatibility
| Laravel Version | Supported PHP Versions |
|---|---|
| 10.x | 8.2 – 8.3 |
| 11.x | 8.2 – 8.4 |
| 12.x | 8.3 – 8.5 |
| 13.x | 8.3 – 8.5 |
Supported databases
Database-agnostic — anything Eloquent supports works. Tested on MySQL, MariaDB, PostgreSQL, SQLite (used in CI), SQL Server.
Gotchas
- Relations are not cascaded. This package only touches the model whose
retention policy expired. Give related models their own policy or use
database-level
ON DELETE CASCADE. - NULL in the
fromfield never expires. AClientwithended_at = nullis treated as still active. Mis-typed or never-populated columns will quietly skip retention. - Soft-deleted rows are force-deleted on expiry. Retention will
eventually empty the trash. Set
include_soft_deletedtofalseonly if you have a separate cleanup process for trashed rows. - The audit log grows forever. Plan to archive
retention_logrows older than your statutory audit-trail retention (usually 5–7 years in NL) to cold storage, not delete them — and verify the hash chain before archiving. - Changing
log_secretinvalidates the existing chain. Rotate it only as part of an explicit audit rotation procedure. hashstrategy needs a wide column. The output is 64 hex chars. If a field isVARCHAR(20), the migration to widen it should land before you turn on retention.
Testing
composer install vendor/bin/pest vendor/bin/phpstan analyse --memory-limit=1G vendor/bin/pint --test
Contributing
Pull requests welcome — read CONTRIBUTING.md first.
Security
Found a vulnerability? Do not open a public issue. See SECURITY.md for the private reporting channel.
Changelog
See CHANGELOG.md.
Reporting bugs
Found a bug or unexpected behaviour? We want to hear about it.
Preferred — open a GitHub issue: https://github.com/ginkelsoft-development/laravel-data-retention/issues/new
When opening an issue, please include:
- Versions — PHP, Laravel, and the package version
(
composer show ginkelsoft/laravel-data-retention). - What you did — the artisan command, code snippet, or steps that triggered the bug.
- What you expected vs what actually happened — include full error output or a stack trace if there is one.
- A minimal reproduction if you can — a failing test or a small code sample beats a long description.
Security-sensitive findings (anything that could expose personal data, break a hash-chain, or bypass an audit log) — please do not open a public issue. E-mail info@ginkelsoft.com directly with "SECURITY" in the subject line and we will respond privately.
Not on GitHub? You can also e-mail info@ginkelsoft.com with the same information.
Contact
For commercial support, integration questions, or anything that doesn't fit a GitHub issue: info@ginkelsoft.com — https://ginkelsoft.com.
License
MIT License — see LICENSE. (c) 2026 Ginkelsoft