Masking sensitive data (such as phone numbers, credit card numbers, national IDs, and email fragments) for logging, audit trails, and user interfaces is an ubiquitous requirement in modern web applications.
Currently, PHP lacks a dedicated standard string masking function. Developers typically rely on custom userland helpers composed of substr_replace(), str_repeat(), or regular expressions. These implementations often suffer from subtle boundary-condition bugs, inconsistent offset handling, and avoidable memory allocations.
This RFC proposes adding a native str_mask() function to PHP's standard library, offering a clear, performant, and safe API for byte-level string masking.
function str_mask( #[\SensitiveParameter] string $string, string $mask_char = '*', int $offset = 0, ?int $length = null ): string {}
str_mask() replaces a portion of $string with $mask_char repeated across the masked segment.
In earlier discussions, returning the original string when boundaries are exceeded was considered. However, because masking is primarily used on sensitive or semi-sensitive output (e.g., logging, partial display), a fail-closed approach is essential:
// Mask everything except the last 4 characters of a credit card: echo str_mask("1234567812345678", "*", 0, -4); // Output: ************5678 // Mask the middle segment of a national ID: echo str_mask("0012345678", "#", 2, 6); // Output: 00######78 // Mask from an offset to the end: echo str_mask("secret_token_abc", "X", 7); // Output: secret_XXXXXXXXX
// Throws ValueError: Mask character must be exactly one byte str_mask("hello", "đź”’"); // Throws ValueError: Offset is out of bounds str_mask("hello", "*", 10);
Writing masking logic with substr_replace() requires combining it with str_repeat() and calculating relative lengths:
// Current userland pattern: $masked = substr_replace($input, str_repeat('*', $maskLength), $offset, $length); // Proposed API: $masked = str_mask($input, '*', $offset, $length);
The proposed function communicates developer intent directly and eliminates common off-by-one errors associated with manual length calculations.
While str_mask() is not a cryptographic operation nor an automated compliance guarantee for regulations like GDPR, providing an ergonomic, standard-library redaction primitive encourages developers to consistently redact personal and sensitive data before logging or emitting it to logs and interfaces.
A native C implementation can pre-allocate the resulting Zend string in a single step and execute in-place byte writes for the replacement segment, avoiding the intermediate string allocation created by str_repeat().
This RFC introduces no backward incompatible changes, other than declaring the global function str_mask(). Codebases defining a userland function named str_mask() in the root namespace will conflict.
PHP 8.7.
As this introduces a new standard library function, a 2/3 majority will be required.
String masking is a very common requirement in modern web applications (e.g., obscuring emails, credit card numbers, phone numbers, and API keys). Because PHP currently lacks a dedicated, native string masking function, developers and major frameworks are forced to manually implement userland masking logic using combinations of `substr()`, `str_repeat()`, or `substr_replace()`.
A quick analysis of popular open-source packages and frameworks demonstrates that “reinventing the wheel” for basic masking is widespread across the ecosystem:
CakePHP Text::mask() Implementation
Providing a native, highly-optimized `str_mask()` in PHP core will standardize this common operation, eliminate redundant userland implementations, avoid multi-byte encoding pitfalls, and improve overall execution performance.
In userland code today, masking sensitive strings is typically achieved by composing `substr_replace()`, `str_repeat()`, and `strlen()`:
// Before: Composing userland functions $masked = substr_replace( $pan, str_repeat('*', $length ?? (strlen($pan) - $offset)), $offset, $length ?? (strlen($pan) - $offset) ); // After: Proposed core primitive $masked = str_mask($pan, '*', $offset, $length);
Existing ecosystem implementations (such as Laravel's `Str::mask()`) converge on this exact parameter set (`string`, `mask_char`, `offset`, `length`).
Standardizing this in PHP core provides: * Fail-closed security: Throws `ValueError` on invalid boundaries instead of silently exposing data. * Sensitive parameter protection: Natively uses `#[SensitiveParameter]` on `$string`. * Zero temporary allocations: Avoids allocating intermediate strings from `str_repeat()`.
While string masking can theoretically be composed in userland using substr_replace() and str_repeat(), this approach presents two key trade-offs:
substr_replace(..., str_repeat(...)) pattern is naturally fail-open and does not enforce bounds or handle negative boundaries safely out-of-the-box. Enforcing strict bounds and a fail-closed model in userland introduces noticeable PHP interpreter overhead (bound validation, argument handling).str_repeat() with substr_replace() forces the engine to allocate an intermediate string buffer for the mask characters, which is then immediately copied and discarded by the GC.A quick benchmark comparing the unsafe legacy pattern against a fail-closed userland implementation demonstrates this overhead over 300,000 iterations:
A native C implementation inside php-src:
zend_string_alloc).memset / memcpy) for in-place masking.Benchmark script is available on GitHub Gist.