In modern web applications, handling Personally Identifiable Information (PII) securely is a critical requirement. Developers frequently need to obfuscate or mask sensitive data such as phone numbers, email addresses, national IDs, or partial credit card numbers before logging them or displaying them to users (e.g., to comply with privacy regulations).
Currently, developers rely on userland implementations combining str_repeat, substr, or substr_replace. However, these native string functions operate exclusively on bytes. When dealing with internationalization (i18n), multibyte characters, combining marks, or emojis, byte-level operations often result in broken strings, corrupted characters, or incorrect masking lengths.
The grapheme_mask function introduces a native, highly performant, and fully Unicode-aware way to mask strings without corrupting complex characters. By utilizing the ICU library, it ensures that grapheme cluster boundaries are always respected.
A common question might be: *Why not introduce a standard str_mask() function first?*
While a byte-level str_mask() is highly valuable for ASCII strings and general use cases, handling masking safely in modern UTF-8 environments is a more pressing issue. Using a byte-level function on a string containing multibyte characters where the offset or length falls in the middle of a byte sequence results in invalid UTF-8 characters.
Therefore, this RFC prioritizes the grapheme-aware masking function in the intl extension to solve the more complex and error-prone problem of Unicode masking.
Note: I am planning to propose a standard str_mask() for byte-level operations in a subsequent, separate RFC.
This RFC proposes adding a new function to the intl extension: grapheme_mask().
function grapheme_mask( string $string, string $mask_char, int $offset = 0, ?int $length = null ): string|false
null, it masks from the $offset to the end of the string.
Returns the masked string on success, or false if the input string is not valid UTF-8 or if the grapheme operation fails. This behavior is consistent with other grapheme functions in the intl extension, such as grapheme_substr() and grapheme_strlen().
Here are 6 examples demonstrating how grapheme_mask() works, covering basic usage, negative offsets, and complex Unicode characters:
Example 1: Masking from the Beginning
// Masking the first 4 characters $result = grapheme_mask('09123456789', '*', 0, 4); var_dump($result); // string(11) "****3456789"
Example 2: Masking with Positive Offset and Length
// Masking 3 characters starting from the 5th character $result = grapheme_mask('HelloWorld', '*', 5, 3); var_dump($result); // string(10) "Hello***ld"
Example 3: Masking with Negative Offset
// Masking the last 4 characters of a string // -4 means start 4 graphemes from the end and mask to the end $result = grapheme_mask('09123456789', '*', -4); var_dump($result); // string(11) "0912345****"
Example 4: Masking with Negative Offset and Negative Length
// Start masking 6 graphemes from the end, and stop 2 graphemes from the end $result = grapheme_mask('1234567890', 'X', -6, -2); var_dump($result); // string(10) "1234XXXX90"
Example 5: Handling Complex Emojis (ZWJ Sequences)
// The family emoji (π¨βπ©βπ§βπ¦) is multiple bytes but ONE single grapheme cluster. // Masking from offset 1 replaces the apple. $result = grapheme_mask('π¨βπ©βπ§βπ¦appleπ', '*', 1, 5); var_dump($result); // string(14) "π¨βπ©βπ§βπ¦*****π"
Example 6: Handling Combining Marks (Persian/Arabic Vowels)
// In "Ψ³ΩΩΨ§Ω ", "Ψ³Ω" is one grapheme cluster (Base character + vowel mark). // Masking starting from index 1 will correctly skip the first grapheme. $result = grapheme_mask('Ψ³ΩΩΨ§Ω βΨ―ΩΫΨ§', '*', 1, 3); var_dump($result); // string(16) "Ψ³Ω***Ψ―ΩΫΨ§"
None.
PHP 8.6
intl extension.None at the moment.
As mentioned, a native str_mask() function operating at the byte level will be proposed in a future RFC to complement grapheme_mask(). Additionally, a multibyte counterpart (mb_mask) for the mbstring extension may be considered for users without the intl extension.
As this is a feature addition, it requires a 2/3 majority in favor to be accepted. Voting will open on 2026-07-10 and close on 2026-07-24.
<DO NOT FORGET TO ADD DOKUWIKI VOTING MACRO HERE WHEN VOTING STARTS>