Mid-level 9 min · March 06, 2026

PHP Type Declarations — The $37k Coercion Bug

Missing type declarations on applyDiscount() caused $37k in silent rounding errors.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is PHP Type Declarations?

PHP type declarations are a language feature that lets you enforce the data type of function parameters, return values, and class properties. They exist because PHP's historically weak typing—where "2" + 2 silently produces 4—causes subtle bugs that cost real money (the article's title references a documented $37k production incident from a type coercion bug).

Imagine a vending machine that only accepts exact change — no cards, no bills, just coins.

Type declarations shift PHP from 'whatever you pass works' to a contract: if you pass a string where an int is declared, PHP either coerces it (default behavior) or throws a TypeError (with declare(strict_types=1)). They're not optional in modern PHP—every major framework (Laravel, Symfony) and static analyzer (PHPStan, Psalm) treats them as mandatory for production code.

Skip them only in throwaway scripts or legacy codebases you're migrating incrementally.

At their core, parameter and return type declarations define the interface between functions and their callers. Without strict_types=1, PHP runs in coercive mode: function add(int $a): int will silently convert "5" to 5. This sounds convenient but is the root of the $37k bug—coercion masks logic errors until they cascade.

Strict mode (declare(strict_types=1) at the file top) kills coercion entirely: add("5") throws immediately. The choice between modes is a project-wide decision—mixing them in the same codebase is a recipe for confusion. Modern PHP (7.1+) adds nullable types (?int), union types (int|string), and return-only types like void and never, giving you fine-grained control over what flows through your system.

Type declarations also govern inheritance through covariance (child return types can be more specific) and contravariance (child parameter types can be less specific). This matters when implementing interfaces or extending classes—PHP enforces that your type contracts remain compatible.

The extremes of the type system—mixed (accept anything, opt out of type safety) and never (a function that never returns, like exit()) —are power tools for edge cases. In practice, mixed should be rare (it defeats the purpose), and never is for control flow functions.

Combined with static analysis, PHP's type system now rivals Java or TypeScript in rigor—but only if you use strict_types=1 and enforce it across your entire codebase.

Plain-English First

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.

Coercion Is Not Validation
PHP's coercive mode does not validate — it converts. '1abc' becomes 1, not an error. Only strict_types=1 gives you actual type safety.
Production Insight
A payment service compared a string '0.01' against float 0.0 using coercive typing; PHP coerced '0.01' to 0, the comparison passed, and a $37k transaction was approved for zero dollars.
Symptom: a balance check that always returned true for any non-numeric string input, silently approving invalid payments.
Rule: always use strict_types=1 on files handling money, IDs, or any value that must not be silently converted.
Key Takeaway
Coercive typing is a footgun — it converts, it does not validate.
Strict_types=1 is per-file, not global — one file without it can break your safety net.
Type declarations are not optional; they are the cheapest runtime guard against data corruption.
PHP Type Declarations: Coercion Bug Flow THECODEFORGE.IO PHP Type Declarations: Coercion Bug Flow From weak typing to strict mode and type safety PHP Weak Typing Coercive mode by default, implicit conversions Parameter & Return Declarations Core syntax for type hints strict_types=1 Enables strict mode per file Nullable & Union Types Allow multiple or null types Mixed & Never Types Extremes: any type or no return Inheritance & Interfaces Type declarations must be compatible ⚠ Coercion bug: loose comparison can bypass type checks Always use strict_types=1 and strict comparison (===) THECODEFORGE.IO
thecodeforge.io
PHP Type Declarations: Coercion Bug Flow
Php Type Declarations

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.

InvoiceCalculator.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

/**
 * Calculates the final price after applying a percentage discount.
 *
 * By declaring float and int types here, PHP guarantees this function
 * can never silently receive a product name or a boolean by accident.
 */
function applyDiscount(float $originalPrice, int $discountPercent): float
{
    if ($discountPercent < 0 || $discountPercent > 100) {
        throw new \InvalidArgumentException(
            "Discount must be between 0 and 100, got {$discountPercent}"
        );
    }

    // PHP coerces "19.99" (string) to 19.99 (float) in non-strict mode.
    // The multiplication result is a float, matching our declared return type.
    $discountMultiplier = 1 - ($discountPercent / 100);
    return round($originalPrice * $discountMultiplier, 2);
}

// Normal usage — works perfectly
$finalPrice = applyDiscount(49.99, 20);
echo "Price after 20% discount: $" . $finalPrice . "\n";

