====== PHP RFC: str_mask ====== * Version: 0.2 * Date: 2026-09-18 * Author: Sepehr Mahmoudi * Status: Draft * Target Version: PHP 8.7 * Implementation: Not yet available ===== 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 ==== * **$string**: The input string to mask. * **$mask_char**: The character used to overwrite the characters. Defaults to '*'. * **Constraint:** Must contain **exactly one byte**. Passing an empty string or a multi-byte string throws a ValueError. * **$offset**: The position at which masking begins. * Can be positive or negative (counted from the end of the string). * If $offset falls outside the bounds of $string, a ValueError is thrown to prevent silent failures or accidental disclosure of unmasked data. * **$length**: The number of characters to mask. * If null, masking extends from $offset to the end of $string. * If positive, masking covers up to $length bytes. * If negative, masking stops abs($length) bytes before the end of the string. * If the calculated slice is invalid, a ValueError is thrown. ==== 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: * Throwing a ValueError when $offset or $length is out-of-bounds ensures that sensitive data is never silently displayed or logged unmasked due to an arithmetic or offset bug in caller code. * Passing a multi-byte $mask_char explicitly fails with ValueError, ensuring callers understand this is a byte-oriented function (similar to str_pad or str_repeat). Future Unicode-aware masking can be separately addressed via ext/intl (grapheme_mask). ===== 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 ===== * Multi-byte / Grapheme cluster masking in ext/intl (e.g., grapheme_mask()) to properly handle complex UTF-8 characters and emojis without byte mutilation. ===== 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 Utility Class:** Frameworks maintain custom helper methods to satisfy userland masking needs: [[https://github.com/cakephp/cakephp/blob/master/src/Utility/Text.php|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: * **Safety vs Overhead:** The traditional ''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). * **Memory Allocations:** Chaining ''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. ==== Benchmark ==== A quick benchmark comparing the unsafe legacy pattern against a fail-closed userland implementation demonstrates this overhead over 300,000 iterations: * **Legacy pattern (substr_replace + str_repeat):** ~53.97 ms (No bound validation) * **Safe userland implementation (Fail-closed):** ~83.62 ms (+55% execution time overhead) A native C implementation inside ''php-src'': * Eliminates intermediate string allocations by allocating the target buffer directly (''zend_string_alloc''). * Uses fast, low-level memory operations (''memset'' / ''memcpy'') for in-place masking. * Provides strict, zero-cost safety checks natively without userland function call overhead. Benchmark script is available on [[https://gist.github.com/sepehrphpr/86c4be78a3f4882dbd58f6598bb92b16|GitHub Gist]].