mtownsend/array-redactor
Redact sensitive data from PHP arrays before logging or storing it. Array Redactor masks values by key using dot notation and wildcards, with a configurable replacement string and Laravel support. Keeps passwords, tokens, and personal data out of your logs and dumps.
Install via Composer:
composer require mtownsend/array-redactor
The package is auto-discovered on Laravel 5.5+. You get a service provider (Mtownsend\ArrayRedactor\ArrayRedactorServiceProvider) and, optionally, a facade alias.
The core API is a small value-object-style class. Pass the array and the keys you want to redact, then call redact():
use Mtownsend\ArrayRedactor\ArrayRedactor;
$payload = [
'username' => 'michael',
'password' => 'super-secret',
'profile' => [
'api_token' => 'tok_abc123',
'email' => '[email protected]',
],
];
$redacted = (new ArrayRedactor($payload, ['password', 'profile.api_token']))->redact();
Result:
[
'username' => 'michael',
'password' => '[REDACTED]', // placeholder from config
'profile' => [
'api_token' => '[REDACTED]',
'email' => '[email protected]',
],
]
As of 1.0.1, ink() also accepts a callback, so the replacement value can be computed per matched key instead of being a single static placeholder:
$redacted = (new ArrayRedactor($payload, ['password', 'profile.api_token']))
->ink(fn ($value) => is_string($value) ? str_repeat('*', min(strlen($value), 12)) : '[REDACTED]')
->redact();
The callback signature is the first thing to confirm in source — check src/ArrayRedactor.php to see exactly what it receives (value only, or value plus key/path in your installed version) before building on it.
First places to look in the repo: README.md for the exact placeholder and the ink() callback contract, src/ArrayRedactor.php for the matching logic (dot notation, wildcards) and where the callback is invoked, and src/ArrayRedactorServiceProvider.php for the facade binding and config merge.
The most useful first use case in a Laravel app is dropping it into your logging so request bodies never leak secrets:
Log::info('Checkout started', [
'payload' => (new ArrayRedactor($request->all(), ['password', 'card.number', 'card.cvv']))->redact(),
]);
Redact before logging request/response data. Wrap any Log::* call that dumps user-supplied data:
public function store(Request $request)
{
Log::debug('Incoming user update', [
'body' => (new ArrayRedactor($request->all(), [
'password',
'password_confirmation',
'api_token',
]))->redact(),
]);
}
Use ink() callbacks for per-field redaction strategies. A single placeholder is fine for passwords but poor for tokens you need to correlate across log lines, or cards where support staff need the last four digits:
$redacted = (new ArrayRedactor($request->all(), [
'password',
'card.number',
'api_token',
]))
->ink(function ($value) {
// Never return the raw value — that defeats the redaction entirely.
return is_string($value) && strlen($value) > 4
? str_repeat('*', strlen($value) - 4) . substr($value, -4)
: '[REDACTED]';
})
->redact();
Stable correlation via hashing. If you need to answer "same token as the previous request?" without storing the token, return a short hash from the callback:
->ink(fn ($value) => is_scalar($value) ? 'sha1:' . substr(hash('sha256', (string) $value), 0, 12) : null)
Use a keyed HMAC (hash_hmac with an app secret) rather than a bare hash for low-entropy values like PINs, or you've just published a rainbow-table-able fingerprint.
Type-preserving redaction. Callbacks let you keep null as null so downstream code that distinguishes "absent" from "present" keeps working:
->ink(fn ($value) => $value === null ? null : '[REDACTED]')
Redact queued job payloads before they hit failed_jobs or your logs. Jobs frequently carry credentials:
public function failed(Throwable $e)
{
Log::error('Job failed', [
'payload' => (new ArrayRedactor($this->payload, ['auth.password', 'auth.token']))->redact(),
]);
}
Sanitize audit trails and exception context. If you report to Sentry/Bugsnag and pass an array into report()'s context, redact first — and prefer callbacks returning fixed-length output so log/event sizes stay predictable.
Use dot notation for nested keys. profile.api_token targets nested data; a flat api_token key will not match a key buried one level down. Combine this with wildcards for collections:
$redacted = (new ArrayRedactor($team, [
'members.*.password',
'members.*.two_factor_secret',
]))->redact();
Wrap it in a small app-level helper so you have one place to change the key list and the callback strategy:
// app/Support/Redact.php
class Redact
{
public static function sensitive(array $data): array
{
return (new ArrayRedactor($data, [
'password',
'password_confirmation',
'token',
'secret',
'api_key',
'authorization',
]))
->ink(fn ($value) => $value === null ? null : '[REDACTED]')
->redact();
}
public static function partially(array $data): array
{
return (new ArrayRedactor($data, ['card.number', 'card.cvv']))
->ink(fn ($value) => is_string($value) && strlen($value) > 4
? str_repeat('*', strlen($value) - 4) . substr($value, -4)
: '[REDACTED]')
->redact();
}
}
Then call Redact::sensitive($request->all()) everywhere instead of repeating key lists — and so the callback logic is testable in one place.
Middleware for request logging. If you have a "log all requests" middleware, run the request payload through the redactor before persisting:
$logged = (new ArrayRedactor($request->all(), config('logging.redact_keys', [])))->redact();
RequestLog::create(['path' => $request->path(), 'payload' => $logged]);
Keep the key list in config, but keep callbacks in code (see gotchas).
The 1.0.1 change is additive, not breaking. If you never call ink(), behavior is unchanged — the placeholder from config is still used. Only adopt callbacks where you actually need per-value output.
Verify the callback contract in source. Release notes only say "support for callbacks in the ink method"; they don't document arity, whether the key/path is passed, or what happens with array values. Read src/ArrayRedactor.php for your installed version before writing a callback that depends on a second argument.
A callback that returns the original value silently leaks. There is no guardrail here. Prefer the static placeholder for the common case and reserve callbacks for masking/hashing; add a test asserting the raw secret is not present in the output.
Never put closures in config/ files. php artisan config:cache cannot serialize closures and will fail (or silently drop them). Keep callbacks in a helper class or a registry keyed by name, and reference the name from config:
// config/logging.php
'redact_strategy' => 'partial_last_four', // not: fn ($v) => ...
This package is a thin utility, not a full PII framework. 1.0.1 is a small, additive release on a tiny codebase; still verify it against your PHP/Laravel version and read src/ArrayRedactor.php yourself. It has essentially no community surface for edge cases.
Keys are matched, not discovered. A redactor cannot find "secrets" on its own; if you add a new sensitive field, you must add it to your key list — a callback does not make matching smarter, it only changes the replacement. Centralize the list in config or a helper to avoid drift.
Nested keys require dot notation. password only matches top-level; user.settings.password requires the full path. Flat key lists silently miss nested data — a common source of leaks.
Wildcards match arrays-of-arrays, not arbitrary depth. Use items.*.token for a list of items; deep nesting often needs an explicit path per level. Confirm wildcard behavior against the source for your version, since the README is light on edge cases — especially whether a callback is invoked once per matched element or once per pattern.
The placeholder is configurable. Publish the config if you need to change [REDACTED] to something that fits your log format (or to a null/"***" value). Don't hardcode the placeholder anywhere — read it from config. Note it is ignored on any path where your ink() callback supplies the value, so mixed usage means two sources of truth for what a redacted value looks like.
Case sensitivity. Key matching is literal; Password and password are different keys. Normalize your request keys or maintain both in your redact list.
Numeric-keyed arrays are trickier. Indexes inside a redacted structure can shift the wildcard pattern; test against array_values-style payloads rather than assuming it works.
Redaction ≠ omission. The key remains, only its value changes. If downstream code branches on isset($data['password']), it will still see the key present with a placeholder — usually what you want, but be aware. A callback returning null does not remove the key either.
Keep callbacks deterministic, cheap, and side-effect-free. They run once per matched key, potentially on every request if you redact in middleware. No DB lookups, no Log:: calls inside the callback (you'll recurse or leak the raw value), no rand().
Return type is a plain array. No fluent chaining beyond ink()/redact(). If you need to feed it into a DTO or a JsonResponse, decode/encode around it explicitly. A callback that returns an array or object changes the shape of your log payload — assert on it in tests.
Prefer it over hand-rolled loops. A foreach over $request->all() that unsets keys is easy to get wrong (mutating by reference, nested arrays, missing keys). This package is small enough to audit and handles the traversal for you; the callback slot removes most of the remaining reasons to hand-roll.
Escape hatch: if the package's behavior doesn't fit, Arr::set($data, $key, $placeholder) in a loop with Arr::has() is a five-line alternative — and if you find yourself writing a large callback, that's a signal you may want the transformation in your own class regardless.
Testing. Because it's a deterministic array transform, assert on the full output array, including untouched keys, so a future change to your key list is caught by a failing test rather than a silent log leak. Add explicit cases for the callback paths: null values, non-string values (ints, booleans, nested arrays), and values shorter than your masking threshold.
How can I help you explore Laravel packages today?