// PHP will coerce the integer 50 to float 50.0 here (coercive mode default)
$salePrice = applyDiscount(50, 10);
echo "Sale price after 10% discount: $" . $salePrice . "\n";

// This next call would throw a TypeError in strict mode — but in coercive mode it might silently convert
// Passing a string that cannot be safely coerced to float:
// applyDiscount('fifty dollars', 10);
Output
Price after 20% discount: $39.99
Sale price after 10% discount: $45.0
Pro Tip:
Always declare return types, even for simple getters. A missing return type means PHP accepts 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.
Production Insight
The ~0.002ms overhead of a type check is trivial compared to hours debugging a weird rounding bug
Coercive mode lets strings sneak into float arithmetic, causing off-by-one-cent errors that evade tests
Rule: never trust input — declare the type and cast at the boundary
Key Takeaway
Type declarations are not documentation. They are runtime enforcement.
Return types catch bugs in your own code; parameter types catch bugs in callers.
Always declare the return type — it's the cheapest, most effective guard.
Should you declare parameter types or return types first?
IfYour function always returns a value of one specific type
UseAlways declare the return type. It's the most impactful guard.
IfThe function is called by external code (API, form handler)
UseDeclare parameter types first — they prevent garbage from entering your logic.
IfIt's a private helper used only internally
UseStill declare both. The typo that returns null instead of string can happen anywhere.

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.

StrictModeDemo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
// This declaration MUST be the very first statement in the file.
// It cannot appear after any other code, not even a blank line matters
// (though a comment above <?php is fine in some setups).
declare(strict_types=1);

function calculateShippingCost(int $weightInGrams, float $ratePerKg): float
{
    // Convert grams to kilograms, then multiply by the rate
    $weightInKg = $weightInGrams / 1000;
    return round($weightInKg * $ratePerKg, 2);
}

// VALID: exact types — no coercion needed
$cost = calculateShippingCost(2500, 3.50);
echo "Shipping cost for 2.5kg: $" . $cost . "\n";

// VALID: integer literal 4 is a valid int
$anotherCost = calculateShippingCost(4000, 3.50);
echo "Shipping cost for 4kg: $" . $anotherCost . "\n";

// INVALID in strict mode — will throw TypeError:
// calculateShippingCost("2500", 3.50);
// Fatal error: Uncaught TypeError: calculateShippingCost():
// Argument #1 ($weightInGrams) must be of type int, string given

// ALSO INVALID in strict mode — float 2500.0 is NOT an int:
// calculateShippingCost(2500.0, 3.50);
// Fatal error: Uncaught TypeError: Argument #1 must be of type int, float given

echo "All valid calls completed successfully.\n";
Output
Shipping cost for 2.5kg: $8.75
Shipping cost for 4kg: $14.0
All valid calls completed successfully.
Watch Out:
In strict mode, passing 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.
Production Insight
If you add strict_types to a library file only, callers without strict_types still coerce — the bug persists
Production fail: adding strict_types to a model layer but forgetting controllers left coercion enabled
Rule: enforce strict_types project-wide via PHP-CS-Fixer rule php_strict_types or PHPStan level 6+
Key Takeaway
strict_types=1 is per-caller-file, not per-function.
It reveals hidden coercion bugs by failing fast.
Enable it everywhere — not just the function definition file.
Should I enable strict_types on this file?
IfFile contains business logic that accepts user input
UseYes — force early failure on type mismatch, don't trust the input
IfFile is a legacy adapter that must handle mixed types
UseKeep coercive mode, but cast at the boundary and call strictly-typed functions internally
IfFile is a third-party vendor library you can't modify
UseLeave it as-is. Your callers will use your strict settings, the vendor calls you without.

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 exit(). Use it on error handlers and redirectors.

UserRepository.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?php
declare(strict_types=1);

// Simulates a simple in-memory user store for the example
class UserRepository
{
    private array $users = [
        1 => ['id' => 1, 'name' => 'Alice Chen',  'email' => 'alice@example.com', 'nickname' => 'ace'],
        2 => ['id' => 2, 'name' => 'Bob Marley',  'email' => 'bob@example.com',   'nickname' => null],
        3 => ['id' => 3, 'name' => 'Carol White', 'email' => 'carol@example.com', 'nickname' => 'caz'],
    ];

