Redacting PII before it reaches an LLM breaks the answer you wanted
This is me
Latest articles
- GDPR cookie consent in Laravel with Wirecookies (opens on another site)
- Localized routes in Laravel with Laralang (opens on another site)
- Collecting user feedback in Laravel with Wirebug (opens on another site)
- The same operation ends up in three places, and none of them agree
- Laravel makes you choose between readable templates and stable translation keys
- Model observers are the wrong home for derived fields
- A coding agent is a generalist, and your codebase is not general
- Translating a Laravel app is two problems, and only one of them is about words
- The tags table is simple until the fourth requirement arrives
I build a legal-tech product where an AI assistant answers questions about real cases. Client names, national IDs, IBANs, phone numbers. That conversation goes to a model I do not run, so sending it raw is not an option.
The reflex is a wall of regexes that blank anything ID-shaped. It fails in both directions at once, and the second failure is the interesting one.
It is wrong about what it matches. Regexes over-match, redacting a reference number that happened to look like an ID, and under-match, missing the ID someone typed with spaces. You cannot tune your way out; tightening one direction loosens the other.
And it destroys the thing you wanted. This is the part people discover late. Redaction is one-way, so the model is now reasoning about [REDACTED] who owes money to [REDACTED] regarding [REDACTED]. It cannot distinguish the two parties, cannot follow a reference across paragraphs, and cannot give you an answer with anyone's name in it. You protected the data by deleting the meaning.
What you actually want is pseudonymization: the model sees consistent, meaningless handles it can reason about, and you swap the real values back into the reply. The identifiers never leave your server, and the answer still makes sense.
Checksums, not patterns
The false-positive problem has a clean solution for the identifier types that matter, and it is that most of them are not patterns at all.
A Spanish DNI is not "eight digits and a letter". It is eight digits and the correct mod-23 control letter. An IBAN is mod-97. A credit card is Luhn plus a valid issuer prefix. These are checkable, and checking them collapses the false positive rate to almost nothing: 12345678A with the wrong letter is not a DNI, and a regex has no way to know that while a validator does.
This is worth stating because it reframes the problem. Detection is not "how clever can my pattern be", it is "which of these things have a verifiable structure", and for national IDs, bank accounts and cards, nearly all of them do.
Tokens belong to words, not to people
The design decision I went back and forth on most was the unit of tokenization.
The intuitive choice is per person: recognise that "John Smith", "Mr. Smith" and "John" are the same human and give them one token. It sounds better. It requires entity resolution, which means guessing, and a wrong guess in this direction is severe: merge two people and the model reasons about a person who does not exist, then you restore the wrong name into the answer.
So no identity is ever inferred. A token belongs to a word:
John Smith -> «PER_1» «AP_1»
...later, "John" -> «PER_1»
"Mr. Baker" -> «AP_2» (same token as in "John Baker")
"John de la Cruz" -> «PER_1» de la «AP_3»
The model gets exactly the information a human reader gets from the same text (that these two mentions share a surname) and nothing that was invented. Particles and honorifics stay in cleartext because they carry grammar, not identity.
The invariant underneath: two different values never share a placeholder. If they did you would merge two people and garble the restore, which is the one failure mode that must not exist.
Restoring tool arguments is where it clicks
Everything above is about the prompt. The part that took a rewrite to get right is what happens when the model wants to call a tool.
The model has been reasoning about «AP_1». It decides to look up the client's open cases and hands you «AP_1» as the argument. If you pass that to your tool, the query looks for a client literally named «AP_1» and finds nothing.
So restoration is not one step at the end. It happens at three points:
$payload = $anon->anonymize($messages, ['content', 'tool_calls.*.function.arguments']);
$toolCalls = $anon->restore($response->toolCalls, 'function.arguments');
$reply = $anon->restore($response->content);
The middle line is the whole trick: the model never sees a real value, and the database never sees a token. Each side operates in its own vocabulary and the map sits between them.
Note the first line covers tool call arguments in the history too. Miss that and every replayed turn leaks the real values you were careful about the first time.
Sessions die, scopes persist, and that is a security decision
A chat turn wants one in-memory map covering the whole prompt, gone when the request ends. That is the default, and it means the ordinary path stores nothing anywhere.
Because tokens are deterministic in reading order, you can keep your chat history in the clear and re-anonymize the whole prompt from scratch each turn: the tokens come out identical, so the conversation stays coherent with zero state carried between turns. No stored map, no expiry, no cleanup.
A queued job cannot do that, because it runs in another process after the request is gone. So there is a scope with a persistent vault, encrypted with your app key. And forget():
Laranon::scope("job-{$id}")->forget();
That call is the line between pseudonymization and anonymization. While the map exists the data is reversible, which under GDPR means it is still personal data. Once the map is gone the tokens cannot be turned back by anyone, including you. Making that an explicit call rather than a TTL is deliberate: deleting the ability to restore should be something you decided, not something that happened.
One concession to language
app('laranon')->except('person')->newSession();
This tokenizes the surname, DNI, IBAN, phone and email, and leaves the given name in cleartext. In Spanish the given name carries grammatical gender, so tokenizing it makes the model guess agreement and produce "estimad@ «PER_1»".
Keeping "María" while hiding "López García" costs little identifying power (a first name alone rarely identifies anyone) and buys back natural language. Worth knowing that privacy tooling has these trade-offs at all, rather than pretending the maximal setting is always the right one.
Detection types, the session and scope APIs, strategies and the chat loop are on the Laranon page. Source on GitHub, package on Packagist.