rfc:grapheme_mask

PHP RFC: grapheme_mask

Introduction

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.

Why grapheme_mask() first instead of str_mask()?

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.

Proposal

This RFC proposes adding a new function to the intl extension: grapheme_mask().

Function Signature

function grapheme_mask(
    string $string,
    string $mask_char,
    int $offset = 0,
    ?int $length = null
): string|false
  • $string: The original input string.
  • $mask_char: The character (or string) to use for masking. If it contains multiple graphemes, only the first grapheme cluster is used to maintain structural integrity.
  • $offset: The starting grapheme position for masking.
    • If positive, masking starts at that grapheme offset from the beginning.
    • If negative, masking starts at that grapheme offset from the end of the string.
  • $length: The number of graphemes to mask.
    • If omitted or null, it masks from the $offset to the end of the string.
    • If negative, masking stops that many graphemes from the end of the string.

Return Value

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

Examples

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) "سَ***Ψ―Ω†ΫŒΨ§" 

Backward Incompatible Changes

None.

Proposed PHP Version(s)

PHP 8.6

RFC Impact

  • SAPIs: No impact.
  • Existing Extensions: This function will be added to the intl extension.
  • Opcache: No impact.

Open Issues

None at the moment.

Future Scope

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.

Voting

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>

rfc/grapheme_mask.txt Β· Last modified: by sepehrphp

ο»Ώ