Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 15 25 MIN

Types and data

Scalar Types and declare(strict_types=1)

What PHP's scalar type declarations actually enforce, the conversions coercive mode performs silently, and why strict types is a property of the calling file.

A function declared as function total(int $cents): int is called with the string '1500' and returns 1500. Nothing is logged, no warning is raised, and the arithmetic downstream is correct. The same function, called with the same argument from a different file in the same project, throws a TypeError and kills the request. Both runs used the same source file.

That is not a bug in PHP. It is the type system doing something most people do not expect from a language that spent its early years being loose about everything: the type declaration on a parameter is enforced on the value, but how strictly it is enforced depends on where the call is written. Learn where the line sits and the surprises stop. Skip it and you will spend an afternoon convinced that a value changed between two lines.

Every example below ran on PHP 8.5.6 from the command line, and the code targets 8.3 and newer. Paths inside error messages are shortened to /path/to/; the messages themselves are verbatim, including the parts that read oddly.

Without strict types a declaration converts instead of refusing

Coercive mode is the default. When a value arrives that does not match the declared type, PHP tries to convert it, and only fails when no sensible conversion exists. The whole behaviour fits in one script:

<?php
// coerce.php — no declare statement in this file

function total(int $cents): int
{
    return $cents;
}

$inputs = [
    'string "1500"' => '1500',
    'string "15.5"' => '15.5',
    'string "12,00"' => '12,00',
    'float 15.9' => 15.9,
    'bool true' => true,
    'null' => null,
];

foreach ($inputs as $label => $value) {
    try {
        printf("%-18s => int(%d)\n", $label, total($value));
    } catch (TypeError $e) {
        printf("%-18s => %s: %s\n", $label, $e::class, $e->getMessage());
    }
}

Run it and the exceptions are the interesting lines:

string "1500"      => int(1500)
string "15.5"      => int(15)
string "12,00"     => TypeError: total(): Argument #1 ($cents) must be of type int, string given
float 15.9         => int(15)
bool true          => int(1)
null               => TypeError: total(): Argument #1 ($cents) must be of type int, null given

The last line is the one people misread. null did not coerce to 0, and '12,00' did not coerce to 1200. Coercion is not permission to pass anything; it is a fixed table of conversions, and the table refuses anything that would lose information it cannot recover. A numeric string becomes an integer. A string that is not a number at all does not, and the TypeError names the function, the parameter, the declared type and the given type. That message is more useful than the numeric result of the conversions that succeeded.

The conversions that did run are still conversions, and two of them lost something. '15.5' became 15 and 15.9 became 15: both truncate toward zero rather than rounding. On PHP 8.1 and newer each of those reports a deprecation, which is easy to miss in a busy log:

PHP Deprecated:  Implicit conversion from float-string "15.5" to int loses precision
PHP Deprecated:  Implicit conversion from float 15.9 to int loses precision

A payment flow that sums floats in cents passes its own tests, prints a deprecation nobody reads, and bills the wrong amount once a truncated half-cent matters. The declaration was satisfied. The value was not what the parameter name promised.

Coercion refuses the conversions that cannot be undone

The rule behind the refusals is worth stating plainly, because it explains the whole table. PHP converts between scalars when the conversion is defined and information is not destroyed beyond recovery; it refuses otherwise. Here is the complete picture for the two scalar types you will pass most often.

Given valueParameter typed intParameter typed string
true1'1'
false0''
0, 42the integer'0', '42'
4.5, 4.0truncated, with a deprecation'4.5', '4'
'1500'1500unchanged
'abc'TypeErrorunchanged
[]TypeErrorTypeError
nullTypeErrorTypeError

Two entries in that table are the ones that bite. false to '' means a boolean flag passed into a string parameter produces an empty string, which is a valid string, which then travels through your code looking like missing input rather than a type mistake. And null never converts, even in coercive mode. The only way to accept null is to declare it, which is the next section.

This table is not what strict types is about

declare(strict_types=1) does not change the conversion rules above; it turns them off for calls made from that file. A conversion that is refused in coercive mode is still refused under strict types, with a different message: the same input now reports the original type instead of a failed conversion.

The same inputs in a strict file produce TypeErrors instead of numbers

Add one line to the top of the file and run the identical loop. Nothing else changes: same function, same inputs, same order.

<?php
declare(strict_types=1);

function total(int $cents): int
{
    return $cents;
}
// the same $inputs loop follows
string "1500"      => TypeError: total(): Argument #1 ($cents) must be of type int, string given
string "15.5"      => TypeError: total(): Argument #1 ($cents) must be of type int, string given
string "12,00"     => TypeError: total(): Argument #1 ($cents) must be of type int, string given
float 15.9         => TypeError: total(): Argument #1 ($cents) must be of type int, float given
bool true          => TypeError: total(): Argument #1 ($cents) must be of type int, true given
null               => TypeError: total(): Argument #1 ($cents) must be of type int, null given

