PHP type system and language semantics
Status: Complete. Last reviewed 2026-08-28.
PHP is dynamically typed at runtime: values carry types, while variables are names pointing at those values rather than permanently typed storage locations. Declarations constrain particular boundaries—parameters, returns, properties, and constants—but they do not turn PHP into a statically typed language or validate untrusted input automatically.
Declarations are runtime contracts
Section titled “Declarations are runtime contracts”A declaration is checked when its boundary is crossed. Parameter types are checked when a function is called, return types when it returns, and property types when a value is assigned. An uninitialized typed property is a distinct state from a property containing null; reading it raises an Error even if the property type is nullable.
mixed explicitly accepts every PHP value. An omitted declaration supplies no runtime constraint and less information to tools. void means a function returns no meaningful value; never means it cannot complete normally because it always throws or terminates. iterable accepts arrays and Traversable; callable accepts invokable forms but cannot be used as a property type because callable validity can depend on scope.
Union types express alternatives such as User|string. Intersection types require one object to satisfy every listed interface. PHP also supports disjunctive-normal-form combinations such as (Countable&Iterator)|array, with parentheses required around intersections inside a union. A nullable type ?T is shorthand for T|null, not a signal that missing external data has been validated.
Declarations do not recursively validate a structure. array says nothing about its keys and values, and object says nothing about its capabilities. That is where application-owned value objects, collection abstractions, assertions, and static-analysis shapes become useful.
Strict typing belongs to the caller
Section titled “Strict typing belongs to the caller”Without strict mode, PHP may coerce scalar arguments where a declared scalar type permits it. declare(strict_types=1) changes scalar argument handling for calls made from that file. It does not attach strictness permanently to functions declared there. A coercive file calling a function defined in a strict file still uses coercive argument rules; a strict file calling it uses strict rules.
Return checking follows the mode of the file containing the function declaration. Strict typing applies to scalar declarations, not class/interface subtype checks, and an integer is accepted for a float declaration even in strict mode. Calls performed by internal functions have their documented internal behavior rather than inheriting the surrounding call site’s declaration mechanically.
This is why strict mode is valuable but not an input-validation strategy. HTTP parameters, JSON, database values, and environment variables still arrive through APIs with their own conversion rules. Parse and validate them explicitly before constructing trusted domain values.
<?php
declare(strict_types=1);
function percentage(float $value): float{ return $value * 100;}
percentage(1); // accepted: int to float is the strict-mode exceptionpercentage('1'); // TypeError from this strict call siteVariance preserves substitutability
Section titled “Variance preserves substitutability”An overriding method may accept a broader parameter type: parameter types are contravariant. It may return a narrower type: return types are covariant. These directions preserve callers’ expectations.
If an interface promises handle(CardPayment): Receipt, an implementation accepting any Payment remains usable wherever the interface is expected, and returning a DigitalReceipt remains safe if it is a subtype of Receipt. Reversing either direction could reject an input the contract promised or return something callers cannot use.
Property types are normally invariant because readable and writable state creates requirements in both directions. Read-only or write-only aspects can permit variance in newer property models, but method boundaries remain the clearest interview example. Variance is about compatibility of overriding declarations, not PHP converting values between unrelated classes.
Enums model closed identity
Section titled “Enums model closed identity”A unit enum defines a closed set of singleton cases. A backed enum associates each case with a unique int or string value suitable for storage or transport. Cases are objects and can implement interfaces and provide behavior, but enums cannot be extended; adding a case is therefore a compatibility decision for exhaustive consumers.
BackedEnum::from() rejects an unknown backing value with ValueError; tryFrom() returns null. Choose between them based on whether unknown data is exceptional at that boundary. Do not confuse accepting an enum instance inside trusted code with parsing an external string—the parsing step must still decide how invalid and future values are handled.
Enums are strongest for truly closed vocabulary controlled by the application, such as a small workflow state. They are weaker for administrator-defined records, externally extensible codes, or data needing independent lifecycle and metadata; those usually belong in persistent entities or value objects.
Readonly limits reassignment, not reachability
Section titled “Readonly limits reassignment, not reachability”A readonly property must be typed and can be initialized according to its permitted set scope, then cannot be reassigned or unset. A readonly class applies readonly behavior to its instance properties and prevents dynamic properties. This protects stable object state, especially for value objects and messages.
Readonly is shallow. If a readonly property contains a mutable object, code can still mutate that nested object. It also does not make an operation pure, make arrays recursively immutable by design, or guarantee that two instances with equal fields represent the same value. Defending a deep invariant may require immutable collaborators, defensive cloning, or storing scalar/value-object components only.
Current PHP also supports asymmetric property visibility, allowing a property to be publicly readable while writes are restricted. That controls who may replace the property value; like readonly, it does not freeze an object reachable through the property. Prefer behavior methods when mutation requires validation or side effects rather than exposing writable state merely because the syntax permits it.
Static analysis adds contracts PHP cannot express
Section titled “Static analysis adds contracts PHP cannot express”PHPStan and Psalm understand PHP declarations and commonly used PHPDoc generics, array shapes, non-empty collections, class-string types, conditional returns, and assertion annotations. These are development-time contracts: the engine does not enforce list<User>, array{email: non-empty-string}, or positive-int at runtime.
Use native declarations for truths the runtime can enforce and analysis annotations for additional useful precision. Keep annotations close to the abstraction that owns them, and test boundary parsing at runtime. A green analyzer cannot prove database constraints, authorization, concurrency safety, or the actual shape of a remote response unless those facts are represented honestly in the analyzed program.
Generics in PHPDoc are particularly useful for collection-like APIs because native array and iterable cannot state element types. They become harmful when annotations contradict runtime behavior or grow into a private type language nobody validates in CI. Configure a strictness level the team can sustain, baseline legacy findings deliberately, and reduce the baseline rather than treating it as permanent approval.
Current and legacy context
Section titled “Current and legacy context”- Current: PHP 8.5 includes union/intersection/DNF types, enums, readonly classes, asymmetric property visibility, and other modern property features. Prefer native types wherever they express the contract.
- Common: PHP 8.2–8.4 code often combines native declarations with mature PHPStan or Psalm annotations. Readonly write-scope details differ across versions, so verify behavior during upgrades.
- Legacy: docblock-only properties, sentinel
false|nullreturns, string constants standing in for closed sets, and weakly typed boundary code remain common. Migrate at boundary seams and add characterization tests rather than performing blind mechanical replacement.
Interview practice
Section titled “Interview practice”- PHP-TYPES-01 — Explain strict typing from the call site
- PHP-TYPES-02 — Design a precise boundary type
- PHP-TYPES-03 — Explain variance through substitutability
- PHP-TYPES-04 — Choose an enum or another model
- PHP-TYPES-05 — State what readonly does not guarantee
- PHP-TYPES-06 — Combine native and static-analysis types