---
title: "When PHP Meet Unicode: A Tour of Identifiers Beyond ASCII"
url: https://www.exakat.io/when-php-meet-unicode-a-tour-of-identifiers-beyond-ascii/
date: 2026-08-04
modified: 2026-08-04
author: "dams"
description: "When PHP Meet Unicode: A Tour of Identifiers Beyond ASCII Every year, like clockwork, someone rediscovers that a day is not reliably 86400 seconds long, gets righteously indignant about daylight..."
categories:
  - "Code auditing"
tags:
  - "naming"
  - "syntax"
  - "unicode"
image: https://www.exakat.io/wp-content/uploads/2026/08/unicode-fun.320.png
word_count: 4347
---

# When PHP Meet Unicode: A Tour of Identifiers Beyond ASCII

# When PHP Meet Unicode: A Tour of Identifiers Beyond ASCII

Every year, like clockwork, someone rediscovers that a day is not reliably 86400 seconds long, gets righteously indignant about daylight change on Hacker News, and two hundred comments later the whole internet has relearned a lesson it relearns every March and October. PHP's "you can name a variable `$🎉`" trick runs on a similar orbital schedule, may be shifted from equinox to solstice, or about. Someone posts a new way to name things in PHP, it does numbers, several thousand people reply "TIL," and almost nobody remembers this has been quietly true since somewhere around PHP 4. The most recent issue came courtesy of Christian Lück, whose [post reignited the usual round of disbelief](https://x.com/exakat/status/2083589384511562092) and, since it's apparently that time of year again, it seemed worth actually explaining why and how this works. We can also marvel at what that does.

Somewhere in the Zend engine's lexer sits a regular expression that nobody designed to be a Unicode feature, and yet here we are, writing a whole blog post about it. If you've spent any time staring at `zend_language_scanner.l`, And if you build static analysis tools for a living, you eventually do. Let me introduce to this old friend:
`LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*`
That's it. That's the whole story of PHP supports Unicode identifiers. This feature just doesn't do it in the deliberate, Unicode-Consortium-approved sense that Python or Java do. What PHP has is a byte-range hack from the Latin-1 era that happened to survive into the UTF-8 world mostly by accident. Nowadays, it let you write `$مرحبا`, `function 你好()`, and possibly `$🎉`. So, let's take the scenic route through where this matters, where it gets beautiful, and where it gets genuinely dangerous.

## First, an inventory: everywhere PHP needs a name

Before we talk about Unicode, it's worth cataloguing just how many places in PHP require you to produce an identifier, also known as a name, because the list is longer than people usually assume:

- **Variables** `$name`
- **Functions** `function name()`
- **Class, interface, trait, and enum names** `class Name {}`
- **Enum cases** `case Name;`
- **Methods and properties** `$obj->name`, `$this->name`
- **Class constants** `const NAME = 1;`
- **Global constants** both the `const` keyword form and, somewhat differently, `define()`
- **Namespaces** `namespace Vendor\Name;`
- **Goto labels** `goto name;`
- **Attribute names** `#[Name]`, which follow class-name rules because, well, they are class names under the hood

Every single one of those, except constant created via `define()`, which I'll come back to because it's an interesting exception, is parsed using that same `LABEL` grammar. Variables simply prepend a `$`; namespaces simply chain labels with backslashes. It's actually one of the unifying rule in an otherwise wonderfully inconsistent language.

Now, the regex again, because it matters: `[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*`. Read literally, it says: the first character must be a letter, underscore, or any byte from 0x80 to 0xFF; subsequent characters may also be digits. Note the word "byte." PHP's lexer, in true C-programmer fashion, doesn't tokenize characters. It tokenizes bytes. It has absolutely no concept of UTF-8, code points, or grapheme clusters at this stage. It just knows that bytes 0x00–0x7F are the boring ASCII range it understands, and everything from 0x80 up is someone else's problem.

