Lock for Craft CMS

Free for answering requests · Pro for the automation

The part that makes the policy true

The Craft plugin store has cookie banners. It has nothing behind them. A privacy policy promises that somebody can ask what you hold on them and have it deleted — Lock is the part that keeps the promise: a door for the request, an assembly of everything the site actually holds about one person across every source it has, the choice between anonymising and erasing, a ledger you can put in front of a regulator, and retention rules that delete on a timer instead of on a good intention.

Lock

One question, two answers

Everything a data subject can ask for is one question with two answers — “what have you got on me”, and “now get rid of it”. Both need the same thing first: a complete and honest list of every place this site holds personal data about one person. Get that list wrong and the disclosure is incomplete, the erasure is partial, and neither failure announces itself. So Lock is built around one object per source — a collector — and each one does all three jobs.

bash
┌──────────────┐
   ada@example.com ──► │  collectors  │ ──► the dossier      "what have you got on me"
                       │  (14 of them)│ ──► the plan   ──►   "now get rid of it"
                       └──────────────┘

php craft lock/erase/assemble --email=ada@example.com   # the dossier, from the shell
php craft lock/erase/preview  --email=ada@example.com   # what would happen, and why
php craft lock/erase/run      --email=ada@example.com   # carry out that plan (needs --force)
php craft lock/erase/check    --email=ada@example.com   # has this address been erased?

Users & addresses        anonymise / erase   the account, its fields, addresses, sessions
Entries & custom fields  anonymise / erase   scanned by field layout element UID
Commerce orders          anonymise only      Art. 17(3)(b) — the record stays, the person goes
Formie submissions       anonymise / erase   read from formie_submissions.content
Freeform submissions     anonymise / erase   schema read at query time, one column per field
Comments, Toss           anonymise / erase   every integration guarded by isAvailable()
Consent records          never deleted       Art. 7(1) — you must be able to demonstrate it
Log files                neither, with a reason, and named in the dossier anyway

Features

The alternative to one collector per source — a finder over here and an eraser over there — drifts. The disclosure lists a table the eraser has never heard of; the eraser clears a column the disclosure never showed anyone. Both are the kind of failure a site only discovers when a regulator asks.

Intake that gives nothing away

A front-end form, a verification link, and a page where somebody can check on a request with their reference and their address. Every response the public side gives is identical — success, unknown address, rate limit, honeypot — because any difference between them is an oracle for finding out who has an account.

  • Twig you write, posting to craft.lock.actionUrl()
  • Double opt-in on the address before anything is assembled
  • Rate limiting and a honeypot, neither of them visible in the response

Everything, or an honest account of why not

Fourteen collectors run, and one of them throwing does not abandon the other thirteen. The failure is recorded against the dossier and the dossier reports itself as partial. Nine sources out of ten with no mention of the tenth is what turns a handled request into a complaint.

  • Users, entries, Commerce, Formie, Freeform, Comments, Toss, sessions, logs and more
  • Sources the plugin cannot know about are declared in the control panel
  • Anything absent from the site is recorded as not searched, not silently skipped

The preview is what runs

A plan is a list of specific records with a decided action each, and pressing the button runs that list — it never searches again. Re-searching is shorter code and wrong twice over: rows created between the preview and the button would be deleted without anybody having seen them, and rows the preview listed might be gone.

  • The plan is fingerprinted, the fingerprint is posted back, and a mismatch stops the run
  • Labels are excluded from the fingerprint — a renamed order is the same deletion
  • An approval that does not bind is not an approval

Anonymise is not a softer erase

A completed order is a tax record — six years in the UK, ten in Germany — and Article 17(3)(b) is explicit that erasure does not override a legal obligation to keep something. But the order is what the obligation covers, not the customer's name inside it. So Lock empties the order and keeps it.

  • The money, the tax, the line items, the date, the country and the region all stay
  • Name, email, phone, street address and the link to the account do not
  • The pseudonym is derived from the address, so two anonymised rows still line up as one person

A consent ledger about people, not browsers

A cookie banner records that a browser agreed to analytics. That is right for an anonymous visitor and useless the moment somebody writes in and asks what you hold on them. Lock's ledger records that a person agreed to something: which purpose, when, through what, and what the wording said at the time.

  • Consent records are never deleted — Article 7(1) requires you to be able to demonstrate them
  • Withdrawal is a new record, not an edit, so the history survives
  • The Article 30 register is a living document a DPO maintains and prints

Holds that outrank a deletion request

"We deleted it under our automated retention policy" is not a defence to a preservation order, and Article 17(3)(e) lets you keep what you need for legal claims. A hold beats a retention rule and beats an erasure request, per person or site-wide, with an expiry and a reason written for somebody reading it in two years.

  • Applied before a plan is built, so held records never reach a preview
  • The suppression list keeps an erased address from being re-added by an import
  • lock_erasures stores a keyed hash, never an address — a table of people you deleted has not deleted anybody

Retention that is held to the same standard

Pro. Instead of "everything belonging to this person", "everything in this scope older than this date" — and from there it is the same plan, the same preview, the same executor, the same ledger. A nightly automated deletion gets exactly the guarantees a hand-run erasure gets, rather than being a second, less careful deletion path.

  • Rules live in project config, because a rule that deletes on a timer should arrive by deploy
  • Every row in a sweep gets its own pseudonym — ten thousand orders sharing one are linkable again
  • lock/report/gaps names the data no rule covers, for CI

A trail nobody can tidy

The activity ledger has no update path and no delete path, and the Activity screen has no buttons. An audit trail administrators can quietly tidy is a diary. The log on disk is the authoritative copy, because a tool whose job is deleting personal data needs a trail that survives the database it was deleting from.

  • lock_activity has no foreign key to the request, so the line saying a request was deleted outlives it
  • User foreign keys are SET NULL, never CASCADE — deleting a person must not delete the evidence
  • storage/logs/lock.log is the record; the database table is the convenient copy

Put the form on the site, put two commands on cron

The intake form is Twig you write, so it looks like the rest of the site rather than like a plugin. Link it from the privacy policy — Article 12(2) asks you to facilitate the exercise of these rights, and a form nobody can find does not. Then the daily commands: the first is the one that matters, because nothing else on the site will tell you it is about to miss a statutory deadline while there is still time to do something about it.

twig
{% if craft.lock.intakeEnabled() %}
    <form method="post" action="{{ craft.lock.actionUrl() }}">
        {{ csrfInput() }}
        {{ hiddenInput('type', 'access') }}

        <label>Your email address
            <input type="email" name="email" required>
        </label>

        {# The honeypot. A bot fills it in; a person never sees it. #}
        {{ craft.lock.honeypot() }}

        <button type="submit">Send the request</button>
    </form>
{% endif %}

{# Every outcome renders the same page. A different one for "no such address" #}
{# would tell a stranger which addresses have accounts here.                   #}

# ── crontab ────────────────────────────────────────────────────────────────
0  3 * * *  cd /path/to/site && php craft lock/requests/deadlines   # reminders
15 3 * * *  cd /path/to/site && php craft lock/retention/due        # rules that are due

Frequently Asked Questions

The questions worth answering before you put a request form on a site.

Answer the request first. Buy the automation when the site outgrows doing it by hand.

Lite is free and covers everything a site needs to answer a subject access request lawfully — intake, the dossier, export, anonymise, erase, consent, holds and the audit trail. Pro is $149 with a $119/year renewal and adds retention rules, the Article 30 register and deadline reminders. Craft 5.3 or later, PHP 8.2 or later, no runtime dependencies.