---
title: "PHP has a Spoofchecker: A 14-Year-Old Tool You’re Probably Not Using"
url: https://www.exakat.io/php-has-a-spoofchecker-a-14-year-old-tool-youre-probably-not-using/
date: 2026-10-17
modified: 2026-10-17
lang: en
author: "dams"
description: "PHP has a Spoofchecker: A 14-Year-Old Tool You're Probably Not Using The Spoofchecker class has shipped with PHP since 5.4, released in March 2012. That's the same year the iPhone..."
categories:
  - "Technology"
tags:
  - "php"
  - "security"
  - "validation"
word_count: 2467
---

# PHP has a Spoofchecker: A 14-Year-Old Tool You’re Probably Not Using

# PHP has a Spoofchecker: A 14-Year-Old Tool You're Probably Not Using

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](https://www.exakat.io/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

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

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

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., `0` and uppercase `O`, `l` and `1`). 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 ICU `U_INVALID_STATE_ERROR` warning. On PHP 8.6-dev there is no warning, but `Admin` and `ADMIN` still 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`:

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:

## 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!).

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.

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.

### 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.

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.

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](https://thephp.foundation/blog/2026/10/08/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](https://github.com/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.

# Check your Unicode strings a bit more

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