PHP 8.3 arrived on the twenty-third of November. Constants were the last declaration in the language that could not carry a type, which meant an interface constant was a promise the engine did not check — and one of ours had been broken since 2019.
The symptom
interface Formatter
{
/** @var string the ISO code this formatter emits */
public const DEFAULT_CURRENCY = 'GBP';
}
final class LegacyCsvFormatter implements Formatter
{
public const DEFAULT_CURRENCY = 826; // the ISO NUMERIC code
}
// legal in every version before 8.3.
// the docblock says string. the value is an int.
// and one report had been emitting "826" as a currency.
found by grep, not by a tool:
$ grep -rn 'const DEFAULT_CURRENCY' src/
src/Formatting/Formatter.php:8 'GBP'
src/Formatting/CsvFormatter.php:14 'GBP'
src/Formatting/PdfFormatter.php:11 'GBP'
src/Formatting/LegacyCsvFormatter.php:9 826
four declarations, one different, and nothing in the
language or the analyser had objected.Why it happens
Interface constants are inherited and overridable, and until 8.3 the only description of their type was a docblock that the engine ignored and most analysers treated as advisory. A class redeclaring one with a different type was legal by design.
The fix
The syntax, and the covariance rule
interface Formatter
{
public const string DEFAULT_CURRENCY = 'GBP';
}
final class LegacyCsvFormatter implements Formatter
{
public const string DEFAULT_CURRENCY = 826;
// Fatal error: Cannot use int as value for class
// constant LegacyCsvFormatter::DEFAULT_CURRENCY of
// type string
}
// narrowing is allowed, and only downwards
class Base { public const string|int VALUE = 'a'; }
class Child extends Base { public const string VALUE = 'b'; } // ok
class Wider extends Base { public const mixed VALUE = 1.5; } // Fatal
// and the type must be declared on the override if it
// is declared on the parent — a bare `const VALUE` in a
// child of a typed parent is a fatal error.
The requirement that an override restate the type is what makes this catch the whole hierarchy rather than only the declaration site, and it is the source of most of the mechanical churn — every child of a typed constant needs the type written out.
Enums are still the better answer for a fixed set
// what a typed constant is NOT competing with
enum Currency: string
{
case Gbp = 'GBP';
case Eur = 'EUR';
public function numeric(): int
{
return match ($this) { self::Gbp => 826, self::Eur => 978 };
}
}
// the constant, properly typed, is for values that are
// genuinely just values: a threshold, a version, a key.
public const int MAX_RETRIES = 7;
public const string CACHE_PREFIX = 'td:v4:';
The LegacyCsvFormatter case was actually an enum wanting to exist — two representations of one concept, which is exactly what a backed enum with a method is for. Typing the constant fixed the symptom and the enum fixed the cause.
Dynamic class constant fetch, from the same release
// before 8.3
$value = constant(Formatter::class . '::' . $name);
// a string concatenation no tool can follow. a rename
// misses it. a typo is a runtime error.
// 8.3
$value = Formatter::{$name};
// still no existence check, and still worse than an
// enum lookup — but greppable, and the error names the
// class rather than a concatenated string.
Typing two hundred constants
$ grep -rhoP '^s*(public |private |protected )?const Kw+ =' src/ | wc -l
204
# the mechanical pass: infer the type from the literal
$ vendor/bin/rector process src --config=rector-typed-constants.php
188 constants typed
# the 16 it would not touch:
# 9 an array literal — const array is legal and the
# element type is not expressible. left untyped.
# 4 a constant expression referencing another
# constant. rector was conservative; typed by hand.
# 3 the actual bug, and its siblings.Nine array constants left untyped is the honest limit: const array is legal and says almost nothing, and the element type has no syntax. Those stay as docblocks and remain unenforced, which is the same position they were in before.
What the analyser had already caught
phpstan at level 8, on the same code, before 8.3:
caught a constant used in a strict comparison
against a string, when the constant was an
int — 1 of the 4 declarations.
missed the declaration itself. the override is
legal PHP and the analyser reads each class
independently unless the constant is used
polymorphically.
which is the general pattern: static analysis finds
wrong USES and the engine now finds wrong DECLARATIONS.The two are complementary rather than redundant. The analyser found one call site where the mismatch produced a comparison that could never be true; it did not object to the declaration, because reading a single class in isolation there is nothing wrong.
Verifying it worked
$ php -v
PHP 8.3.0 (cli)
$ php -l src/Formatting/LegacyCsvFormatter.php
No syntax errors detected
# because it is now an enum, and the constant is gone
$ grep -rc 'public const [a-z]' src/ | awk -F: '{s+=$2} END {print s}'
195
$ grep -rcP 'public const (?![a-z])' src/ | awk -F: '{s+=$2} END {print s}'
9 # the array constants, deliberately
$ vendor/bin/phpstan analyse
[OK] No errors
# the report that had emitted 826 as a currency
$ php artisan report:export --format=csv | head -2
reference,total,currency
ORD-8814,49.00,GBPThe report emitting GBP rather than 826 is the user-visible outcome, and it had been wrong in one export format since 2019 — used by one integrator who had written a mapping for it and never mentioned it.
What this costs
A minimum version bump for a correctness gain that is small in most codebases. Typing two hundred constants found one genuine bug, and the honest framing is that the bug was found by grep during the exercise rather than by the feature — the feature is what stops the next one.
It also adds a keyword to every constant declaration forever, and a child class overriding a typed constant must restate the type, which is verbose in a hierarchy. That is the same trade typed properties made in 7.4 and it settled the same way: mildly annoying, and nobody wants to go back.