    /**
     * Nullable return type: the user might not exist, so we return
     * the array on success or null on failure — both are valid outcomes.
     *
     * @return array<string,mixed>|null
     */
    public function findById(int $userId): ?array
    {
        // Returns null automatically if the key doesn't exist
        return $this->users[$userId] ?? null;
    }

    /**
     * Nullable parameter: nickname is optional — some users haven't set one.
     * The ?string type is more honest than leaving it as untyped.
     */
    public function updateNickname(int $userId, ?string $nickname): void
    {
        if (!isset($this->users[$userId])) {
            throw new \RuntimeException("User {$userId} not found.");
        }
        // Storing null here is intentional — it clears the nickname
        $this->users[$userId]['nickname'] = $nickname;
    }

    /**
     * Union type return (PHP 8.0+): returns the nickname string if set,
     * or false if the user has no nickname. Both are meaningful results.
     */
    public function getNickname(int $userId): string|false
    {
        $user = $this->findById($userId);
        if ($user === null) {
            throw new \RuntimeException("User {$userId} not found.");
        }
        // Returns false when nickname is null — false means 'not set'
        return $user['nickname'] ?? false;
    }

    /**
     * The void return type says: "I do work but hand nothing back."
     * Trying to use the return value of this method is pointless by design.
     */
    public function logAccess(int $userId): void
    {
        // In production this would write to a log file or database
        echo "[LOG] User {$userId} accessed at " . date('H:i:s') . "\n";
        // A bare 'return;' is allowed in void functions, but returning a value is not
    }
}

$repo = new UserRepository();

// findById returns ?array — we must null-check before using it
$alice = $repo->findById(1);
if ($alice !== null) {
    echo "Found user: " . $alice['name'] . "\n";
}

// User 99 doesn't exist — findById correctly returns null
$ghost = $repo->findById(99);
echo "Unknown user: " . ($ghost === null ? 'not found' : $ghost['name']) . "\n";

// getNickname uses union type string|false
$aliceNick = $repo->getNickname(1);
echo "Alice's nickname: " . ($aliceNick !== false ? $aliceNick : 'none set') . "\n";

$bobNick = $repo->getNickname(2);
echo "Bob's nickname: " . ($bobNick !== false ? $bobNick : 'none set') . "\n";

// updateNickname accepts ?string — passing null clears the nickname
$repo->updateNickname(3, null);
echo "Carol's nickname after clearing: " . ($repo->getNickname(3) !== false ? $repo->getNickname(3) : 'none set') . "\n";

// logAccess returns void — calling it for its side effect only
$repo->logAccess(1);
Output
Found user: Alice Chen
Unknown user: not found
Alice's nickname: ace
Bob's nickname: none set
Carol's nickname after clearing: none set
[LOG] User 1 accessed at 14:23:07
Interview Gold:
Interviewers love asking about the difference between ?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.
Production Insight
Union types with false often lead to loose comparisons — if ($result) treats false as falsy, but also treats empty string as falsy
Prefer nullable true for existence checks: string|null means 'present or absent', not 'present or error'
Rule: use string|null over string|false when the alternative value means 'not found', and throw for errors
Key Takeaway
Nullable means null is business-meaningful.
Union types let you model multiple outcomes without losing type safety.
Avoid string|false — prefer ?string and throw on error.
Union or nullable?
IfThe value can be either valid data or 'nothing' (e.g., middle name)
UseUse nullable: ?string
IfThe function has exactly two valid outcomes (e.g., success string or failure false)
UseUse union: string|false
IfMore than two possible types
UseUse union: string|int|array — but consider if the function is doing too much

Mixed 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 exit(), or hits an infinite loop. This is invaluable for functions like redirectOrDie() or abort() that you want to call without needing to handle a return value.

Together, mixed and never bookend the type system: one says 'I accept everything', the other says 'I give back nothing — ever'.

ExtremeTypes.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
declare(strict_types=1);

/**
 * Accepts any type and processes it generically.
 * Use mixed as a last resort — it disables all type safety for this parameter.
 */
function logVar(mixed $data): void
{
    if (is_object($data)) {
        error_log('Object: ' . get_class($data));
    } elseif (is_resource($data)) {
        error_log('Resource type: ' . get_resource_type($data));
    } else {
        error_log('Value: ' . (string) $data);
    }
}

/**
 * A function that never returns — always redirects.
 * Type declared as never, so PHP will enforce that no code after a call to this
 * function is reachable (static analysis can also use this).
 */
