The Spoofchecker class has shipped with PHP since 5.4, released in March 2012. That’s the same year the iPhone 4S was current, “Gangnam Style” dropped, and PHP 5.3 was still the dominant version on the west. Fourteen years later, most PHP developers, including me, have never heard of it. Yet, it’s still the single best PHP built-in defense against Unicode-based visual spoofing.
Start here: when visual traps aren’t a runtime problem
Before going further, you can read my recent blog When PHP reads something else: Visual traps in source code. It’s about a related yet distinct problem: characters in your source code that look identical to other characters, silently breaking comparisons, identifiers, or function calls. The post focuses on the editor side: the developer’s eye can’t tell the difference between an ASCII hyphen - and a Unicode minus −: can you? Or between a Latin o and a Greek ο; between a regular space and a non-breaking space; … there are so many.
Spoofchecker is the runtime counterpart to that problem. Where Exakat helps you spot visual traps in your codebase, Spoofchecker helps you spot them in user input: the strings your application accepts from forms, APIs, emails, and URLs. It’s the same family of attack: characters that look the same to a human but are different to a machine. The difference is who’s being fooled, aka a developer vs. an end user, and where the trap is set, aka your repo vs. incoming data.
If Exakat’s article is about keeping your own house clean, Spoofchecker is about recognizing when someone else is tracking dirt across your floor. This is often underestimated.
Three configurations worth knowing in detail
Out of the box, Spoofchecker runs a default set of checks. But the real power is in setChecks(), where you configure exactly the checks you want. Here are three configurations I keep coming back to.
1. Mixed-script confusables: the brand-impersonation check
setChecks(Spoofchecker::MIXED_SCRIPT_CONFUSABLE);
var_dump($checker->areConfusable('раypal.com', 'paypal.com')); // true
var_dump($checker->areConfusable('example.com', 'paypal.com')); // false
?>
The string раypal.com looks identical to paypal.com in almost any font, but the first two characters are Cyrillic: р is U+0440 and а is U+0430, not the Latin p and a. areConfusable() compares two whole strings and tells you whether they look alike, which is the question a brand-impersonation check asks. This is the configuration that catches the classic homograph domain attack.
2. Invisible characters: the password and ID hygiene check
setChecks(Spoofchecker::INVISIBLE); $spoofed = "ad\xE2\x80\x8Bmin"; // 'ad' + zero-width space + 'min' var_dump($checker->isSuspicious($spoofed)); // true ?>
Zero-width spaces, zero-width joiners, and certain combining marks are invisible to humans but live in the string. INVISIBLE should catch them. This is the configuration you want for any context where the user might retype or visually verify a string: passwords, 2FA seeds, share codes, short IDs. A zero-width character in a password is a guaranteed support ticket. Copy paste is not your friend.
3. Restriction level: the policy-grade check
setRestrictionLevel(Spoofchecker::ASCII);
var_dump($checker->isSuspicious('café')); // true (non-ASCII)
var_dump($checker->isSuspicious('hello')); // false
?>
The restriction level is the most opinionated setting: it grades the whole string against one of Unicode’s identifier profiles. It’s set with setRestrictionLevel(), not with a check constant, so keep it separate from setChecks(). The levels are:
| Level | What it allows |
|---|---|
Spoofchecker::ASCII |
ASCII only |
Spoofchecker::SINGLE_SCRIPT_RESTRICTIVE |
A single script only |
Spoofchecker::HIGHLY_RESTRICTIVE |
ASCII + one other script (e.g., Latin + one CJK) |
Spoofchecker::MODERATELY_RESTRICTIVE |
Allows reasonable mixtures, blocks exotic ones |
Spoofchecker::MINIMALLY_RESTRICTIVE |
All identifier characters |
Spoofchecker::UNRESTRICTIVE |
Anything goes |
This is the configuration to reach for when you have a policy about what an identifier should look like, not just a question about spoofing. For machine-generated IDs, URLs, and API tokens, ASCII or HIGHLY_RESTRICTIVE is right. For human-facing display names where multilingual input is legitimate, drop to MODERATELY_RESTRICTIVE.
The rest of the checks
Spoofchecker exposes more checks than the three above. PHP 8.4 added IGNORE_SPACE, CASE_INSENSITIVE, ADD_CASE_MAPPINGS and SIMPLE_CASE_INSENSITIVE, which the manual documents. Here’s the full list, briefly:
SINGLE_SCRIPT_CONFUSABLE: confusable letters within one script (e.g.,0and uppercaseO,land1). Useful for short identifiers.WHOLE_SCRIPT_CONFUSABLE: an entire script that visually mimics another. This is the case of Greek, that resembles Latin. This check is broader than mixed-script.ANY_CASE: ICU’s “any case” option. It does not make comparisons case-insensitive: on PHP 8.5.11,areConfusable()with it raised an ICUU_INVALID_STATE_ERRORwarning. On PHP 8.6-dev there is no warning, butAdminandADMINstill don’t match. Normalize case yourself, as shown in the username example below.CHAR_LIMIT: a character-limit check. I haven’t verified its exact behavior, so check the manual before relying on it.MIXED_SCRIPT_CONFUSABLE: the brand-impersonation check illustrated above.INVISIBLE: the invisible-character check illustrated above.SINGLE_SCRIPT: the identifier mixes scripts, e.g.ceo@раypal.com. This is the check the egulias/EmailValidator library uses (see the conclusion).
Combine them with bitwise OR: $checker->setChecks(Spoofchecker::MIXED_SCRIPT_CONFUSABLE | Spoofchecker::INVISIBLE | Spoofchecker::SINGLE_SCRIPT);
There are also two helper methods that don’t involve setChecks:
areConfusable('Admin', 'Аdmin', $errorCode); // true
// Restrict allowed scripts to specific locales (affects restriction level checks)
$checker->setAllowedLocales('en_US,ru_RU,el_GR');
?>
Note the third argument. areConfusable() and isSuspicious() both take an int &$errorCode, not a human-readable message. You get a code, not a sentence, so map it to your own message if you need one.
New in PHP 8.6: skeletons and bidi comparison
PHP 8.6 adds getSkeleton(), getBidiSkeleton() and areBidiConfusable(), plus the LTR and RTL constants. I tested them on PHP 8.6.0-dev (built 15 October 2026); none of them exist in PHP 8.5. A skeleton maps each confusable character to a canonical form, which makes it a good way to show someone why a string looks suspicious:
getSkeleton('Damien'); // 'Darnien'
// The Cyrillic letters map to Latin, and `m` maps to `rn`
$checker->getSkeleton('раypal.com'); // 'paypal.corn'
// Bidi-aware skeleton, given the text direction
$checker->getBidiSkeleton(Spoofchecker::LTR, 'Elephpant'); // 'Elephpant'
// Bidi-aware comparison
$checker->areBidiConfusable(Spoofchecker::LTR, 'Аdmin', 'Admin'); // true
?>
Various usages of Spoofchecker
The Spoofchecker class is general-purpose, but in practice it shines in a handful of PHP situations. Here are a few of them.
Domain names and URLs
This is the headline use case. When your application accepts a URL from a user, for link preview, webmention, bookmark, social share, you’re implicitly trusting that the hostname is what it claims to be. IDN domains, aka Punycode, make this worse, because xn--80ak6aa92e.com decodes to аррӏе.com, an all-Cyrillic string that looks like apple.com (Don’t sue me!).
setChecks(Spoofchecker::MIXED_SCRIPT_CONFUSABLE);
$checker->setRestrictionLevel(Spoofchecker::HIGHLY_RESTRICTIVE);
return $checker->isSuspicious($decoded);
}
isLikelySpoofedUrl('https://paypal.com'); // false
isLikelySpoofedUrl('https://раypal.com'); // true
isLikelySpoofedUrl('https://xn--80ak6aa92e.com'); // true (Punycode → Cyrillic apple)
?>
Pair it with idn_to_ascii() for full domain validation: Spoofchecker looks at the visual content, idn_to_ascii() confirms the domain is well-formed.
Username and handle registration
Account impersonation is one of the most common abuse vectors in any platform with user-visible names. Without Spoofchecker, the only way to prevent Аdmin, with a Cyrillic А, from sneaking past a “don’t register as admin” check is to maintain your own confusable mapping. Usually, which you’ll get it wrong. Trust me, bro.
setChecks(
Spoofchecker::MIXED_SCRIPT_CONFUSABLE
| Spoofchecker::SINGLE_SCRIPT_CONFUSABLE
);
// Uppercase both sides: `0` looks like uppercase `O`, not lowercase `o`
foreach ($existingNames as $existing) {
if ($checker->areConfusable(mb_strtoupper($newName), mb_strtoupper($existing), $errorCode)) {
return true;
}
}
return false;
}
$existing = ['Admin', 'moderator', 'support'];
isUsernameImpersonation('Аdmin', $existing); // true
isUsernameImpersonation('moderator', $existing); // true (exact match handled by other logic)
isUsernameImpersonation('m0derat0r', $existing); // true (single-script confusable)
isUsernameImpersonation('alice', $existing); // false
?>
Case is the awkward part. Uppercasing both sides, as above, makes the comparison case-insensitive for the letters that matter, and it doesn’t rely on the ANY_CASE option.
Email addresses and display names
filter_var($email, FILTER_VALIDATE_EMAIL) checks the syntax, and it rejects raw Unicode in the domain. So the attacker sends the Punycode form instead: ceo@xn--ypal-43d9g.com is ceo@раypal.com, and it passes validation. The syntax check does its job; it just can’t see that раypal is visually paypal. Display names are even worse: they’re free-form text, so they are prone to social engineering tactics.
setChecks(Spoofchecker::SINGLE_SCRIPT);
return $checker->isSuspicious($local . '@' . $decodedDomain);
}
function isLikelySpoofedDisplayName(string $name, array $trustedNames): bool
{
$checker = new Spoofchecker();
$checker->setChecks(Spoofchecker::MIXED_SCRIPT_CONFUSABLE);
foreach ($trustedNames as $trusted) {
if ($checker->areConfusable(mb_strtoupper($name), mb_strtoupper($trusted), $errorCode)) {
return true;
}
}
return false;
}
isLikelySpoofedEmail('ceo@xn--ypal-43d9g.com'); // true (decodes to ceo@раypal.com)
isLikelySpoofedEmail('ceo@paypal.com'); // false
isLikelySpoofedDisplayName('Аlex from IT', ['Alex from IT']); // true
?>
Password policy: the lesser taken road
Passwords with zero-width characters are a support nightmare: the user thinks they typed hunter2, but they copy pasted it, and the database stores hunter\xE2\x80\x8B2: the login fails forever. It is recommended to reject these at registration and update time.
setChecks(Spoofchecker::INVISIBLE);
return !$checker->isSuspicious($password);
}
isPasswordSafeToStore('correct horse battery staple'); // true
isPasswordSafeToStore("correct\xE2\x80\x8B horse"); // false
?>
You probably don’t want to enforce full ASCII restriction on passwords: users legitimately use accented characters and Cyrillic in passphrases. But blocking invisible characters is almost always correct.
Content moderation and banned-word filters
Word filters are a classic spoofing target. If you block the string viagra in a comment, the first thing an attacker does is substitute visually identical Cyrillic characters: vіagra, with a Cyrillic і. The second thing they do is use single-script confusables: v1agra. Spoofchecker catches both.
setChecks(
Spoofchecker::SINGLE_SCRIPT_CONFUSABLE
| Spoofchecker::MIXED_SCRIPT_CONFUSABLE
);
foreach ($bannedWords as $word) {
// Compare the whole text against the banned word
// (in practice, tokenize first)
if ($checker->areConfusable(mb_strtoupper($text), mb_strtoupper($word), $errorCode)) {
return true;
}
}
return false;
}
?>
For real production use, tokenize the input first and compare token-by-token rather than the whole string at once. The areConfusable method is a per-string check, not a substring search.
Other situations worth a pass
A few more places where the same pattern pays off, with less code:
- Payment and financial contexts: payee names, bank references, invoice descriptions that visually mimic a known legitimate counterparty.
- Brand and keyword monitoring: in marketplaces, app stores, and ad networks, to detect typosquatting and brand squatting in user-submitted product names and store names.
- Anti-phishing in messaging and email: scan incoming sender display names and embedded links against a list of known-internal executive names and vendor domains before display.
Limitations and gotchas
Spoofchecker is definitely better than nothing. And probably better than anything you can come up with. Yet, there are a few things Spoofchecker is not: let’s review them so you don’t get surprised.
Spoofchecker requires the intl extension. Most modern PHP builds include intl by default, though shared hosting and minimal Docker images sometimes don’t. Check extension_loaded('intl') before relying on it; provide a fallback, or refuse the input otherwise.
isSuspicious is heuristic, not authoritative. It will produce false positives on legitimate multilingual names. A real Greek user named Αλέξ is flagged by setRestrictionLevel(Spoofchecker::ASCII). Always combine the check with locale context via setAllowedLocales(), and prefer MODERATELY_RESTRICTIVE for human-facing identifiers rather than the strictest levels.
Behavior depends on the ICU version. The confusable tables are maintained upstream in ICU and updated periodically. The same string may produce different results on PHP 8.1 with ICU 70 vs. PHP 8.3 with ICU 74. Don’t store isSuspicious results as long-lived authoritative flags; re-run the check when needed.
areConfusable is a per-string check, not a substring search. It compares two whole strings and tells you whether they look alike. It will not find раypal inside https://раypal.com/login. For substring detection, tokenize first: that is, split the URL on /, split the email on @, split text on whitespace, etc. and compare per-token.
It does not validate format. A Punycode string that doesn’t decode, or a domain with illegal characters, will pass Spoofchecker if no confusable pattern matches. Pair with idn_to_ascii() for full domain validation and with Normalizer::normalize() for general Unicode hygiene.
The default check set is conservative. If you don’t call setChecks(), you get a reasonable starting point but not a strict one. For anything security-sensitive, be explicit about which checks you want and which restriction level applies.
It is not a substitute for output encoding. Spoofchecker tells you whether input is suspicious. It doesn’t make output safe. Always pair with proper HTML escaping, URL encoding, and CSP headers.
Unicode tricks in your tests too
The same trick works against the output of your test suite. On 8 October 2026, The PHP Foundation published Your tool has a new reader, about PHPUnit. Its argument: a terminal now has two readers. A human sees the rendered output, and a coding agent reads the raw bytes and acts on them. Any gap between the two is a risk.
The post shows how a test name, data set name, or assertion message can carry control characters. An ESC[2K (erase line) or a carriage return can make a failing test look like a pass to a human, while the agent reads the failure. A line feed inside a data set name can forge the header of a record for a test that doesn’t exist. The fix is to show these characters as visible \u{NNNN} escapes rather than strip them. The lesson is the same as for Spoofchecker: the bytes differ from what the eye sees, and your test output is input too.
Conclusion: you may already be running Spoofchecker
Thanks to egulias/EmailValidator, which is included in many applications, Spoofchecker may already run in your code. Its SpoofCheckValidation strategy calls Spoofchecker with SINGLE_SCRIPT on the whole address, and its constructor throws a LogicException if the intl extension is missing. It is not part of the default RFC validation: it runs only when you add it to a validation chain. On the address from the email example above, it flags ceo@xn--ypal-43d9g.com and passes ceo@paypal.com.
To check your own code, run composer show egulias/email-validator and search your codebase, including your framework’s validation layer, for SpoofCheckValidation. If it is there, you’re already using Spoofchecker. If it is not, the three configurations above are a good place to start.
Spoofchecker is fourteen years old, underused, and still the right tool whenever your PHP application accepts Unicode that other humans will see.
Worth ten minutes of your time to wire in.

