PHP type declarations can name a scalar type such as int, float or string, but they cannot restrict a value to a specific member of that type. PHP already has types that name a single value: null and, since PHP 8.2, the true and false types (Add true type). Each of these is a type whose entire domain is one value, and, importantly, PHP never coerces a value to any of them, in either typing mode.
This RFC generalises that idea to integer, floating point and string literals, so that a parameter, property or return type can be limited to an exact set of values:
function setLogLevel('debug'|'info'|'warning'|'error' $level): void {} function setSign(-1|0|1 $sign): int {}
Three questions are decided separately by vote: whether integer and string literals are supported, whether floating point literals are supported, and how a literal type matches a value. See Matching semantics and Proposed Voting Choices.
Integer, floating point and string literals may be used as a type, both standalone and as a member of a union. A value satisfies a literal type when it is of the literal's base scalar type and equal to the literal. Whether any coercion is applied before that equality check is the subject of Matching semantics below.
function f(1|2|3 $x): int { return $x; } f(1); // int(1) f(2); // int(2) f(4); // TypeError: f(): Argument #1 ($x) must be of type 1|2|3, int given
This RFC concerns integer, float and string literals only. true, false and null are already types in their own right and are unrelated to this proposal, except as the precedent it follows.
Integer and string literals are decided by a single vote; floating point literals by a separate one. The feature may therefore ship with integer and string literals, with floating point literals, or with all three.
Integer literals, with an optional leading minus sign: 1, 0, -1.
Single or double quoted string literals. The two quoting styles are equivalent and escape sequences are resolved at compile time, so “\n” and the equivalent single-character literal denote the same type. Interpolation is not allowed; a double quoted string containing a variable, such as “$x”, is a parse error.
Floating point literals such as 1.5, -0.5 and 4e3 may be used as types. Whether they should be supported at all is the subject of a separate vote, because floating point introduces a difficulty the other two base types do not: precision.
A literal type tests a value for equality against a literal. For floats, equality is exact comparison, and many decimal fractions are not exactly representable in IEEE-754 double precision. The classic example:
function f(0.3 $x): float { return $x; } f(0.3); // float(0.3) - ok f(0.1 + 0.2); // TypeError - 0.1 + 0.2 is not exactly 0.3
This is not a bug in the proposal. 0.1 + 0.2 === 0.3 already evaluates to false in PHP today, because 0.1 + 0.2 produces 0.30000000000000004. The type check simply inherits that fact.
What makes it feel surprising is a change of frame. When you write 0.1 + 0.2 === 0.3 you are comparing two values, and an experienced developer expects floating point noise. A type, however, reads like a contract about which values are allowed, and “the value 0.3 is not allowed here” reads strangely when the caller believes they produced 0.3. The relation being tested is between a value and a type that happens to be a value, and intuition about value-to-value comparison does not always carry over to value-to-type membership.
This is precisely the reasoning that led the Enumerations RFC to allow only int and string as backing types and to exclude float. The same argument transfers to literal types, and is the reason float support is proposed as a separable, independently votable feature: it is entirely reasonable to ship integer and string literal types and leave floats out.
There is, however, a case for including them. PHP developers already work with floats and are expected to understand IEEE-754 behaviour. 0.1 + 0.2 failing to match 0.3 is not an inconsistency in the language; it is the language being consistent with itself. A float literal type that matches exactly the values === the literal is predictable once you accept the floating point model you are already living with. Excluding floats also creates its own small asymmetry: int and string literals would be expressible while float literals would not, even though float is an equally fundamental scalar type. Under strict matching the behaviour is at least simple to state (the value must be a float exactly equal to the literal, with the usual int-to-float widening) and carries no hidden coercion rules layered on top of the precision issue.
The RFC takes no hard position and asks the question directly in the vote.
Any lexical form that PHP already recognises as an integer or floating point literal is accepted, and forms that denote the same value are the same type:
42), hexadecimal (0x2A), octal (0o52 and the legacy 052) and binary (0b101010).1.5) and scientific (4e3, 1.5e-3) notation. Note that 4e3 is the float 4000.0, distinct from the integer 4000.1_000, 1_000_000, 0xFF_FF.
A numeric literal may carry a leading unary + or -: -1, +1, -0.5. Strictly these are not single literal tokens (PHP lexes -1 as the unary operator - applied to the integer literal 1), but they read as “the literal negative one” and “the literal positive one”, and both are accepted in type position. +1 denotes the same type as 1 (and is therefore redundant with it in a union; see Redundancy checks). No other unary operator is recognised: ~1 and !0 are not literals and are rejected; only a single leading + or - on a numeric literal is accepted.
Because a literal type is defined by its value, 0x1 and 1 denote the same type and may not both appear in one union (see Redundancy checks), while 4000 (int) and 4e3 (float) are different types.
Only literal tokens are accepted. In particular:
INF and NAN may not be used. They are predefined constants, not literals, and cannot be written as a numeric token. NAN could never function as a type in any case: a NaN is not equal to itself (NAN === NAN is false), so no value could ever satisfy it.const values may not be used. Foo $bar already has a meaning: Foo is a class-like type. PHP stores constants and class-likes in separate symbol tables, so const Foo = 1; and class Foo {} may both exist at the same time. There is therefore no unambiguous way to decide whether Foo in type position means the constant or the class, and resolving it by “use the class if one exists, otherwise the constant” would make a type's meaning depend on load order, which is unacceptable. Restricting types to literal tokens, which can never be identifiers, keeps the grammar unambiguous.1 + 1 or PHP_INT_MAX are likewise not literals and are not accepted.How a literal type checks a value is the fourth vote. There are two candidate behaviours.
true, false and null behave today. The only widening permitted is the universal int-to-float exception.strict_types=1 no coercion occurs (other than int-to-float). This is the behaviour that was proposed in version 0.1 of this RFC.
In a union, this choice governs only how literal members are tested. Non-literal members (such as string in 1|string) continue to follow the calling mode as they do today.
Under strict matching, a literal member behaves identically in both modes: the same way strict_types=1 already treats it, and the same way true/false/null already behave regardless of mode. No string is coerced to a number, no number to a string, no bool to anything. The single exception is the existing int-to-float widening: an int argument satisfies a float literal when its float value equals the literal.
function f(1|2|3 $x): int { return $x; } f(2); // int(2) - ok f("2"); // TypeError - string given, never coerced f(2.0); // TypeError - float given, never coerced f(true); // TypeError - bool given, never coerced f(4); // TypeError - not a member
function f(1.5|2.0 $x): float { return $x; } f(1.5); // float(1.5) - ok f(2); // float(2.0) - ok, int 2 widens to 2.0, which is a member f(1); // TypeError - 1 widens to 1.0, which is not a member f("2"); // TypeError - string given, never coerced
When a literal is combined with a wider type, the literal member is matched first against its exact value; otherwise the wider member applies under its normal rules. The literal member itself never coerces:
function f(1|string $x) {} // the literal 1, unioned with the wide string type // In coercive mode: f(1); // int(1) - matches the literal 1 f("x"); // string("x") - matches the wide string type f(2); // string("2") - 2 is not the literal 1; the wide string member coerces it
The case for strict matching:
true, false and null are already types that name a single value, and PHP never coerces to them, not even with strict_types disabled. Passing 1 to a true parameter is a TypeError in every mode. A literal scalar type is the same kind of thing: a type whose identity is one value. Having 1 (the type) behave differently from true (the type) would be a surprising and arbitrary inconsistency.null, true, false, and every literal, instead of two competing behaviours for the same conceptual category.
Under coercive matching, a literal type coerces exactly like its base scalar type. In coercive mode the value is coerced to the base type and then checked for membership; under strict_types=1 no coercion occurs, with the same int-to-float exception.
function f(1|2|3 $x): int { return $x; } f("2"); // int(2) f(2.0); // int(2) f(true); // int(1) f("4"); // TypeError, 4 is not a member of 1|2|3
The case for coercive matching: a literal such as 1 can be seen as “an int with a smaller domain”, so it should coerce the way an int parameter does and then range-check. Under this view, strict matching makes 1|2|3 behave unlike int in coercive code and may surprise weak-mode callers that routinely pass numeric strings.
The author recommends strict matching for the reasons above, but the decision is left to the vote.
A union may not contain a literal that is already implied by another member. Redundancy is determined by the literal's value and base type, not by its surface spelling. All of these are compile time errors:
function f(1|1 $x) {} // Literal type 1 is redundant as it is already present in the union function f(0x1|1 $x) {} // 0x1 and 1 denote the same value function f(1|int $x) {} // Literal type 1 is redundant as the union already allows its base type
Literals of different base types are never redundant with one another, even where int-to-float widening connects them:
function f(1|1.0 $x) {} // ok: 1 is an int literal, 1.0 is a float literal - distinct types function f(1|'1' $x) {} // ok: 1 is an int literal, '1' is a string literal - distinct types
A literal denotes a single value and carries no interface, so it cannot take part in an intersection type. 1&Foo is rejected at compile time, consistent with how scalar types are already rejected from intersections.
A default value must be equal to one of the type's literals:
function f(1|2 $x = 3) {} // Cannot use int as default value for parameter $x of type 1|2 function f(1|2 $x = 1) {} // ok
A literal type is a subtype of its base scalar type, and a union of literals is a subtype of the union of the corresponding base types. The standard variance rules therefore apply: return types may be narrowed and parameter types may be widened.
class A { public function r(): int { return 1; } public function m(1|2 $x): void {} } class B extends A { public function r(): 1|2 { return 1; } // ok, covariant return public function m(1|2|3 $x): void {} // ok, contravariant parameter }
A new ReflectionLiteralScalarType class is added, extending ReflectionType and exposing the literal value:
class ReflectionLiteralScalarType extends ReflectionType { public function getValue(): int|float|string {} }
Standalone literals, including nullable ones, and the members of a literal union are reported as ReflectionLiteralScalarType. A union such as 1|2|'foo' is a ReflectionUnionType whose members are each a ReflectionLiteralScalarType. The inherited allowsNull() and __toString() behave as for any other type, rendering for example 1, ?1 or 1|2|'foo'.
getValue() returns int, float or string according to the literal's base type. If a given base type is not adopted by its vote, no literal of that base type can exist, but the class and its return type are unchanged.
A literal union is checked at the type-check boundary by comparing the incoming value against each member in turn, stopping at the first match. Membership in a union of n literals is therefore an O(n) operation: matching the last member, or rejecting a value that is not a member at all, compares against all n entries.
This is not specific to literal types. Every union type is checked member by member, and the same linear cost applies to A|B|C|D as to 1|2|3|4. What is worth calling out is that literal unions invite far larger member counts than ordinary unions do. A type such as 0|1|2|3|4|5|6|7|8|9 is easy to write and reads as a single small idea (a “decimal digit”), yet every call that passes 9, or any non-member, performs a ten-way scan. Because each member looks small (a bare number rather than a class name), it is tempting to treat a fifty- or hundred-member literal union as cheap. It is not: the per-call cost grows linearly with the number of members, exactly as it would for a union of fifty classes.
The cost is paid per call, is proportional to union size, and is incurred every time the boundary is crossed; it is not a one-time compile-time cost. Authors writing large literal unions should be aware of this. Range types, discussed below, would let the engine collapse the common contiguous-run case into a bounds check and remove the linear scan for it.
None. Literal scalar types are a new construct and do not change the behaviour of any existing declaration, under either of the proposed matching semantics. The only addition to userland is the ReflectionLiteralScalarType class.
PHP 8.6.
Adds the ReflectionLiteralScalarType class described above.
None.
Extensions that interact with zend_type directly, for example to render or analyse parameter and return types, need to be updated to handle the new literal-type representation. Extensions that construct type information for internal functions via stubs are not required to use literal types and continue to work unchanged.
Static analysers already model literal types in their own type systems: Mago, PHPStan, Psalm and Phan understand literal/constant types such as 1|2|3 and “a”|“b”, and template constraints built on them. Native syntax therefore aligns with concepts these tools already expose. Beyond static analysis, every tool that reads PHP source needs to handle the new syntax in type position: parsers, IDEs, formatters and linters all need updating to recognise literal types.
Anything that inspects types through Reflection is affected as well: a new ReflectionLiteralScalarType subtype joins the ReflectionType hierarchy, and code that branches on reflection type classes (for example, handling only ReflectionNamedType and ReflectionUnionType) will not recognise literal members until it is updated to account for the new subtype.
The matching-semantics decision is relevant to the ecosystem as well as to the engine: tools that reason about assignability and call compatibility must model whether a literal member coerces. Strict matching is the simpler model to implement and matches how these tools already treat true/false/null.
Literal types are a building block that several richer type features would sit directly on top of:
[“status” => “ok” | “error”, “message” => string] or the tuple [0|1, ?Error].0.., 0..10, ..=10 and similar. Beyond expressiveness, ranges address the linear-scan cost of large literal unions directly. Once ranges exist, the compiler or optimizer can collapse a contiguous run such as 0|1|2|3|4|5|6|7|8|9 into the single range 0..9, which is checked with two bound comparisons rather than a ten-way scan. More generally, a union of ranges such as 1..10|15..40 is checked in O(r), where r is the number of ranges (here 2), instead of O(k) in the number of individual values it covers (here 36): each range is a pair of bound checks, so the number of ranges, not the span, drives the cost.These are out of scope for this RFC.
All votes run concurrently for the standard two-week voting period.
First vote, requiring a 2/3 majority, adds integer and string literal types. It is independent of the floating point vote below; the RFC is accepted, and an implementation merged, if at least one of the two type votes passes.
Second vote, requiring a 2/3 majority, adds floating point literal types. It is independent of the vote above. If both type votes fail, the RFC is rejected.
Third vote, decided by simple majority, selects the matching semantics if at least one type vote passes, and has no effect otherwise. The author recommends strict matching (see Matching semantics).
Implementation: php/php-src#22314. Tests live under Zend/tests/type_declarations/literal_types/.
After the project is implemented, this section should contain the version(s) it was merged into, a link to the git commit(s), a link to the PHP manual entry, and a link to the language specification section, if any.
+/-, reworded the strict-matching rationale, and clarified the intersection and Reflection impacts.