---
title: "When PHP Reads Something Else: Visual Traps in Source Code"
url: https://www.exakat.io/when-php-reads-something-else-visual-traps-in-source-code/
date: 2026-09-10
modified: 2026-09-10
lang: en
author: "dams"
description: "When PHP Reads Something Else: Visual Traps in Source Code PHP code is read by two very different audiences: human developers and the PHP parser. Humans read glyphs, the shapes..."
categories:
  - "Technology"
tags:
  - "bug"
  - "php"
  - "security"
  - "suprise"
  - "unicode"
image: https://www.exakat.io/wp-content/uploads/2026/09/sunset.320.jpg
word_count: 1950
---

# When PHP Reads Something Else: Visual Traps in Source Code

# When PHP Reads Something Else: Visual Traps in Source Code

PHP code is read by two very different audiences: human developers and the PHP parser. Humans read glyphs, the shapes our eyes recognise. The parser reads bytes or characters, raw octets encoded in a file. Most of the time these two views agree perfectly. Sometimes they disagree in ways that are merely confusing. And occasionally they disagree in ways that are actively exploitable.

This post takes a tour through the full spectrum, from the classic letter `O` versus the digit `0`, to the genuinely dangerous Cyrillic homoglyphs in class names. Along the way we will look at Unicode comment starters, non-breaking spaces, dollar-sign imposters, and invisible variable names. Each chapter builds on the same underlying idea: **the character you see and the byte PHP reads are not always the same thing**.

## 1. The classic confusables: human eyes under pressure

Long before Unicode entered the picture, programmers were already fooling themselves with plain ASCII. The most famous offenders are the pairs that look nearly identical in many monospace fonts:

| Looks like | Actually | Code point |
| ---------- | -------- | ---------- |
| `O` (letter oh) | `0` (digit zero) | U+004F vs U+0030 |
| `l` (lowercase L) | `1` (digit one) | U+006C vs U+0031 |
| `S` | `$` (dollar) | U+0053 vs U+0024 |
| `Q` | `0` or `O` | U+0051 vs U+0030 / U+004F |

The `$` / `S` pair is particularly dangerous in PHP because `$` is syntactically significant. A variable `$status` misread as `Status` becomes a bare string constant or, with `strict_types`, a fatal error. In the other direction, a stray `$` in a string context can silently interpolate a variable you never intended to reference.

These problems are not purely historical. They still bite developers who:

- Work in low-contrast dark themes where `O` and `0` are hard to tell apart.
- Copy-paste from PDFs, which sometimes substitute similar glyphs.
- Review code at the end of a long day (yes, cognitive fatigue matters).
- Get older and need help with vision (not me, I have had glasses since the age of 7)

The traditional defences are the same ones your IDE enforces without you thinking about them: **80-column limits** and **short methods**. A narrow column means fewer characters per line, which reduces the visual search space. A short method means fewer lines on screen at once, which means each identifier is referenced more often and any typo is spotted sooner. These are not arbitrary style rules: they are empirical answers to the limits of human pattern matching.

## 2. Unicode comment starters pack: the #️⃣️ trick

PHP has four comment syntaxes: `//`, `#`, `/* … */`, and `/** … */`. There is, in fact, a fifth, or rather a family of them, thanks to Unicode.

PHP's lexer works byte by byte. When it encounters byte `0x23` outside a string, it opens a line comment and discards everything until the next newline. `0x23` is the ASCII code for `#`. The trick is that several Unicode grapheme clusters, that is sequences of code points that a human perceives as one character, begin with the byte `0x23` followed by combining code points.

```
#️⃣️ => 23 EF B8 8F E2 83 A3 EF B8 8F (keycap hash emoji)
#⃣ => 23 E2 83 A3 (hash + combining enclosing keycap)
#️ => 23 EF B8 8F (hash + variation selector-16)
```

PHP sees the leading `0x23` and opens a comment. The remaining bytes of the emoji are swallowed as comment content. The result: this is valid PHP that executes without error:

What works and what does not:

