Type Declarations, strict_types, and Type Juggling
Write parameter, return, property, and class-constant type declarations — including nullable, union, intersection, and DNF types — and correctly reason about coercion, casting, and type juggling under both weak and strict typing modes.
PHP is a gradually-typed language: type declarations are optional but, once added, are enforced by the engine at runtime, not merely suggested. This lesson covers where you can put a type declaration, how modern PHP composes types together (nullable, union, intersection, and DNF types), the crucial difference strict_types makes to how those declarations are enforced, and how PHP's type juggling and casting rules behave when types aren't forced to match exactly.
Where Type Declarations Go
PHP allows type declarations in four places. Parameter types constrain what a function or method accepts: function greet(string $name): void. Return types constrain what comes back: the : void after the parameter list above. Property types (since PHP 7.4) constrain what a class property can hold: class User { public int $id; public ?string $email = null; }. Class constant types (since PHP 8.3) constrain a class constant's value: class Status { public const string ACTIVE = 'active'; }.
Type declarations are checked and enforced by the Zend Engine at the moment a value is assigned, passed, or returned — passing a string to a parameter typed int either triggers automatic coercion (in weak mode) or throws a TypeError (in strict mode), it never silently ignores the mismatch the way a plain code comment or docblock hint would.
Every type declaration except those explicitly unioned with null (or marked with the nullable ? prefix) rejects null by default — this was a deliberate design choice so that accepting null is always an opt-in, visible decision in the signature, not an accidental gap.
• Parameter types: function f(int $x). • Return types: function f(): string. • Property types (PHP 7.4+): public int $id;. • Class constant types (PHP 8.3+): public const string NAME = 'x';. • null is rejected by every type unless explicitly allowed (nullable ? or a union with null).
Nullable, Union, Intersection, and DNF Types
A nullable type is written with a leading ?: ?string $name means 'a string, or null' — shorthand for the union string|null. Union types (PHP 8.0+) let a declaration accept any one of several types, written with a pipe: int|float $amount accepts either an int or a float; string|int|null $id accepts any of the three. Order doesn't affect behavior, but conventionally the most common/expected type is listed first for readability.
Intersection types (PHP 8.1+) require a value to satisfy every listed type simultaneously — used exclusively with interfaces/classes, never scalars: function process(Countable&Iterator $collection) demands an object that implements both Countable and Iterator at once, which is common when you need a value to satisfy multiple capability contracts without creating a single combined interface just for that purpose.
DNF (Disjunctive Normal Form) types, added in PHP 8.2, let you combine unions and intersections together, with intersection groups wrapped in parentheses: (Countable&Iterator)|array $items means 'either an array, or an object implementing both Countable and Iterator'. This reads as an OR of groups, where each group is itself an AND — hence 'disjunctive (OR) normal form (of ANDs)'.
• Nullable: ?Type ≡ Type|null. • Union (8.0+): TypeA|TypeB — accepts any listed type. • Intersection (8.1+): TypeA&TypeB — value must satisfy every listed interface/class at once. • DNF (8.2+): (TypeA&TypeB)|TypeC — combines unions and parenthesized intersection groups.
strict_types: Coercive vs Strict Mode
By default, PHP runs in weak/coercive typing mode: when a value's type doesn't exactly match a declaration, PHP tries to automatically convert it — passing the string "5" to an int parameter silently becomes the integer 5. Adding declare(strict_types=1); as the very first statement of a file switches that file's own type-checking behavior to strict mode, where scalar type declarations (int, float, string, bool) must match exactly, with exactly one deliberate exception: an int is still automatically widened to float where a float is expected, because that particular conversion never loses information.
Crucially, strict_types is a per-file, caller-side setting: it governs type checking based on which file the function CALL is written in, not which file defines the function. If file A has strict_types=1 and calls a function defined in file B (which does not declare strict_types), the call from A is still checked strictly, because the calling context is what matters — this trips up many developers who assume it's a global, project-wide switch.
strict_types applies only to scalar type declarations for arguments and return values; it does not affect internal PHP functions built before scalar type declarations existed in some edge cases, and it never changes how loose comparison operators like == behave elsewhere in your code — that's a separate topic entirely (comparisons, covered below).
• Default (no declare): weak/coercive mode — types are auto-converted when mismatched. • declare(strict_types=1); — must be the first statement in the file; enables strict mode for calls made FROM that file. • Strict mode still allows int → float widening (never loses precision). • strict_types is per calling file, not global — the caller's file setting governs the check.
Coercion, Casts, Type Juggling, and Comparisons
Type juggling is PHP's automatic conversion of a value from one type to another based on context — it happens in weak-mode type declarations, in arithmetic operators applied to non-numeric-looking operands, and inside loose (==) comparisons. Explicit casting forces a conversion on demand using cast operators: (int) "42abc" yields 42, (float) "3.14" yields 3.14, (string) 42 yields "42", (bool) 0 yields false, (array) $obj converts an object's public properties into an array.
A 'numeric string' is a string that looks like a valid number, optionally with leading whitespace and a sign — "42", " -3.14", and "1e10" all qualify; PHP treats numeric strings specially in arithmetic and certain comparisons, converting them to their numeric value automatically. A non-numeric string like "abc" used in arithmetic instead triggers a TypeError in PHP 8+ (older versions merely warned and treated it as 0), which is one of many small but important behavior changes PHP 8 made to reduce silent bugs.
Comparisons come in two flavors with very different rules: loose comparison (==, !=) applies type juggling before comparing, which is the source of PHP's infamous historical gotchas (0 == "abc" was true before PHP 8, but PHP 8 fixed this specific case so a non-numeric string no longer loosely equals 0); strict comparison (===, !==) requires both the value AND the type to match exactly, with zero conversion, and is almost always the safer default to reach for in real code. The <=> spaceship operator returns -1, 0, or 1 for less-than, equal, or greater-than, following the same rules as == unless combined with explicit type checks.
• Type juggling: automatic conversion based on context (weak-mode declarations, arithmetic, loose comparison). • Casts: (int), (float), (string), (bool), (array), (object) — explicit, on-demand conversion. • Numeric strings ("42", "3.14", "1e10") convert cleanly to numbers; non-numeric strings in arithmetic throw a TypeError in PHP 8+. • Loose (==) juggles types before comparing; strict (===) requires identical type and value — prefer === by default.
Type Declarations in Practice
readonly combined with a type declaration is a common PHP 8.1+ pattern for immutable value objects. The DNF type (Countable2&Iterable2)|array requires parentheses around the intersection group.
<?php
declare(strict_types=1);
class Product {
public function __construct(
public readonly string $name,
public readonly int|float $price,
public ?string $description = null,
) {}
}
interface Countable2 {}
interface Iterable2 {}
function handle((Countable2&Iterable2)|array $items): void {
// accepts array, or an object satisfying both interfaces
}
function findUser(int $id): ?Product {
return null; // explicit ?Product allows returning null
}Code Examples
The exact same function signature behaves differently purely based on whether the CALLING file declared strict_types — strict.php rejects the numeric string, weak.php silently coerces it. Expected result: addStrict(2,3) => 5 addStrict("2",3) => Fatal error: Uncaught TypeError addWeak("2",3) => 5
PHP 8 tightened several of the historically confusing type-juggling rules: purely non-numeric strings in arithmetic now throw instead of silently becoming 0, and 0 no longer loosely equals a non-numeric string. Expected result: int(15) float(15.5) Uncaught TypeError bool(false) bool(false)
The union return type int|float|null documents exactly the three possible shapes of data this function can hand back, and strict_types ensures the casts inside are the only source of type conversion — nothing is coerced implicitly on the way out. Expected result: int(19) float(19.99) NULL
<?php
// file: strict.php
declare(strict_types=1);
function addStrict(int $a, int $b): int { return $a + $b; }
echo addStrict(2, 3); // OK: both are already int
echo addStrict("2", 3); // TypeError: "2" not accepted in strict mode
// file: weak.php (no declare(strict_types=1))
function addWeak(int $a, int $b): int { return $a + $b; }
echo addWeak("2", 3); // OK: "2" is coerced to int 2, result 5<?php
var_dump("10" + 5); // int(15) -- numeric string coerces cleanly
var_dump("10.5" + 5); // float(15.5)
var_dump("10abc" + 5); // PHP 8+: TypeError, "10abc" is not a well-formed numeric string
var_dump(0 == "abc"); // PHP 8+: false (fixed from true in PHP 7)
var_dump(0 === "0"); // false -- different types, strict comparison<?php
declare(strict_types=1);
function parsePrice(string $raw): int|float|null {
if (!is_numeric($raw)) {
return null;
}
return str_contains($raw, '.') ? (float) $raw : (int) $raw;
}
var_dump(parsePrice("19")); // int(19)
var_dump(parsePrice("19.99")); // float(19.99)
var_dump(parsePrice("free")); // NULLWeak vs Strict Type Checking
strict_types is decided by the calling file, and only removes coercion for scalar types — int→float widening always still works.
Weak mode (default) Strict mode (declare(strict_types=1))
┌─────────────────────┐ ┌─────────────────────────────┐
│ call: f("5") │ │ call: f("5") │
│ int $x expected │ │ int $x expected │
│ "5" ──coerce──▶ 5 │ │ "5" ──▶ TypeError (no coerce) │
└─────────────────────┘ └─────────────────────────────┘
int◀float widening still allowed in BOTH modes (never lossy)Important Rules
Put declare(strict_types=1); as the first line of every new PHP file you write.
Prefer strict (===) comparison over loose (==) by default; reach for == only when you deliberately want juggling.
Make nullability explicit with ?Type or a |null union rather than relying on default parameter values alone.
Use intersection types only with interfaces/classes — never with scalar types.
Reach for DNF types only when a real API genuinely needs 'array OR (this interface AND that interface)' — don't over-engineer simple signatures.
strict_types in Framework Codebases
Modern PHP frameworks like Laravel and Symfony, and most PHP packages published after 2020, declare strict_types=1 in essentially every file, because it turns a whole category of accidental-coercion bugs (like a numeric string silently passing where an int was expected) into an immediate, loud TypeError during development instead of a subtle data bug discovered in production.
function setAge(int $age) { $this->age = $age; } setAge("25"); // works silently in weak mode, hides a bug upstream declare(strict_types=1); function setAge(int $age) { $this->age = $age; } setAge((int) $rawInput); // explicit, visible conversion at the call site
Relying on weak-mode coercion to 'fix' a string that should have been an int hides where the type mismatch actually originated; strict_types plus an explicit cast makes the conversion visible and intentional.
function findUser(int $id): Product { return null; } // no ?Product function findUser(int $id): ?Product { return null; }
Every type declaration rejects null unless explicitly marked nullable; returning null from a function typed to return Product throws a TypeError at runtime.
Where must declare(strict_types=1); appear for it to be valid? A. Anywhere before the first function call B. As the very first statement in the file C. Inside the function whose parameters should be strict D. It can be placed anywhere; order doesn't matter
B. As the very first statement in the file — declare(strict_types=1); must be the first statement in the file, before any other code, or PHP raises a fatal compile error.
If file A (strict_types=1) calls a function defined in file B (no strict_types declared), which mode applies to that call? A. Weak mode, because the function was defined in a weak-mode file B. Strict mode, because the calling file (A) has strict_types enabled C. It depends on which file is loaded first by the autoloader D. PHP throws a fatal error mixing the two modes
B. Strict mode, because the calling file (A) has strict_types enabled — strict_types is a per-calling-file setting: it's evaluated based on the file where the function CALL is written, not where the function is defined.
Which pair of types can be combined with & in an intersection type? A. int and float B. string and bool C. Two interfaces/classes, e.g. Countable&Iterator D. Any type at all, including scalars
C. Two interfaces/classes, e.g. Countable&Iterator — Intersection types require the value to satisfy multiple class/interface contracts simultaneously and are only valid with class/interface types, never scalars.
Write a function parseId(string $raw): int|null with declare(strict_types=1) at the top of the file, that returns (int) $raw if is_numeric($raw) is true, otherwise null. Test it with "42", "42abc", and "7.5".
<?php declare(strict_types=1); function parseId(string $raw): int|null { if (!is_numeric($raw)) { return null; } return (int) $raw; } var_dump(parseId("42")); // int(42) var_dump(parseId("42abc")); // NULL var_dump(parseId("7.5")); // int(7) -- (int) truncates the decimal
Use is_numeric() to check validity before casting, and remember the return type must allow null explicitly.