Type Declarations And Strict Types
Type declarations make a function's accepted inputs and promised result visible in its signature. PHP checks those declarations at runtime, while editors and static-analysis tools can use them before execution.
Types reduce ambiguity, but they do not replace validation. int $quantity says the value is an integer. It does not say the integer is positive, within stock limits, or authorised for the current user.
Declare Parameter And Return Types
<?php
declare(strict_types=1);
function applyDiscountCents(int $priceCents, int $discountCents): int
{
return max(0, $priceCents - $discountCents);
}
echo applyDiscountCents(2500, 400), PHP_EOL;
The signature documents two integer-cent inputs and an integer result. The names carry business meaning and units that int alone cannot express.
Use PHP's real scalar type names: int, float, string, and bool. Aliases such as integer and boolean are not scalar declaration aliases; PHP interprets unsupported names as class names.
Place strict_types Correctly
Enable strict scalar checking with:
<?php
declare(strict_types=1);
The declaration belongs at the beginning of the file, after the opening tag. It is a per-file setting rather than a project-wide switch.
Strict typing applies to scalar function calls made from that file. It does not attach permanently to functions declared there. If a non-strict file calls a function declared in a strict file, the caller's coercive mode governs scalar arguments.
This caller rule matters in multi-file applications. Projects commonly place declare(strict_types=1); in every PHP source file so behavior does not depend on which file performs a call.
Compare Coercive And Strict Calls
Without strict mode, PHP may convert compatible scalar values:
<?php
function doubleQuantity(int $quantity): int
{
return $quantity * 2;
}
var_dump(doubleQuantity('4'));
The numeric string can be coerced to integer 4 and the result is 8.
With strict mode, the same string argument causes TypeError:
<?php
declare(strict_types=1);
function doubleQuantity(int $quantity): int
{
return $quantity * 2;
}
try {
doubleQuantity('4');
} catch (TypeError $exception) {
echo "Quantity has the wrong PHP type\n";
}
The correct fix is to validate and convert external text before calling the typed function. Removing the declaration only moves the ambiguity deeper into the program.
Strict scalar matching has one deliberate exception: an integer can satisfy a float parameter because every integer value has a corresponding floating-point representation for the call.
Return Types Are Enforced Too
A return declaration checks the value leaving the function:
<?php
declare(strict_types=1);
function formatCents(int $cents): string
{
return 'GBP ' . number_format($cents / 100, 2, '.', ',');
}
If this function returns the raw integer instead, strict mode produces a TypeError because the implementation violated its string promise.
Do not weaken a return type to make a failing implementation pass. Decide what the caller should receive, then make every normal return path honor that contract.
A function can also fail by throwing an exception. An exception leaves the normal return path, so it does not need to match the declared return type.
Use Nullable Types Only When Null Has Meaning
A nullable type accepts one type or null:
<?php
declare(strict_types=1);
function customerLabel(?string $name): string
{
if ($name === null) {
return 'Guest';
}
$name = trim($name);
return $name === '' ? 'Guest' : $name;
}
?string is shorthand for string|null. Use it when absence is part of the domain, such as an optional middle name or unknown completion date.
Do not add null merely to silence a caller error. If a value is required, keep the non-null type and fix the boundary that failed to supply it.
A nullable parameter with a default can be omitted explicitly:
function customerLabel(?string $name = null): string
The default and the allowed type are separate decisions: the default controls omission, while the type controls supplied values.
Use Union Types For Real Alternatives
PHP supports unions such as int|string:
<?php
declare(strict_types=1);
function normaliseReference(int|string $reference): string
{
return (string) $reference;
}
A union should reflect a genuine API contract. Do not add many alternatives until nearly any value is accepted. Broad unions make callers and implementations handle more branches.
Prefer converting boundary data into one application type where possible. If every downstream function accepts int|string, the conversion decision has not actually been made.
Use void For Intentional Side Effects
A void function does not return a usable value:
<?php
declare(strict_types=1);
function printLine(string $message): void
{
echo $message, PHP_EOL;
}
printLine('Ready');
A void function may use bare return; to leave early, but it must not return a value. Use void when the action is the contract, such as writing output or recording a log entry.
Business calculations and formatters are usually easier to reuse and test when they return values instead of printing directly.
Type External Input At The Boundary
Query parameters, form fields, environment variables, CLI arguments, and CSV cells commonly arrive as strings. Strict types do not parse them for you.
<?php
declare(strict_types=1);
function parsePositiveQuantity(string $input): int
{
if (!ctype_digit($input)) {
throw new InvalidArgumentException('Quantity must contain digits only.');
}
$quantity = (int) $input;
if ($quantity < 1) {
throw new InvalidArgumentException('Quantity must be positive.');
}
return $quantity;
}
function calculateLineTotal(int $unitPriceCents, int $quantity): int
{
return $unitPriceCents * $quantity;
}
$quantity = parsePositiveQuantity('3');
echo calculateLineTotal(1299, $quantity), PHP_EOL;
The parser owns validation and conversion. The calculation receives application-ready integers and does not need to understand raw text formats.
Prove The Calling-File Rule
Suppose functions.php declares strict types and defines an integer function:
<?php
declare(strict_types=1);
function increment(int $value): int
{
return $value + 1;
}
A different file without strict mode can require it and call increment('4'); the calling file permits scalar coercion. If that caller also begins with declare(strict_types=1);, the same string call throws TypeError.
This is why adding strict mode only to helper files is insufficient. Apply the project convention consistently to entry points, tests, scripts, and included source files. The declaration does not propagate through require into another file's calls.
Parse Booleans Instead Of Casting Text
Casting external text to bool is often wrong because every non-empty string except '0' becomes true. The string 'false' is therefore true when cast.
Use a parser whose accepted spellings are explicit:
<?php
declare(strict_types=1);
function parseBoolean(string $input): bool
{
return match (strtolower(trim($input))) {
'1', 'true', 'yes' => true,
'0', 'false', 'no' => false,
default => throw new InvalidArgumentException('Invalid boolean value.'),
};
}
After parsing, pass the real boolean into typed application functions. Type declarations protect the internal boundary; the parser defines which external representations are valid.
Declarations Do Not Describe Array Shapes
An array declaration accepts any PHP array:
function orderTotal(array $order): int
PHP does not enforce that the array contains items, that every item has price_cents, or that those values are integers. Runtime checks, value objects, tests, PHPDoc array shapes, and static analysis can provide stronger guarantees.
Do not assume array $order makes nested reads safe. Validate external structures before passing them deeper into the application.
Likewise, callable says a value can be called but does not declare its parameter and return signature. Static-analysis annotations can add that precision.
Read TypeError As A Contract Failure
A TypeError usually means code crossed a typed boundary with the wrong PHP value or returned the wrong type. Catching one can be useful in a small demonstration or at a top-level failure boundary, but routine application logic should not use TypeError as user-input validation.
Validate raw input before the typed call and handle expected validation exceptions. If an internal TypeError occurs, fix the caller or implementation rather than broadly catching and continuing with uncertain state.
Strict Types Do Not Make PHP Statically Typed
PHP still determines many values at runtime, arrays can contain mixed shapes, and variables can be reassigned to another type unless another declaration constrains the boundary.
Strict types improve scalar function-call and return behavior. They do not validate database rows, infer business units, prevent every coercion in operators, or replace static analysis. Treat them as one layer in a broader correctness strategy.
Common Type Mistakes
| Symptom | Likely cause |
|---|---|
Numeric CLI value causes TypeError |
Raw string was not parsed at the boundary |
| Behavior differs between files | One caller lacks declare(strict_types=1) |
| Function returns wrong type | Implementation violates its return declaration |
| Negative quantity is accepted | PHP type is correct but business validation is missing |
| Missing nested key warning | array declaration was mistaken for an array-shape guarantee |
| Nullable type spreads everywhere | null was allowed instead of resolving absence at the boundary |
| Function accepts too many unrelated values | Union type is hiding an unclear contract |
| Internal bug is treated as bad user input | TypeError was caught too broadly |
| Editor cannot verify callback arguments | callable lacks a declared signature |
What You Should Be Able To Do
After this lesson, you should be able to add parameter and return declarations, place declare(strict_types=1); correctly, explain why the calling file controls strict scalar arguments, predict coercive versus strict behavior, recognise the integer-to-float exception, use nullable and union types only for meaningful alternatives, choose void for an intentional side effect, parse external strings before typed calls, distinguish PHP types from business validation, and explain why array does not enforce a record shape.
Official references: PHP's manual pages for type declarations and declare.
Practice
Task: Typed Discount
Task
Create a strict PHP file and implement:
function applyDiscountCents(int $priceCents, int $discountCents): int
Assume both inputs were already validated as non-negative. Return the price after discount, but never below 0.
Run these calls and print the results:
applyDiscountCents(2500, 400);
applyDiscountCents(2500, 2500);
applyDiscountCents(2500, 3000);
Expected output:
2100
0
0
Hints
- Put
declare(strict_types=1);immediately after<?php. max(0, ...)expresses the lower boundary.- Test below, exactly at, and beyond the boundary.
Show solution
Solution
<?php
declare(strict_types=1);
function applyDiscountCents(int $priceCents, int $discountCents): int
{
return max(0, $priceCents - $discountCents);
}
echo applyDiscountCents(2500, 400), PHP_EOL;
echo applyDiscountCents(2500, 2500), PHP_EOL;
echo applyDiscountCents(2500, 3000), PHP_EOL;
Explanation
The signature requires integer-cent arguments and promises an integer result. max() clamps negative subtraction results to zero.
The three calls verify the ordinary path and both sides of the lower boundary. The type declarations do not enforce non-negative inputs; that remains the stated validation responsibility of the caller.
Task: Fix Return Type
Task
This strict function promises a string but returns an integer:
<?php
declare(strict_types=1);
function formatCents(int $cents): string
{
return $cents;
}
Fix it so it returns a GBP label with two decimal places, a decimal point, and comma thousands separators.
Verify these calls:
formatCents(99) -> GBP 0.99
formatCents(1299) -> GBP 12.99
formatCents(123456) -> GBP 1,234.56
Hints
- Divide cents by
100only for presentation. - Use all four
number_format()arguments so separators are explicit. - Keep the numeric parameter and string return declaration.
Show solution
Solution
<?php
declare(strict_types=1);
function formatCents(int $cents): string
{
return 'GBP ' . number_format($cents / 100, 2, '.', ',');
}
echo formatCents(99), PHP_EOL;
echo formatCents(1299), PHP_EOL;
echo formatCents(123456), PHP_EOL;
Explanation
The implementation now fulfills its declared string result. Integer cents remain the input unit, while conversion and separators belong to presentation.
The three cases verify a value below one major currency unit, an ordinary value, and a value that requires a thousands separator. Removing the return declaration would hide the original contract error rather than fixing it.
Task: Write Typed Normalizer
Task
Create a strict PHP file and implement:
function normaliseRequiredText(string $value): string
Trim the value, throw InvalidArgumentException('Value is required.') when the cleaned string is empty, and otherwise return it.
Loop over [' PHP ', ' ', '0']. Catch the expected exception for each value and print:
Accepted: PHP
Rejected
Accepted: 0
Hints
- A string can satisfy the PHP type while still failing a business rule.
- Compare the trimmed value with
=== ''. - Do not use
empty(), because the valid string'0'would be rejected.
Show solution
Solution
<?php
declare(strict_types=1);
function normaliseRequiredText(string $value): string
{
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('Value is required.');
}
return $value;
}
foreach ([' PHP ', ' ', '0'] as $value) {
try {
echo 'Accepted: ' . normaliseRequiredText($value) . "\n";
} catch (InvalidArgumentException $exception) {
echo "Rejected\n";
}
}
Explanation
All three inputs have the declared PHP type string. The whitespace-only value still violates the function's required-text rule after trimming.
The exact empty-string comparison preserves '0' as valid text. This demonstrates why type declarations and business validation are complementary rather than interchangeable.