This is a huge tell about why the rule exists at all: back when this grammar was written, high bytes meant Latin-1 or Windows-1252 accented characters, such as `é`, `ü`, `ñ`, `ç`, used by European PHP developers who wanted `$café` to work without anyone at Zend having to think hard about internationalization. Nobody in that room was picturing Mandarin or Devanagari. But here's the beautiful coincidence: UTF-8 was specifically engineered so that every byte in a multi-byte sequence, lead byte and continuation bytes, is always >= 0x80, while every ASCII byte stays untouched below 0x80. That design choice, by Ken Thompson and Rob Pike, on a napkin, allegedly, in a New Jersey diner in 1992, a genuinely great engineering anecdote if you haven't read it, is precisely what makes PHP's dumb byte-range hack just work for UTF-8 without a single line of code ever being written for it. PHP got Unicode identifiers the same way I get my steps in: entirely by accident, while aiming at something else.

Contrast this with languages that did it on purpose. Python's [PEP 3131](https://peps.python.org/pep-3131/) defines identifiers using the Unicode `XID_Start` / `XID_Continue` character properties and even runs identifiers through NFKC normalization so that visually-equivalent-but-differently-composed strings collapse to the same identifier. JavaScript does something similar with `ID_Start`/`ID_Continue`. PHP does none of that. It just checks "is this byte at least 0x80?" and moves on. Which, incidentally, means PHP will happily accept a name made of bytes that don't form valid UTF-8 at all: a lone continuation byte, an overlong encoding, garbage... The lexer doesn't validate encoding, it validates a byte range. Your terminal will render it as mojibake, but PHP won't complain until much later, if ever.

## Where this gets genuinely useful

None of the above is purely academic. PHP is a deeply international language, with huge developer communities in China, South Korea, Japan, India, across the Arabic-speaking world, and for a lot of domain modeling, writing business logic in your own language is not a party trick, it's clarity. If your product managers, your tickets, and your client's contracts all talk about "採購訂單", a purchase order, there's a real argument for:

Korean works exactly the same way:

And Devanagari for Hindi:

All of that is completely legal PHP, right now, on any version you're running. I've seen this show up in real codebases we've scanned over the years: usually not for an entire application, but for domain-specific vocabulary that has no good English translation, where programmers are not literate in English or where a non-technical stakeholder occasionally reads the code. It's a legitimate use of the feature, and I'd rather see a clearly-named `$保証期間` warranty period than a cryptically transliterated `$hoshoKikan` that nobody outside the original author can parse, if she still can.

## The other legitimate trick: readable test names via invisible spaces

While we're cataloguing genuine, defensible uses of the `LABEL` regex, there's one that has nothing to do with internationalization. It's purely about readability, and it comes from a corner of the PHP community that noticed, back in 2017, that `\x80-\xff` doesn't just admit Chinese ideographs and Cyrillic letters. It also admits `U+00A0 NO-BREAK SPACE`, the character HTML calls `&nbsp;`. In UTF-8 that's two bytes, `0xC2 0xA0`, both comfortably inside the allowed range. In essentially every font and editor on Earth, it renders as a space. A space that isn't a space, as far as the parser is concerned.

