graystackit / laravel-gdpr-compliance
GDPR/DSGVO compliance toolkit for Laravel applications: consent, data export, erasure, legal hold, audit logging and package inventory.
Package info
github.com/GraystackIT/laravel-gdpr-compliance
pkg:composer/graystackit/laravel-gdpr-compliance
Requires
- php: ^8.3
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/notifications: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A comprehensive GDPR/DSGVO compliance toolkit for Laravel applications. Declare personal data on your Eloquent models, manage consent, export subject data, schedule erasure with grace periods and legal hold, and maintain a tamper-evident audit trail — all through a fluent PHP API.
Features
- Fluent personal data declaration on any Eloquent model via a builder DSL
- Registry-driven processing — one config array lists all PII-holding models; no relation graph to maintain
- Subject data export (DSGVO Art. 15) as a structured JSON file, dispatched as a queue job
- Subject data erasure (DSGVO Art. 17) with configurable grace period, three retention modes (
delete,anonymize,legal_hold), and deterministic processing order for FK safety - Per-purpose consent management — append-only
consentstable, cookie consent helper, middleware - Policy version tracking with subject acceptance records
- Event-driven audit log (
gdpr_audits) that never stores PII values — only field names, event names, and metadata - 7 built-in anonymizers (name, email, phone, IP address, address, free text, static text) with custom alias support
- Package inventory scanner that reads
composer.lock+package-lock.jsonand writes a JSON snapshot - 4 Laravel Notifications for deletion requested/cancelled/completed and export ready — overridable via config
- 3 middleware for consent-gated routes, cookie propagation, and deletion-pending auth blocking
- 8 Artisan commands for daily operations, reporting, pruning, and inventory scanning
- 3 queue jobs for async export, deletion processing, and legal hold cleanup
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
Installation
composer require graystackit/laravel-gdpr-compliance
Publish the config and migrations:
php artisan vendor:publish --tag=gdpr-config php artisan vendor:publish --tag=gdpr-migrations php artisan migrate
Optionally publish translations for customization:
php artisan vendor:publish --tag=gdpr-lang
Quick Start
1. Declare personal data on your models
Every model that holds personal data implements PersonalData and uses one or more GDPR traits:
namespace App\Models; use GraystackIt\Gdpr\Contracts\PersonalData; use GraystackIt\Gdpr\Enums\RetentionMode; use GraystackIt\Gdpr\Support\PersonalDataBlueprint; use GraystackIt\Gdpr\Traits\HasConsentRecords; use GraystackIt\Gdpr\Traits\HasPersonalData; use GraystackIt\Gdpr\Traits\IsPersonalDataSubject; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable implements PersonalData { use HasPersonalData, IsPersonalDataSubject, HasConsentRecords; public function personalData(PersonalDataBlueprint $b): PersonalDataBlueprint { return $b // PII: anonymize AND export ->field('name')->anonymizeWith('name')->exportable() ->field('email')->anonymizeWith('email')->exportable() ->field('phone')->anonymizeWith('phone')->exportable() // PII internal: anonymize only, do NOT export ->field('password') ->anonymizeWith('static_text', ['value' => '[ANONYMIZED]']) // Non-PII metadata: export only, never touched ->field('created_at')->exportable() ->field('locale')->exportable() ->retention( mode: RetentionMode::Delete, gracePeriodDays: 7, // 0 = immediate, max 30 ) ->processOrder(1000); // subject is processed last } }
Related models (that are NOT subjects) use HasPersonalData and define a scope:
namespace App\Models; use GraystackIt\Gdpr\Contracts\PersonalData; use GraystackIt\Gdpr\Enums\RetentionMode; use GraystackIt\Gdpr\Support\PersonalDataBlueprint; use GraystackIt\Gdpr\Traits\HasPersonalData; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; class Order extends Model implements PersonalData { use HasPersonalData; public function personalData(PersonalDataBlueprint $b): PersonalDataBlueprint { return $b ->field('shipping_address')->anonymizeWith('address')->exportable() ->field('billing_email')->anonymizeWith('email')->exportable() ->field('total')->exportable() ->field('created_at')->exportable() ->retention( mode: RetentionMode::LegalHold, legalHoldDays: 3650, // 10 years legalBasis: '§ 147 AO — tax record retention', ) ->processOrder(100); // children before subject } public function scopePersonalDataForSubject(Builder $query, Model $subject): Builder { return match (true) { $subject instanceof \App\Models\User => $query->where('user_id', $subject->getKey()), default => $query->whereRaw('1 = 0'), }; } }
A subject scope must filter on $subject->getKey() alone. By the time a row is processed the subject itself can be gone — deleted by your application during grace, or force-deleted in an earlier pass — and the package then passes an instance carrying nothing but the primary key, which is all gdpr_deletions records of it. A scope reading any other attribute of the subject would match nothing at that point, and the rows it should have reached would stay behind.
2. Register models in config
In config/gdpr.php, list every model that contains personal data:
'models' => [ \App\Models\User::class, \App\Models\Order::class, \App\Models\Address::class, \App\Models\Comment::class, // Vendor models with external profile and scope \Vendor\Package\ExternalModel::class => [ 'profile' => \App\Gdpr\Profiles\ExternalProfile::class, 'scope' => \App\Gdpr\Scopes\ExternalScope::class, ], ],
3. Use the API
use GraystackIt\Gdpr\Facades\GDPR; // --- Deletion --- $user->requestDeletion(); // schedule with grace period $user->deleteImmediately(); // skip grace, process now $user->cancelDeletion(); // cancel during grace GDPR::isDeletionPending($user); // bool User::whereDeletionPending()->get(); // query scope User::whereNotDeletionPending()->get(); // --- Export --- $request = $user->requestExport(); // creates a GdprRequest // Dispatch the export job manually or let the command do it: \GraystackIt\Gdpr\Jobs\PreparePersonalDataExportJob::dispatch($request->id); // --- Consent --- use GraystackIt\Gdpr\Enums\ConsentPurpose; $user->grantConsent(ConsentPurpose::Analytics, 'cookie_banner'); $user->withdrawConsent(ConsentPurpose::Marketing); $user->hasConsent(ConsentPurpose::Analytics); // bool $user->consentStatus(); // ['necessary' => true, 'analytics' => true, ...] // --- Package inventory --- GDPR::packageInventory(); // returns array from last scan, or null
Trait Reference
| Trait | Who uses it | What it provides |
|---|---|---|
HasPersonalData |
Any model with PII (User, Order, Address, ...) | Marker trait. No runtime behavior — the package reads personalData() via the registry. |
IsPersonalDataSubject |
Only subjects (User, Customer, ...) | requestDeletion(), deleteImmediately(), cancelDeletion(), requestExport(), isDeletionPending(), scopeWhereDeletionPending(), scopeWhereNotDeletionPending() |
HasConsentRecords |
Subjects that need consent tracking | grantConsent(), withdrawConsent(), hasConsent(), consentStatus(), consents() relationship |
Per-Field DSL
Both behaviors are opt-in per field:
| Call | Effect |
|---|---|
->field('x')->anonymizeWith('alias') |
Anonymize only |
->field('x')->exportable() |
Export only |
->field('x')->anonymizeWith('alias')->exportable() |
Both |
->field('x') (nothing further) |
Throws on build() — the field is functionless |
Built-in anonymizer aliases
| Alias | Class | Behavior |
|---|---|---|
name |
NameAnonymizer |
Replaces with "Anonymous User" (configurable via placeholder) |
email |
EmailAnonymizer |
Replaces with anonymized_<random>@example.invalid (configurable domain) |
phone |
PhoneAnonymizer |
Replaces with +00 000 0000000 (configurable placeholder) |
ip_address |
IpAddressAnonymizer |
IPv4: masks last octet by default (octet), or half or full. IPv6: keeps first 4 groups. |
address |
AddressAnonymizer |
Strings → [REDACTED ADDRESS]. Arrays → each value [REDACTED]. |
free_text |
FreeTextAnonymizer |
Full replacement by default. Set replace_email, replace_phone, replace_urls to selectively replace patterns. |
static_text |
StaticTextAnonymizer |
Returns config.value (default [REDACTED]). |
Register custom anonymizers in config/gdpr.php:
'anonymizers' => [ // ... built-in aliases 'ssn' => \App\Gdpr\Anonymizers\SsnAnonymizer::class, ],
Your class must implement GraystackIt\Gdpr\Contracts\Anonymizer.
Retention Modes
Configured per model via ->retention():
->retention( mode: RetentionMode::Delete, // 'delete' | 'anonymize' | 'legal_hold' gracePeriodDays: 7, // 0 = immediate, max 30 (DSGVO Art. 12(3)) legalHoldDays: 3650, // required if mode = legal_hold legalBasis: '§ 147 AO', // optional, recommended for legal_hold )
| Mode | After grace | Terminal state |
|---|---|---|
delete |
Row is hard-deleted | erased |
anonymize |
Fields wiped via anonymizers, row stays | anonymized |
legal_hold |
Fields wiped, row retained until hold_until |
pending_legal_hold → erased after expiry |
Processing order
->processOrder(int) controls the sequence when multiple models are processed for the same subject. Lower numbers go first. Convention:
| Range | Use |
|---|---|
| 1–99 | Pivot/junction tables |
| 100–199 | Direct children (Order, Address, Comment) |
| 200–299 | Indirect children (LoginAttempt, Metrics) |
| 1000 | The subject itself |
This prevents FK constraint violations when children reference the subject with NOT NULL foreign keys.
Deletion Lifecycle
$user->requestDeletion()
→ gdpr_deletions rows created per affected model (with retention snapshot)
→ Host model rows UNTOUCHED during grace
→ Cancellation at any time: $user->cancelDeletion()
Cron Pass 1 (gdpr:process-deletions, daily):
→ Grace expired: process per snapshot mode (delete/anonymize/legal_hold)
→ Each model follows its OWN retention, sorted by processOrder
Cron Pass 2:
→ Legal hold expired: mandatory forceDelete
During the grace period, nothing is modified on host model rows. The entire grace state lives in the gdpr_deletions table. This means:
- Cancellation is trivial — just flip the state
- The package never forces
SoftDeleteson your models - Auth behavior during grace is your app's decision (see below)
If the subject itself disappears during grace — deleted by your application, by a cascading FK — the passes still process the rows of every other model that belonged to it, matched by the subject key recorded on the request. A gdpr_deletions row is closed as erased without processing only when nothing of its model is left.
Auth during grace
The package does not lock users out during grace. Use these helpers to implement your preferred UX:
// In your auth logic if (GDPR::isDeletionPending($user)) { // Block login, show banner, redirect, etc. } // Or as middleware on auth routes Route::middleware('gdpr.no-deletion-pending')->group(function () { // ... }); // Or as a query scope User::whereNotDeletionPending()->where('email', $email)->first();
Consent Management
Database consent (authenticated users)
$user->grantConsent(ConsentPurpose::Analytics, 'cookie_banner'); $user->withdrawConsent(ConsentPurpose::Analytics, 'profile_settings'); $user->hasConsent(ConsentPurpose::Analytics); // latest action wins
The gdpr_consents table is append-only. Each grant and withdraw is a new row. The current state is the latest row per (subject, purpose).
ConsentPurpose::Necessary always returns true without any database check. Every other case requires an explicit grant:
| Case | Value |
|---|---|
Necessary |
necessary |
Analytics |
analytics |
Marketing |
marketing |
EmbeddedContent |
embedded_content |
Personalization |
personalization |
TalentPool |
talent_pool |
TalentPool covers keeping an applicant's data on file after the vacancy they applied for is closed. Processing the application itself runs on a legitimate interest with its own deletion deadline; keeping it for the next vacancy is a separate purpose the person has to agree to — and withdrawing that consent brings the deletion deadline back.
Cookie consent (anonymous visitors)
The ConsentCookieManager reads/writes a JSON cookie (gdpr_consent) with per-purpose booleans:
{
"necessary": true,
"analytics": false,
"marketing": false,
"embedded_content": true,
"policy_version": "2026-04",
"updated_at": "2026-04-08T12:00:00Z"
}
Consent middleware
// Block routes that require marketing consent Route::middleware('gdpr.consent:marketing')->group(function () { // Returns 451 Unavailable For Legal Reasons if consent is missing }); // Necessary always passes Route::middleware('gdpr.consent:necessary')->group(function () { // Always accessible });
Audit Log
The gdpr_audits table records deletion/export pipeline events only. It never stores:
- Field values (only field names)
- Raw user agents or full IP addresses
- Consent grants/withdrawals (those live in
consents) - Policy acceptances (those live in
gdpr_policy_acceptances)
After a subject is hard-deleted, their audit entries survive as orphans — the subject_id FK points nowhere, which means no re-identification is possible. This is by design.
Logged events: deletion_requested, deletion_scheduled, deletion_cancelled, anonymization_completed, deletion_completed, deletion_deferred, legal_hold_started, legal_hold_expired, export_requested, export_completed.
Events
The package fires these events for external system integration (e.g., deleting Stripe customers, removing Mailchimp subscribers):
| Event | Payload | When |
|---|---|---|
PersonalDataDeletionRequested |
GdprRequest |
requestDeletion() called |
PersonalDataDeletionCancelled |
GdprRequest |
cancelDeletion() called |
PersonalDataAnonymized |
GdprDeletion |
After fields wiped on a model |
PersonalDataErased |
GdprDeletion |
After row hard-deleted |
LegalHoldStarted |
GdprDeletion |
Row enters legal hold |
LegalHoldExpired |
GdprDeletion |
Row exits legal hold (force-deleted) |
PersonalDataExported |
GdprRequest |
Export job completed |
use GraystackIt\Gdpr\Events\PersonalDataErased; Event::listen(PersonalDataErased::class, function ($event) { // $event->deletion->subject_type, $event->deletion->subject_id // Clean up Stripe, Mailchimp, S3 avatars, etc. });
Notifications
Four mail notifications are sent automatically (when config('gdpr.notifications.enabled') is true):
| Notification | When | Final? |
|---|---|---|
PersonalDataDeletionRequestedNotification |
On requestDeletion() |
No |
PersonalDataDeletionCancelledNotification |
On cancelDeletion() |
Yes (email wiped) |
PersonalDataDeletionCompletedNotification |
After processing | Yes (email wiped) |
PersonalDataExportReadyNotification |
After export job | Yes (email wiped) |
The recipient email is snapshotted into gdpr_requests.notification_email at request time, so notifications work even after the subject's data has been anonymized or deleted. After the final notification, the email is wiped.
Customizing notifications
Text only: publish translations with php artisan vendor:publish --tag=gdpr-lang and edit lang/vendor/gdpr/en/gdpr.php.
Deep customization: override the class in config/gdpr.php:
'notifications' => [ 'deletion_requested' => \App\Notifications\MyDeletionRequested::class, 'deletion_cancelled' => false, // disable this notification 'deletion_completed' => null, // use package default 'export_ready' => null, ],
Artisan Commands
| Command | Purpose |
|---|---|
gdpr:process-deletions |
Run daily via scheduler. Processes grace-expired and legal-hold-expired rows. Exits non-zero when a row had to be left pending (see Subjects behind a global scope). |
gdpr:export {subject} {id} |
Create an export request and dispatch the export job. |
gdpr:erase {subject} {id} [--now] |
Request deletion. --now skips grace. |
gdpr:audit [--subject=] [--id=] [--event=] |
Show recent audit entries with filters. |
gdpr:report |
Summary of requests, deletions, consent counts, audit counts. |
gdpr:packages-scan |
Scan composer.lock + package-lock.json and write inventory JSON. |
gdpr:cleanup-exports [--disk=local] |
Delete expired export files from storage. |
gdpr:prune [--dry-run] [--table=] |
Time-based pruning of audits, consents, policy acceptances, and stale notification emails. |
Scheduling
Add to your routes/console.php or scheduler:
use Illuminate\Support\Facades\Schedule; Schedule::command('gdpr:process-deletions')->daily(); Schedule::command('gdpr:cleanup-exports')->daily(); Schedule::command('gdpr:prune')->weekly();
Package inventory
Wire the scanner into your host app's composer.json:
"scripts": { "post-update-cmd": [ "@php artisan gdpr:packages-scan" ] }
Access the inventory programmatically:
$inventory = GDPR::packageInventory(); // Returns: ['generated_at' => '...', 'composer' => [...], 'npm' => [...]]
Pruning & Retention
The gdpr:prune command handles time-based cleanup:
| Table | Default retention | Special rules |
|---|---|---|
gdpr_audits |
3 years (1095 days) | — |
consents |
3 years | Latest row per (subject, purpose) is always preserved |
gdpr_policy_acceptances |
3 years | — |
gdpr_requests.notification_email |
7 days after terminal status | Wiped to NULL, row itself retained for 3 years |
Configure in config/gdpr.php:
'retention' => [ 'audits_days' => 1095, 'consents_days' => 1095, 'policy_acceptances_days' => 1095, 'notification_email_days' => 7, ],
Database Tables
| Table | Purpose |
|---|---|
gdpr_consents |
Append-only consent records (grant/withdraw) per subject and purpose |
gdpr_requests |
Top-level request lifecycle (export/delete), email snapshot |
gdpr_deletions |
One row per (request x affected model), retention snapshot, state machine, process_order |
gdpr_audits |
Event-driven audit log for the deletion/export pipeline |
gdpr_policy_versions |
Policy version definitions (privacy, imprint, ToS) |
gdpr_policy_acceptances |
Subject acceptance records per policy version |
GDPR Compliance Notes
Anonymization vs. pseudonymization
The anonymize mode replaces personal field values with non-identifying placeholders. Whether the result qualifies as true anonymization (GDPR no longer applies) or pseudonymization (GDPR still applies) depends on which fields you configure.
To achieve proper anonymization, ensure you mark all identifying fields — including quasi-identifiers:
- Birthdate + ZIP + gender can uniquely identify 87% of the US population (Sweeney 2000)
- Behavioral patterns in related tables (order timestamps, login times) may re-identify subjects
- External datasets can be joined to remaining data points
The package gives you the tooling. Field selection is your responsibility.
Grace period
- Default
0days (immediate processing). Opt in per model. - Hard cap at 30 days (DSGVO Art. 12(3): "without undue delay, in any event within one month").
- During grace, no host model data is modified — cancellation is a clean rollback.
Legal hold
- Enabled via
RetentionMode::LegalHoldwithlegalHoldDays. - The
legalBasisfield is optional but recommended — the audit log records it for compliance evidence. - After
hold_untilexpires, the row is mandatorily force-deleted by the cron command.
Backups
The package cannot reach into backup files. If you restore from a backup, pending deletion requests should be re-applied. Document your backup retention in your privacy policy and ensure backups rotate within a documented window.
Subject key types
Subjects are stored as a morph tuple (subject_type + subject_id), not a foreign key, so the package cannot infer the key type of your subject models — config('gdpr.subject_key_type') declares it:
| Value | subject_id column |
Use when |
|---|---|---|
bigint (default) |
unsignedBigInteger |
All subjects use auto-incrementing keys |
uuid |
uuid |
All subjects use UUID keys |
ulid |
ulid |
All subjects use ULID keys |
string |
varchar(64) |
Subjects have mixed key types (e.g. a User with a bigint key and an Applicant with a UUID) |
string is the only value that holds numeric and non-numeric subject keys side by side, so it is the one to pick as soon as a second subject model with a different key type is registered.
Changing this on an existing installation is a schema change. Publish and run the upgrade migration, which rewrites subject_id in all five tables to the configured type:
php artisan vendor:publish --tag=gdpr-upgrade-migrations php artisan migrate
The migration refuses conversions it cannot perform safely — it has no way to know which UUID a subject that used to have the key 42 now carries, so mapping stored keys onto new values is your job. It checks all five tables before altering any of them, so a refusal leaves the schema untouched. Widening to string is always allowed and keeps the stored keys as they are.
Subjects behind a global scope
A subject model that carries a global scope — multi-tenancy is the common case — is invisible to the package wherever no tenant is bound, which is exactly the state the queue worker and the scheduler run in. Let the model's subject scope take that global scope off:
class Member extends Model implements PersonalData { use IsPersonalDataSubject; protected static function booted(): void { static::addGlobalScope('organization', function (Builder $query) { // ... matches nothing while no organization is bound }); } public function scopePersonalDataForSubject(Builder $query, Model $subject): Builder { return $query->withoutGlobalScope('organization')->whereKey($subject->getKey()); } }
For the subject's own model, the package reads this scope only for the global scopes it removes — the row is always selected by primary key. A subject model whose scope is written for a different subject (and answers 1 = 0 for itself) therefore keeps working, and a subject model without a scope at all is unaffected.
If a subject row exists that no scope reaches, the package refuses rather than reporting an erasure that never happened:
requestDeletion()throwsSubjectNotReachablebefore writing anything.gdpr:process-deletionsleaves that rowpending_grace, writes adeletion_deferredaudit entry, logs an error and exits non-zero. Every other row of the run is still processed, and the next run retries.
Subject-to-subject references
When processing Subject A, the package never modifies Subject B — even if B has a foreign key to A. Use onDelete('set null') on FK migrations or listen to the PersonalDataErased event to handle cross-subject cleanup in your app code.
Publishing Reference
| Tag | What it publishes | Required? |
|---|---|---|
gdpr-config |
config/gdpr.php |
Yes |
gdpr-migrations |
database/migrations/*.php |
Yes |
gdpr-upgrade-migrations |
database/migrations/*_change_gdpr_subject_id_type.php |
No — only when changing subject_key_type |
gdpr-lang |
lang/vendor/gdpr/en/gdpr.php |
No — for text customization |
gdpr-notifications |
app/Notifications/*.php |
No — for deep notification customization |
gdpr |
Config, migrations, lang and notifications | Convenience |
Testing
composer test
The suite runs on SQLite. The Drivers suite additionally asserts the subject_id behavior that depends on the database — the deletion-pending scopes across all four subject_key_type values and the upgrade migration in both directions — against a real PostgreSQL and MySQL server. Those tests skip themselves when no server answers; point them somewhere with GDPR_PGSQL_HOST, GDPR_PGSQL_PORT, GDPR_PGSQL_USERNAME, GDPR_PGSQL_PASSWORD, GDPR_PGSQL_DATABASE (and the GDPR_MYSQL_* equivalents). The test database is created on first use.
License
The MIT License (MIT). See LICENSE for details.