Laranon

Laranon detects PII, swaps it for stable placeholders an LLM can reason about, and swaps the real values back into the answer. The token map never leaves your server.
Installation
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 table involved is the optional database vault for queued jobs.
The basic round trip
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.
Detection
Detection is checksum-based, not pattern-based:
| Type | Validation |
|---|---|
| Spanish DNI | mod-23 control letter |
| IBAN | mod-97 |
| Credit card | Luhn plus IIN |
| NIE, CIF, NSS, CCC | Their respective checksums |
A 12345678A with the wrong letter is not flagged.
Names are tokenized per word, never per person, and no identity is guessed. "John Smith" becomes «PER_1» «AP_1»; a later bare "John" gets «PER_1» again because the token belongs to the word. "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».
Two different values never share a placeholder. Restoration is byte-exact, and streaming-safe: a token split across two SSE chunks, even inside the multibyte «, is buffered and restored correctly.
Sessions
For a chat turn you want one in-memory map shared across the whole prompt, gone when the request ends.
use EduLazaro\Laranon\Anonymizer;
$anon = Anonymizer::create();
$messages = $anon->anonymize($messages, 'content');
$reply = $anon->restore($model->send($messages));
// $anon goes out of scope. The map is gone. Nothing was stored.
anonymize() and restore() take a string, a list, or a key path, including nested dot paths and a * wildcard:
$anon->anonymize($messages, 'content');
$anon->anonymize($messages, 'tool_calls.*.function.arguments');
Laranon neither defines nor requires a message shape: like data_get(), it walks the dot path through any nested array. Roles, ids and tool names are left alone.
Because you keep chat history in the clear, nothing anonymized is persisted. Each turn builds a fresh session and re-anonymizes from scratch; tokens come out identical because they are deterministic in reading order, so a multi-turn conversation stays coherent with no state carried between turns.
Scopes, for queued jobs
A queued job runs in another process, long after the session is gone. A scope stores the map encrypted with your app key, under a key you choose.
// In the request
$safe = Laranon::scope("job-{$id}")->anonymize($text);
ProcessWithLlm::dispatch($safe->text, $id);
// Later, inside the queued job
$reply = Laranon::scope("job-{$id}")->restore($model->send($payload));
Laranon::scope("job-{$id}")->forget();
By default a scope uses your cache. If a job can sit longer than the cache TTL, or you cannot risk eviction, switch the vault to database:
php artisan vendor:publish --tag=laranon-config
php artisan vendor:publish --tag=laranon-migrations
forget() is what turns reversible pseudonymization into real anonymization: once the map is gone the tokens can never be turned back.
Wiring a chat loop
$anon = Anonymizer::create();
// 1. Anonymize before it leaves. Cover message content AND tool call arguments
// in the history, or PII leaks back on replay.
$payload = $anon->anonymize($messages, ['content', 'tool_calls.*.function.arguments']);
$response = $client->chat($payload);
// 2. Restore tool arguments so your tools query the database with REAL values.
$toolCalls = $anon->restore($response->toolCalls, 'function.arguments');
$result = runTools($toolCalls);
// 3. Restore the answer before showing or storing it.
$reply = $anon->restore($response->content);
Hook 2 is the important one: the model reasons over «AP_1», hands it back when it wants a lookup, you restore it, and the query hits the database. The model never sees a real value; the database never sees a token.
Keeping the given name in cleartext
$anon = app('laranon')->except('person')->newSession();
Tokenizes surname, DNI, IBAN, phone and email but leaves the given name alone. In Spanish the given name carries grammatical gender, so tokenizing it makes the model guess agreement wrong ("estimad@ «PER_1»").
Strategies
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, for generating documents that must read naturally. redact is the one-way version for logs and anything outbound.
Logging and outbound HTTP
Laranon drops into your logging stack to scrub every log line, and into the HTTP client:
Http::scrubPii()->post($url, $payload);
Auditing
php artisan laranon:scan
Reports what a corpus would detect, before you trust it in production.
built and maintained by Edu Lazaro · MIT license