A GDPR deletion request across eleven tables

An erasure request arrived by email on a Tuesday with a thirty-day statutory deadline. The first question — where is this person’s data — took two days to answer and the answer was eleven tables, a search index, four Redis key patterns, a log store, an error tracker and two third-party processors. None of that had been written down.

The symptom

$ mysql -Nse "SELECT table_name, column_name
  FROM information_schema.columns
  WHERE table_schema='shop'
    AND column_name REGEXP 'email|phone|name|address|ip'"
customers        email
customers        phone
customers        name
customers        address_line_1
orders           billing_name          ← a denormalised copy
orders           billing_address
invoices         customer_name         ← another
audit_log        actor_email
audit_log        ip_address
received_webhooks payload              ← JSON. unknown contents.
...

# 11 tables. and the last one is a blob nobody can grep.

The column-name search finds the obvious ones and misses the interesting ones: a JSON payload column containing whatever a payment provider sent, a serialised job payload, a notes field where somebody typed a phone number. The obvious ones took an hour and the rest took two days.

Why it happens

Personal data spreads through denormalisation, through logging and through integration payloads, and each of those is a reasonable decision made at a different time by a different person. There is no moment at which somebody decides to put a customer’s name in eleven places.

The fix

An inventory that is a file

customers:
  personal: [name, email, phone, address_line_1, address_line_2]
  action: anonymise
  reason: order history must survive for accounting

orders:
  personal: [billing_name, billing_address]
  action: anonymise
  reason: denormalised at time of order; required on invoices

audit_log:
  personal: [actor_email, ip_address]
  action: retain
  reason: legitimate interest, fraud investigation, 7 years
  review: 2023-07-01

received_webhooks:
  personal: [payload]
  action: delete
  reason: raw provider payloads; 14-day retention already

search_index:
  personal: [name, email]
  action: delete
  system: elasticsearch

The reason field is what makes this defensible rather than merely a list — a decision to retain needs a stated legal basis, and writing it when the table is created is much easier than reconstructing it under a thirty-day deadline. The review date on the retention entries is what stops “seven years” becoming “forever”.

// the CI check that keeps it current
public function testEveryPersonalColumnIsInTheInventory(): void
{
    $suspicious = $this->columnsMatching('email|phone|name|address|ip');
    $covered    = $this->inventory()->allColumns();

    $this->assertEmpty(
        array_diff($suspicious, $covered),
        'columns not in data-inventory.yml: ' . implode(', ', $missing)
    );
}

The heuristic is crude and catches the common case, which is a migration adding a column called contact_email to a new table. It cannot catch a JSON blob or a free-text field, so the inventory needs a manual review at a cadence as well — quarterly, in the same slot as the flag review.

Deleting versus anonymising

-- deleted outright: no legal basis to keep
DELETE FROM sessions WHERE user_id = ?;
DELETE FROM saved_searches WHERE user_id = ?;
DELETE FROM received_webhooks WHERE customer_id = ?;

-- anonymised: the row must survive for accounting
UPDATE customers SET
  name  = 'Deleted customer',
  email = CONCAT('deleted-', id, '@invalid'),
  phone = NULL,
  address_line_1 = NULL, address_line_2 = NULL,
  anonymised_at = NOW()
WHERE id = ?;

-- and the denormalised copies, which are the ones that
-- get missed
UPDATE orders SET billing_name = 'Deleted customer',
                  billing_address = NULL
WHERE customer_id = ?;

Keeping the row and its identifier is what preserves referential integrity and every aggregate that depends on it — an order with a null customer breaks the revenue report. Using a syntactically valid but undeliverable email is the detail that matters, because a null in a unique column collides on the second deletion.

The anonymised_at column is what makes the state visible afterwards: without it, a support agent looking at an order sees “Deleted customer” and cannot tell whether that is an erasure or a data quality problem.

The stores that are not the database

  the search index    a delete by id, and a reindex if the
                      document is derived from several rows
  the cache           a key pattern, or a version bump.
                      SCAN, never KEYS.
  the queue           a job enqueued BEFORE the request
                      carries the data in its payload and
                      will run afterwards. drain, or filter.
  the logs            a retention window. there is no
                      delete, and pretending otherwise is
                      the dishonest answer.
  the error tracker   an API call, per event. rate limited.
  the analytics       their API, their timeline, their SLA.
  the backups         a retention window, and a replay of
                      erasures after any restore.