Read the fifth line twice. The message says true given, not bool given, because the engine formats a boolean by its value. The message is strange and the behaviour is right: the caller passed a boolean to a parameter that promised an integer, and no conversion was attempted.

So strict types does not add checks that were missing. The check on the value was always there. Strict types removes the guessing layer in front of it, and the effect is that a mistake fails at the call site with the original type named, instead of failing later with a silently different value. That is the entire trade: coercive mode keeps more programs running, strict mode keeps more mistakes visible. For code that handles money, identifiers or anything a user typed, the second behaviour is worth the friction.

Strict types belongs to the file that makes the call

This is the part that produces the impossible-looking bug in the opening paragraph. declare(strict_types=1) is not a property of the functions defined in that file. It is a property of the calls written in that file. The mode travels with the call site, and the manual states it as an explicit note on the type declarations page.

Two files calling one function make it visible. The callee declares strict types and the callers differ:

<?php
// callee.php
declare(strict_types=1);

function addVat(int $cents): int
{
    return $cents + intdiv($cents * 21, 100);
}
<?php
// loose.php — no declare

require __DIR__ . '/callee.php';

printf("addVat('1000') = %d\n", addVat('1000'));
addVat('12,00');
<?php
// strict.php
declare(strict_types=1);

require __DIR__ . '/callee.php';

addVat('1000');
loose.php:  addVat('1000') = 1210
loose.php:  TypeError: addVat(): Argument #1 ($cents) must be of type int, string given, called in /path/to/loose.php on line 8
strict.php: TypeError: addVat(): Argument #1 ($cents) must be of type int, string given, called in /path/to/strict.php on line 7

callee.php declares strict types and loose.php still coerces, because the call is written in loose.php. The message names the calling file and line for exactly this reason: when a value arrives with a type you did not expect, the file to inspect is the one in the message, not the function's own file. Signatures, defaults and the rest of what a declaration can and cannot promise get a full treatment later in the course; what matters here is that you cannot make your functions strict on behalf of their callers.

Two limits are worth knowing now. The declaration has to be the first statement in the file, before any other code, and it applies to calls, not to the operators. Arithmetic under strict types behaves exactly as it does without it, which is the subject of the division section below. It also does not apply to internal functions in the way people assume: strlen(42) in a file without the declaration returns 2, and the same call in a strict file throws TypeError: strlen(): Argument #1 ($string) must be of type string, int given. Internal functions respect the caller's mode too, because the mode is about the call.

Nullable, union, void and never describe the contract, not the value

Once a declaration is enforced, the question becomes which declaration to write. ?string and string are different contracts, and choosing the wrong one moves the failure from the call site into the function body where it is harder to see.

<?php
declare(strict_types=1);

function nickname(?string $name): string
{
    return $name ?? 'anonymous';
}

function formatAmount(int|float $amount, string $currency = 'EUR'): string
{
    return sprintf('%.2f %s', $amount, $currency);
}

var_dump(nickname(null), nickname('ana'));
echo formatAmount(10), PHP_EOL;
echo formatAmount(10.5, 'USD'), PHP_EOL;
string(9) "anonymous"
string(3) "ana"
10.00 EUR
10.50 USD

?string is shorthand for string|null, and without the ?? in the body the function would throw on null even though the declaration advertised that null is acceptable. That is the trade a nullable parameter makes: it moves the obligation to decide what absent means from the caller to the function. Prefer null in a signature only when absent is a legitimate state you can describe, and not as a way to make a call site compile.

A union narrows to a set of named types, and the error message shows the whole set. A string passed to formatAmount() reports TypeError: formatAmount(): Argument #1 ($amount) must be of type int|float, string given, which tells the reader what the function accepts without opening it. What a union does not give you is a float. formatAmount(10) prints 10.00 because sprintf formats it, and any arithmetic inside the function keeps an int result for two int operands, which brings us back to division.

The two return types that describe absence of a return are often confused. A void function returns nothing, and using its result is an error. A never function does not return at all: every path either throws or exits, which makes it useful for the branch of a helper that cannot produce a value. A function that throws when a value is invalid can mark that path never and the analyser stops asking what happens after the guard.

<?php
declare(strict_types=1);

function fail(string $message): never
{
    throw new RuntimeException($message);
}

function describe(int $n): string
{
    if ($n < 0) {
        fail('negative');
    }

    return "value {$n}";
}

echo describe(3), PHP_EOL;

