PHP Type Declarations Explained — Parameters, Returns & Strict Mode
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.
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.
<?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 — passing a string that // cannot be safely coerced to float (uncomment to see the error): // applyDiscount('fifty dollars', 10);
Sale price after 10% discount: $45.0
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.
<?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";
Shipping cost for 4kg: $14.0
All valid calls completed successfully.
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.
<?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);
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
| Feature | Coercive Mode (default) | Strict Mode (strict_types=1) |
|---|---|---|
| Passing '42' where int expected | Silently converted to 42 | TypeError thrown immediately |
| Passing 3.14 where int expected | Converted to 3 (truncated) | TypeError thrown immediately |
| Passing 1 where bool expected | Converted to true | TypeError thrown immediately |
| Passing null where string expected | Converted to empty string '' | TypeError thrown immediately |
| Scope of effect | Global — applies to all calls | Per-file — only calls FROM this file |
| Best used when | Integrating loosely-typed legacy data | Building new, well-tested application code |
| IDE & static analysis support | Partial — types can drift silently | Full — tools trust declared types completely |
| Introduced in PHP version | Always available | PHP 7.0+ |
🎯 Key Takeaways
- 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.
- 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.
- 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.
- 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: 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 codebase, not just the file containing the function. Use a PHP_CS_Fixer or PHPStan rule to enforce this project-wide.
- ✕Mistake 2: 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'.
- ✕Mistake 3: 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.
Interview Questions on This Topic
- QWhat'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?
- QIf I add `declare(strict_types=1)` to a file that *calls* a function defined in another file without strict_types, which file's rules govern the type checking on that call — and why is this behaviour useful?
- QI have a function declared as `function findUser(int $id): array` and it returns an empty array when no user is found. What's the problem with this design, and how would you use PHP type declarations to make the two outcomes — found vs not found — explicit and type-safe?
Frequently Asked Questions
Does adding PHP type declarations slow down my application?
The performance overhead is negligible in production — we're talking microseconds per call. The real cost of not using type declarations is engineer time spent debugging silent type coercions and runtime errors that should have been caught at the call site. Always favour correctness over micro-optimisation here.
Can I use type declarations in PHP 7 or do I need PHP 8?
Scalar type hints (int, float, string, bool) and return types arrived in PHP 7.0. Nullable types (?string) came in PHP 7.1. Union types (string|int) and mixed arrived in PHP 8.0. The never return type and intersection types came in PHP 8.1. Check your minimum supported PHP version and use the features available to it — but if you're still on PHP 7.x, it's well past end-of-life and migration to 8.x should be a priority.
What's the difference between void and never as return types?
A void function completes normally and simply returns nothing useful — it might write to a database, fire an event, or update state. A never function never completes normally at all: it always throws an exception or terminates the script. Use void for side-effect-only methods and never for functions like redirectAndExit() or throwNotFound() that unconditionally halt execution.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.