PHP Type Declarations — The $37k Coercion Bug
Missing type declarations on applyDiscount() caused $37k in silent rounding errors.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
- Type declarations enforce contracts at runtime with TypeError, not PHPDoc comments
- strict_types=1 affects the caller's file, not the function's file — the single most counterintuitive rule
- Nullable types (?string) should only represent meaningful business absent states
- Union types (string|int) accurately model functions that handle multiple shapes
- Return types constrain your own code — catch null or wrong return instantly
- Mixed type disables all type safety for that parameter — use only for truly flexible boundaries
Imagine a vending machine that only accepts exact change — no cards, no bills, just coins. PHP type declarations work the same way: you tell a function exactly what kind of data it can accept and what kind it will hand back. If someone tries to shove a banana into the coin slot, the machine rejects it immediately instead of silently breaking. That early rejection is the whole point — catch the wrong data at the door, not buried in a bug report three weeks later.
Every PHP application that lives longer than a weekend starts to rot in the same way: a function that was supposed to receive a price gets passed a product name, a method expecting an integer silently receives a floating-point number and rounds it wrong, and suddenly your invoice totals are off by a cent per line. These bugs are invisible until they hit production, and they're humiliating to explain to a client. Type declarations are PHP's answer to this exact problem — they let you encode your assumptions about data directly into the function signature, so PHP enforces them automatically instead of trusting every caller to get it right.
Before type declarations, PHP's dynamic typing was simultaneously its superpower and its biggest footgun. You could prototype fast, but a large codebase with no type hints was a minefield. A calculateTax(float $amount) declaration does more documentation work than three paragraphs of PHPDoc, and unlike comments, it actually runs. It tells the next developer — and PHP itself — what the contract of this function is, and PHP will throw a TypeError the moment someone violates it.
By the end of this article you'll know how to declare parameter types, return types, nullable types, and union types, and you'll understand the critical difference between coercive mode and strict mode. More importantly, you'll know when each feature earns its keep in real production code, and you'll have a clear mental model for why PHP's type system works the way it does — which is the knowledge that separates developers who use type hints from developers who truly understand them.
Why PHP Type Declarations Are Not Optional
PHP type declarations let you specify the expected type of function parameters, return values, and class properties. The core mechanic is simple: declare a type, and PHP enforces it at runtime. Without them, PHP coerces values silently — '123' becomes 123, 1.0 becomes 1 — which is the root of the $37k bug where a float comparison passed validation because a string '0.01' was coerced to 0, then 0.0, then matched a zero balance check.
By default, PHP uses coercive typing: it converts values to the declared type if possible. Declare strict_types=1 at the file top to switch to strict mode, which throws a TypeError on mismatch. This is a per-file directive, not an application-wide setting — a common source of confusion. Properties and return types are enforced at write/return time; parameter types at call time.
Use type declarations on every function and method in production code. They turn silent data corruption into immediate, catchable errors. In systems handling money, IDs, or user input, strict mode is mandatory. Without them, you're one accidental string-to-int coercion away from a logic bug that passes all tests and corrupts your database.
Parameter & Return Type Declarations — The Core Contract
A type declaration is a promise written into the function signature itself. You place the type before the parameter name (for inputs) and after the closing parenthesis with a colon (for the return value). PHP supports scalar types — int, float, string, bool — along with array, callable, object, class names, and from PHP 8.0 onwards, mixed and never.
The reason this matters isn't just validation. It's communication. When you write function applyDiscount(float $price, int $discountPercent): float, three things happen at once: PHP will enforce those types at runtime, your IDE can autocomplete callers correctly, and static analysis tools like PHPStan can catch type errors before you even run the code. You get documentation, enforcement, and tooling support from a single line.
Return types are arguably more valuable than parameter types, because they constrain your own code. If you declare : string and accidentally return null in one branch, PHP throws a TypeError on that path immediately — not when some caller eventually tries to use the null as a string two stack frames up. That proximity to the bug is priceless in debugging.
null, an empty array, or a boolean — whatever you accidentally return. The return type declaration is your last line of defence against your own typos.strict_types=1 — Coercive Mode vs Strict Mode, and Why It Changes Everything
By default, PHP runs in coercive mode: if you declare int and pass "42", PHP silently converts the string to an integer and carries on. That sounds helpful, but it hides real bugs. If a database query returns "0" (the string) instead of 0 (the integer), coercive mode swallows the difference, and you never know your data pipeline had a problem.
Adding declare(strict_types=1) as the very first line of a PHP file flips the switch: PHP now demands an exact type match. Pass a string where an int is expected and you get a TypeError immediately, no silent conversion. This is the behaviour most statically-typed developers expect PHP to have.
The critical detail that trips up even experienced developers: strict_types is per file. It only affects calls made from that file, not the function definitions themselves. So if strict_types=1 is in your controller file, calls from that controller are strict. But if a third-party library calls your function without strict mode, PHP uses coercive rules for that call. Think of it as the caller setting the rules, not the callee. This is unusual and counterintuitive, so it's worth memorising before your next interview.
2500.0 (a float) where int is expected throws a TypeError — even though mathematically 2500.0 equals 2500. PHP does not consider them the same type in strict mode. Use explicit casting (int) $value at the boundary where you receive external data, then pass the clean integer to your typed functions.php_strict_types or PHPStan level 6+Nullable Types, Union Types & Return-Only Types — Modern PHP's Power Features
Real data isn't always clean. A user's middle name might be null. A function might legitimately return either a string or false. PHP has evolved its type system across versions to handle this reality without abandoning type safety.
Nullable types (PHP 7.1+) use a leading ? to declare that a value can be either the specified type or null. ?string means 'a string or null, nothing else'. This is far more honest than just leaving the type off.
Union types (PHP 8.0+) go further with the pipe syntax: int|float or string|null. This lets you accurately describe functions that genuinely handle multiple types — like a parser that returns a string on success or false on failure.
void is a return-only type that tells PHP — and every future developer — that this function intentionally returns nothing. Not null, not false. Nothing. A void function that has return $something is a TypeError. It's the clearest possible signal that a function works entirely through side effects (writing to a database, sending an email, updating state).
never (PHP 8.1+) is even stronger: it declares the function never returns at all — it always throws an exception or calls . Use it on error handlers and redirectors.exit()
?string and string|null. They're functionally identical in PHP — ?string is simply shorthand for string|null. Prefer ?string for single nullable types (it's more readable), and the pipe syntax string|null|int when you have more than two options in a union.if ($result) treats false as falsy, but also treats empty string as falsystring|null means 'present or absent', not 'present or error'string|null over string|false when the alternative value means 'not found', and throw for errorsstring|false — prefer ?string and throw on error.?stringstring|falsestring|int|array — but consider if the function is doing too muchMixed and Never Types — The Extremes of PHP's Type System
PHP 8.0 introduced mixed — the type that explicitly says 'I genuinely don't know what this will be'. When you declare a parameter as mixed, PHP accepts literally any value: int, string, object, null, resource, closure. No type check is performed. Use it sparingly — it's the opposite of a type declaration.
The never type (PHP 8.1+) is a return-only type that declares the function never returns normally. It always throws an exception, calls , or hits an infinite loop. This is invaluable for functions like exit()redirectOrDie() or that you want to call without needing to handle a return value.abort()
Together, mixed and never bookend the type system: one says 'I accept everything', the other says 'I give back nothing — ever'.
- mixed says: 'I will handle anything you throw at me — but you lose all static analysis guarantees.'
- never says: 'This function will never come back — don't bother writing code after the call.'
- In between, you have typed parameters and typed returns that give you real safety.
- Use mixed only at true integration boundaries (e.g., a generic logging function or a var-dump helper).
- Use never on abort functions, redirect functions, and exception-only dispatchers.
Type Declarations in Inheritance and Interfaces — Covariance & Contravariance
When you extend a class or implement an interface, PHP enforces signature compatibility. The child method must accept the same parameter types as the parent (or wider types — contravariance). The child must return a narrower type or the same (covariance). This sounds academic, but it matters every time you override a method.
Covariance: A child's return type can be more specific. If the parent returns object, the child can return User. This is safe because every caller expects at least an object — they can handle the more specific type.
Contravariance: A child's parameter types can be less specific. If the parent accepts User, the child can accept object. This is also safe: the child can handle any object, so it can still process a User.
PHP 7.4 introduced covariant return types. PHP 8.0 added contravariant parameter types. Before that, child signatures had to match exactly — pain for many OOP designs.
PHP 7 Is a Weakly Typed Language — And That's a Trap for the Unwary
Type declarations in PHP don't make PHP strongly typed. They make PHP a weakly typed language where you can optionally slap hints on some function signatures. This isn't a philosophical nitpick — it's a production hazard.
In a truly strongly typed language like Rust or Go, the compiler enforces type rules everywhere, always. Throw an integer at a function expecting a string and the compiler says no. PHP? By default, it shrugs, silently coerces your integer to a string, and moves on. That silent coercion is where bugs breed.
The reason is historical: PHP 7 had to maintain backward compatibility with millions of lines of legacy code. The PHP internals team knew strict types were necessary but couldn't break the ecosystem overnight. So they made type declarations available, but optional. The result is a hybrid: you get type hints, but the engine treats them as suggestions, not contracts — unless you explicitly tell it otherwise.
array_search() return false on failure, but false coerces to 0 in loose comparisons. That's not a type declaration bug — that's PHP being PHP. Don't assume type hints protect you from the standard library's loose typing.Strict Types Are Imposed on a File-by-File Basis — Yes, Really
Here's the gotcha that sinks teams migrating to strict types: declare(strict_types=1) only applies to the file where you write it. Not to files that call your functions. Not to files you include. One file, and one file only.
This means you can have a strict-typed library file and a coercive-typed consumer calling it with garbage. The coercive caller passes an integer to your strictly typed function — and because the declaration is evaluated per-file, the function's own strict mode is what matters. But only if the function is defined in a strict file. If the caller is loose and the callee is strict, the callee wins. If both are loose, the caller's coercion passes through and the callee never sees the original type.
The architecture forces you to audit every file's header. Miss one include path? Your strict boundary leaks. This isn't academic — I've debugged payment processing pipelines where a single missing declare statement let a float slip through an int parameter, rounding pennies into oblivion.
Pro tip: enforce this with a CI linting rule that checks every PHP file's first line. Painful setup, but cheaper than silent truncation.
The Consequences — What Your Production Logs Will Show You
The combination of coercive-by-default and per-file strictness creates three concrete failure modes you will encounter in production.
First: silent data corruption. A function expecting int gets a float due to JSON deserialization. In coercive mode, PHP truncates 12.95 to 12. Your inventory count just lost 0.95 units. No log, no stack trace. Just wrong data.
Second: unexpected TypeError exceptions. You write a library with strict types, a consumer has coercive mode, everything works in dev because inputs are clean. In staging, a third-party API returns "123" instead of 123. Your strictly typed library throws a TypeError that the controller doesn't catch. Now you've got a 500 error in sales reports every Friday.
Third: false confidence in interfaces. You declare an interface method with a string return type, implement it in a strict class, all good. A coworker implements the same interface in a new file without strict_types=1, returns an array instead of a string, and PHP silently coerces it to the string "Array". Your frontend renders "Array" as a username. Users notice.
The fix isn't complex: add declare(strict_types=1) to every file, run static analysis (PHPStan at level max, Psalm at level 2), and enforce both in CI. But knowing the consequences means you stop treating type declarations as a nice-to-have and start treating them as baseline infrastructure.
json_decode() when combined with coercive mode. JSON has no distinction between int and float for whole numbers, and PHP's decoder will create an int. Your strictly typed function expecting float gets an int, and coercive mode makes it disappear. Use json_decode($json, true, 512, JSON_THROW_ON_ERROR) and validate types explicitly before passing to typed functions.A $37,000 Invoice Error Caused by Loose Type Coercion
round($amount, 2) sometimes produced off-by-one-hundredth errors due to floating point precision with string coercion.applyDiscount($price, $percent) had no type declarations. It accepted strings for price, and the implicit string-to-float coercion in $price * $percent could produce edge-case rounding errors. Additionally, because the function returned a value without a declared return type, PHP never checked the result — it just passed through whatever came out of round().function applyDiscount(float $price, int $percent): float with declare(strict_types=1) in the caller files. This forced the form handler to cast the input to float before passing it, eliminating the string coercion path. The rounding errors stopped immediately.- Always declare parameter types and return types, even when you trust the caller. Type declarations are not optional documentation — they are runtime guards.
- Coercive mode hides bugs. Use
declare(strict_types=1)in every file that handles external input to force early failures instead of silent corruption. - A missing return type means PHP will happily return null, false, or any other accidental value. Always declare
: floator: stringto make your intention unignorable.
declare(strict_types=1). If it's absent, PHP coerced before — now it's strict. Cast the input with (int) $value at the boundary.function foo(): string to function foo(): ?string if null is valid. If null means 'not found', throw an exception instead.array on the parameter and cast the input: (array) $response.Check file header: `grep 'declare(strict_types=1)' app/Http/Controllers/OrderController.php`Dump the argument type: `var_dump($value); die();` just before the call.(int) $request->input('quantity')Key takeaways
Common mistakes to avoid
4 patternsThinking strict_types=1 affects the function definition, not the call site
php_strict_types or PHPStan level to enforce project-wide.Using ?string when you mean to disallow null entirely
strtolower() on the result, causing a fatal TypeError.Omitting return types on class methods that override a parent
Using false in union types instead of nullable for 'not found' scenarios
function findUser(int $id): User|false — callers use loose comparison if ($result) and treat empty User as falsy, or forget to check. Production gets silent errors.?User instead. It's safer: callers must explicitly check for null, and static analysis can track the null state. Reserve false for functions where false is the intended failure value (e.g., strpos).Interview Questions on This Topic
What's the difference between declaring a parameter as `?string` versus `string|null`, and is there a situation where you'd prefer one over the other?
?string is syntactic sugar for string|null. I prefer ?string when there's only one nullable type because it's shorter and more conventional. The pipe syntax string|null|int is useful when you have multiple union members and one of them is null, as you'd write string|int|null. There's no performance difference. The key is consistency across the codebase.Frequently Asked Questions
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's PHP Basics. Mark it forged?
9 min read · try the examples if you haven't