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.

Maintainers

Package info

bitbucket.org/man2achieve/laravel-pii-log-masking

Issues

pkg:composer/altinvest/laravel-pii-log-sanitizer

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v2.0.2 2026-07-17 09:53 UTC

This package is auto-updated.

Last update: 2026-07-17 11:02:25 UTC


README

Packagist Version PHP Version Laravel License: MIT

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?

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 implementing Arrayable/JsonSerializable/Traversable, and plain objects with public properties are all walked and masked in place.
  • Context & extra sanitization — both the Monolog context and extra payloads 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

RequirementVersion
PHP^8.1
Laravel (illuminate/support, illuminate/log)9.x, 10.x, 11.x, 12.x
Monolog3.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:cache was run before you published/edited config/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

KeyEnv variableDefaultWhat it does
enabledPII_LOG_MASKINGtrueMaster on/off switch. When false, log records pass through completely untouched.
auto_tap.enabledPII_LOG_AUTO_TAPtrueWhether the package automatically attaches its Monolog processor to the channels listed below, without you editing config/logging.php.
auto_tap.channelsPII_LOG_CHANNELSstack,single,daily,slack,papertrail,stderr,syslog,errorlog,cloudwatchComma-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_strategyPII_DEFAULT_STRATEGYfullThe 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_depthPII_MAX_DEPTH10Recursion ceiling for nested arrays/objects. Anything deeper becomes the literal string [MAX_DEPTH_REACHED] — see Performance.
mask_detected_valuesPII_MASK_DETECTED_VALUEStrueMaster 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_keyscountry_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.
strategies20+ named strategiesReusable named masking definitions (type + parameters) referenced by key_strategy/pattern_strategy/type_strategy/default_strategy.
type_strategyemail → email_partial, phone → mobile, ...Which named strategy handles each auto-detected value type.
key_strategyemail → 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 keysExact keys masked with default_strategy if not already covered by key_strategy.
patterns~50 patternsWildcard 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=true and PII_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=full is 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, not debug — this reduces the overall volume of incidentally-logged detail, independent of masking.
  • Make sure PII_LOG_CHANNELS actually lists every channel your app writes to. A channel that exists in logging.php but 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 typeBehavior
fullReplaces the entire value with a fixed replacement string, regardless of content — including an empty string.
firstKeeps the first N (visible) characters, masks the rest.
lastKeeps the last N (visible) characters, masks the rest.
first_lastKeeps first characters at the start and last characters at the end, masks the middle.
email_partialMasks 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_domainMasks the local-part only, keeps the domain fully visible (****@example.com).
email_nameKeeps the local-part visible, masks the entire domain (including the TLD) except dots (john@*******.***).
mask_first / mask_lastMasks exactly length characters from the start/end, using a per-strategy mask_char. Explicitly leaves an empty string as '' (unlike full).
auto_detectClassifies 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_mobileMasks 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:

  1. 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 (if auto_tap.enabled) injects RegisterPiiLogSanitizerProcessor::class into the tap array of each configured channel's config, before Laravel builds that channel's Monolog logger.
  2. 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 built Illuminate\Log\Logger. RegisterPiiLogSanitizerProcessor then pushes a SanitizeLogProcessor instance 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 via stack).

From then on, every record written to that channel passes through SanitizeLogProcessor::__invoke() before any formatter or handler sees it:

  1. If log_pii.enabled is false, the record is returned untouched — this check happens on every call, so you can flip it at runtime.
  2. 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.
  3. The context and extra arrays are recursively walked: every array key, stdClass/Collection/Arrayable/JsonSerializable/Traversable value, and generic object with public properties is inspected.
  4. Throwable objects (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 keyNormalized toMatches
contactNocontact_no
contact_nocontact_no
contact-nocontact_no
Contact Nocontact_no
CONTACT_NOcontact_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) over pattern_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 — source being allowlisted does not protect a nested email field inside source.

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:

ShapeExampleDetected as
Emailjohn@example.comemail
JWTeyJhbGciOi...xyz.abcjwt
UUID9dbedff1-826e-4d10-aead-85ea7f122197uuid
PAN (Indian tax ID)ABCDE1234Fpan
Aadhaar (Indian national ID)123456789012aadhaar
Phone / mobile9876543210, +919876543210phone

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 + a type_strategy entry). 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 — use keys/key_strategy/ patterns/pattern_strategy instead.

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 full strategy (used as default_strategy for keys like ssn that don't have a specific key_strategy entry) uses [*****] (5 stars) as its replacement, while password/otp/pin/ token are each their own named strategy using [****] (4 stars) — these are two different config entries that happen to both be type: full. Don't assume they're interchangeable if you're matching on the exact replacement string somewhere downstream.

Masking styles at a glance

StyleExample strategyUse it for
Full replacementfull, password, tokenSecrets that must never be partially visible (passwords, OTPs, tokens, addresses).
Keep last Nlast, bank, card, aadhaarValues useful for support/debugging by their tail (...1234).
Keep first + last Nfirst_last, mobile, pan, uuid_partialValues useful for fuzzy identification without full exposure.
Email-awareemail_partial, email_domain, email_nameEmails specifically — domain-aware masking that still looks like an email.
Fixed-length maskmask_first, mask_lastReference/verification codes where a fixed number of characters must be hidden regardless of total length.
Value-shape awareauto_detect, email_or_mobileGeneric fields (identifier, value) that could hold different kinds of PII depending on the request.

Edge cases worth knowing

  • null under a masked key stays null (never coerced to a string).
  • An empty string under a full-type strategy is still replaced ('' → '[****]') — the replacement is unconditional. Under mask_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' => 0 still 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 Throwable object 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:

  • method and route are allowlisted (unmasked_keys) and stay visible.
  • authorization matches 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.
  • contactDetail matches 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 — id and country_code (allowlisted) stay visible, only contact_no and email_id are 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 keyAdd it to keys, or to key_strategy if it needs a specific strategy.
Add a new sensitive wildcardAdd it to patterns, or to pattern_strategy for a specific strategy.
Stop masking a specific keyAdd it to unmasked_keys (don't delete it from keys — see below).
Stop masking a wildcardAdd it to unmasked_patterns.
Change how a key is maskedPoint its key_strategy/pattern_strategy entry at a different (or new) strategy name.
Add an entirely new masking ruleAdd a new entry under strategies, then reference its name from key_strategy/pattern_strategy/type_strategy.
Disable masking entirelyPII_LOG_MASKING=false (or log_pii.enabled = false).
Disable only value-based detectionPII_MASK_DETECTED_VALUES=false — key-based masking still applies.
Disable auto-attaching to channelsPII_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 (default 10) — 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 = false if 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 headersaccess_token, refresh_token, Authorization should 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.php under 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 to tests/Unit/SanitizeLogProcessorTest.php for the new rule.
  • Keep PII out of exception messages — this package does not sanitize Throwable objects (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.

  1. Fork the repository and create a feature branch.
  2. Follow the existing style: PSR-4 namespace AltInvest\PiiLogSanitizer\, one class per file, verbose example-driven PHPDoc blocks on non-trivial protected methods (see the existing SanitizeLogProcessor methods for the expected level of detail).
  3. Prefer a config change over a code change — see Understanding Pattern Matching and For Package Maintainers for what counts as which.
  4. 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 only
    

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

  5. 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.
  6. Update this README and CHANGELOG.md if 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.jsonauthors).

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.