Table of Contents

PHP RFC: str_mask

Introduction

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.

Proposed Function Signature

function str_mask(
    #[\SensitiveParameter] string $string,
    string $mask_char = '*',
    int $offset = 0,
    ?int $length = null
): string {}

Detailed Behavior & Semantics

str_mask() replaces a portion of $string with $mask_char repeated across the masked segment.

Parameters

Error Handling (Fail-Closed Design)

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:

Examples

1. Basic Usage

// 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

2. Error Cases

// Throws ValueError: Mask character must be exactly one byte
str_mask("hello", "đź”’");
 
// Throws ValueError: Offset is out of bounds
str_mask("hello", "*", 10);

Rationale & Motivation

1. Ergonomics & Intent

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.

2. Redaction & Privacy Awareness

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.

3. Performance

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().

Backward Incompatible Changes

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.

Proposed PHP Version

PHP 8.7.

Future Scope

Voting

As this introduces a new standard library function, a 2/3 majority will be required.

Real-World Usage & Ecosystem Need

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.

Comparison (Before & After)

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()`.

Performance & Safety Considerations

While string masking can theoretically be composed in userland using substr_replace() and str_repeat(), this approach presents two key trade-offs:

Benchmark

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:

Benchmark script is available on GitHub Gist.