privacy-ci / laravel
Find where personal data lives in your Laravel app, classify it in policy-as-code, and fail CI when new user-linked storage has no policy.
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nikic/php-parser: ^5.0
Requires (Dev)
- illuminate/database: ^12.0|^13.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Find where personal data lives in your Laravel application, classify it in policy-as-code, and fail CI when new user-linked storage has no policy.
Every backend team can delete a user from their primary database. Almost none can prove the user is gone from Redis, S3, the search index, the warehouse and six SaaS vendors. This makes that provable, and catches the next gap at pull-request time instead of during a regulator's 30-day clock.
The whole package is free. No account, no limits, no telemetry.
Why bother before you have to
Under the GDPR, failing to honour an erasure request sits in the higher penalty tier: up to 20 million euros or 4% of worldwide annual turnover, whichever is larger. The UK and most other regimes mirror that shape.
The fine is rarely the expensive part, though. What costs money is the scramble: a regulator or a customer gives you 30 days, and an engineering team spends them reconstructing a data map nobody wrote down, hunting through Redis keys and S3 prefixes by hand, under a deadline, on work that ships nothing.
Building the map while nobody is asking takes an afternoon. Building it during the clock takes a sprint, and you still cannot prove the deletion worked.
Install
composer require privacy-ci/laravel php artisan vendor:publish --tag=privacy-config php artisan privacy:discover
While the version is 0.x the policy DSL and the command surface may change in
any minor release. Composer treats 0.x as breaking by default, so ^0.1 will
not quietly move you to 0.2.
To try the erasure lifecycle as well:
php artisan vendor:publish --tag=privacy-migrations php artisan migrate php artisan privacy:forget 1 --force # suspends, 14-day countdown php artisan privacy:forget 1 --cancel # reactivates php artisan privacy:process-deletions --dry-run
In suspend mode (the default) privacy:forget will tell you to bind a
SubjectSuspender first. It refuses to report an account closed while it is
still fully active.
Use
php artisan privacy:discover # what personal data do we store, and where? php artisan privacy:discover --json # emit the findings manifest
Or without booting Laravel at all, useful in CI:
vendor/bin/privacy-ci --path=. --no-ansi
PERSONAL DATA. HIGH CONFIDENCE
comments.user_id foreign key 1.00 certain
recommendation_events.device_id name match 0.98
users.email name match 1.00
users.id subject root 1.00 certain
PERSONAL DATA. POSSIBLE (review required)
orders.shipping_address name match 0.80
users.name name match 0.65
STORES AND SERVICES DETECTED
Redis predis/predis scannable
S3 config/filesystems.php scannable
Snowflake config/services.php not yet scannable
1 store detected that this version cannot scan: Snowflake
Personal data may be flowing there unmapped.
13 locations found · 0 classified · 13 unclassified · 4 would fail CI
The CI gate
php artisan privacy:baseline # once, when adopting: forgive existing debt php artisan privacy:check # in CI: fail only on what is new
PRIVACY IMPACT DETECTED
New user-linked storage with no policy:
recommendation_events.user_id recommendation_events.user_id -> users.id
Classify each in your privacy policy, or run:
php artisan privacy:baseline # grandfather pre-existing findings
POSSIBLE PERSONAL DATA. REVIEW, NOT BLOCKING
users.email on the subject root table
sessions.payload reachable from users in 1 hop(s)
A name match is never certain, so it will never fail a build.
CHECK FAILED · 1 unclassified location introduced
Exit code 1. Two rules decide it, and neither is configurable:
- Only deterministic findings can fail, a foreign key or a declared relationship. A name match warns and nothing more. One false positive that blocks a deploy costs more trust than a missed column ever will.
- Only new findings can fail. Anything in
privacy-baseline.jsonpre-dates adoption. Without that, dropping this into a mature codebase means three hundred failures on day one and a deleted workflow by the afternoon.
privacy:baseline refuses to overwrite an existing file without confirmation,
re-baselining is how a team accidentally forgives everything it meant to fix.
Stale entries (a baselined column that no longer exists) are reported, because
leaving them in silently grandfathers a future column that reuses the name.
For gradual adoption, --warn-only reports everything and always exits 0.
Suppression needs a reason
IGNORE is the escape hatch, so it is not free:
$this->ignore(FeatureFlag::class)->reason('internal flag, no subject link');
RETAIN and IGNORE both require a written reason, enforced at policy load.
An unexplained suppression is indistinguishable from an oversight when someone
reads the diff two years later.
Running it anywhere
The gate is one command and an exit code, so any runner works. GitLab, Jenkins, Buildkite and a git pre-push hook all behave the same way.
./vendor/bin/privacy-ci --path=. --check --no-ansi # 0 = pass, 1 = fail
Prefer the binary over php artisan privacy:check in CI. Artisan has to boot
the application, so it wants an .env and an APP_KEY; the binary reads the
checkout and nothing else. Neither needs a database.
A complete GitHub Actions workflow:
name: privacy on: pull_request jobs: privacy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' coverage: none - name: Cache Composer uses: actions/cache@v4 with: path: ~/.composer/cache/files key: composer-${{ hashFiles('composer.lock') }} - run: composer install --prefer-dist --no-interaction --no-progress - name: Privacy check run: ./vendor/bin/privacy-ci --path=. --check --no-ansi
No database service, no .env, no secrets: discovery reads migrations, models,
config and composer.lock from the checkout. Verified from a clean clone with
neither file present.
While a team is adopting the check, swap the last step for one that reports without blocking anyone:
- name: Privacy check (reporting only) run: ./vendor/bin/privacy-ci --path=. --check --no-ansi || true
privacy-baseline.json has to be committed. Without it every run reports the
whole of your existing debt and fails from the first day.
What it does not do
It does not comment on the pull request. You get a red check and the reason in the job log. Inline comments need a GitHub App holding a token, which is a hosted piece rather than something this package can do from inside a job.
Scaffold the policy
Discovery on a mature codebase finds dozens of locations. Rather than composing a policy from scratch, generate one to edit:
php artisan privacy:make-policy
php artisan privacy:make-policy --only-blocking # just what fails CI today
final class UserPrivacyPolicy extends PrivacyPolicy { public function configure(): void { $this->subject(User::class); // ── users ───────────────────────────────────── subject root // email 0.99 email address // password 0.95 credential // name 0.65 possibly a personal name // $this->delete(User::class); // $this->anonymize(User::class, ['email' => 'deleted@example.invalid', 'name' => 'Deleted user']); // Masking keeps the row so foreign keys survive. Use placeholders, // not null: identifying columns are usually NOT NULL. // ── comments ────────────────────────────────── linked by user_id // author_ip 0.99 IP address // user_id 1.00 comments.user_id -> users.id // $this->anonymize(Comment::class, ['author_ip' => null, 'user_id' => null]); // $this->delete(Comment::class); // ── subscribers ─────────────────────────────── no link to the subject // email 0.99 email address // No foreign key to the subject, a generated handler cannot look these // rows up. Supply the lookup yourself, or rule the table out: // $this->custom('subscribers', \App\Privacy\PurgeTheseRows::class); // $this->ignore('subscribers')->reason(""); } }
Every rule arrives commented out, and there is a test that fails if one ever
doesn't. Discovery suggests; you decide. An uncommented delete() here would
become real deletion code the moment somebody ran privacy:make-handler.
Findings are grouped by table with evidence inline, the most plausible rule is
written on the line below, and tables that fail CI today sort first. A table with
no key to the subject gets no delete() or anonymize()
suggestion, neither could address the rows, so offering one would be a lie.
Policy as code
Rules live in your repository, so they are versioned, reviewed in pull requests,
and deployed with the code that created the data. Changing a table from RETAIN
to DELETE becomes a code review with a named approver.
namespace App\Privacy; use App\Models\{Comment, Order, User}; use PrivacyCI\Policy\PrivacyPolicy; class UserPrivacyPolicy extends PrivacyPolicy { public function configure(): void { $this->subject(User::class); $this->delete(User::class); $this->anonymize(Comment::class, ['user_id' => null, 'author_ip' => null]); $this->retain(Order::class)->reason('statutory accounting retention, 7y'); $this->deleteRedis('profile:{id}'); $this->deleteStorage('avatars/{id}.jpg', disk: 's3'); } }
Register it in config/privacy.php, and findings gain a classification:
comments.user_id foreign key 1.00 certain ANONYMIZE
orders.buyer_id foreign key 1.00 certain RETAIN
users.email name match 0.99 DELETE
profile:{id} declared 1.00 certain DELETE
audit_entries.actor_id relationship 1.00 certain UNCLASSIFIED
A rule naming specific columns narrows one covering the whole table. RETAIN
without a reason and CUSTOM without a handler are rejected outright, an
undocumented retention is the thing an auditor asks about first.
Rules for Redis, object storage, search indexes and third-party services add locations to the map. The developer is describing somewhere the scanner cannot reach, and a declared location belongs on the map just as much as a found one.
Models anywhere, not just app/Models
Point model_paths wherever your models actually live, any namespace, any
depth. The scanner reads the namespace from the file rather than assuming one:
'discovery' => [ 'model_paths' => [base_path('src/Domain/Models')], ],
Relationships resolve across namespaces, and a policy can still name a model by
its short name ($this->delete(Member::class) or 'Member').
Model detection follows ancestry, so Invoice extends BaseModel extends Model
works as long as the base class is inside a scanned path. When it lives in a
package, and isn't named *Model, declare it:
'model_base_classes' => [ \Acme\Support\Database\Entity::class, ],
Generated files follow the application's own PSR-4 map, read from
composer.json, so --namespace="Acme\Shop\Domain\Privacy" writes to
src/Domain/Privacy/ rather than somewhere the autoloader will never look.
Models are read too
The model scanner catches associations the database does not know about. Plenty
of production schemas declare belongsTo(User::class) with no matching foreign
key, the link is real, the migration scanner cannot see it and it is still
deterministic enough to fail a build on.
It also reads $hidden and encrypted casts: a developer marking a column
sensitive in the framework's own vocabulary is weak evidence on its own, but it
usefully raises confidence on a column a name heuristic was unsure about.
Verification: did the erasure actually land?
php artisan privacy:verify 42
avatars/42.png UNCHECKED probe failed: S3 driver not installed
failed_jobs UNCHECKED no foreign key to the subject; cannot address these rows by id alone
password_reset_tokens UNCHECKED no foreign key to the subject; cannot address these rows by id alone
recommendation_events where user_id = 42 PASS
sessions where user_id = 42 PASS
user:42 UNCHECKED no probe registered for redis_key
users where id = 42 PASS
VERIFIED WITH GAPS · 3 pass · 5 unchecked · sha256:88aa82bf…
The snapshot has to come first
Once the subject's row is gone, the Redis keys, storage paths and dependent rows
that were reachable from it can no longer be derived. So the footprint, every
address resolved for that one person, is captured immediately before
deletion and checked immediately after. privacy:process-deletions does
both automatically and attaches them to the request, so one row carries the whole
story: what was there, what ran and what remained.
Verifying without a captured footprint still works, and says what it is:
No footprint was captured for this subject, so addresses are being derived from policy now. That confirms policy coverage; it cannot prove what was removed.
UNCHECKED is not PASS
The distinction is the entire point. A report that silently passed what it never looked at would be worse than no report.
| Situation | Outcome |
|---|---|
| Probe looked, found nothing | PASS |
| Probe looked, data remains | FAIL, exit 1 |
| Kept on purpose | RETAINED, with the documented reason |
| No probe for that store | UNCHECKED |
| Probe threw (store unreachable) | UNCHECKED, with the error |
| PII with no key to the subject | UNCHECKED, we know it is there, we cannot address it |
A run with any gaps reports VERIFIED WITH GAPS, never VERIFIED and exits
non-zero. Each result carries a stable sha256 fingerprint that excludes the
timestamp, so the same evidence hashes identically whenever it is re-rendered.
Probes ship for database, object storage and Redis; register them in
verification.probes. Removing one does not weaken the report, the addresses it
covered simply show as unchecked, which is the honest outcome.
Generate the deletion handler
Policy-as-code only describes the world. privacy:make-handler makes it
produce something, at zero runtime risk, because we emit code, not effects.
php artisan privacy:make-handler
final class DeleteUser implements SubjectDeleter { public function delete(DeletionRequest $request): void { $subjectId = $request->subjectId; Storage::disk('s3')->delete("avatars/{$subjectId}.png"); Redis::del("user:{$subjectId}"); DB::table('sessions') ->where('user_id', $subjectId) ->update([ 'ip_address' => null, 'user_id' => null, ]); // retained: statutory accounting retention, 7y \App\Models\User::query()->whereKey($subjectId)->delete(); // TODO: these locations have no policy and are therefore untouched. // - recommendation_events.user_id } }
It lands in your repository, you review it as a diff and you own it afterwards.
It implements SubjectDeleter, so pointing privacy.lifecycle.deleter at it
wires the whole erasure lifecycle together.
Ordering is the hard part, and it is handled. Non-database stores are cleared first, once the subject's row is gone, a key pattern built from it can no longer be resolved. Then rows furthest from the subject, then the subject's own row last, so foreign keys stay satisfied throughout.
The rest of the behaviour:
| Unclassified locations | Loud TODOs, never silently omitted |
RETAIN |
A comment carrying the documented reason, no action |
IGNORE |
Absent entirely |
CUSTOM |
Calls your handler class |
| A table with no model | Falls back to DB::table() |
| PII with no foreign key | TODO, we know it is there, you supply the lookup |
| An existing file | Refuses to overwrite without --force |
| Unchanged policy | Byte-identical output |
Use --print to see it without writing, --class and --namespace to place it.
Deleting or masking the subject
Neither is a default. Every location starts unclassified and every scaffolded rule arrives commented out, so the choice is made once, in your policy, and reviewed like any other change.
For the subject's own row privacy:make-policy offers both, because neither is
obviously right:
// $this->delete(User::class); // $this->anonymize(User::class, ['email' => 'deleted@example.invalid', 'name' => 'Deleted user']);
Deleting forces every foreign key pointing at the row to be nullable or cascading, or the delete is refused. Masking keeps orders, comments and audit rows attributable to somebody, just not to a person.
Mixing is fine and usual: mask the subject, delete the sessions, anonymise the comments, keep the orders.
Masking
$this->anonymize(User::class, [ 'name' => 'Deleted user', 'email' => 'deleted@example.invalid', 'password' => '', ]);
Mask to placeholders, not to null. On most subject tables the identifying
columns are NOT NULL, so an update to null fails at erasure time. If you try,
privacy:make-handler says so before you ship it:
Anonymising sets these to null, but the schema declares them NOT NULL, so the erasure will fail: users.email, users.name. Make the columns nullable, or delete the rows instead of anonymising them.
Three things behave differently for the subject's own row:
| It is found by its own key | not by a foreign key it does not have |
| The update is not chunked | one row, and the key must survive, so a loop would never end |
| Verification asks a different question | not "is the row gone" but "did anything identifying survive" |
That last one matters. A masked subject that still held its old email would pass an absence check, since the row is meant to be there. Verification compares each masked column against the value the policy declared:
users where id = 1, masked PASS
users where id = 1, masked FAIL data still present
The second line is a real run after putting the original email back.
If your email column is unique, give each erased subject a distinct placeholder, or the second erasure collides with the first.
Self-service erasure: suspend, then delete
There is no undo. So a deletion request does not erase anything. It stops processing immediately and starts a 14-day clock; only when the clock runs out does anything become irreversible.
use PrivacyCI\Lifecycle\Laravel\RequestsDeletion; class User extends Authenticatable { use RequestsDeletion; }
$user->requestDeletion(via: 'account settings'); // suspended now, erased in 14 days $user->deletionPending(); // true $user->isSuspendedForDeletion(); // true $user->daysUntilDeletion(); // 14 $user->reactivate(); // "actually, keep my account"
Why suspend rather than just wait
GDPR separates storing data from processing it. Suspending on day zero means the request is substantively honoured within hours, and the window becomes a recovery period for the data rather than a month of doing nothing.
| Day 0 | Day 14 | Posture | |
|---|---|---|---|
mode: suspend (default) |
processing stops | deleted | Acted immediately, recovery preserved |
mode: hold |
nothing changes | deleted | Simpler; a long window means continued processing |
grace_days: 0 |
deleted | . | No recovery at all. Set it knowingly |
Window length and mode are linked. Art. 12(3) allows one month to respond, so a 30-day hold spends the entire allowance and leaves nothing if the erasure then fails. If you do not want suspension, shorten the window instead.
You write suspend and delete; we own the timing
Two interfaces, both implemented by your application. Neither has a working default, until they are bound, requests fail loudly, because an app must never be able to tell someone their account is closed while it stays fully active.
use PrivacyCI\Lifecycle\{DeletionRequest, SubjectSuspender, SubjectDeleter}; class SuspendUser implements SubjectSuspender { public function suspend(DeletionRequest $request): void { // set status to pending_delete, revoke sessions and tokens, hide the // profile, unsubscribe from sends, drop from search and analytics. // Nothing here deletes, every change must be undoable below. } public function reactivate(DeletionRequest $request): void { // put it all back } } class DeleteUser implements SubjectDeleter { public function delete(DeletionRequest $request): void { // your existing DeleteUserJob, unchanged } }
Point privacy.lifecycle.suspender and privacy.lifecycle.deleter at them.
Reactivation is deliberate
Under suspension the subject cannot sign in, so login can no longer be the cancellation signal, and the schedule ignores it in this mode. Instead they get a signed link, and the package ships the route, the pages and the email.
Send it from your suspend(), where you already have the user:
use Illuminate\Support\Facades\Mail; use PrivacyCI\Mail\DeletionScheduledMail; public function suspend(DeletionRequest $request): void { $user = User::findOrFail($request->subjectId); $user->update(['status' => 'pending_delete']); $user->tokens()->delete(); // ...hide the profile, unsubscribe, drop from indexes Mail::to($user)->send(new DeletionScheduledMail($request)); }
The package does not try to work out how to contact a subject. Only your application knows that. Subclass the mailable or publish the views to match your own tone:
php artisan vendor:publish --tag=privacy-views
Three properties of the link, all tested:
- It reactivates and nothing else. A link in an inbox is a bearer token. The worst case here is an account staying alive that should have been deleted, which the subject can simply re-request. A link that could delete would let anyone who forwarded it destroy an account permanently.
- It expires exactly when the grace period does. A link that outlives the window implies a rescue that is no longer possible.
- Opening it changes nothing. The link renders a confirmation page; the
reactivation happens on
POST. Mail scanners and prefetchers. Outlook Safe Links, corporate proxies, some mobile clients, follow every URL in an email, and a one-clickGETwould silently cancel deletions nobody asked to cancel.
It does not sign anyone in, either. Restoring the account and proving who you are stay separate; turning an emailed link into a session is a much larger security surface than this feature needs.
If you would rather intercept the login attempt instead, the primitives are there:
if ($user->deletionPending()) { return view('auth.reactivate', ['days' => $user->daysUntilDeletion()]); }
In hold mode the login listener still cancels automatically and no email is
needed.
Reminders
Nobody reads the first email. remind_days (default [7, 1]) sends a
last-chance notice as the deadline approaches, usually the difference between
the grace period working and not.
use PrivacyCI\Lifecycle\{DeletionRequest, SubjectNotifier}; use PrivacyCI\Mail\DeletionReminderMail; class NotifySubject implements SubjectNotifier { public function remind(DeletionRequest $request, int $daysRemaining): void { $user = User::findOrFail($request->subjectId); Mail::to($user)->send(new DeletionReminderMail($request, $daysRemaining)); } }
Point privacy.lifecycle.notifier at it. The reminder carries the same signed
reactivation link, and reads more urgently at one day out.
This is a third interface rather than a config'd email column for the same reason as the other two, but note the difference: the first notice is sent by your suspender, which already has the user. Reminders fire days later from a scheduled command holding nothing but an id, so resolution has to happen here.
What the scheduler guarantees, all tested:
| A daily tick | Sends each mark exactly once, never re-sends |
| A missed tick | Still sends, late, rather than skipping the mark |
| A week-long outage | Sends only the most urgent message, not "7 days left" then a correction |
| A failed send | Not marked sent; retried next tick, because it is their last chance |
| One failure | Recorded against that subject; everyone else still gets theirs |
| Reactivating | Stops reminders immediately |
remind_days: [] |
Disables reminders without touching the notifier |
Reminders run before erasure on each tick, so a "1 day left" notice can never go out on the same run that erases the account it refers to.
From the console
php artisan privacy:forget 123 # suspend now, erase in 14 days php artisan privacy:forget 123 --cancel # reactivate and call it off php artisan privacy:process-deletions --dry-run
privacy:process-deletions is scheduled daily at 03:00 and erases only subjects
whose window closed and who did not come back.
Guarantees, all covered by tests
| Asking twice | Returns the original request, you cannot buy another window |
| A failed suspension | Rolls the request back; nobody is told they are closed while active |
| One failed erasure | Recorded against that subject; everyone else still proceeds |
| A failed erasure | Stays visible for a human, never silently retried |
| In-flight requests | Claimed before work starts, so two ticks cannot collide |
| Two open requests | Refused by a database constraint, not just application code |
| Repeated request/cancel | Allowed, each cycle leaving its own audit row |
| An unsigned or tampered link | Rejected with 403 |
| A link followed twice | Shows "already active" rather than erroring |
Static analysis finds what the schema cannot
Plenty of personal data never reaches a column. Stage B reads your application code for identifiers written to cache, object storage and Redis:
Cache::put("user:{$user->id}", $payload); Storage::disk('s3')->put("avatars/{$user->id}.jpg", $file); Redis::set('profile:' . $user->id, $json);
user:{user.id} inferred 0.80 UNCLASSIFIED
avatars/{user.id}.jpg inferred 0.80 UNCLASSIFIED
Patterns keep the interpolated expression, user:{user.id}, not user:*,
because a developer reading the report can judge the first at a glance and
cannot judge the second at all. Concatenation and interpolation both work, and
Storage::disk('s3') attributes the write to that disk rather than the default.
Precision here is inherently poor, and the design admits it. PHP interpolates
dynamically, applications wrap everything in repositories and helpers and a key
is only recognisable as personal by how it is named, user:{$userId} and
report:{$reportId} are identical in shape. So every Stage B finding is
inferred, which means it can only ever warn:
report:{reportId} |
Ignored, names something other than the subject |
app:config |
Ignored, wholly literal, cannot key on a person |
Cache::get(...) |
Ignored, a read is not a write |
| Same key, three files | One finding, not three |
| Any finding at all | inferred · warns · never fails CI |
A false positive that blocks a deploy costs the customer permanently; a missed
Redis key costs one review. Set discovery.source_paths to [], or pass
--no-static, to switch it off.
It never touches your database
Discovery reads migrations, config and composer.lock from source. It does not
open a database connection and never reads a single row, so it runs safely in CI
against a bare checkout with no credentials present.
Migrations are replayed as a history, not unioned: a column added in 2021 and dropped in 2023 does not appear in the results.
Raw SQL migrations are read too. Long-lived applications often never used the
Blueprint builder, their schema arrived as a dump wrapped in DB::statement(),
and the table it defines is usually the oldest and most important one:
DB::statement("CREATE TABLE `legacy_members` ( `member_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `state` enum('Active','Suspended','Pending Close') NOT NULL, PRIMARY KEY (`member_id`), CONSTRAINT `fk` FOREIGN KEY (`owner_id`) REFERENCES `legacy_members` (`member_id`) ) ENGINE=InnoDB;");
Columns, nullability and inline FOREIGN KEY constraints are all extracted, and
enum('Active','Pending Close') does not shred the column list the way a naive
comma split would.
If your subject table is not users.id, say so:
'subjects' => ['user' => 'legacy_members.member_id'],
Certain findings versus likely ones
Every finding carries a confidence and a linkage explaining why we believe it.
| Linkage | Example | Can fail CI |
|---|---|---|
subject_root |
users.id |
yes |
foreign_key |
comments.user_id → users.id |
yes |
relationship |
belongsTo(User::class) with no constraint |
yes |
declared |
named in a privacy policy | yes |
heuristic |
orders.shipping_address |
no, warns only |
inferred |
Cache::put("user:{$user->id}", …) |
no, warns only |
Only deterministic findings may ever fail a build. A false positive that blocks a deploy costs more trust than a missed column, so this is not configurable.
The findings manifest
Everything is an operation on one document. Diff it across commits and you have the CI check; sign and retain it and you have audit evidence; join several and you have a cross-service map.
{
"schema_version": "1.0",
"project": "acme/api",
"subjects": [{ "type": "user", "root": "users.id" }],
"locations": [
{
"id": "db:primary:comments.user_id",
"kind": "database_column",
"store": "primary",
"path": "comments.user_id",
"subject": "user",
"linkage": "foreign_key",
"confidence": 1,
"classification": "UNCLASSIFIED",
"evidence": ["comments.user_id -> users.id"]
}
]
}
It carries structure and identifiers, never values. Serialisation is deterministic, so two scans of unchanged code produce byte-identical output, without which diffing would be meaningless.
What ends up in the audit trail
One row per erasure, in privacy_deletion_requests, carrying the whole story:
subject_type user
subject_id 1
status completed
requested_at 2026-09-13 04:08:40 requested_via account settings
suspended_at 2026-09-13 04:08:40 resolved_at 2026-09-13 04:08:41
policy_fingerprint sha256:6f9fb576... attempts 1
footprint 10 addresses captured before deletion
verification passed=true complete=false
evidence hash sha256:3c89eaaa...
policy_fingerprint hashes the rules that were in force, so the trail can
answer which policy governed an erasure once the policy has changed. Changing a
rule changes the hash, including a retention reason, since that is the
justification an auditor reads.
Events, for your own logging
Nothing here writes to a log channel. It announces the lifecycle instead, and your application decides what that means:
Event::listen(DeletionRequested::class, LogPrivacyEvents::class); Event::listen(DeletionCompleted::class, LogPrivacyEvents::class);
DeletionRequested, DeletionCancelled, DeletionCompleted and
DeletionFailed each carry the request and nothing else, and have no framework
dependency. A listener that throws cannot undo an erasure that already happened.
What this is not
The row is updated in place, so the transitions pending -> suspended -> completed overwrite one another: you get the outcome and its timestamps, not
the sequence. Rows are not hash chained either, so a deleted row leaves no
trace. The evidence hash proves that evidence is unmodified. It does not prove
the set of rows is complete.
What this is not
The output is engineering evidence, not legal certification.
privacy:check passing means no deterministic, newly introduced user-linked
storage lacks a policy. It does not mean the application is compliant with the
GDPR, the CCPA or anything else. VERIFIED means every address in a captured
footprint was inspected and found clear, it says nothing about data in stores
that were never mapped, addresses reported UNCHECKED or personal data the
heuristics did not recognise.
Three limits worth stating plainly:
- Discovery is incomplete by construction. It finds what migrations, models, config and code reveal. Data written by a service you do not own, by raw SQL the parser could not read, or by a key built dynamically at runtime is not in the map, and a location that is not in the map is never verified.
- Heuristic findings are guesses. A column matching a name pattern may hold nothing personal; a column matching nothing may hold a great deal. That is why they only ever warn.
- Verification proves absence at known addresses. It cannot prove that the set of known addresses was complete.
Deciding what counts as personal data, what must be erased and what may be retained is a legal judgement this tool does not make. It gives an engineering team a map, a gate and a record, take those to whoever owns that judgement.
Provided under the MIT licence, without warranty of any kind.
Development
composer install vendor/bin/phpunit --testdox # unit + feature vendor/bin/phpunit --testsuite Unit # no Laravel boot required ./bin/privacy-ci --path=tests/fixtures/demo-app --no-ansi
The demo app under tests/fixtures/demo-app wires migrations, models and a
policy into the conventional Laravel locations, so it exercises the whole path.
### Working against a real application
To develop against an app on the same machine, point Composer at the checkout
rather than the repository. Composer symlinks it, so edits take effect with no
reinstall:
```jsonc
"repositories": [
{ "type": "path", "url": "../privacy-ci", "options": { "symlink": true } }
],
composer require privacy-ci/laravel:@dev
A path repository resolves to dev-main from the branch name, which is why the
@dev constraint and minimum-stability: dev are needed. Neither applies to a
normal install, which resolves a tagged version from Packagist.
License
MIT.