Hi internals,
I would like to gauge reaction before writing an RFC.
PHP's scanner defines identifiers on bytes rather than code points:
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
Every byte >= 0x80 is accepted, so UTF-8 identifiers work by accident;
the manual says as much. There is no encoding validation, no
normalisation requirement and no UAX #31 conformance. As a result
${"\xFF\xFE"} is a valid variable name, and $x and $x<U+00A0> are two
distinct variables that render identically.
I am not proposing that PHP accept more characters -- it already accepts
everything. I am proposing a per-file declare under which the accepted
set is specified: well-formed UTF-8, UAX31-R1-2 with the standard
Default-Ignorable Exclusion Profile, and NFC required rather than
applied. Identifiers consisting only of bytes below 0x80 are never
examined, so existing code pays nothing.
To find out what this would break I surveyed the 250 most-downloaded
Packagist packages and 250 GitHub repositories -- 168,604 PHP files --
using ext/tokenizer. The Packagist corpus contains exactly one
non-ASCII identifier, and no identifier in either corpus is non-NFC.
Tooling, raw data and per-identifier CSVs are here:
https://github.com/Otzie2023/PHP
The patch would not touch the re2c scanner: the byte rule is already
maximally permissive and cannot split a UTF-8 sequence, so a strict
mode only ever rejects and the check can run after the token is formed.
About 13.7 KiB of generated tables, with no dependency on intl, ICU or
mbstring. I would write and maintain it.
Is this worth an RFC, or is there an objection I should know about
before I spend the time?
Regards,
Luca Rodenhaeuser
-----Original Message-----
From: otzelot2021@outlook.de otzelot2021@outlook.de
Sent: Wednesday, August 26, 2026 6:15 PM
To: internals@lists.php.net
Subject: [PHP-DEV] [Concept] declare(strict_identifiers=1)[..] I am proposing a per-file declare under which the accepted set
is specified: well-formed UTF-8, UAX31-R1-2 with the standard Default-
Ignorable Exclusion Profile, and NFC required rather than applied. Identifiers
consisting only of bytes below 0x80 are never examined, so existing code
pays nothing.
Hey Luca,
To prevent errors? I must admit I don't rly understand all the terms.
I assume it implies identifiers should be more visible/readable, right?
To find out what this would break I surveyed the 250 most-downloaded
Why would anything break if it's per-file?
Packagist packages and 250 GitHub repositories -- 168,604 PHP files -- using
ext/tokenizer. The Packagist corpus contains exactly one non-ASCII identifier,
and no identifier in either corpus is non-NFC.
Do I understand it correctly that by adding that declare to 168604 you would uncover
a single risky identifier? Not that convincing...
Would it be fair to say that the same constraints can be enforced by linters/cs tooling?
BR,
Juris
Hey Juris,
To prevent errors? I must admit I don't rly understand all the terms.
I assume it implies identifiers should be more visible/readable, right?
Not readability -- unambiguity. Three concrete things, no jargon:
-
Two identifiers that look identical on screen are currently two
different identifiers. $x and $x followed by U+00A0 NO-BREAK SPACE
are separate variables. So are "a" plus a combining diaeresis and the
single character U+00E4; both display as a-umlaut. -
Text that is not valid UTF-8 at all is currently a valid identifier.
${"\xFF\xFE"} = 1; compiles. -
Nobody can state what a PHP identifier is except by copying the byte
class out of the scanner. The manual does exactly that, and so does
PHP-Parser.
The declare says: in this file, an identifier is well-formed UTF-8, is a
Unicode identifier in the sense of UAX #31 (the Unicode annex that
defines this for programming languages), and is written in one canonical
spelling. Anything else is a compile error.
Why would anything break if it's per-file?
Nothing breaks. That was bad wording on my part.
The survey answers a different question: if a maintainer adds the declare
to a file they already have, does it still compile? That is adoption
friction, not breakage. It is also a calibration check -- a rule that
rejected a lot of legitimate existing code would be the wrong rule, and I
wanted to know that before proposing it rather than after.
Do I understand it correctly that by adding that declare to 168604 you
would uncover a single risky identifier? Not that convincing...
That number is the cost, not the benefit, and I should have separated the
two more clearly.
The single Packagist finding is what would stop compiling: symfony/cache
declares a class whose entire name is the single byte 0xA9. That is the
whole measured adoption cost across the 250 most-installed packages.
What the rule catches can only be measured where non-ASCII identifiers
actually occur, which is not in the top Packagist packages. In the GitHub
corpus, 33 of 136 non-ASCII identifiers fail the rule and 11 contain an
invisible character. One is live code: the Alipay OpenAPI SDK has
$chrtext<U+00A0> = null;
// ...
openssl_public_encrypt($block, $chrtext<U+00A0>, $res);
with a no-break space inside the name. It works only because the typo is
consistent throughout the function. Anyone who types $chrtext normally
gets a different variable, passed by reference, that stays null. Five
vendored copies across four unrelated projects in my sample.
But you are right that these are small numbers, and I would rather say so
than dress them up. The case does not rest on the bug count.
Would it be fair to say that the same constraints can be enforced by
linters/cs tooling?
Partly yes, and I will concede that plainly: UTF-8 validity, NFC and
UAX #31 conformance are all statically checkable. My survey tool is
exactly such a linter, written in PHP, and it is in the repository.
Three things it cannot do.
It cannot define the language. "What is a valid PHP identifier" currently
has no answer other than "whatever bytes the scanner happened to accept",
which is why the manual, PHP-Parser, every IDE and every static analyser
separately reverse-engineer the same byte class. A declare makes it a
versioned, testable statement.
It does not travel with the code. A declare is in the source file; a lint
configuration is in someone's toolchain. The file keeps its guarantee
after composer install, and the rule also covers generated and eval'd
code that never reaches a linter.
And it cannot touch semantics. Case-insensitive symbol lookup folds ASCII
only, so Stra<U+00DF>e and STRA<U+00DF>E are the same class while
Stra<U+1E9E>e is not, and Strasse and STRASSE are. No linter can fix
that, and I do not think it can sensibly be fixed before there is a
definition of what an identifier is. I deliberately kept case folding out
of this proposal, but that is the thing underneath it.
The same objection would apply to strict_types -- static analysers check
types, so why does the engine need a per-file declaration? I do not think
the answer there was "it doesn't", though I accept the parallel is not
exact, since strict_types changes runtime behaviour and this does not.
This is the weakest point of the proposal and you found it on the first
reading. If the list's view is that specifying the rule and leaving
enforcement to tooling is the right scope, that is a smaller and possibly
better RFC, and I would rather establish that now than after writing the
patch.
Thanks for the questions.
Regards,
Luca
The survey answers a different question: if a maintainer adds the declare
to a file they already have, does it still compile? That is adoption
friction, not breakage. It is also a calibration check -- a rule that
rejected a lot of legitimate existing code would be the wrong rule, and I
wanted to know that before proposing it rather than after.Do I understand it correctly that by adding that declare to 168604 you
would uncover a single risky identifier? Not that convincing...That number is the cost, not the benefit, and I should have separated the
two more clearly.The single Packagist finding is what would stop compiling: symfony/cache
declares a class whose entire name is the single byte 0xA9. That is the
whole measured adoption cost across the 250 most-installed packages.
If this is so rarely seen in the wild (something that should be verified with more than 250 packages), why make it an option? Just plan that PHP 9 will enforce UTF-8-or-GTFO rules on identifiers, Symfony updates one oddball class, and we move on with life. 99.99% of developers won't notice anything happened.
--Larry Garfield
The survey answers a different question: if a maintainer adds the declare
to a file they already have, does it still compile? That is adoption
friction, not breakage. It is also a calibration check -- a rule that
rejected a lot of legitimate existing code would be the wrong rule, and I
wanted to know that before proposing it rather than after.Do I understand it correctly that by adding that declare to 168604 you
would uncover a single risky identifier? Not that convincing...That number is the cost, not the benefit, and I should have separated the
two more clearly.The single Packagist finding is what would stop compiling: symfony/cache
declares a class whose entire name is the single byte 0xA9. That is the
whole measured adoption cost across the 250 most-installed packages.If this is so rarely seen in the wild (something that should be verified with more than 250 packages), why make it an option? Just plan that PHP 9 will enforce UTF-8-or-GTFO rules on identifiers, Symfony updates one oddball class, and we move on with life. 99.99% of developers won't notice anything happened.
--Larry Garfield
Is it important enough to have this memory footprint added to each PHP process though?
About 13.7 KiB of generated tables
cheers
Derick
در تاریخ پنجشنبه ۲۷ اوت ۲۰۲۶، ۰۱:۲۳ Derick Rethans derick@php.net نوشت:
On 26 August 2026 22:24:06 BST, Larry Garfield larry@garfieldtech.com
wrote:The survey answers a different question: if a maintainer adds the
declare
to a file they already have, does it still compile? That is adoption
friction, not breakage. It is also a calibration check -- a rule that
rejected a lot of legitimate existing code would be the wrong rule, and
I
wanted to know that before proposing it rather than after.Do I understand it correctly that by adding that declare to 168604
you
would uncover a single risky identifier? Not that convincing...That number is the cost, not the benefit, and I should have separated
the
two more clearly.The single Packagist finding is what would stop compiling: symfony/cache
declares a class whose entire name is the single byte 0xA9. That is the
whole measured adoption cost across the 250 most-installed packages.If this is so rarely seen in the wild (something that should be verified
with more than 250 packages), why make it an option? Just plan that PHP 9
will enforce UTF-8-or-GTFO rules on identifiers, Symfony updates one
oddball class, and we move on with life. 99.99% of developers won't notice
anything happened.--Larry Garfield
Is it important enough to have this memory footprint added to each PHP
process though?About 13.7 KiB of generated tables
cheers
Derick
Hi Derick,
Good point. The ~13.7 KiB footprint comes from the static lookup tables
generated for fast classification.
A couple of aspects regarding how this is handled / can be optimized:
- Shared Memory (
.rodata): Since these tables are declared asstatic const, in standard multi-process setups (e.g. PHP-FPM), they reside in
read-only memory pages shared across processes rather than allocating
per-process heap memory. - Compacting / Range Encoding: We can definitely look into compressing the
lookup tables (e.g., using run-length/interval encoding or two-stage lookup
tables) to bring the table size well under a few kilobytes if the raw table
footprint is a concern.
I’m happy to explore compressing the tables or benchmarking the memory
impact across different setups to ensure the footprint remains negligible.
Best regards,
Sepehr
در تاریخ پنجشنبه ۲۷ اوت ۲۰۲۶، ۰۰:۵۶ Larry Garfield larry@garfieldtech.com
نوشت:
The survey answers a different question: if a maintainer adds the declare
to a file they already have, does it still compile? That is adoption
friction, not breakage. It is also a calibration check -- a rule that
rejected a lot of legitimate existing code would be the wrong rule, and I
wanted to know that before proposing it rather than after.Do I understand it correctly that by adding that declare to 168604 you
would uncover a single risky identifier? Not that convincing...That number is the cost, not the benefit, and I should have separated the
two more clearly.The single Packagist finding is what would stop compiling: symfony/cache
declares a class whose entire name is the single byte 0xA9. That is the
whole measured adoption cost across the 250 most-installed packages.If this is so rarely seen in the wild (something that should be verified
with more than 250 packages), why make it an option? Just plan that PHP 9
will enforce UTF-8-or-GTFO rules on identifiers, Symfony updates one
oddball class, and we move on with life. 99.99% of developers won't notice
anything happened.--Larry Garfield
Fair point, Larry.
Moving to enforce strict UTF-8 identifier rules in PHP 9 would definitely
simplify things and eliminate edge cases cleanly. The main goal here was to
highlight the current ambiguity and explore whether a transitional path or
immediate strictness is preferred. Doing a broader ecosystem check before
finalizing the PHP 9 deprecation path makes total sense.
Not readability -- unambiguity. Three concrete things, no jargon:
Two identifiers that look identical on screen are currently two
different identifiers. $x and $x followed by U+00A0 NO-BREAK SPACE
are separate variables. So are "a" plus a combining diaeresis and the
single character U+00E4; both display as a-umlaut.Text that is not valid UTF-8 at all is currently a valid identifier.
${"\xFF\xFE"} = 1; compiles.Nobody can state what a PHP identifier is except by copying the byte
class out of the scanner. The manual does exactly that, and so does
PHP-Parser.The declare says: in this file, an identifier is well-formed UTF-8, is a
Unicode identifier in the sense of UAX #31 (the Unicode annex that
defines this for programming languages), and is written in one canonical
spelling. Anything else is a compile error.
Hi Luca & list,
Reading this and seeing you talk about making it a compile time error,
raises the question for me of how this will interact with variable
variables which don't comply with the proposed rules - AFAICS those
wouldn't be able to be a compile time error and they also wouldn't have
been found in the scan of Packagist files.
I imagine "on the fly" class creation, like when mocking code may also
run into issues with this up to a point ?
Those are also the things which static analysis of code would not be
able to find or flag (if this were left to static analysis).
Curious to hear your thoughts on this.
Smile,
Juliette
Am 26.08.2026 um 17:15 schrieb otzelot2021@outlook.de:
I am not proposing that PHP accept more characters -- it already accepts
everything. I am proposing a per-file declare under which the accepted
set is specified: well-formed UTF-8, UAX31-R1-2 with the standard
Default-Ignorable Exclusion Profile, and NFC required rather than
applied. Identifiers consisting only of bytes below 0x80 are never
examined, so existing code pays nothing.
What problem would this restriction solve?
Is it about an code smuggling attack vector using code obfuscation with indistinguishable Unicode sequences?
The patch would not touch the re2c scanner: the byte rule is already
maximally permissive and cannot split a UTF-8 sequence, so a strict
mode only ever rejects and the check can run after the token is formed.
About 13.7 KiB of generated tables, with no dependency on intl, ICU or
mbstring. I would write and maintain it.Is this worth an RFC, or is there an objection I should know about
before I spend the time?
Not sure if I think it is worth the effort but maybe you can shine some light on why we want this.
I'm currently leaning to -1 on this,
- Chris
What problem would this restriction solve?
Is it about an code smuggling attack vector using code obfuscation with
indistinguishable Unicode sequences?
No, and I would rather decline that framing than borrow it.
I did look for it. Across both corpora -- 168,604 files -- there is not
one identifier mixing Latin with Cyrillic or Greek, and not one that maps
to a plausible ASCII identifier under homoglyph substitution. The
proposal does not reject confusables and the draft says so explicitly.
UAX #31 takes the same position: for programming languages, spoofing is
better addressed by higher-level diagnostics than in the lexer.
What it does solve, ordered by how much I think each is actually worth:
-
PHP has no definition of an identifier. The only answer to "what is a
valid PHP identifier" is "whatever bytes the scanner accepted", which
is why the manual, PHP-Parser, every IDE and every static analyser
each copy out the same byte class. That is a language-definition gap,
not a bug report. -
Identifiers that render identically are different identifiers. A
no-break space or a decomposed umlaut inside a name is invisible in
every editor. Real, but rare: 11 instances in 168,604 files. -
Case-insensitive lookup folds ASCII only. Stra<U+00DF>e and
STRA<U+00DF>E are the same class; Stra<U+1E9E>e is not, and Strasse is
not. That rule is coherent only if identifiers are ASCII.
Taken alone, (2) is thin. Juris made the same point in the other
subthread and he is right; I am not going to inflate it.
Which leaves the question I think has to be answered before this is worth
either of our time:
PHP accepts non-ASCII identifiers today, and not by decision. The manual
says outright that PHP "doesn't support Unicode variable names" and that
they work because of how UTF-8 happens to encode. So either they are a
supported feature, in which case they need a definition and (3) is an
inconsistency in the engine -- or they are not, in which case that ought
to be a decision somebody made rather than a note explaining an accident,
and I will drop this.
I would rather have a clear answer than a favourable one, because
anything else I might propose in this area depends on it.
If your -1 rests on "not supported, and we are not going to build on it",
I would sooner have that now than after writing a patch. It is a
legitimate answer and it settles the question.
Regards,
Luca
Am 26.08.2026 um 21:57 schrieb Luca Rodenhäuser otzelot2021@outlook.de:
What it does solve, ordered by how much I think each is actually worth:
- PHP has no definition of an identifier. The only answer to "what is a
valid PHP identifier" is "whatever bytes the scanner accepted", which
is why the manual, PHP-Parser, every IDE and every static analyser
each copy out the same byte class. That is a language-definition gap,
not a bug report.
I'm not sure why you consider a formal definition like
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
not a definition. Personally I find this a simpler definition (and easier to implement in a parser) than something like
UTF-8, UAX31-R1-2 with the standard Default-Ignorable Exclusion Profile
But then again I'm not really using non-ASCII identifiers today.
- Identifiers that render identically are different identifiers. A
no-break space or a decomposed umlaut inside a name is invisible in
every editor. Real, but rare: 11 instances in 168,604 files.
I understand your point. But I'm not so worried about accidental mixups here. And this is also something an LSP or Linter can help you with if it a real concern for you.
- Case-insensitive lookup folds ASCII only. Stra<U+00DF>e and
STRA<U+00DF>E are the same class; Stra<U+1E9E>e is not, and Strasse is
not. That rule is coherent only if identifiers are ASCII.
Case-insensitive folding adds another problem: Would you be using IntlChar::FOLD_CASE_DEFAULT or IntlChar::FOLD_CASE_EXCLUDE_SPECIAL_I to fold "I"? Or would you base it on a language setting?
In general I think most people consider the case-folding for identifiers nowadays to be a bug, not a feature, so I would probably rather try to reduce than extend it.
Regards,
- Chris
در تاریخ پنجشنبه ۲۷ اوت ۲۰۲۶، ۰۲:۵۷ Christian Schneider <
cschneid@cschneid.com> نوشت:
Am 26.08.2026 um 21:57 schrieb Luca Rodenhäuser otzelot2021@outlook.de:
What it does solve, ordered by how much I think each is actually worth:
- PHP has no definition of an identifier. The only answer to "what is a
valid PHP identifier" is "whatever bytes the scanner accepted", which
is why the manual, PHP-Parser, every IDE and every static analyser
each copy out the same byte class. That is a language-definition gap,
not a bug report.I'm not sure why you consider a formal definition like
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
not a definition. Personally I find this a simpler definition (and easier
to implement in a parser) than something like
UTF-8, UAX31-R1-2 with the standard Default-Ignorable Exclusion
ProfileBut then again I'm not really using non-ASCII identifiers today.
- Identifiers that render identically are different identifiers. A
no-break space or a decomposed umlaut inside a name is invisible in
every editor. Real, but rare: 11 instances in 168,604 files.I understand your point. But I'm not so worried about accidental mixups
here. And this is also something an LSP or Linter can help you with if it a
real concern for you.
- Case-insensitive lookup folds ASCII only. Stra<U+00DF>e and
STRA<U+00DF>E are the same class; Stra<U+1E9E>e is not, and Strasse is
not. That rule is coherent only if identifiers are ASCII.Case-insensitive folding adds another problem: Would you be using
IntlChar::FOLD_CASE_DEFAULT or IntlChar::FOLD_CASE_EXCLUDE_SPECIAL_I to
fold "I"? Or would you base it on a language setting?In general I think most people consider the case-folding for identifiers
nowadays to be a bug, not a feature, so I would probably rather try to
reduce than extend it.Regards,
- Chris
Hi Chris,
Thanks for the solid points. Let me clarify the perspective behind these:
-
Lexer simplicity vs. Semantic definition:
[a-zA-Z_\x80-\xff] is indeed trivial for the lexer, but it isn't an
identifier specification in terms of character semantics—it's essentially
"ASCII identifiers plus any high byte". This was originally a pragmatic way
to allow Latin-1 / UTF-8 bytes to pass through unchanged. The problem
arises when we consider what an identifier semantically is across tooling,
ASTs, and static analyzers versus raw byte streaming. -
Invisible characters and Linters:
You're right that linters/LSPs can catch these, but language specifications
usually define identifier boundaries (such as TR31 / UAX #31) precisely so
that the baseline definition of a valid symbol doesn't require third-party
tooling to reject canonically confusing or invisible code points. -
Case Folding:
I completely agree with your assessment here. Extending ASCII case-folding
to full Unicode casing (with all the locale subtleties like the
dotted/dotless Turkish I) would be opening Pandora's box. The argument
wasn't necessarily to expand case-folding to Unicode, but rather to
highlight the existing inconsistency: PHP treats identifiers as
case-insensitive on the ASCII plane while allowing non-ASCII bytes that are
strictly case-sensitive.
If the consensus leans toward treating case-insensitivity as historical
baggage, clarifying the identifier grammar and transition paths (especially
looking ahead to PHP 9 / UTF-8 requirements) is exactly the right
discussion to have.
Best regards,
Sepehr