DEV Community

Cover image for Reversible PII anonymization for Laravel with Laranon
Eduardo Lázaro
Eduardo Lázaro

Posted on

Reversible PII anonymization for Laravel with Laranon

I build a legal-tech product where an AI assistant answers questions about real cases: client names, national IDs, IBANs, phone numbers, the lot. That conversation goes to an LLM I do not control. Sending it raw is not an option.

The obvious fix, a pile of regexes that blank out anything that looks like an ID, breaks in two directions at once. It has false positives (it redacts things that were not IDs) and false negatives (it misses the ones with odd formatting), and worst of all it is one-way: once you have blanked the data you cannot get a coherent answer back, because the model is now reasoning about [REDACTED] talking to [REDACTED].

I wanted something that detects PII properly, swaps it for stable placeholders the model can reason about, and swaps the real values back into the answer. That is Laranon.

How to install

It ships as a single Composer package:

composer require edulazaro/laranon
Enter fullscreen mode Exit fullscreen mode

That is all it needs. Laranon keeps nothing in a database by default: a chat turn uses an in-memory map that dies with the request. The only time you touch a table is the optional database vault for queued jobs, which I cover further down.

How to use it

Anonymize on the way out, restore on the way back. The token map never leaves your server.

use EduLazaro\Laranon\Laranon;

$result = Laranon::anonymize(
    'Client John Smith, SSN 536-90-4399, wants the transfer to GB29 NWBK 6016 1331 9268 19.'
);

$result->text;
// "Client «PER_1» «AP_1», SSN «SSN_1», wants the transfer to «IBAN_1»."

$reply = $chat->send($result->text);

$result->restore($reply);
// The tokens become the real values again.
Enter fullscreen mode Exit fullscreen mode

That is the entire idea. Everything below is the machinery that makes it trustworthy, and the ways to wire it into a real app.

What makes it more than a regex

A few design choices are the difference between a demo and something you trust with client data.

Checksums, not just patterns

A Spanish DNI is not "eight digits and a letter", it is eight digits and the correct mod-23 control letter. Laranon validates that letter, IBANs by mod-97, credit cards by Luhn plus IIN, and the same for NIE, CIF, NSS, CCC. A 12345678A with the wrong letter is not flagged, which removes most of the false positives that make naive scrubbers useless.

Per-word name tokens

Names are tokenized per word, never per person, and no identity is ever guessed. "John Smith" becomes «PER_1» «AP_1», given name and surname each getting their own stable token. A later bare "John" gets «PER_1» again because the token belongs to the word, not the person. "Mr. Baker" shares the «AP_2» of "John Baker". Honorifics and particles stay in cleartext ("John de la Cruz" reads «PER_1» de la «AP_3»). Exactly the information a human reader has, nothing more.

Replacements never repeat

The token map guarantees two different values never share a placeholder. If they did, you would merge two people and garble the restore.

Exact, reversible restoration

Every token maps back to the literal original text, byte for byte. And it is streaming-safe: a token split across two SSE chunks, even inside the multibyte «, is buffered and restored correctly.

Sessions: the right shape for an LLM turn

For a chat turn you want one in-memory map shared across the whole prompt (the user message, the retrieved context, the tool results), and you want it gone when the request ends. That is a session: a throwaway object that owns its map, persists nothing, and dies with the request.

use EduLazaro\Laranon\Anonymizer;

$anon = Anonymizer::create();

$messages = $anon->anonymize($messages, 'content');   // tokenize the prompt
$reply    = $anon->restore($model->send($messages));  // real values back in the answer
// $anon goes out of scope here. The map is gone. Nothing was stored.
Enter fullscreen mode Exit fullscreen mode

anonymize() and restore() take a string, a list, or a key path into a list of messages, including nested dot paths and a * wildcard:

$anon->anonymize($messages, 'content');
$anon->anonymize($messages, 'tool_calls.*.function.arguments');
Enter fullscreen mode Exit fullscreen mode

$messages here is a plain PHP array in the usual OpenAI chat shape (role, content, tool_calls...). Laranon neither defines nor requires that shape: like Laravel's data_get(), it just walks the dot path you give it through any nested array and anonymizes the string it lands on. Roles, ids, tool names and everything else are left alone.