try {
    describe(-1);
} catch (RuntimeException $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
value 3
RuntimeException: negative

Both broken versions of never fail loudly and early, at compile time for the file rather than at the call:

PHP Fatal error:  A never-returning function must not return in /path/to/never_bad.php on line 6
PHP Fatal error:  never cannot be used as a parameter type in /path/to/never_param.php on line 4

A never function that returns is a contradiction the compiler catches, and never is a return type only, so it cannot appear in a parameter list. Without it, describe(-1) would need a return after the guard that no reader believes is reachable, and the analyser in module 5 would want a decision from you.

Division produces a float unless the operands divide exactly

The / operator does not care about the declared types around it. Two integers produce an integer only when the division is exact; the rest of the time you get a float.

<?php
declare(strict_types=1);

var_dump(10 / 4);
var_dump(10 / 5);
var_dump(intdiv(10, 4));
var_dump(10 % 4);
echo 1 / 3, PHP_EOL;
var_dump(PHP_INT_MAX, PHP_INT_MAX + 1);
float(2.5)
int(2)
int(2)
int(2)
0.33333333333333
int(9223372036854775807)
float(9.223372036854776E+18)

Three facts are visible there. 10 / 5 returns int(2) rather than float(2), so the return type of a function doing integer division cannot be trusted to be either. intdiv(10, 4) is the operation people expect from integer division: it returns 2 and discards the remainder, while 10 % 4 returns the remainder on its own. And when PHP_INT_MAX + 1 cannot be an int, PHP widens the result to a float without asking, which is how a counter quietly stops being exact at nine quintillion.

Money is where this stops being trivia. Cents as integers avoid the problem entirely: intdiv($cents * 21, 100) produces whole cents with no float in the path, which is why the VAT example earlier was written that way. Cents as floats invites the truncation deprecation from the first section and a rounding drift that appears only when the numbers are large. If you do need a float result and both operands are floats, fmod(10.5, 4.0) gives 2.5; % on float operands is deprecated as of PHP 8.1, so do not reach for it.

Errors from the division operators are exceptions, not warnings. 10 / 0, intdiv(10, 0) and 10 % 0 all throw DivisionByZeroError and stop the expression, with the message Division by zero for the first two and Modulo by zero for the third. Since DivisionByZeroError extends ArithmeticError, a catch on the parent covers all of them, and the guard you want in most code is a check before the division rather than a catch after it.

Lab 25 minutes

Write invoice.php with a strict types declaration and a function lineTotal(int $cents, int $quantity): int that returns the product.

Then write two caller files. loud.php declares strict types; quiet.php does not. Both require invoice.php and call lineTotal('1999', '3'), printing the result or the TypeError message. Run both and keep the output.

Add a third function averageLine(int $total, int $lines): float that divides the total by the number of lines, and call it with averageLine(1999, 0). Record which exception class arrives and from which line.

Finally, change the callers to pass integers and change averageLine to use intdiv with a return type of int. Answer two questions: which of the two callers changed behaviour when you switched the argument to 3.0, and what does that tell you about where strict types is enforced?

Show the solution and the reasoning

The design decision in this lab is where the conversion is allowed to happen. Put it at the call site, in the file that knows whether the input is trusted, and the function body can assume its types.

<?php
// invoice.php
declare(strict_types=1);

function lineTotal(int $cents, int $quantity): int
{
    return $cents * $quantity;
}

function averageLine(int $total, int $lines): float
{
    return $total / $lines;
}
<?php
// quiet.php — no declare

require __DIR__ . '/invoice.php';

printf("quiet lineTotal = %d\n", lineTotal('1999', '3'));
printf("quiet averageLine = %f\n", averageLine(1999, 0));
quiet lineTotal = 5997

Fatal error: Uncaught DivisionByZeroError: Division by zero in /path/to/invoice.php:11
Stack trace:
#0 /path/to/quiet.php(5): averageLine(1999, 0)
#1 {main}
  thrown in /path/to/invoice.php on line 11

loud.php produces no number at all. The same call throws Uncaught TypeError: lineTotal(): Argument #1 ($cents) must be of type int, string given, called in /path/to/loud.php on line 6 and defined in /path/to/invoice.php:4. Two locations appear because PHP 8 names both the call site and the declaration. The class is TypeError, which reaches Throwable through Error rather than Exception, so a catch block written for Exception will not see it.

The DivisionByZeroError comes from inside invoice.php, not from the caller, because strict types has nothing to say about arithmetic. It arrives on the line with the division, through the file that made the call, and it extends ArithmeticError. The honest fix is not a catch: a function that averages lines should refuse an empty set at its own boundary, which is what never is for.

function averageLine(int $total, int $lines): float
{
    if ($lines === 0) {
        fail('cannot average zero lines');
    }

    return $total / $lines;
}

Switching the argument to 3.0 changes the behaviour of quiet.php and not loud.php, and the reason is the one worth carrying out of this lesson. The declaration lives on the function, but the mode lives in the file where the call is written. quiet.php truncates the float to 3 with a deprecation; loud.php reports must be of type int, float given. A defensible alternative is to declare int|float and round explicitly inside the function, which is more code and removes the ambiguity about whether the caller or the function owns the rounding.

One reasonable variation on this lab: make quiet.php declare strict types and have invoice.php not declare it. The output does not change, which is the proof that the declaration belongs to the caller's file.

Progress

Mark this lesson done and it stays marked in this browser.