function redirectLogout(): never
{
    // Clear session, redirect, then stop
    session_destroy();
    header('Location: /login');
    exit(); // Always terminates
}

logVar('string input');
logVar(42);
logVar(new stdClass());

// The following line would never execute because redirectLogout() exits
// redirectLogout();
echo "This line is unreachable if we call redirectLogout above.";
Output
[log output to error_log] Value: string input
[log output to error_log] Value: 42
[log output to error_log] Object: stdClass
Mental Model: mixed and never as Bookends
  • 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.
Production Insight
Using mixed on function parameters makes every call site unanalyzable by PHPStan — you lose all type flow
A function that returns never but actually returns (e.g., because the redirect header wasn't sent) throws a TypeError
Rule: prefer union types over mixed, and always test never paths with a unit test that expects exit
Key Takeaway
mixed is a safety-off switch — use it only when absolutely necessary.
never tells the world this function never comes back.
Both are rare: most functions should use concrete types.
Should you use mixed or never?
IfYou need a catch-all parameter that could truly be any type
UseUse mixed, but consider if you can narrow with a union or a class hierarchy.
IfYour function always throws an exception or exits
UseUse never as the return type. It helps static analysis understand control flow.
IfYou're not sure if the function always terminates
UseDo not use never. Prefer void or a union return type that includes the value on normal paths.

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.

CovarianceContravariance.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
declare(strict_types=1);

// Base interface
interface PaymentProcessor
{
    public function charge(float $amount): bool;
}

// A child that returns a more specific type (covariance) is NOT allowed here
// because the parent returns bool, and we can't return a subtype of bool.
// But we can use a more specific parameter type (contravariance) if we widen?
// Actually contravariance means we can accept a wider type as a parameter.
// Let's demonstrate properly:

/**
 * Base class with contravariance example.
 */
class BaseHandler
{
    /**
     * Accepts objects of type \stdClass or wider.
     */
    public function handle(object $data): void
    {
        // base implementation
    }
}

/**
 * Child class uses contravariance: it can accept a wider parameter type (mixed).
 * Note: the parameter type is wider than the parent's (object).
 * This is allowed because any caller that passes object to parent can still pass object to child.
 */
class FlexibleHandler extends BaseHandler
{
    // Contravariance: accepts mixed (wider than object)
    public function handle(mixed $data): void
    {
        // custom implementation
    }
}

// Covariance example with return types
abstract class Factory
{
    abstract public function build(): object;
}

class UserFactory extends Factory
{
    // Covariance: returns a more specific type (User) instead of object
    public function build(): User
    {
        return new User('Alice');
    }
}

class User
{
    public function __construct(public string $name) {}
}

$factory = new UserFactory();
$user = $factory->build();
echo "Built user: " . $user->name . "\n";
Output
Built user: Alice
Before PHP 7.4:
Covariant return types were not allowed. If you tried to narrow the return type in a child class, PHP would throw a fatal error about signature incompatibility. Upgrading to PHP 8+ gives you much more flexibility in design.
Production Insight
If you declare a parameter type in an interface and a child widens it, callers that rely on the interface contract may pass values that the child can't handle — that's why contravariance is safe only because the child can handle everything the parent could.
Production gotcha: You cannot narrow parameter types (make them more specific) in a child — that would break Liskov substitution.
Rule: when designing interfaces, prefer the most general parameter types and the most specific return types you can.
Key Takeaway
Covariance: child returns narrower type.
Contravariance: child accepts wider type.
These rules preserve type safety in polymorphic code.
How to design type-safe inheritance?
IfI want to override a method and return a more specific type
UseThat's covariance — allowed in PHP 7.4+. Ensure child's return type is a subtype of parent's return type.
IfI want to override a method and accept a more general type
UseThat's contravariance — allowed in PHP 8.0+. Ensure child's parameter type is a supertype of parent's parameter type.
IfI need to change the type in a way that violates these rules
UseRedesign the interface or use composition instead of inheritance.

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.

CoercionTrap.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — php tutorial

// Coercive mode: type hints are suggestions, not rules
declare(strict_types=0); // default, you can omit this line

function processInvoiceId(string $invoiceId): string {
    return strtoupper($invoiceId);
}

$incomingValue = 872344; // integer from some legacy system
$result = processInvoiceId($incomingValue); // coerced silently to "872344"
echo $result; // "872344" — no error, no warning, just a quiet bug
Output
872344
Production Trap:
Built-in PHP functions behave differently from your user-defined functions. Functions like 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.
Key Takeaway
Type declarations in PHP are opt-in guardrails, not language-level invariants. Without strict_types=1, a type hint is just a comment the engine sometimes checks.

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.

StrictBoundaryLeak.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — php tutorial

// File: lib/UserRepository.php — strict mode ON
declare(strict_types=1);

function findUser(int $userId): array {
    // expects an integer, strictly
    return ['id' => $userId];
}

// File: controllers/UserController.php — no declare, uses default coercive
// This file calls findUser() with a string
$user = findUser("42"); 
// Because lib/UserRepository.php has strict_types=1, this throws a TypeError at runtime.
// The caller's coercive mode does NOT make the callee accept strings.
// But if lib/UserRepository.php forgot strict_types=1, this would silently coerce.
Output
Fatal error: Uncaught TypeError: findUser(): Argument #1 ($userId) must be of type int, string given
Senior Shortcut:
Set up a PHP_CodeSniffer rule that forces declare(strict_types=1) as the first statement (after the opening <?php tag) in every PHP file. Make it a blocking CI check. Namespace declarations must come after the declare, not before.
Key Takeaway
declare(strict_types=1) is per-file, not global. One missing declaration in a dependency graph can silently undo your entire type safety architecture.

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.

SilentCorruption.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — php tutorial

// No strict_types=1 — coercive mode is active
function calculateDiscount(float $price): string {
    // Expects float, but receives something that looks like an int
    return sprintf('$%.2f', $price * 0.9);
}

$rawData = json_decode('{"price": 24.99}', true);
// $rawData['price'] is a float 24.99 — works fine

$rawData2 = json_decode('{"price": 25}', true); 
// $rawData2['price'] is an int 25 — silently coerced to float 25.0
// No problem here. But what about this?

$rawData3 = json_decode('{"price": "nineteen"}', true);
$result = calculateDiscount($rawData3['price']);
// PHP coerces "nineteen" to float 0.0 — silent truncation, no error
echo $result; // "$0.00"
Output
$0.00
Production Trap:
Never trust 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.
Key Takeaway
Coercive mode + per-file strictness + external data = silent corruption. Always validate external data types before they hit your typed boundaries.
● Production incidentPOST-MORTEMseverity: high

A $37,000 Invoice Error Caused by Loose Type Coercion

Symptom
An invoicing service generated totals that were off by 0.01–0.02 cents per line for a fraction of orders. Customer support saw small discrepancies, but no error was logged — the function returned a valid float every time.
Assumption
The team assumed that because the input came from a validated form, it was always a proper float. The form sent the price as a string "19.99", which the framework converted to a float. But PHP's default coercive mode accepted "19.99" as a float, and the rounding in round($amount, 2) sometimes produced off-by-one-hundredth errors due to floating point precision with string coercion.
Root cause
The function 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().
Fix
Added 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.
Key lesson
  • 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 : float or : string to make your intention unignorable.
Production debug guideCommon symptoms when your type declarations backfire3 entries
Symptom · 01
Uncaught TypeError: Argument #1 must be of type int, string given
Fix
Check the calling file for declare(strict_types=1). If it's absent, PHP coerced before — now it's strict. Cast the input with (int) $value at the boundary.
Symptom · 02
TypeError: Return value must be of type string, null returned
Fix
A nullable return was declared incorrectly. Change function foo(): string to function foo(): ?string if null is valid. If null means 'not found', throw an exception instead.
Symptom · 03
TypeError: Cannot use object of type stdClass as array
Fix
Some external API returned an object, but your code expects an array. Declare the expected type as array on the parameter and cast the input: (array) $response.
★ Quick Debug Cheatsheet: PHP Type ErrorsWhat to do the moment a TypeError appears in your logs.
TypeError on parameter mismatch
Immediate action
Look at the calling line. Is the argument the correct type? Check if strict_types=1 is active.
Commands
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.
Fix now
If the data comes from request, cast at the boundary: (int) $request->input('quantity')
TypeError on return type mismatch+
Immediate action
Trace the function execution paths. One branch returns the wrong type.
Commands
Add a `var_dump(gettype($result));` before the return.
Check all early returns: `grep 'return' src/Service/OrderService.php`
Fix now
Add a union return type (PHP 8+) or change the method to throw on invalid state.
Fatal error: Cannot redeclare function with different signature+
Immediate action
Check if you're overriding a parent method with a narrower return type than the parent.
Commands
Run `php -l` on both files.
Verify the parent's return type: `grep ': .*$' parent.php`
Fix now
Make the child's return type covariant (narrower) or match exactly. Don't widen.
Coercive vs Strict Mode
FeatureCoercive Mode (default)Strict Mode (strict_types=1)
Passing '42' where int expectedSilently converted to 42TypeError thrown immediately
Passing 3.14 where int expectedConverted to 3 (truncated)TypeError thrown immediately
Passing 1 where bool expectedConverted to trueTypeError thrown immediately
Passing null where string expectedConverted to empty string ''TypeError thrown immediately
Scope of effectGlobal — applies to all callsPer-file — only calls FROM this file
Best used whenIntegrating loosely-typed legacy dataBuilding new, well-tested application code
IDE & static analysis supportPartial — types can drift silentlyFull — tools trust declared types completely
Introduced in PHP versionAlways availablePHP 7.0+
void return typeAllowed, but no enforcement if function returns a valueSame — TypeError if return statement has a value
never return typeN/A (PHP 8.1+ only)TypeError if function returns or reaches end without throw/exit

Key takeaways

1
Type declarations are a contract, not just documentation
PHP enforces them at runtime with a TypeError, making them far more reliable than PHPDoc comments that can silently drift out of sync with the actual code.
2
strict_types=1 is scoped to the calling file, not the function's file
this is the single most counterintuitive fact about PHP's type system and the one most likely to catch you out in a real codebase or interview.
3
Nullable types (?string) should signal that null is a *meaningful business value
not a lazy 'I might not return anything'. If null means 'something failed', throw an exception instead and keep your return type clean.
4
Union types (PHP 8.0+) let you accurately type functions that genuinely handle multiple data shapes, but treat them as a design smell if overused
a function returning int|string|array|null is probably doing too many jobs.
5
Covariance (narrower return in child) and contravariance (wider parameter in child) are allowed in PHP 7.4+ and 8.0+ respectively. Use them to build polymorphic hierarchies that remain type-safe.
6
mixed and never are extreme types
mixed disables safety, never declares the function never returns. Use them only in clear, limited scenarios.

Common mistakes to avoid

4 patterns
×

Thinking strict_types=1 affects the function definition, not the call site

Symptom
You add strict_types to a library file and expect all callers to be strict, but a controller without strict_types still passes a string as int without any error.
Fix
Remember that strict_types governs the calling file. Add declare(strict_types=1) to every file in your application codebase. Use a PHP_CS_Fixer rule php_strict_types or PHPStan level to enforce project-wide.
×

Using ?string when you mean to disallow null entirely

Symptom
A function declared as getUserEmail(int $userId): ?string returns null for missing users, and callers skip the null-check and call strtolower() on the result, causing a fatal TypeError.
Fix
If null is not a valid business outcome, don't declare it nullable. Return an empty string, throw an exception, or use a null object pattern instead. Nullable types are for cases where null is a meaningful value, not a lazy shortcut for 'something went wrong'.
×

Omitting return types on class methods that override a parent

Symptom
A child class overrides a parent method but adds a return type the parent doesn't have; PHP 8.1+ will throw a fatal error because the child's signature is not compatible with the parent's declared contract.
Fix
Add return types to the parent (or interface) first, then ensure all child classes match exactly. A child class CAN narrow a return type (covariance) but cannot widen it. Declare types at the interface/abstract class level and let all implementations inherit the contract.
×

Using false in union types instead of nullable for 'not found' scenarios

Symptom
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.
Fix
Use nullable ?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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between declaring a parameter as `?string` versus ...
Q02SENIOR
If I add `declare(strict_types=1)` to a file that *calls* a function def...
Q03SENIOR
I have a function declared as `function findUser(int $id): array` and it...
Q01 of 03JUNIOR

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?

ANSWER
They are functionally identical — ?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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Does adding PHP type declarations slow down my application?
02
Can I use type declarations in PHP 7 or do I need PHP 8?
03
What's the difference between void and never as return types?
04
Can I use type declarations with PHP's array and object types?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's PHP Basics. Mark it forged?

9 min read · try the examples if you haven't

Previous
PHP Regular Expressions
14 / 14 · PHP Basics
Next
OOP in PHP — Classes and Objects