cd /news/developer-tools/reversible-pii-anonymization-for-lar… · home topics developer-tools article
[ARTICLE · art-82403] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Reversible PII anonymization for Laravel with Laranon

Eduardo Lázaro released Laranon, an open-source Laravel package that anonymizes personally identifiable information (PII) in AI chat prompts by replacing it with stable placeholders, then restores the original values in the model's response. The package validates identifiers like Spanish DNI, IBAN, and credit card numbers to reduce false positives, and keeps the token map in memory by default, with an optional database vault for queued jobs.

read7 min views1 publishedJul 31, 2026

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.

It ships as a single Composer package:

composer require edulazaro/laranon

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.

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.

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

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

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.

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.

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

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.

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.

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');

$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.

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

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

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.

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);

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();

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.

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

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);

And there is a laranon:scan

command to audit what a corpus would detect before you trust it in production.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @laranon 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/reversible-pii-anony…] indexed:0 read:7min 2026-07-31 ·