The queue is the one that surprises people and it is genuinely awkward: a job enqueued an hour before the request contains the customer’s name in its serialised payload and runs after the deletion has completed. Draining the queue before processing an erasure is the simple answer and it means the erasure waits for the slowest job.

// the erasure is itself a job, and it runs LAST
Bus::chain([
    new DrainCustomerJobs($customer),
    new EraseFromDatabase($customer),
    new EraseFromSearchIndex($customer),
    new EraseFromCache($customer),
    new EraseFromErrorTracker($customer),
    new RecordErasureCompleted($customer),
])->dispatch();

A chain rather than a batch is deliberate: the order matters, and a failure at step three must not leave steps four and five running against a half-deleted record. Each step is idempotent so the chain can be retried from the beginning, which is the only recovery that is simple enough to be correct.

Backups, where there is no clean answer

the three defensible positions:

  1  a bounded retention window, documented. the data is
     gone within N days, and no backup is restored without
     re-applying pending erasures.

  2  crypto-shredding — a per-subject encryption key, and
     deleting the key. expensive, and a decision made
     before the first row is written.

  3  an erasure replay log, applied after any restore.
     the operational answer, and it must be TESTED.

what is not defensible: claiming the data is deleted.
// the replay log, which is the only part of this that is
// cheap and is routinely omitted
ErasureLog::create([
    'subject_type' => Customer::class,
    'subject_id'   => $customer->id,
    'completed_at' => now(),
    'operations'   => $appliedOperations,
]);

// and in the restore runbook, as a mandatory step:
//   php artisan erasure:replay --since=<backup date>

The replay step in the restore runbook is the piece that makes position three real, and it is exactly the sort of step that is omitted from a runbook written before the capability existed. Testing it during a restore drill is the only way to know it works, and the drill has to include a subject who was erased after the backup was taken.

The third parties

  the payment provider   their API supports it. 30-day SLA.
                         recorded, with the request id.
  the email platform     their API supports it. immediate.
  the error tracker      an API call per event, rate
                         limited to 100/minute.
  the analytics platform an export-and-delete flow that
                         takes up to 14 days.
  a legacy CRM           an email to a support address.
                         no API. no SLA. logged as a risk.

the last one is the honest entry in the register: a
processor with no programmatic erasure is a compliance
liability that engineering cannot fix.

Recording the request identifier from each processor is what turns “we asked them” into evidence, and it is the difference between a defensible position and an assertion. The processor with no API is a risk to be escalated rather than engineered around, and naming it in the register is the correct engineering response.

Verifying it worked

# a test account, erased, and every store checked
$ php artisan erasure:verify --customer=99999
  database                                 clean
    customers               anonymised
    orders                  anonymised (4 rows)
    sessions                deleted (0 remain)
    received_webhooks       deleted (12 rows)
  elasticsearch                            clean
  redis (4 patterns)                       clean
  error tracker                            clean
  analytics                                PENDING (day 3 of 14)
  legacy CRM                               MANUAL (emailed 2022-07-04)

$ vendor/bin/phpunit --filter DataInventoryCoverage
Tests: 3 passed

A verification command that reports per store, including the ones that are pending and the ones that are manual, is what makes the thirty-day deadline manageable — the status is a command rather than an investigation. Running it against a deliberately created and erased test account, monthly, is what keeps it true as the schema changes.

What this costs

A map that must be updated with every schema change, enforced by a heuristic that catches the obvious cases and misses the JSON blobs. The quarterly manual review is the part that will lapse, and lapsing means the inventory describes last year’s schema — which is worse than no inventory because it will be relied upon.

The chain of erasure jobs is also a piece of infrastructure that runs rarely and therefore rots. It calls five external APIs, each of which can change, and the failure is discovered on the twenty-eighth day of a thirty-day deadline. The monthly test-account verification is what catches that, and it costs a scheduled job and somebody reading its output.

The honest position on backups is the one that requires the most explaining to people outside engineering: the data is not deleted from a backup, it is deleted from every restored copy and the backup expires within the retention window. That is what the regulation expects and it is not what “delete my data” sounds like, so the privacy policy has to say it in words a person can read.