Because you keep your chat history in the clear (the real values), you do not persist anything anonymized. Each turn just builds a fresh session and re-anonymizes the whole prompt from scratch. The tokens come out identical (they are deterministic in reading order), so a multi-turn conversation stays coherent with zero state carried between turns.

Queued jobs need a scope, not a session

A session lives in memory and dies with the request, which is exactly right for a synchronous chat turn. A queued job is different: it runs later, in another process, long after the request that created the session is gone. There is no in-memory map to share.

For that, use a scope backed by a persistent vault. The map is stored encrypted (with your app key) under a key you choose, so the job can reopen it and restore:

// In the request
$safe = Laranon::scope("job-{$id}")->anonymize($text);
ProcessWithLlm::dispatch($safe->text, $id);

// Later, inside the queued job (a different process)
$reply = Laranon::scope("job-{$id}")->restore($model->send($payload));
Laranon::scope("job-{$id}")->forget(); // drop the map once you are done
Enter fullscreen mode Exit fullscreen mode

The database vault is the one piece that needs a table. Publish its config and migration once:

php artisan vendor:publish --tag=laranon-config
php artisan vendor:publish --tag=laranon-migrations
Enter fullscreen mode Exit fullscreen mode

Then point the scope vault at database in config/laranon.php so it survives across the job boundary; cache is fine for short-lived work and array only lasts one request. And forget() is the switch that turns reversible pseudonymization into real anonymization: once the map is gone the tokens can never be turned back.

Wiring it into a chat loop

The three hooks are the whole pattern. I run it in a legal AI assistant, but none of it is app-specific.

$anon = Anonymizer::create();

// 1. Anonymize the prompt before it leaves. Cover the message content AND the
//    arguments of any tool calls in the history, or PII leaks back on replay.
$payload = $anon->anonymize($messages, ['content', 'tool_calls.*.function.arguments']);

$response = $client->chat($payload);

// 2. The model asked to call tools. Restore the arguments so the tools query
//    your database with the REAL values. The model only ever saw tokens.
//    tool_calls is a list, so the keyed path applies to each call, same as hook 1.
$toolCalls = $anon->restore($response->toolCalls, 'function.arguments');
$result    = runTools($toolCalls);

// 3. Restore the model's answer before you show it or store it.
$reply = $anon->restore($response->content);
Enter fullscreen mode Exit fullscreen mode

Hook 2 is the one that makes it click. The model reasons over «AP_1», but when it decides to look up "the client's open cases" it hands you «AP_1», you restore it to the real surname, and the query hits the database. The model never sees a real value; the database never sees a token.

One language detail worth calling out is except('person'):

$anon = app('laranon')->except('person')->newSession();
Enter fullscreen mode Exit fullscreen mode

That tokenizes the surname, DNI, IBAN, phone and email but leaves the given name in cleartext. In Spanish the given name carries grammatical gender, so tokenizing it makes the model guess agreement wrong ("estimad@ «PER_1»"). Keeping "María" while hiding "López García" gives it enough to write natural Spanish and still protects the identifying part.

Strategies, and the rest

The token strategy above is the reversible one for LLM round-trips. There are two more:

Laranon::strategy('faker')->anonymize($text);  // valid surrogates, same format, reversible
Laranon::strategy('redact')->anonymize($text); // [DNI], one-way, nothing vaulted
Enter fullscreen mode Exit fullscreen mode

faker swaps a real DNI for a valid fake DNI and a name for a plausible name, which is what you want when you generate a document that has to read naturally. redact is the one-way version for logs and anything outbound. On that note, Laranon also drops into your logging stack to scrub every log line, and into the HTTP client for one-way scrubbing of outbound request bodies:

Http::scrubPii()->post($url, $payload);
Enter fullscreen mode Exit fullscreen mode

And there is a laranon:scan command to audit what a corpus would detect before you trust it in production.

Wrapping up

Anonymize on the way out, restore on the way back, with checksums keeping the false positives out and a token map that never leaves your server. What won me over against a hand-rolled regex layer was the unglamorous part: the restore is exact, the same value always maps to the same token, and the session and scope models drop straight into a request or a queued job. It took an afternoon to bolt onto an existing chat loop, not a sprint.

📌 You can see Laranon in action via Inis legal chatbot.

👉 Package on Packagist.
👉 Source on GitHub.

Top comments (0)