| Character | First byte | PHP treats as comment? |
| --------- | ---------- | ---------------------- |
| `#` | 23 | ✓ always (standard) |
| `#️` (# + VS-16) | 23 | ✓ yes |
| `#⃣` (# + keycap) | 23 | ✓ yes |
| `#️⃣️` (full keycap emoji) | 23 | ✓ yes |
| `＃` U+FF03 fullwidth | EF | ✗ no |
| `﹟` U+FE5F small | EF | ✗ no |

The look-alike Unicode `#` symbols, fullwidth `#` and small `#` do not work because their UTF-8 encoding begins with `0xEF`, not `0x23`. The illusion only works for grapheme clusters that literally start with the plain ASCII `#`.

## 3. Non-breaking spaces: the non-breakable space

Copy a PHP snippet from a rendered HTML page, a PDF, or a Word document and you may import a character that looks exactly like a space but is not: the non-breaking space, also known as U+00A0, UTF-8 bytes `C2 A0`.

PHP's lexer recognises as whitespace only the six ASCII control characters`0x20 0x09 0x0A 0x0D 0x0B 0x0C`. A non-breaking space is not among them. Its two bytes `C2`and `A0` are both in the range `0x80–0xFF`, which PHP's identifier rules accept as valid identifier characters. The parser therefore tries to incorporate the non-breaking space into whatever token it is currently building.

The error message is baffling because the file looks perfectly formatted. Most editors render U+00A0 identically to U+0020. The fix is a linter or editor rule that highlights non-ASCII bytes outside strings and comments — or, better, a `editorconfig` + IDE combination that refuses to save files with unexpected encoding.

Zero-width characters compound this further. U+200B (ZERO WIDTH SPACE), U+200C (ZERO WIDTH NON-JOINER), and U+200D (ZERO WIDTH JOINER) are completely invisible, yet their bytes (`E2 80 8B`, `E2 80 8C`, `E2 80 8D`) are valid PHP identifier bytes. A function named `validate​Input` (with an invisible U+200B between `validate` and `Input`) is a different function from `validateInput`. The call site and the definition can be made to reference different functions while looking completely identical in any editor.

## 4. Dollar-sign look-alikes: the imposters that fail silently

After comments and spaces, we can start looking at the weirder characters. Unicode contains several characters that look like the PHP variable-starting `$` `U+0024`:

| Character | Code point | UTF-8 |
| --------- | ---------- | ----- |
| `＄` | U+FF04 fullwidth dollar | EF BC 84 |
| `﹩` | U+FE69 small dollar | EF B9 A9 |
| `💲` | U+1F4B2 heavy dollar sign | F0 9F 92 B2 |

None of these starts a PHP variable. PHP's variable rule triggers on byte `0x24` only. Every UTF-8 multi-byte sequence has a lead byte ≥ `0xC0`, so no single Unicode character, other than `$` itself, can trigger variable parsing.

A subtle code-review trap: a line that reads `＄config['key']` looks like it is reading a variable, but PHP would produce a parse error or treat the whole thing as an unexpected token. An attacker who replaces a legitimate `$config['key']` with `＄config['key']` in a patch could silently neutralise a security check while the diff looks harmless to a distracted reviewer.

## 5. The dollar sign combined with Unicode: invisible variable names

The flip side of the look-alike problem is the extension problem. Just as `#️⃣️` combines the ASCII `#`with emoji-forming code points, you can combine the ASCII `$` with Unicode combining characters to create grapheme clusters that start a variable but embed the combining bytes into the variable name:

```
$️⃣ → 24 EF B8 8F E2 83 A3 ($ + VS-16 + combining keycap)
```

This is where the analogy with comments breaks down sharply:

- **Comment**: bytes after `0x23` on that line are inert: they vanish into comment text.
- **Variable**: bytes after `0x24` are parsed as the variable name: they survive and matter.

Consequence: `$️⃣secret` and `$secret` are completely different variables.

A malicious contributor could introduce a variable assignment that looks identical to a legitimate one but writes to a different slot in the symbol table. The legitimate variable remains unmodified. The injected value is never used. Both facts are invisible without byte-level inspection.

May be malicious is not the right adjective: facetious may be better. But now, we can meet the real serious cases.

## 6. Cyrillic homoglyphs: the real security threat

Everything above can be classified as confusing, surprising, or exotic. Cyrillic homoglyphs are in a different category: they are a documented attack vector used in real-world supply-chain compromises.

Numerous Cyrillic letters are visually indistinguishable from their Latin counterparts in most fonts:

| Cyrillic | Code point | Latin look-alike | Code point |
| -------- | ---------- | ---------------- | ---------- |
| `а` | U+0430 | `a` | U+0061 |
| `е` | U+0435 | `e` | U+0065 |
| `о` | U+043E | `o` | U+006F |
| `р` | U+0440 | `p` | U+0070 |
| `с` | U+0441 | `c` | U+0063 |
| `х` | U+0445 | `x` | U+0078 |
| `А` | U+0410 | `A` | U+0041 |
| `В` | U+0412 | `B` | U+0042 |
| `С` | U+0421 | `C` | U+0043 |

Because PHP identifiers accept bytes `0x80–0xFF`, a class defined as `Rеquest` (where `е` is Cyrillic U+0435) is syntactically valid and entirely distinct from `Request` (Latin `e`). The bytes differ; PHP sees two different class names.

The attack surface is significant:

**Shadow class injection**. An attacker adds a file to a project that defines a class whose name is the Cyrillic homoglyph of a framework class. A crafted `use` statement or a subtly altered autoloader path causes the shadow class to be loaded instead of the real one. The diff shows only what appears to be a trivial typo or whitespace change.

**Bypassing `instanceof` checks**. A guard like `if (!$obj instanceof Request)` will pass silently when `$obj` is an instance of Cyrillic `Rеquest`, because the two class names do not match.

**Autoloader confusion**. PSR-4 autoloaders translate class names to file paths. `Rеquest` with Cyrillic bytes would resolve to a file path that contains those bytes, a file that probably does not exist, or in a hostile scenario, one that was planted.

**String comparisons**. Any code that compares class names as strings, `get_class($obj) === 'Request'`, will silently fail or pass incorrectly depending on which alphabet was used in the definition.

This class of attack is not theoretical. It mirrors the IDN homograph attack with domain names mixing scripts, and the Trojan Source attack, CVE-2021-42574, which uses bidirectional Unicode control characters to reorder how code appears in an editor versus how the parser reads it. Both have real CVEs.

## 7. Defence: what to actually do

Use `token_get_all()` as a source-code auditor. PHP's own tokenizer exposes every token and its byte content. A short script can scan a codebase for non-ASCII bytes appearing outside string literals and comments:

**Configure your editor** to render non-ASCII bytes visibly: most IDEs have a "show invisible characters" or "highlight non-ASCII" option. Pair this with an `.editorconfig` rule enforcing ASCII-only identifiers.

**Add a CI lint step** that rejects PHP source files containing non-ASCII bytes in identifier positions. Tools like `grpc/php-cs-fixer`, static analysers, or a custom pre-commit hook can enforce this.

**For OSS projects: require signed commits** and enforce Unicode identifier restrictions in code review checklists, particularly for any file that touches authentication, authorisation, or class-loading paths.

## Summary

PHP source code sits at the intersection of two audiences: human eyes and a byte-eating parser. And the gap between them is wider than most developers realise.

| Topic | Human sees | PHP reads |
| ----- | ---------- | --------- |
| `S` vs `$` | same glyph in bad fonts | different bytes entirely |
| `#️⃣️` | keycap emoji | `#` comment starter |
| Non-breaking space | a space | an identifier byte |
| `＄varname` | a variable | a parse error |
| `$️⃣varname` | a dollar sign with decoration | a different variable |
| Cyrillic `Rеquest` | `Request` | a completely separate class |

The theme is consistent: **PHP's lexer is byte-precise, but human perception is not**. The good news is that the same tooling that has always helped with readability, narrow columns, short methods, strict linters, CI enforcement, also helps catch these issues before they become vulnerabilities.