Mathieu Napoli wrote this up in ["Using non-breakable spaces in test method names"](https://mnapoli.fr/using-non-breakable-spaces-in-test-method-names), and the idea is disarmingly simple: instead of naming a test `testProjectMultiVendorProductWithOneDetached()` and trusting everyone to decode the camelCase back into a sentence, you write:

Every gap between those words is a non-breaking space, not the space bar. To PHP, that's one single, unbroken, entirely legal method name: the whole sentence is a `LABEL`, no different in kind from `$小山` two paragraphs up. To a human reading the test output, the IDE outline, or a failing stack trace, it just reads as plain English, no translation step required. Mathieu reports the idea started as an office joke, got tried as a genuine experiment, and a year later his team was "completely happy with it". Not everyone was convinced: Freek Van der Herten [replied three days later](https://freek.dev/787-using-non-breakable-spaces-in-test-method-names) that he'd rather test frameworks accept a plain description string as an argument, Jest-style, than lean on an invisible Unicode character to do the job: a fair complaint, and it names the real tradeoff. This reads beautifully right up until someone greps the codebase for an ordinary ASCII space and quietly finds half their test names invisible to `grep`, or copies a method name into Slack and watches it silently collapse into `testproductandmultivendor...`. Exakat's own [PHP Dictionary](https://php-dictionary.readthedocs.io/en/latest/dictionary/non-breakable-space.ini.html) sums it up about as fairly as I could: handy for readable test names, but "quite rare, and confusing for newcomers". This is dictionary-speak for "you will, eventually, watch someone stare at a failing `const A B = 1;` and question their understanding of reality".

Worth noting, in fairness: PHPUnit didn't strictly need any of this. Its [TestDox](https://docs.phpunit.de/en/12.5/testdox.html) feature already turns `testGreetsWithName()` into "Greets with name" automatically, stripping the `test` prefix and splitting the identifier at case boundaries. No invisible characters, just a `--testdox` flag at run time. Napoli's approach doesn't generate better documentation output; it makes the method name itself, at the source level, already be the sentence. It is visible identically in your IDE, your terminal, and your stack traces, with nothing to enable. Which of the two you reach for says a fair amount about whether you think readable belongs baked into the identifier or bolted on by the tool.

## Right-to-left: where the fun really starts

Arabic, Hebrew, and other RTL scripts are where `PHP allows it` and `PHP is pleasant to use with it` start to diverge sharply. It is not because of anything PHP does, but because of what your screen does.

As a side note, you can try your hand on [this code above](https://3v4l.org/7bUdd/rfc#vgit.master) and realize how much fun preparing this piece of code was.

That's valid, working PHP. But notice something: Unicode text is always stored in logical order: it is the order you'd type it, left-to-right in memory regardless of script. What flips is the visual rendering, governed by the [Unicode Bidirectional Algorithm (UAX #9)](https://www.unicode.org/reports/tr9/). The editor looks at each run of characters, classifies it as strong-LTR, strong-RTL, or weak/neutral, and then decides how to lay it out on screen. The result is that a line mixing Arabic identifiers with PHP's fundamentally LTR punctuation `$`, `=`, `+`, `;` can look like it flows in an order that has nothing to do with how it's actually stored or parsed. PHP itself doesn't care one bit: it's reading a flat byte stream, left to right, top to bottom, the same as always. The confusion is 100% a human-rendering problem, but it's a real one, and it's precisely the kind of thing that makes RTL code review genuinely harder than LTR code review, independent of which language you're reading.

This is also the seam that the infamous ["Trojan Source"](https://trojansource.codes/) attack, described by Boucher & Anderson and tracked as [CVE-2021-42574](https://www.cve.org/CVERecord?id=CVE-2021-42574) drove a truck through. The attack doesn't use natural RTL text at all: it abuses invisible Unicode bidi control characters, like U+202E RIGHT-TO-LEFT OVERRIDE, that can be slipped into a comment or string literal to make the visual rendering of subsequent source code lie to a human reviewer while the compiler or interpreter sees something entirely different. A comment that visually reads `// Everyone is admin: false` can, via a couple of invisible control characters, actually contain code that logically executes as `admin = true`, hidden right there in plain sight of a code review. It affected essentially every mainstream language whose lexer treats source as an undifferentiated byte/character stream and doesn't police bidi control characters specially: which is to say, most of them, PHP included. The practical mitigation didn't come from the language core so much as from editors, GitHub's own bidi-character warnings, and static analysis tooling flagging these control characters wherever they appear. If you're writing an analyzer that touches raw source bytes, this is one of those checks that costs you almost nothing to add and occasionally saves someone's week and job.

## A digression: what about vertical writing?

Since we're on the subject of Chinese and Arabic, I can't resist a detour into something that trips people up: Unicode does define vertical text layout: the `Vertical_Orientation` property, used for traditional Chinese/Japanese typesetting, `縦書き` or tategaki, the kind you see in novels, newspapers, and shop signage, read top-to-bottom, and columns right-to-left. CSS even has `writing-mode: vertical-rl` for exactly this.

But vertical writing is purely a rendering/layout concern. It changes nothing about the underlying code points, and absolutely nothing about how a lexer consumes the file. PHP reads your source file as a sequence of bytes, full stop; it has no idea, nor any way to know, whether some text editor somewhere chose to lay those glyphs out top-to-bottom instead of left-to-right. There is no time to add `declare(orientation=vertical);` before PHP 8.6. Nobody is going to hand you a `.php` file that scrolls downward through your `class` declaration like a hanging scroll. I did, briefly, entertain the mental image of a heredoc block rendered as a vertical banner next to a horizontally-scrolling `foreach` loop, and concluded that whoever builds that editor extension deserves neither our gratitude nor our code review approval. Vertical CJK typesetting is a genuinely rich and interesting corner of the Unicode standard: it's just not one your interpreter will ever meet.

## Where it stops being subtle: emoji

If Chinese identifiers are using the byte-range hack for practical internationalization and non-breaking spaces are using it for readability, emoji are what happens when someone uses it purely because it's funny that it works. And it works far more extensively than intuition suggests. Terence Eden's ["Where you can (and can't) use Emoji in PHP"](https://shkspr.mobi/blog/2024/04/where-you-can-and-cant-use-emoji-in-php/) is the definitive tour, and it goes further than most people would guess:

[![](https://www.exakat.io/wp-content/uploads/2026/08/emoji.variables.png)](https://www.exakat.io/wp-content/uploads/2026/08/emoji.variables.png)

Even multi-codepoint, zero-width-joined emoji sequences survive. Eden gets a family emoji built from ten-plus stitched-together codepoints working as an identifier without complaint, which tells you the lexer genuinely doesn't care how many code points are glued together behind the glyph, as long as every one of their bytes clears 0x80. What doesn't work is just as instructive: an emoji built on a digit-like glyph, `5️⃣`, can't open a variable name, because underneath it's still starts with something PHP's grammar reads as a leading digit. And PHP has never allowed `$5foo`. And emoji that merely resemble punctuation don't get reinterpreted as operators just because they look the part: `echo 5 ❗= 6;`, hoping `❗` reads as `!`, is simply a parse error. PHP's lexer works from grammar categories, not from vibes, no matter how hard `❗` is trying to convince you otherwise.

## Unicode comments in PHP

At that point, it is important to remember the lesser known [Unicode comment](https://php-tips.readthedocs.io/en/latest/tips/unicode_comments.html) in PHP. It is also a Unicode look alike, which, luckily enough, falls back to an actual PHP comment. Do not confuse it with the infamous [URL-as-a-comment](https://php-tips.readthedocs.io/en/latest/tips/URL_as_comments.html) trick, which turn a URL into a goto label, followed by a single line comment. This last one has no relationship with our current blog.

[![](https://www.exakat.io/wp-content/uploads/2026/08/unicode.comment.png)](https://www.exakat.io/wp-content/uploads/2026/08/unicode.comment.png)

## Emojis in variables and function names

Then there's the part I can't, in good conscience, leave out. Mazin Ahmed actually [built a working PHP webshell](https://mazinahmed.net/blog/creating-emojis-php-webshell/) entirely out of emoji-named variables: `$😶="Hello World!"; echo($😶);`, escalated to command execution via a URL parameter like `?👽=pwd`. Ahmed is refreshingly honest that this buys an attacker essentially nothing operationally over an ordinary one-line shell; his stated motivation reads closer to `because I could` than `because it evades detection`, though he does flag, correctly, that it might confuse a WAF or a naive pattern-matcher tuned for ASCII payloads, unverified against anything real. I mention it not because you should lose sleep over emoji webshells specifically, but because it's a clean demonstration of the principle running through this entire post: anywhere a language quietly widens what counts as just a name, someone will eventually widen it all the way to attack surface, if only for the conference talk. Mea culpa.

And since you're wondering: no, I have never once seen anyone document bug severity with an emoji in the identifier itself: `function 🔥deleteEverything()` to mean calling this is dangerous, or `class 🐛KnownRaceCondition` to save writing a comment: despite it being completely legal PHP, right now, on whatever version you're running. Plenty of teams put emoji in commit messages, PR titles, Slack threads, even docblocks, quite happily. Nobody puts them in the method signature. I suspect the one team that tried it is still explaining to `grep -P` why `🔥` won't behave like a normal regex class.

## Now, the part that should actually worry you: look-alikes

Here's where Unicode support stops being a nice feature and starts being a security consideration. Unicode's [confusables](https://util.unicode.org/UnicodeJsps/confusables.jsp) characters is a thing. They are characters from different scripts that render as visually identical or near-identical glyphs. They are entirely as capable of appearing in PHP identifiers as they are in URLs, and PHP's byte-range lexer draws no distinction whatsoever between the letter you meant and the letter that merely looks like it.

The classics:

- Latin `a` (U+0061) vs. Cyrillic `а` (U+0430)
- Latin `e` (U+0065) vs. Cyrillic `е` (U+0435)
- Latin `p` (U+0070) vs. Cyrillic `р` (U+0440) vs. Greek `ρ` (U+03C1)
- Latin `o` (U+006F) vs. Cyrillic `о` (U+043E)
- Fullwidth Latin forms, e.g. `Ａ` (U+FF21) from the CJK-typesetting-oriented "Fullwidth Forms" block, vs. plain `A` (U+0041)

Now imagine this diff lands in a pull request, reviewed quickly on a laptop screen at 11pm:

Somewhere else in the file, the real check is `$isAdmin`, in all Latin letters. To PHP, these are two completely unrelated variables: no relation, no warning beyond the usual `undefined variable`notice if you're lucky enough to have `error_reporting` cranked up, and total silence if you're not. In the best case, this is an infuriating, hours-long bug hunt where two identifiers that are pixel-for-pixel identical in your editor's font are, byte for byte, nothing alike. In the worst case, it's precisely the shape of a deliberately planted supply-chain trick: the source-code equivalent of registering `аpple.com`with a Cyrillic а to phish people who trust the padlock icon. This is a well-documented category: Trojan Source covers it as the second of its two techniques, alongside bidi overrides. And it's why the Unicode Consortium maintains an entire technical standard on the subject: [UTS #39, Unicode Security Mechanisms](https://www.unicode.org/reports/tr39/), which defines restriction levels for mixing scripts within a single identifier precisely so that tools can flag `pаypal`, with that sneaky Cyrillic а, as suspicious even when a human eye sails right past it.

### The punctuation you're not allowed to use, except when you are

Homoglyphs don't stop at letters. PHP's grammar deliberately reserves the ASCII punctuation marks, familiar characters like `'`, `"`, `?`, `!`, `;`, `:`, `@`, `(`, `)`, for syntax: string delimiters, the ternary operator, statement terminators, error suppression, attributes, argument lists. Every one of them sits below byte 0x80, which is exactly why none of them can appear inside a `LABEL`, and exactly why `$don't` is a syntax error. But Unicode, being Unicode, has spent decades accumulating other characters that look near-identical to that punctuation while being, categorically, letters or symbols rather than ASCII punctuation. Every one of those sails straight through the same `\x80-\xff` gate that keeps the real thing out.

The apostrophe is the one you've probably already met without noticing. U+2019 RIGHT SINGLE QUOTATION MARK ( ’ ) is the smart quote that Word, Google Docs, Notion, iMessage, and roughly every autocorrect engine on the planet substitutes for a plain apostrophe the moment you stop typing code and start typing prose. This is precisely the mechanism by which it ends up pasted into a commit message, a ticket, or a code sample lifted from a blog post. There's also U+02BC MODIFIER LETTER APOSTROPHE ( ʼ ), which Unicode classifies as an actual letter: it is used to write glottal stops in several languages. It is not punctuation, and it is close enough in most fonts to pass a glance test. Either one, dropped into an identifier, produces something that reads as plain English and compiles as an entirely unremarkable multi-byte `LABEL`:

Nobody sits down and types the U+2019 version on purpose here: that's rather the point. This is far more often an accident, a paste from a smart-quotes source, an autocorrect firing mid-rename in a misconfigured editor, than a deliberate trick, and PHP's total indifference to the difference is exactly why it goes unnoticed: the identifier just works, the code just runs, and nobody double-checks their apostrophes byte by byte, because why on earth would they.

The same game plays out with other reserved punctuation. The Fullwidth Forms block, built for CJK typesetting, where ASCII punctuation needs to occupy a full character cell next to wide ideographs, hands you `？`, aka U+FF1F FULLWIDTH QUESTION MARK and `！`, aka U+FF01 FULLWIDTH EXCLAMATION MARK, both multi-byte, both perfectly welcome inside a `LABEL`:

Squint at either of those in a proportional font and you'd swear you're looking at a question mark and an exclamation point sitting where PHP would normally throw a parse error. You're not: you're looking at their wider Unicode cousins, quietly exploiting the fact that `reserved for syntax` only ever meant `this specific ASCII byte`, never `anything that resembles it`. And if you want the properly unsettling one: U+037E GREEK QUESTION MARK ( ; ) is, glyph for glyph in most fonts, indistinguishable from an ASCII semicolon. It genuinely is the character Greek prose uses to end a question, which is a lovely bit of script history and a faintly alarming source of confusable statement terminators the day a reviewer is skimming rather than reading.

None of this is exotic by Unicode's own accounting. It's precisely what [UTS #39](https://www.unicode.org/reports/tr39/) and the [confusables.txt](https://util.unicode.org/UnicodeJsps/confusables.jsp)data catalogue, the same mechanism behind the letter look-alikes above and behind IDN homograph phishing domains. PHP just happens to be unusually exposed to it, because its identifier grammar was never taught the difference between `a letter` and `a punctuation mark` in the first place. It only ever learned less than 0x80 versus everything else. And when you dig a bit in Unicode, you can also discover [Characters operators](https://adamadam.blog/2025/04/22/unicode-facts-you-should-know/) to make polar bears with bear (sic) and snow. No, it doesn't work with elephpants...

PHP itself enforces none of this. There is no restriction-level check in the parser, no mixed-script warning, nothing. Which is, frankly, exactly the kind of gap a static analyzer earns its keep by covering: flagging identifiers that mix scripts within a single token, or that are dangerously confusable with an existing identifier elsewhere in the file, is cheap to implement against Unicode's own confusables data and catches a class of bug that a human reviewer is, almost by definition, the worst-equipped tool in the room to catch. If you can't trust your own eyes to tell `р` from `p`, you shouldn't be relying on your eyes as the last line of defense.

## The loophole nobody mentions: dynamic names bypass all of this

And let's add one more twist, because it's too good to leave out. Everything above, the `LABEL`grammar, the byte-range check, all of it, only applies to statically written identifiers, the ones the lexer sees at parse time. The moment you go dynamic, none of it applies, because you're no longer handing PHP a token, you're handing it a runtime string:

`define()` takes a plain string, so it was never bound by the parser's identifier grammar in the first place: you can `define()` a constant with spaces, emoji, control characters, whatever `constant()` can later look up as a string key. Same story for variable variables, `$obj->{$expr}` dynamic property access, and `call_user_func($name)`. The `LABEL`regex is a constraint on the lexer, not on PHP's runtime symbol tables, which are just hash maps keyed by arbitrary strings underneath. So the honest summary is: PHP's source-level Unicode support is a lucky byte-range accident, but PHP's runtime naming is basically unconstrained Unicode all the way down: this is either delightfully flexible or faintly terrifying depending on how much coffee you've had or who you ask.

## So, should you actually do any of this?

Sparingly, and with your eyes open. Domain-language identifiers for genuinely domain-specific vocabulary: fine, and, if you ask someone who managed Chinese developers, even good, when your whole team reads that script fluently and your tooling, from editors and IDEs to CI and static analyzers, handles it cleanly. A non-breaking space in a test name is a defensible, if slightly cheeky, readability choice, as long as everyone on the team has been let in on the joke. Emoji identifiers belong in conference talks and April blog posts, not in production method signatures: Ahmed's webshell and Eden's catalogue of what works are both, in their own way, proof of concept rather than recommendation. And mixing scripts within a single identifier, relying on RTL text alongside dense punctuation, or leaving your codebase exposed to homoglyph and confusable-punctuation substitution without any linting in place: that's asking for a bug report you'll spend a strange afternoon debugging by literally counting bytes with `bin2hex()`. Not a hypothetical: I've been there, more than I want to remember, and can confirm it is not how anyone wants to spend an afternoon.

PHP will let you do almost anything with a name, mostly because forty years of C-flavored lexer design never bothered to stop you. Unicode gave PHP this power more or less by coincidence. What you do with it is, as ever, entirely between you and your code reviewers: assuming they can actually tell your `а` from your `a`.