altinvest / laravel-pii-log-sanitizer
Automatically sanitize and mask sensitive PII data in Laravel application logs using configurable key and pattern-based masking. A Laravel package that automatically masks sensitive data such as emails, phone numbers, passwords, tokens, PAN, Aadhaar, and credit cards before writing logs.
Package info
bitbucket.org/man2achieve/laravel-pii-log-masking
pkg:composer/altinvest/laravel-pii-log-sanitizer
Requires
- php: ^8.1
- illuminate/log: ^9.0 || ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^9.0 || ^10.0 || ^11.0 || ^12.0
- monolog/monolog: ^2.9 || ^3.0
Requires (Dev)
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- laravel/framework: Required for Laravel applications.
README
A drop-in Monolog processor for Laravel that automatically masks Personally
Identifiable Information (PII) — emails, phone numbers, passwords, tokens,
national IDs, bank details, and more — before it is written to your log
files, so a careless Log::info($request->all()) never turns into a data
leak.
Log::info('User login', [
'email' => 'mahendra.ulaka@altinvest.ai',
'mobile' => '9876543210',
'password' => 'SuperSecret123',
]);
[2026-07-17 10:00:00] local.INFO: User login {"email":"ma************@a********.ai","mobile":"98******10","password":"[****]"}
No code changes required in the rest of your application — install the package, and every log write that goes through a configured channel is sanitized automatically.
Table of Contents
- What Problem Does This Solve?
- Features
- Requirements & Compatibility
- Installation
- Configuration
- How It Works
- Understanding Sensitive Keys
- Understanding Pattern Matching
- Masking Behaviour
- Usage Examples
- Verifying The Installation
- Sample Log Output
- Real-World Example
- Customizing The Configuration
- Performance
- Troubleshooting
- FAQ
- Best Practices
- For Package Maintainers
- Changelog
- Contributing
- Security
- License
What Problem Does This Solve?
Application logs are one of the most under-protected surfaces of a system.
Logs are copied, shipped, tailed, grepped, forwarded to third-party
services (CloudWatch, Papertrail, Slack, Sentry), and often kept far longer
— and with far looser access control — than the primary database. It is
extremely easy for a well-intentioned Log::info(), Log::error(), or an
uncaught exception context to end up printing a customer's email, phone
number, password, OTP, PAN, Aadhaar number, or card details straight into a
plaintext file that ten engineers and a log-aggregation SaaS can read.
Why PII should never reach application logs:
- Logs are frequently shipped off-box to log aggregators, dashboards, and alerting tools with broader access than your production database.
- Logs are often retained longer than the data they describe (for debugging/audit purposes), long after a user has deleted their account.
- Logs are usually plaintext and unencrypted at rest, unlike database columns that may have field-level encryption.
- Regulatory frameworks (GDPR, India's DPDP Act, PCI-DSS) treat log exposure of personal/financial data the same as any other unauthorized disclosure.
Benefits of automatic log sanitization:
- Removes the human factor — no developer has to remember to
Str::mask()a value before logging it. - Centralizes masking rules in one config file, reviewed once, applied everywhere.
- Fails safe: a bug in the sanitizer never crashes your application or blocks logging (see Performance and FAQ).
- Works retroactively on already-written application code — you don't
have to touch every
Log::call site.
This package solves it by attaching itself to Laravel's logging pipeline
as a Monolog processor: every log record's message, context, and extra
data is inspected and masked before it reaches a file, Slack webhook, or
any other handler — without you changing a single Log:: call in your
application.
Features
- Automatic PII masking — attaches itself to your log channels during boot; no manual wiring required for the default channel set.
- Recursive array & object sanitization — nested arrays, Eloquent
models and relations,
Collections,stdClass, anything implementingArrayable/JsonSerializable/Traversable, and plain objects with public properties are all walked and masked in place. - Context & extra sanitization — both the Monolog
contextandextrapayloads are sanitized, not just the message string. - Free-text value detection — emails, phone numbers, PAN, Aadhaar,
JWTs, and UUIDs embedded inside a plain message string
(
Log::info("failed for {$email}")) are masked even without a matching key name. - 60+ pre-configured sensitive keys covering authentication, contact details, government IDs, banking/payment fields, addresses, and device identifiers — see config/log_pii.php.
- Wildcard key patterns (
*email*,*contact*,*token*, ...) so you don't have to enumerate every possible key spelling. - Configurable masking strategies — full mask, first/last-N-visible, email-aware partial masking, auto-detection by value shape, and more — assignable per key, per pattern, or per detected value type.
- Allowlist support (
unmasked_keys/unmasked_patterns) for fields that must stay visible (status,type,ip, ...) — without accidentally exposing sensitive data nested underneath them. - Custom masking rules with zero package code changes — add a key, a
pattern, or an entirely new named strategy purely through
config/log_pii.php. - Zero application code changes after installation — no changes to
existing
Log::call sites, controllers, or middleware. - Fail-open safety net — if sanitization itself throws for an unexpected data shape, the original log call still succeeds (see Performance).
- Lightweight — no HTTP calls, no queue, no database, no cache; a pure in-process Monolog processor.
- Production ready — ships with a full PHPUnit + Orchestra Testbench test suite covering every masking strategy, config-merge rule, and the real Laravel logging pipeline (see Contributing).
Requirements & Compatibility
| Requirement | Version |
|---|---|
| PHP | ^8.1 |
Laravel (illuminate/support, illuminate/log) | 9.x, 10.x, 11.x, 12.x |
| Monolog | 3.x |
Note on Monolog 3 This package's processor is written against Monolog 3's
Monolog\LogRecord(an immutable value object), not Monolog 2's array-based log record. This means Laravel 8 / PHP 7.x / Monolog 2 projects are not supported by this version of the package. If you're on Laravel 8, you'll need a separate, Monolog-2-compatible build rather than a version bump of this one.
The package has no other runtime dependencies — no HTTP client, no queue, no database, no cache. It only touches the copy of data that Monolog is about to write out; your application's real data is never modified (see FAQ).
Installation
1. Require the package via Composer
composer require altinvest/laravel-pii-log-sanitizer
2. Let Laravel discover it
The service provider (AltInvest\PiiLogSanitizer\PiiLogSanitizerServiceProvider)
is registered via Laravel's package auto-discovery
(extra.laravel.providers in composer.json). You do not need to add it
to config/app.php manually on Laravel 9–12.
At this point, the package is already active: it will merge its default
masking configuration and attempt to auto-attach itself to your standard
log channels (stack, single, daily, slack, papertrail, stderr,
syslog, errorlog, cloudwatch) — see How It Works for
exactly when and how.
3. (Optional but recommended) Publish the configuration
Publishing your own copy of the config lets you review, customize, and version-control the exact masking rules used in your application:
php artisan vendor:publish --tag=pii-log-sanitizer-config
This creates config/log_pii.php in your application. If you don't
publish it, the package's own defaults (shipped inside the package) are
used automatically — publishing is optional, not required for the package
to work.
To overwrite an already-published copy with the package's latest defaults (careful — this discards your local edits unless you diff first):
php artisan vendor:publish --tag=pii-log-sanitizer-config --force
4. Clear cached config/bootstrap files
Whenever you install, update, or change this package, clear Laravel's caches so it picks up the new provider/config:
php artisan config:clear
php artisan optimize:clear
If you use php artisan config:cache in production, re-run it after any
config/log_pii.php change:
php artisan config:cache
Warning If
config:cachewas run before you published/editedconfig/log_pii.php, your changes will silently not take effect until you re-cache. This is the single most common "why isn't masking picking up my change" issue — see Troubleshooting.
That's it — no .env changes are required to get default masking
behavior. The environment variables below are all optional overrides.
Configuration
Everything the package does is driven by one file:
config/log_pii.php. This section explains every
top-level key, its default, and when you'd change it — assuming you've
never opened the file before.
Top-level options
| Key | Env variable | Default | What it does |
|---|---|---|---|
enabled | PII_LOG_MASKING | true | Master on/off switch. When false, log records pass through completely untouched. |
auto_tap.enabled | PII_LOG_AUTO_TAP | true | Whether the package automatically attaches its Monolog processor to the channels listed below, without you editing config/logging.php. |
auto_tap.channels | PII_LOG_CHANNELS | stack,single,daily,slack,papertrail,stderr,syslog,errorlog,cloudwatch | Comma-separated list of channel names (matched against config('logging.channels')) to auto-attach to. A channel not present in your logging.channels config is silently skipped. |
default_strategy | PII_DEFAULT_STRATEGY | full | The masking strategy used when a key/pattern matches but has no specific strategy assigned in key_strategy/pattern_strategy. |
mask_char | — | * | The character used to build masked segments for partial-masking strategies (first, last, first_last). |
max_depth | PII_MAX_DEPTH | 10 | Recursion ceiling for nested arrays/objects. Anything deeper becomes the literal string [MAX_DEPTH_REACHED] — see Performance. |
mask_detected_values | PII_MASK_DETECTED_VALUES | true | Master switch for value-based detection (masking PII found by shape inside a string, independent of key name). When false, only key/pattern-based masking runs. |
unmasked_keys | — | country_code, platform, source, status, type, ip, ... | Allowlist — exact keys that must never be masked directly (see Understanding Sensitive Keys). |
unmasked_patterns | — | *status, *type, *platform, *ip, ... | Allowlist — wildcard key patterns that must never be masked. |
strategies | — | 20+ named strategies | Reusable named masking definitions (type + parameters) referenced by key_strategy/pattern_strategy/type_strategy/default_strategy. |
type_strategy | — | email → email_partial, phone → mobile, ... | Which named strategy handles each auto-detected value type. |
key_strategy | — | email → email_partial, password → password, ... | Exact key → named strategy. Highest-priority key-based rule. |
pattern_strategy | — | *email* → email_partial, *token* → token, ... | Wildcard key → named strategy, checked after exact key_strategy. |
keys | — | ~90 keys | Exact keys masked with default_strategy if not already covered by key_strategy. |
patterns | — | ~50 patterns | Wildcard keys masked with default_strategy if not already covered by pattern_strategy. |
.env reference
# Master switch
PII_LOG_MASKING=true
# Value-based (shape) detection for emails/phones/PAN/Aadhaar/JWT/UUID in free text
PII_MASK_DETECTED_VALUES=true
# Fallback strategy for keys/patterns without a specific strategy assigned
PII_DEFAULT_STRATEGY=full
# Recursion depth ceiling for nested payloads
PII_MAX_DEPTH=10
# Auto-attach to log channels without editing config/logging.php
PII_LOG_AUTO_TAP=true
PII_LOG_CHANNELS=stack,single,daily,slack,papertrail,stderr,syslog,errorlog,cloudwatch
Production recommendations:
- Keep
PII_LOG_MASKING=trueandPII_MASK_DETECTED_VALUES=true— these are your safety net; only disable them for a specific debugging session, never as a standing production setting. PII_DEFAULT_STRATEGY=fullis the safest default (masks unknown-shape matched values completely). Only loosen it if you have a concrete, reviewed reason to keep partial visibility.- Keep production log level at
info, notdebug— this reduces the overall volume of incidentally-logged detail, independent of masking. - Make sure
PII_LOG_CHANNELSactually lists every channel your app writes to. A channel that exists inlogging.phpbut is missing from this list is a silent gap — nothing errors, it just logs unmasked.
The shipped strategy library (strategies)
Every strategy is a type plus type-specific parameters, defined once and
referenced by name. These ship by default:
Click to see the full default strategies array
```php
'strategies' => [
'full' => ['type' => 'full', 'replacement' => '[*****]'],
'first' => ['type' => 'first', 'visible' => 4],
'last' => ['type' => 'last', 'visible' => 4],
'first_last' => ['type' => 'first_last', 'first' => 4, 'last' => 2],
'mobile' => ['type' => 'first_last', 'first' => 2, 'last' => 2],
'mobile_last_four' => ['type' => 'last', 'visible' => 4],
'email_partial' => ['type' => 'email_partial', 'name_first' => 2, 'domain_first' => 1, 'keep_tld' => true],
'email_domain_only' => ['type' => 'email_domain'],
'email_mask_domain' => ['type' => 'email_name'],
'uuid_partial' => ['type' => 'first_last', 'first' => 8, 'last' => 12],
'pan' => ['type' => 'first_last', 'first' => 2, 'last' => 2],
'aadhaar' => ['type' => 'last', 'visible' => 4],
'passport' => ['type' => 'first_last', 'first' => 1, 'last' => 2],
'name' => ['type' => 'first', 'visible' => 1],
'token' => ['type' => 'full', 'replacement' => '[****]'],
'password' => ['type' => 'full', 'replacement' => '[****]'],
'otp' => ['type' => 'full', 'replacement' => '[****]'],
'pin' => ['type' => 'full', 'replacement' => '[****]'],
'bank' => ['type' => 'last', 'visible' => 4],
'card' => ['type' => 'last', 'visible' => 4],
'address' => ['type' => 'full', 'replacement' => '[****]'],
'mask_first' => ['type' => 'mask_first', 'length' => 4, 'mask_char' => '*'],
'mask_last' => ['type' => 'mask_last', 'length' => 4, 'mask_char' => '*'],
'auto_detect' => ['type' => 'auto_detect'],
'email_or_mobile' => ['type' => 'email_or_mobile'],
],
```
Strategy type | Behavior |
|---|---|
full | Replaces the entire value with a fixed replacement string, regardless of content — including an empty string. |
first | Keeps the first N (visible) characters, masks the rest. |
last | Keeps the last N (visible) characters, masks the rest. |
first_last | Keeps first characters at the start and last characters at the end, masks the middle. |
email_partial | Masks the local-part and domain-name of an email independently, keeps the TLD if keep_tld is true. Falls back to a full mask if the value isn't a valid email. |
email_domain | Masks the local-part only, keeps the domain fully visible (****@example.com). |
email_name | Keeps the local-part visible, masks the entire domain (including the TLD) except dots (john@*******.***). |
mask_first / mask_last | Masks exactly length characters from the start/end, using a per-strategy mask_char. Explicitly leaves an empty string as '' (unlike full). |
auto_detect | Classifies the value itself (email/phone/PAN/Aadhaar/JWT) and applies that type's type_strategy, falling back to default_strategy for anything unrecognized. |
email_or_mobile | Masks the value only if it looks like an email or a phone number; anything else passes through unchanged (useful for generic identifier fields that might be either). |
How It Works
flowchart TD
A[Application code<br/>Log::info / Log::error / Log::warning] --> B[Laravel Log Channel]
B --> C[RegisterPiiLogSanitizerProcessor<br/>Monolog 'tap' — runs once per channel]
C --> D[Pushes SanitizeLogProcessor onto<br/>the channel's Monolog processor stack]
D --> E[SanitizeLogProcessor::__invoke]
E --> F{log_pii.enabled?}
F -- no --> Z[Record passed through unchanged]
F -- yes --> G[Sanitize message string<br/>free-text PII detection]
G --> H[Sanitize context array/object<br/>recursively]
H --> I[Sanitize extra array/object<br/>recursively]
I --> J[Monolog formatter + handler<br/>file / stderr / Slack / CloudWatch ...]
J --> K[Log destination<br/>already masked]
Two Laravel/Monolog extension points work together:
- Service provider boot — once per request/worker boot, the package
merges its default config with anything your app has published to
config/log_pii.php, then (ifauto_tap.enabled) injectsRegisterPiiLogSanitizerProcessor::classinto thetaparray of each configured channel's config, before Laravel builds that channel's Monolog logger. - The tap class runs on first use — the first time a tapped channel
is actually resolved (the first
Log::channel('single')->...call, for example), Laravel instantiates the tap class and hands it the builtIlluminate\Log\Logger.RegisterPiiLogSanitizerProcessorthen pushes aSanitizeLogProcessorinstance onto that channel's underlying Monolog processor stack — guarding against pushing a second instance if the channel is somehow tapped twice (e.g. once directly, once viastack).
From then on, every record written to that channel passes through
SanitizeLogProcessor::__invoke() before any formatter or handler sees it:
- If
log_pii.enabledisfalse, the record is returned untouched — this check happens on every call, so you can flip it at runtime. - The log message string is scanned for free-text PII fragments (email, phone, PAN, Aadhaar, JWT, UUID) and those substrings are masked in place, independent of any key.
- The context and extra arrays are recursively walked: every array
key,
stdClass/Collection/Arrayable/JsonSerializable/Traversablevalue, and generic object with public properties is inspected. Throwableobjects (exceptions) are passed through completely unchanged — Laravel/Monolog already have their own exception serialization, and this package does not attempt to rewrite exception messages or stack traces (see FAQ).
Recursive sanitization, precisely: if a key matches a masking rule
and its value is itself an array, Collection, stdClass, or any other
"structured" value, the package does not replace the whole thing with
a mask — it recurses into it instead, masking only the sensitive leaves.
This is what lets you log an entire Eloquent relation (e.g. a
contactDetail relation, whose name happens to match the *contact*
pattern) and see it stay visible as a structure, with only its
contact_no/email_id fields actually masked — see
Real-World Example.
Understanding Sensitive Keys
Instead of one flat list, the package uses four cooperating config sections, checked in this priority order:
1. unmasked_keys / unmasked_patterns (allowlist — always wins)
2. key_strategy (exact key → named strategy)
3. keys (exact key → default_strategy)
4. pattern_strategy (wildcard key → named strategy)
5. patterns (wildcard key → default_strategy)
6. (no match) → value-based detection, or left alone
'key_strategy' => [
'email' => 'email_partial',
'mobile' => 'mobile',
'password' => 'password',
'access_token' => 'token',
'pan' => 'pan',
'aadhaar' => 'aadhaar',
// ...60+ more, see config/log_pii.php
],
'keys' => [
'ssn', 'sin', 'tin', 'national_id', 'voter_id', 'driving_license',
// exact keys masked with default_strategy — no specific strategy needed
],
Exact matching, but convention-agnostic
Keys are matched case-insensitively and independent of naming
convention. Internally, every key is run through a normalizer that
converts it to lowercase snake_case before comparison, so all of these
are treated identically:
| Original key | Normalized to | Matches |
|---|---|---|
contactNo | contact_no | ✅ |
contact_no | contact_no | ✅ |
contact-no | contact_no | ✅ |
Contact No | contact_no | ✅ |
CONTACT_NO | contact_no | ✅ |
You never need to list every casing variant of a key yourself — add it once, in whichever style is natural to you.
Nested keys
Matching happens per key, at every level of nesting — not just at the
top level of context. ['user' => ['profile' => ['email' => '...']]]
masks email correctly even though user and profile aren't
sensitive keys themselves. See How It Works for the
recursion rule that keeps structured values (arrays/objects) visible while
still masking their sensitive leaves.
Adding a custom key
No package code changes are needed — just edit your published
config/log_pii.php:
'keys' => [
// ...existing keys
'loyalty_card_number',
],
// or, if it needs a specific strategy rather than default_strategy:
'key_strategy' => [
// ...existing entries
'loyalty_card_number' => 'mask_last',
],
Removing/relaxing a default key
If a default key is too aggressive for your use case (rare, but
possible), you have two safe options — don't delete the entry from the
array you publish, since a future composer update re-adds the
package's own copy anyway (see
Customizing The Configuration):
// Option 1: allowlist it explicitly (highest priority, always wins)
'unmasked_keys' => [
// ...existing entries
'reference_code',
],
// Option 2: point it at a less aggressive strategy
'key_strategy' => [
'reference_code' => 'mask_last', // instead of e.g. 'full'
],
Best practices
- Prefer
key_strategy(exact) overpattern_strategy(wildcard) when you can — exact keys are cheaper to reason about and can't accidentally catch an unrelated field. - Be conservative with
unmasked_keys/unmasked_patterns— a too-broad wildcard here (e.g. a careless*id*) can silently exempt sensitive fields. Review additions to these lists with the same care as reviewing what gets masked. - Remember that allowlisting a parent key does not allowlist its
children —
sourcebeing allowlisted does not protect a nestedemailfield insidesource.
Understanding Pattern Matching
There are two independent pattern mechanisms in this package — don't confuse them:
1. Wildcard key-name patterns (config-only)
patterns / pattern_strategy match key names, not values, using a
simple * wildcard (not full regex):
'pattern_strategy' => [
'*email*' => 'email_partial', // matches email, workEmail, alternate_email
'*contact*' => 'mobile', // matches contact, contactNo, emergency_contact
'*token*' => 'token', // matches token, access_token, refresh_token
'*aadhaar*' => 'aadhaar',
'*pan*' => 'pan',
'*card*' => 'card',
],
Adding a new wildcard is a pure config change — no source edit needed:
'pattern_strategy' => [
// ...existing entries
'*loyalty*' => 'mask_last',
],
2. Value-shape detection (built into the processor)
Independent of any key name, the processor also scans string values
(both the log message and any string encountered during recursion) for
recognizable PII shapes using regular expressions built into
SanitizeLogProcessor:
| Shape | Example | Detected as |
|---|---|---|
john@example.com | email | |
| JWT | eyJhbGciOi...xyz.abc | jwt |
| UUID | 9dbedff1-826e-4d10-aead-85ea7f122197 | uuid |
| PAN (Indian tax ID) | ABCDE1234F | pan |
| Aadhaar (Indian national ID) | 123456789012 | aadhaar |
| Phone / mobile | 9876543210, +919876543210 | phone |
This is what lets Log::info("Login failed for {$email}") get masked even
though there's no key at all. Each detected type is masked using whatever
strategy type_strategy assigns to it (type_strategy.email,
type_strategy.phone, etc.).
Adding a brand-new value shape (e.g. a different country's national ID format that doesn't look like anything above) is not a pure config change — the regex lives in
SanitizeLogProcessor, so it needs a small source change (a new regex + atype_strategyentry). Adding a new sensitive key name or wildcard, by contrast, is always a config-only change. If you only need to catch a value by its key, you don't need a new regex at all — usekeys/key_strategy/patterns/pattern_strategyinstead.
Credit cards, custom IDs, and other formats already covered by key
Card numbers, bank accounts, IBAN/SWIFT/IFSC, GST/GSTIN, device
identifiers (IMEI/MAC), and client/OAuth UUIDs are not value-detected
by shape (too easy to collide with ordinary numeric IDs) — they're covered
by dedicated keys/patterns instead (card_number, *card*, client_id,
*client_id*, etc.). This is deliberately more precise than shape-only
detection for these formats.
Masking Behaviour
Exactly how a value is transformed depends on which strategy applies to
it. These are real, verified outputs from the shipped default config (see
tests/Unit/SanitizeLogProcessorTest.php):
mahendra.ulaka@altinvest.ai (email_partial) → ma************@a********.ai
john@example.com (email_partial) → jo**@e******.com
9876543210 (mobile) → 98******10
ABCDE1234F (pan) → AB******4F
234567891012 (aadhaar) → ********1012
M1234567 (passport) → M*****67
Mahendra (name) → M*******
9dbedff1-826e-4d10-aead-85ea7f122197 (uuid_partial) → 9dbedff1****************85ea7f122197
CODE123456 (mask_last) → CODE12****
ABCDEF1234 (mask_first) → ****EF1234
password / otp / pin / token (full) → [****]
Note: the named
fullstrategy (used asdefault_strategyfor keys likessnthat don't have a specifickey_strategyentry) uses[*****](5 stars) as its replacement, whilepassword/otp/pin/tokenare each their own named strategy using[****](4 stars) — these are two different config entries that happen to both betype: full. Don't assume they're interchangeable if you're matching on the exact replacement string somewhere downstream.
Masking styles at a glance
| Style | Example strategy | Use it for |
|---|---|---|
| Full replacement | full, password, token | Secrets that must never be partially visible (passwords, OTPs, tokens, addresses). |
| Keep last N | last, bank, card, aadhaar | Values useful for support/debugging by their tail (...1234). |
| Keep first + last N | first_last, mobile, pan, uuid_partial | Values useful for fuzzy identification without full exposure. |
| Email-aware | email_partial, email_domain, email_name | Emails specifically — domain-aware masking that still looks like an email. |
| Fixed-length mask | mask_first, mask_last | Reference/verification codes where a fixed number of characters must be hidden regardless of total length. |
| Value-shape aware | auto_detect, email_or_mobile | Generic fields (identifier, value) that could hold different kinds of PII depending on the request. |
Edge cases worth knowing
nullunder a masked key staysnull(never coerced to a string).- An empty string under a
full-type strategy is still replaced ('' → '[****]') — the replacement is unconditional. Undermask_first/mask_last, an empty string is explicitly left as''. - Non-string scalars (
int,float,bool) under a matched key are cast to a string and masked like any other value —'pin' => 0still becomes[****]. - Re-processing an already-masked value is stable/idempotent — masking a log twice never produces a different or "double-masked" result.
Usage Examples
You don't need to change how you call Log:: — every example below is
completely ordinary Laravel logging code. What changes is what ends up in
your log file.
Simple
use Illuminate\Support\Facades\Log;
Log::info('User login attempt', [
'email' => 'mahendra.ulaka@altinvest.ai',
'mobile' => '9876543210',
'password' => 'secret123',
]);
Before (what your code passes in):
email: mahendra.ulaka@altinvest.ai
mobile: 9876543210
password: secret123
After (what lands in the log file):
email: ma************@a********.ai
mobile: 98******10
password: [****]
Errors, with nested arrays
Log::error('Payment failed', [
'customer' => [
'name' => 'Mahendra Ulaka',
'email' => 'mahendra.ulaka@altinvest.ai',
],
'card' => [
'number' => '4111111111111111',
'cvv' => '123',
],
'status' => 'declined',
]);
After:
customer.name: M*************
customer.email: ma************@a********.ai
card.number: ************1111
card.cvv: [****]
status: declined <- allowlisted, stays visible
API response payload
Log::info('Fetched user profile', [
'response' => $apiResponse->json(), // a plain array
]);
Because $apiResponse->json() is a plain PHP array, every sensitive key
inside it — no matter how deeply nested — is masked exactly the same way
as any other array. If your API client returns a raw JSON string
instead of a decoded array, see the note in
Real-World Example about JSON strings.
Request payload
Log::info('Incoming request', $request->all());
$request->all() is a plain array, so every field name (email,
password, mobile, ...) is matched exactly as in the examples above.
Database payload (Eloquent model / relation)
$user = User::with('contactDetail')->find(1);
Log::info('User loaded', ['user' => $user]);
$user implements Arrayable (via Eloquent), so it's converted with
toArray() and then sanitized recursively — including the contactDetail
relation, which stays visible as a nested object with only its sensitive
fields masked. See Real-World Example for the full
output.
Exception context
try {
$paymentGateway->charge($card);
} catch (\Throwable $e) {
Log::error('Charge failed', [
'card_number' => $card->number,
'customer_email' => $customer->email,
'exception' => $e, // passed as context, not thrown further
]);
}
After:
card_number: ************1111
customer_email: ma************@a********.ai
exception: <unchanged — see warning below>
Warning: the
Throwableobject itself ($e) is passed through completely unmodified — this package does not rewrite exception messages or stack traces. If your exception message itself contains PII (throw new Exception("Failed for {$user->email}")), that PII will reach your logs unmasked. Keep PII out of exception messages; put it in the surrounding context array instead, as shown above.
Large / deeply nested JSON-like payloads
Log::info('Webhook received', ['payload' => $decodedWebhookBody]);
Recursion is bounded by max_depth (default 10). Anything nested deeper
becomes the literal string [MAX_DEPTH_REACHED] rather than being masked
field-by-field — this protects against pathological payloads causing
runaway recursion. Increase PII_MAX_DEPTH if you have legitimately
deep-but-safe structures, or restructure the log call to log a flatter
subset.
Verifying The Installation
Method 1 — Temporary route
Add a route (delete it once you're done): ```php use Illuminate\Support\Facades\Log; Route::get('/pii-test', function () { Log::info('Testing PII', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'phone' => '9876543210', 'password' => 'secret123', 'token' => 'abc123456', ]); return 'Check storage/logs/laravel.log'; }); ``` Visit `/pii-test`, then: ```bash tail -n 5 storage/logs/laravel.log ``` **Expected:** `name` → `J*******`, `email` → `jo**@e******.com`, `phone` → `98******10`, `password` and `token` → `[****]`. None of the raw values should appear anywhere in the line.Method 2 — Tinker
```bash php artisan optimize:clear php artisan tinker ``` ```php use Illuminate\Support\Facades\Log; Log::info('PII sanitizer test mahendra.ulaka@altinvest.ai 9876543210', [ 'email' => 'mahendra.ulaka@altinvest.ai', 'mobile_number' => '9876543210', 'password' => 'secret123', 'otp' => '123456', ]); ``` ```bash tail -n 5 storage/logs/laravel.log ``` **Expected:** both the free-text email/phone in the **message string** and the keyed `email`/`mobile_number`/`password`/`otp` values in context are masked.Method 3 — Controller
```php class ProfileController extends Controller { public function update(Request $request) { Log::info('Profile update requested', $request->only([ 'name', 'email', 'mobile', 'address', ])); // ... your actual update logic ... return back(); } } ``` Submit the form with real-looking test data and check the log file the same way as above.Method 4 — Exception context
```php try { throw new \RuntimeException('Simulated failure'); } catch (\Throwable $e) { Log::error('Something failed', [ 'email' => 'john@example.com', 'exception_message' => $e->getMessage(), // safe: no PII in this message ]); } ``` **Expected:** `email` is masked; `exception_message` stays as-is (it contains no PII in this example — see the warning in [Usage Examples](#usage-examples) about *not* putting PII inside the actual exception message).Method 5 — Nested JSON-like payload
```php Log::info('Nested payload test', [ 'user' => [ 'profile' => [ 'contact' => [ 'email' => 'john@example.com', ], ], ], ]); ``` **Expected:** `user.profile.contact.email` is masked, while `user`, `profile`, and `contact` remain visible as normal nested arrays/objects — demonstrating the recursive, structure-preserving masking described in [How It Works](#how-it-works).Sample Log Output
Raw (what you'd get without this package):
[2026-07-17 10:00:00] local.INFO: User registered {"name":"Mahendra Ulaka","email":"mahendra.ulaka@altinvest.ai","mobile":"9876543210","password":"SuperSecret123","pan":"ABCDE1234F"}
Sanitized (what actually gets written with this package installed):
[2026-07-17 10:00:00] local.INFO: User registered {"name":"M*************","email":"ma************@a********.ai","mobile":"98******10","password":"[****]","pan":"AB******4F"}
Real-World Example
A typical authenticated API request, logged for debugging, with headers, authorization, and a loaded Eloquent relation:
Log::info('Incoming authenticated request', [
'method' => 'POST',
'route' => '/api/v1/profile',
'headers' => [
'authorization' => 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature',
],
'customer' => [
'id' => 408,
'email' => 'mahendra.ulaka@altinvest.ai',
'contactDetail' => [
'id' => 10,
'country_code' => '91',
'contact_no' => '9876543210',
'email_id' => 'mahendra.ulaka@altinvest.ai',
],
],
]);
[2026-07-17 10:00:00] local.INFO: Incoming authenticated request {
"method": "POST",
"route": "/api/v1/profile",
"headers": { "authorization": "[****]" },
"customer": {
"id": 408,
"email": "ma************@a********.ai",
"contactDetail": {
"id": 10,
"country_code": "91",
"contact_no": "98******10",
"email_id": "ma************@a********.ai"
}
}
}
Notice:
methodandrouteare allowlisted (unmasked_keys) and stay visible.authorizationmatches the*authorization*/*auth*pattern and is fully masked, even though the raw JWT would also have been caught by free-text detection if it appeared in the message string instead.contactDetailmatches the*contact*wildcard pattern, but because its value is a structured array (not a scalar), the package recurses into it instead of replacing the whole relation with a single mask —idandcountry_code(allowlisted) stay visible, onlycontact_noandemail_idare masked.
A note on raw JSON strings
If you log a value that is a JSON-encoded string rather than a decoded PHP array —
Log::info('Webhook body: ' . $jsonString);
— the package cannot see individual JSON keys inside that string (it's
just one big string to PHP). It will still catch anything matching a
value shape it recognizes (an embedded email, phone number, JWT,
etc. — see Understanding Pattern Matching),
but it will not apply key-based rules like password → [****] to keys
inside that string. Decode JSON payloads into arrays before logging
them (json_decode($jsonString, true)) if you want full key-based
masking.
Customizing The Configuration
All customization happens in your published config/log_pii.php — the
package's own copy stays untouched and provides the defaults.
| I want to... | Do this |
|---|---|
| Add a new sensitive key | Add it to keys, or to key_strategy if it needs a specific strategy. |
| Add a new sensitive wildcard | Add it to patterns, or to pattern_strategy for a specific strategy. |
| Stop masking a specific key | Add it to unmasked_keys (don't delete it from keys — see below). |
| Stop masking a wildcard | Add it to unmasked_patterns. |
| Change how a key is masked | Point its key_strategy/pattern_strategy entry at a different (or new) strategy name. |
| Add an entirely new masking rule | Add a new entry under strategies, then reference its name from key_strategy/pattern_strategy/type_strategy. |
| Disable masking entirely | PII_LOG_MASKING=false (or log_pii.enabled = false). |
| Disable only value-based detection | PII_MASK_DETECTED_VALUES=false — key-based masking still applies. |
| Disable auto-attaching to channels | PII_LOG_AUTO_TAP=false, then tap channels manually (see For Package Maintainers). |
Why you shouldn't just delete a default array entry
config/log_pii.php is deep-merged with the package's own copy at boot
(package values are the base, your published values win on conflicts —
list arrays like keys/patterns are concatenated and de-duplicated
rather than overwritten). If you simply delete an entry from keys in
your local copy, the package's own copy still contributes it back on
every request, because merging is additive for list arrays. Use
unmasked_keys/unmasked_patterns (which are checked with the highest
priority) to actually suppress a default key, or override its
key_strategy entry with a more permissive strategy instead of removing
it.
Configuration caching
Like any other Laravel config file, changes to config/log_pii.php are
invisible after php artisan config:cache until you re-run it:
php artisan config:clear # dev — stop using a stale cache
php artisan config:cache # prod — re-cache after every change
Performance
- Recursive traversal is bounded by
max_depth(default10) — this is a hard ceiling, not a soft hint, protecting against runaway recursion on pathological payloads (circular-looking structures, very deep API responses). - Regex matching (free-text value detection) runs once per string
value encountered during recursion. It can be turned off entirely via
mask_detected_values = falseif you only need key-based masking and want to shave a small amount of per-string overhead — most applications won't need to. - No I/O — the processor does not make network calls, queue jobs, hit a database, or use a cache. It only transforms the in-memory copy of the log record already being written.
- Fail-open by design — if sanitizing a specific value throws for any
reason (an unexpected object shape, for example), the exception is
caught internally, reported via PHP's
error_log()(never back through Laravel's own logger, to avoid recursively re-entering the pipeline), and the original, unsanitized value is logged rather than crashing the request. This is a deliberate trade-off: availability over strict masking guarantees in the face of a masking bug — see Security. - Memory usage is proportional to the size of the log payload itself (the processor builds a new sanitized copy rather than mutating in-place) — the same order of magnitude as encoding the payload to JSON for the log line in the first place.
In practice, for typical application log payloads (a handful of context keys, a few levels of nesting), the overhead is negligible compared to the I/O cost of actually writing the log line.
Troubleshooting
Package installed but nothing is masked
Most likely causes, in order of frequency: 1. **Stale cached config** — run `php artisan config:clear`. 2. **`log_pii.enabled` is `false`** — check `.env` for `PII_LOG_MASKING`. 3. **You're logging to a channel not in `auto_tap.channels`** — check `config('log_pii.auto_tap.channels')` includes the channel you're actually using (`config('logging.default')`). 4. **A custom Monolog stack was built before boot finished** — see below.Forgot to publish config
You don't have to — the package's own defaults are used automatically if you never publish. If you expected your **published** config to be in effect but it isn't, confirm the file actually exists at `config/log_pii.php` in your app (not just inside `vendor/`).Forgot config:clear after a change
```bash
php artisan config:clear
```
If you run `config:cache` in production, you must re-run it after every
`config/log_pii.php` edit — a cached config completely ignores the file on
disk.
Wrong log channel
Masking is per-channel. If `config('logging.default')` points at a channel that isn't in `log_pii.auto_tap.channels`, that channel logs unmasked with no error. Add it to `PII_LOG_CHANNELS`, or tap it manually (see [For Package Maintainers](#for-package-maintainers)).Custom Monolog stack / channel built before the package boots
Auto-tap mutates `config('logging.channels.*.tap')` during the service provider's `register()`, which runs before Laravel builds any channel's Monolog logger — but **only** for channels that already exist in `config('logging.channels')` at that point. A channel created dynamically at runtime *after* boot (e.g. via `config(['logging.channels.new' => ...])` inside a request) will not have been auto-tapped; add `RegisterPiiLogSanitizerProcessor::class` to its `tap` array manually.Regex not matching a value you expected to be masked
Free-text value detection only recognizes a fixed set of shapes (email, phone, PAN, Aadhaar, JWT, UUID — see [Understanding Pattern Matching](#understanding-pattern-matching)). A novel format (a different country's ID, a custom token shape) is only caught if its **key name** matches `keys`/`key_strategy`/`patterns`/ `pattern_strategy` — add the key/pattern rather than expecting shape detection to catch it.Key name mismatch
Check `config('log_pii.key_strategy')`/`keys`/`patterns` for the exact (or wildcard) key you expect. Remember key matching is convention-agnostic (see [Understanding Sensitive Keys](#understanding-sensitive-keys)) but still requires *some* substring/exact match — a completely unrelated key name (`x1`, `val`) won't be caught by `*email*`.Nested object not masked
Confirm the object type is one the recursion actually understands: arrays, `Collection`, `Arrayable`, `JsonSerializable`, `stdClass`, `Traversable`, or a plain object with **public** properties. Private/ protected properties on an arbitrary object are not inspected (there's no safe generic way to read them) — expose the data via `toArray()` (`Arrayable`) instead.FAQ
Does it modify the original data? No. It only transforms the copy of the log record Monolog is about to write. Your application's variables, Eloquent models, and request objects are never mutated.
Does it affect the database? No. This package has no database dependency and never touches persisted data — logging only.
Does it affect API responses? No. It hooks into the logging pipeline exclusively; HTTP responses are untouched.
Does it work with queued jobs?
Yes. The tap registration happens during framework boot, which happens
for queue worker processes the same way it happens for web requests —
Log:: calls from inside a job are sanitized the same way as anywhere
else.
Does it work with Horizon? Yes — Horizon queue workers are ordinary Laravel worker processes; there's nothing Horizon-specific about how this package attaches to logging.
Does it work with Octane?
Yes, with one caveat: Octane keeps the application (and its container
state) alive across multiple requests in the same worker. The masking
rules (keys, patterns, strategies, ...) are read once when the
SanitizeLogProcessor is constructed (the first time a channel is
resolved in that worker), not re-read on every request. If you mutate
config('log_pii.*') at runtime on a per-request basis (e.g. per-tenant
masking rules), an already-tapped channel in a long-lived Octane worker
will keep using whatever config was in effect when it was first
constructed. log_pii.enabled is the one exception — it's read fresh on
every single log call, so toggling it at runtime works correctly even
under Octane.
Does it support Laravel 10/11/12?
Yes — 9.x through 12.x are all supported (see
Requirements & Compatibility).
Can I disable it?
Yes, several ways depending on scope: PII_LOG_MASKING=false (everywhere,
instantly, no restart needed since it's checked live), PII_LOG_AUTO_TAP=false
(stop auto-attaching to new channels — requires a fresh boot to take
effect), or remove a specific channel from PII_LOG_CHANNELS.
Best Practices
- Never log passwords, OTPs, or PINs even with masking in place — treat masking as a backstop, not permission to log more freely. Prefer not logging them at all.
- Mask tokens and authorization headers —
access_token,refresh_token,Authorizationshould never appear even partially. - Mask customer identifiers consistently — email, phone, and national IDs should use the same strategy everywhere they appear, so support tooling that greps logs sees a predictable format.
- Review custom keys/patterns/allowlist entries regularly — especially
unmasked_keys/unmasked_patterns, since a too-broad wildcard there silently widens what leaks. - Keep
config/log_pii.phpunder version control — it's the single source of truth for what's protected; treat changes to it like any other security-relevant code review. - Test after changing patterns or strategies — run
composer test(see Contributing) and, ideally, add a case totests/Unit/SanitizeLogProcessorTest.phpfor the new rule. - Keep PII out of exception messages — this package does not sanitize
Throwableobjects (see the warning in Usage Examples); put sensitive data in the log context array instead, where it will be masked normally.
For Package Maintainers
How Auto Tap Works (internals, for people extending this package)
The package automatically attaches this tap class to your configured log channels: ```php AltInvest\PiiLogSanitizer\RegisterPiiLogSanitizerProcessor::class ``` which, on first resolution of that channel, pushes this Monolog processor onto its processor stack: ```php AltInvest\PiiLogSanitizer\SanitizeLogProcessor::class ``` Because auto-tap is enabled by default, you usually never need to touch `config/logging.php` yourself. If you do need to tap a channel manually (e.g. one outside the default `auto_tap.channels` list, or a dynamically created channel — see [Troubleshooting](#troubleshooting)): ```php 'custom' => [ 'driver' => 'single', 'path' => storage_path('logs/custom.log'), 'tap' => [ AltInvest\PiiLogSanitizer\RegisterPiiLogSanitizerProcessor::class, ], ], ``` Avoid tapping both `stack` and its child channels together — the processor guards against being pushed twice onto the *same* Monolog logger, but tapping `stack` in addition to `single`/`daily` etc. individually means each underlying handler still only gets sanitized once per channel it belongs to, which is usually what you want anyway; the guidance is about avoiding confusion, not a hard bug.Adding New Configuration Keys (extending the package itself, not just using it)
1. Add the key (with an `env()` default, if it should be environment overridable) to `config/log_pii.php`. 2. Read it via `config('log_pii.your_key', $default)` in `SanitizeLogProcessor`. 3. Document the new `.env` variable in this README's [Configuration](#configuration) section. 4. Tell consuming services to force-publish + clear cache: `php artisan vendor:publish --tag=pii-log-sanitizer-config --force && php artisan optimize:clear`. 5. Add a test in `tests/Unit/SanitizeLogProcessorTest.php` or `tests/Unit/PiiLogSanitizerServiceProviderTest.php` covering the new option.Where To Update If Change Is Required
| Requirement | File to update | |---|---| | Add new config key | `config/log_pii.php` | | Change default config value | `config/log_pii.php` | | Use a config value in logic | `src/SanitizeLogProcessor.php` | | Add new sensitive key or pattern | `config/log_pii.php` | | Add a new masking *strategy type* (new algorithm) | `src/SanitizeLogProcessor.php` (`applyStrategy()` + a new `mask*` helper) | | Change an existing masking implementation | `src/SanitizeLogProcessor.php` | | Change tap/processor registration | `src/RegisterPiiLogSanitizerProcessor.php` | | Change config publish path/tag | `src/PiiLogSanitizerServiceProvider.php` | | Change auto-tap behavior | `src/PiiLogSanitizerServiceProvider.php` | | Change config merge semantics | `src/PiiLogSanitizerServiceProvider.php` (`mergeConfigRecursive()` — high blast radius, see inline docblock) | | Update installation/usage docs | `README.md` | | Add/update automated tests | `tests/` (see [Contributing](#contributing)) |Development Workflow (working on the package itself)
```bash # Whenever package PHP code changes composer dump-autoload php artisan optimize:clear # Whenever package configuration changes php artisan vendor:publish --tag=pii-log-sanitizer-config --force composer dump-autoload php artisan optimize:clear # Whenever a new class is added composer dump-autoload php artisan package:discover php artisan optimize:clear # If provider discovery looks stale (Class Not Found, tap not applying) rm -f bootstrap/cache/packages.php bootstrap/cache/services.php \ bootstrap/cache/config.php bootstrap/cache/events.php composer dump-autoload php artisan package:discover php artisan optimize:clear ```Changelog
See CHANGELOG.md for a full history of changes, following Keep a Changelog conventions.
Contributing
Contributions are welcome — bug reports, new masking strategies, new sensitive-key defaults, and documentation fixes alike.
- Fork the repository and create a feature branch.
- Follow the existing style: PSR-4 namespace
AltInvest\PiiLogSanitizer\, one class per file, verbose example-driven PHPDoc blocks on non-trivialprotectedmethods (see the existingSanitizeLogProcessormethods for the expected level of detail). - Prefer a config change over a code change — see Understanding Pattern Matching and For Package Maintainers for what counts as which.
Run the test suite before opening a pull request:
composer install composer test # everything composer test:unit # tests/Unit only composer test:feature # tests/Feature onlyThe suite is PHPUnit + Orchestra Testbench (
tests/Unit/SanitizeLogProcessorTest.php,tests/Unit/PiiLogSanitizerServiceProviderTest.php,tests/Feature/LoggingPipelineTest.php) and boots a minimal in-memory Laravel app — no separate Laravel installation is required to run it.- Add or update a test alongside any behavioral change — a new strategy, a new key, or a bug fix should all come with a regression test.
- Update this README and
CHANGELOG.mdif you're changing documented behavior or public configuration.
For coverage reporting (needs Xdebug or PCOV):
vendor/bin/phpunit --coverage-html build/coverage
Security
If you discover a security vulnerability, please do not open a public GitHub issue. Report it privately instead, so it can be assessed and fixed before public disclosure:
- Open a private security advisory on the repository, or
- Contact the maintainer directly (see
composer.json→authors).
Please include: the version affected, a minimal reproduction, and the potential impact (e.g. "a value under key X is not masked when Y").
This package is a defense-in-depth control, not a substitute for never logging sensitive data in the first place — see Best Practices. See also its documented fail-open behavior in Performance: a masking bug fails toward "log continues to work" rather than "application breaks," which means a regression here could result in unmasked data reaching a log destination rather than a loud failure. Treat findings of that shape as security reports too.
License
Released under the MIT License.