{"slug": "reversible-pii-anonymization-for-laravel-with-laranon", "title": "Reversible PII anonymization for Laravel with Laranon", "summary": "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.", "body_md": "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.\n\nThe 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]`\n\ntalking to `[REDACTED]`\n\n.\n\nI 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.\n\nIt ships as a single Composer package:\n\n```\ncomposer require edulazaro/laranon\n```\n\nThat 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.\n\nAnonymize on the way out, restore on the way back. The token map never leaves your server.\n\n``` php\nuse EduLazaro\\Laranon\\Laranon;\n\n$result = Laranon::anonymize(\n    'Client John Smith, SSN 536-90-4399, wants the transfer to GB29 NWBK 6016 1331 9268 19.'\n);\n\n$result->text;\n// \"Client «PER_1» «AP_1», SSN «SSN_1», wants the transfer to «IBAN_1».\"\n\n$reply = $chat->send($result->text);\n\n$result->restore($reply);\n// The tokens become the real values again.\n```\n\nThat is the entire idea. Everything below is the machinery that makes it trustworthy, and the ways to wire it into a real app.\n\nA few design choices are the difference between a demo and something you trust with client data.\n\nA 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`\n\nwith the wrong letter is not flagged, which removes most of the false positives that make naive scrubbers useless.\n\nNames are tokenized per word, never per person, and no identity is ever guessed. \"John Smith\" becomes `«PER_1» «AP_1»`\n\n, given name and surname each getting their own stable token. A later bare \"John\" gets `«PER_1»`\n\nagain because the token belongs to the word, not the person. \"Mr. Baker\" shares the `«AP_2»`\n\nof \"John Baker\". Honorifics and particles stay in cleartext (\"John de la Cruz\" reads `«PER_1» de la «AP_3»`\n\n). Exactly the information a human reader has, nothing more.\n\nThe token map guarantees two different values never share a placeholder. If they did, you would merge two people and garble the restore.\n\nEvery 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 `«`\n\n, is buffered and restored correctly.\n\nFor 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.\n\n``` php\nuse EduLazaro\\Laranon\\Anonymizer;\n\n$anon = Anonymizer::create();\n\n$messages = $anon->anonymize($messages, 'content');   // tokenize the prompt\n$reply    = $anon->restore($model->send($messages));  // real values back in the answer\n// $anon goes out of scope here. The map is gone. Nothing was stored.\n```\n\n`anonymize()`\n\nand `restore()`\n\ntake a string, a list, or a key path into a list of messages, including nested dot paths and a `*`\n\nwildcard:\n\n``` php\n$anon->anonymize($messages, 'content');\n$anon->anonymize($messages, 'tool_calls.*.function.arguments');\n```\n\n`$messages`\n\nhere is a plain PHP array in the usual OpenAI chat shape (`role`\n\n, `content`\n\n, `tool_calls`\n\n...). Laranon neither defines nor requires that shape: like Laravel's `data_get()`\n\n, 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.\n\nBecause 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.\n\nA 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.\n\nFor 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:\n\n``` php\n// In the request\n$safe = Laranon::scope(\"job-{$id}\")->anonymize($text);\nProcessWithLlm::dispatch($safe->text, $id);\n\n// Later, inside the queued job (a different process)\n$reply = Laranon::scope(\"job-{$id}\")->restore($model->send($payload));\nLaranon::scope(\"job-{$id}\")->forget(); // drop the map once you are done\n```\n\nThe database vault is the one piece that needs a table. Publish its config and migration once:\n\n```\nphp artisan vendor:publish --tag=laranon-config\nphp artisan vendor:publish --tag=laranon-migrations\n```\n\nThen point the scope vault at `database`\n\nin `config/laranon.php`\n\nso it survives across the job boundary; `cache`\n\nis fine for short-lived work and `array`\n\nonly lasts one request. And `forget()`\n\nis the switch that turns reversible pseudonymization into real anonymization: once the map is gone the tokens can never be turned back.\n\nThe three hooks are the whole pattern. I run it in a legal AI assistant, but none of it is app-specific.\n\n``` php\n$anon = Anonymizer::create();\n\n// 1. Anonymize the prompt before it leaves. Cover the message content AND the\n//    arguments of any tool calls in the history, or PII leaks back on replay.\n$payload = $anon->anonymize($messages, ['content', 'tool_calls.*.function.arguments']);\n\n$response = $client->chat($payload);\n\n// 2. The model asked to call tools. Restore the arguments so the tools query\n//    your database with the REAL values. The model only ever saw tokens.\n//    tool_calls is a list, so the keyed path applies to each call, same as hook 1.\n$toolCalls = $anon->restore($response->toolCalls, 'function.arguments');\n$result    = runTools($toolCalls);\n\n// 3. Restore the model's answer before you show it or store it.\n$reply = $anon->restore($response->content);\n```\n\nHook 2 is the one that makes it click. The model reasons over `«AP_1»`\n\n, but when it decides to look up \"the client's open cases\" it hands you `«AP_1»`\n\n, 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.\n\nOne language detail worth calling out is `except('person')`\n\n:\n\n``` php\n$anon = app('laranon')->except('person')->newSession();\n```\n\nThat 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.\n\nThe token strategy above is the reversible one for LLM round-trips. There are two more:\n\n``` php\nLaranon::strategy('faker')->anonymize($text);  // valid surrogates, same format, reversible\nLaranon::strategy('redact')->anonymize($text); // [DNI], one-way, nothing vaulted\n```\n\n`faker`\n\nswaps 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`\n\nis 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:\n\n``` php\nHttp::scrubPii()->post($url, $payload);\n```\n\nAnd there is a `laranon:scan`\n\ncommand to audit what a corpus would detect before you trust it in production.\n\nAnonymize 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.\n\n📌 You can see Laranon in action via [Inis](https://inis.legal/) legal chatbot.", "url": "https://wpnews.pro/news/reversible-pii-anonymization-for-laravel-with-laranon", "canonical_source": "https://dev.to/edulazaro/reversible-pii-anonymization-for-laravel-with-laranon-16ie", "published_at": "2026-07-31 19:19:25+00:00", "updated_at": "2026-07-31 19:42:00.889026+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "natural-language-processing"], "entities": ["Laranon", "Eduardo Lázaro", "Laravel", "Composer"], "alternates": {"html": "https://wpnews.pro/news/reversible-pii-anonymization-for-laravel-with-laranon", "markdown": "https://wpnews.pro/news/reversible-pii-anonymization-for-laravel-with-laranon.md", "text": "https://wpnews.pro/news/reversible-pii-anonymization-for-laravel-with-laranon.txt", "jsonld": "https://wpnews.pro/news/reversible-pii-anonymization-for-laravel-with-laranon.jsonld"}}