Functions, classes and namespaces
Signatures, Defaults and Named Arguments
What a return type really enforces, why defaults must be constant expressions, and when named arguments and variadics make a call site clearer instead of longer.
A payment bug shipped because a call site read charge($amount, $currency,
$account) and the function wanted charge(string $currency, int $amount,
string $account). PHP ran it. The currency was coerced to an amount, the amount to a
currency, and the code only failed in production when someone paid 12,00 EUR instead of
EUR 12.00. The type declarations were all there. They were facing the wrong way.
That is the recurring shape of signature bugs. PHP checks that a value matches a declared type. It cannot check that the value means what the parameter name promises. What you get from a signature is narrower than what people assume: a guard on the value, a contract on the name, and a set of rules about what may legally appear in the parentheses. Learn the three separately and the surprises stop.
Run every example in this lesson against PHP 8.3 or newer. The output shown is from PHP 8.5.6.
A return type enforces the value at the return point
Type declarations are checked where the value crosses the boundary. For an argument,
that is the moment of the call, before the function body runs. For a return value, it is
the moment of return, before the caller sees anything. The check is on the
value, not on the expression that produced it.
<?php
declare(strict_types=1);
function discount(float $price): int
{
return (int) round($price * 0.9);
}
function broken(float $price): int
{
return $price * 0.9;
}
echo discount(19.99), PHP_EOL;
try {
echo broken(19.99), PHP_EOL;
} catch (TypeError $e) {
echo get_class($e), ': ', $e->getMessage(), PHP_EOL;
}
18
TypeError: broken(): Return value must be of type int, float returned
Both functions promise an int. The first casts and keeps the promise. The
second multiplies two floats, gets a float, and fails at the return statement with a
TypeError naming the function, the declared type and the actual type. That
message is generated by the return type, and it appears the moment control leaves the
function, which is far better than a wrong value travelling three layers before someone
notices.
Two details trip people up. Widening int to float is always
allowed, even under strict types, so a function declared to return float may
return 3. And void is not null: a
void function must not return a value at all, and using its result anywhere is
a fatal error, not a warning.
<?php
declare(strict_types=1);
function logLine(string $message): void
{
echo $message, PHP_EOL;
return strlen($message);
}
logLine('hello');
PHP Fatal error: A void function must not return a value in ... on line 8
The failure arrives when the file is compiled, not when logLine() is
called, so the program never starts. When you want a function that never returns normally,
because it always throws or exits, the declaration you want is never, and it
is a return type only: a parameter cannot be typed never.
strict_types belongs to the caller's file, not to the function
This is the rule that makes a codebase behave differently in two files that look identical, and it is documented as an explicit note on the type declarations page: strict typing applies to function calls made from within the file that declares it, not to the functions defined in that file. The mode travels with the call site.
Here is the demonstration. Two caller files, one callee file that declares strict types:
<?php
// strict_callee.php
declare(strict_types=1);
function addVat(int $cents): int
{
return $cents + intdiv($cents * 21, 100);
}
<?php
// loose_caller.php: no declare statement
require __DIR__ . '/strict_callee.php';
var_dump(addVat('1000'));
<?php
// strict_caller.php
declare(strict_types=1);
require __DIR__ . '/strict_callee.php';
try {
var_dump(addVat('1000'));
} catch (TypeError $e) {
echo get_class($e), ': ', $e->getMessage(), PHP_EOL;
}
int(1210)
TypeError: addVat(): Argument #1 ($cents) must be of type int, string given, called in .../strict_caller.php on line 7
The same function, called with the same argument, coerces in one file and throws in the
other. declare(strict_types=1) is not a property of the function you wrote; it
is a property of the file that calls it. The practical consequences are worth stating
plainly. Adding strict types to your own files protects the calls you make. It does not
protect your functions from callers who have not added it. The TypeError
message even names the calling file, which tells you to look there when a value arrives
with the wrong type for no visible reason.
Coercion is not free typing
In coercive mode PHP converts a numeric string to int, a float to
int by truncation, and int to string. It refuses
conversions that would lose information, such as a non-numeric string to
int. That is why '1000' works and '12,00' throws
anyway. The mode changes how much guessing PHP does, not whether it checks.
Default values must be constant expressions
A default is evaluated at compile time and stored with the function, so it cannot depend
on anything that only exists at runtime. Constants, literals, arrays of literals and, since
PHP 8.1, new with constant arguments are all acceptable. A function call, a
variable or a property is not, and the error message names the rule directly.
<?php
declare(strict_types=1);
function connect(string $dsn, int $retries = strlen('abc')): string
{
return "$dsn/$retries";
}
echo connect('mysql:host=db'), PHP_EOL;
PHP Fatal error: Constant expression contains invalid operations in ... on line 4
The fix is a constant, and a class constant works as well as a global one:
<?php
declare(strict_types=1);
const RETRY_LIMIT = 3;
function describeRetries(string $dsn, int $retries = RETRY_LIMIT): string
{
return "$dsn with $retries retries";
}
echo describeRetries('mysql:host=db'), PHP_EOL;
echo describeRetries('mysql:host=db', retries: 5), PHP_EOL;
mysql:host=db with 3 retries
mysql:host=db with 5 retries
The constant is resolved when the file is compiled, so if it is not defined by then the
call fails with Error: Undefined constant "RETRY_LIMIT". That is a runtime
error at the call, not a compile error at the declaration, which is why the function still
loads. The other rule to keep in mind is ordering: an optional parameter declared before a
required one has been deprecated since PHP 8.0, because the default can never be used.
Put required parameters first and give the rest defaults.
Named arguments buy clarity and cost you rename safety
PHP 8.0 added named arguments, and the syntax is the parameter name, a colon, then the value. Order stops mattering. You can skip any subset of the optional parameters without passing placeholders.
<?php
declare(strict_types=1);
function total(int $cents, int $tax = 0, string $currency = 'EUR'): string
{
$amount = ($cents + $tax) / 100;
return number_format($amount, 2, '.', '') . ' ' . $currency;
}
echo total(129900), PHP_EOL;
echo total(129900, currency: 'USD'), PHP_EOL;
echo total(tax: 2100, cents: 5000), PHP_EOL;
1299.00 EUR
1299.00 USD
71.00 EUR
That third call is the one that reads better than any positional version of it. Now the
cost. A named argument names the parameter, so the parameter name becomes part of your
public API. Renaming $cents to $amountInCents in that function
breaks every caller that used the name, in a way no test will catch until it runs. That is
the trade: the call site becomes self-documenting, and the signature loses the freedom to
be renamed quietly.
The rules that produce errors are worth knowing because the messages do not always
point at the real mistake. A name that matches no parameter raises
Error: Unknown named parameter $retry. Passing the same parameter twice, once
positionally and once by name, raises an error about overwriting the previous argument.
And a missing name can surface as an argument count error instead of a name error, because
PHP fills parameters positionally before it notices.
<?php
declare(strict_types=1);
function connect(string $dsn, int $retries = 3): string
{
return "$dsn retries=$retries";
}
try {
echo connect(dsn: 'mysql:host=db', retry: 5), PHP_EOL;
} catch (Error $e) {
echo get_class($e), ': ', $e->getMessage(), PHP_EOL;
}
Error: Unknown named parameter $retry
Variadics collect what is left, and spreading is not symmetric
A parameter prefixed with ... collects the remaining arguments into an
array. Positional parameters may come before it; nothing may come after it. A type before
the ellipsis applies to every collected value, which is how you get a typed list without a
wrapper class.
<?php
declare(strict_types=1);
function tag(string $first, string ...$rest): string
{
return implode(', ', [$first, ...$rest]);
}
function sum(int ...$amounts): int
{
return array_sum($amounts);
}
echo tag('php'), PHP_EOL;
echo tag('php', 'composer', 'psr-4'), PHP_EOL;
$tags = ['php', 'strict_types'];
echo tag('intro', ...$tags), PHP_EOL;
echo sum(), PHP_EOL, sum(1, 2, 3), PHP_EOL;
php
php, composer, psr-4
intro, php, strict_types
0
6
Calling with ... spreads an array into the argument list, and there is an
asymmetry between the two directions. In a declaration, the variadic parameter must be
last. In a call, a positional argument cannot follow a spread array, but a named argument
can, because PHP 8.1 allows it. The other thing variadics do is collect named
arguments that match no parameter into the array under their names, which is how a variadic
function can accept keywords it never declared:
<?php
declare(strict_types=1);
function names(string ...$values): array
{
return $values;
}
var_dump(names(first: 'a'));
array(1) {
["first"]=>
string(1) "a"
}
Useful when you are forwarding configuration to something else. Dangerous when you are not, because a typo in a name becomes an extra array entry instead of an error.
First-class callable syntax replaces the closure you did not want to write
When a function only needs to pass a callable around, the closure is noise. The
... syntax in call position, added in PHP 8.1, produces a
Closure from a function, a static method or an instance method, and it is
checked at creation time rather than at the first call.
<?php
declare(strict_types=1);
$trim = trim(...);
$upper = strtoupper(...);
echo $trim(' php 8.5 '), PHP_EOL;
echo $upper('psr-4'), PHP_EOL;
$lengths = array_map(strlen(...), ['php', 'composer', 'psr-4']);
var_dump($lengths);
final class Formatter
{
public function __construct(private string $prefix)
{
}
public function apply(string $value): string
{
return $this->prefix . $value;
}
public static function slugify(string $value): string
{
return strtolower(str_replace(' ', '-', $value));
}
}
$formatter = new Formatter('id-');
$apply = $formatter->apply(...);
$slugify = Formatter::slugify(...);
echo $apply('42'), PHP_EOL;
echo $slugify('PSR 4 Autoloading'), PHP_EOL;
var_dump(is_callable($apply));
php 8.5
PSR-4
array(3) {
[0]=>
int(3)
[1]=>
int(8)
[2]=>
int(5)
}
id-42
psr-4-autoloading
bool(true)
The instance form captures $formatter, so the closure keeps the object
alive for as long as it exists. That matters when you store callables in a long-lived
container: the syntax reads like a reference, and it is one. Compared with
[$formatter, 'apply'], the array form is resolved later and never fails at
creation, so the ... version is the better default. Write
static fn (string $value): string => strtoupper($value) when you need body
logic, and the callable syntax when you do not.
Write a function lineTotal(int $cents, int $quantity = 1, int $discountPercent = 0): int
that returns the line total in cents, rounding half up at the end. Call it three times:
once positionally with every argument, once with only the arguments you need passed by
name, and once from a second file that does not declare strict types, passing
'1500' as a string.
Then add -- optional -- below that and write a second function
sum(...$amounts): int that adds every amount it receives, including the
case where it receives none.
Finally, answer three questions in comments above the calls. Why does the string call
behave the way it does? What happens to the first function if you rename
$quantity to $count? What does sum() return when
called with no arguments, and is that the value you want?
Show the solution and the reasoning
One function covers the three call shapes, and the rounding decision is the only part worth arguing about:
<?php
declare(strict_types=1);
function lineTotal(int $cents, int $quantity = 1, int $discountPercent = 0): int
{
$gross = $cents * $quantity;
$discounted = $gross * (100 - $discountPercent);
return (int) round($discounted / 100);
}
echo lineTotal(1500, 3, 10), PHP_EOL;
echo lineTotal(cents: 1500, discountPercent: 10), PHP_EOL;
echo lineTotal(1500), PHP_EOL;
function sum(int ...$amounts): int
{
return array_sum($amounts);
}
echo sum(), PHP_EOL;
echo sum(1500, 2500), PHP_EOL;
4050
1350
1500
0
4000
Multiply first and round once. Rounding each line and then multiplying turns a
half-cent into a visible difference at quantity three, and the customer-facing total is
the number you agreed to charge, so the rounding belongs at the boundary. That is the
reasoning behind (int) round($discounted / 100) rather than
intdiv($discounted, 100), which truncates and quietly loses money on every
line.
The string call only behaves well because of the caller's file. In a file without
declare(strict_types=1), '1500' is coerced to
1500 and the function never knows. The same call from a strict file throws
TypeError: lineTotal(): Argument #1 ($cents) must be of type int, string given.
Write both files and run them, because the difference is the whole point of the first
section: your function cannot tell which kind of caller it got.
Renaming $quantity to $count breaks
lineTotal(cents: 1500, quantity: 3) with
Error: Unknown named parameter $quantity, while the positional call keeps
working. That is not a reason to avoid named arguments. It is a reason to treat parameter
names on a widely used function the same way you treat a method name, and to rename them
in one commit with the call sites rather than as a tidy-up later.
sum() returns 0, because array_sum([]) is
0. Whether that is correct depends on the caller: a 0 is right for a running
total and wrong for "we could not read the lines". If the empty case is a failure, throw
or return ?int and let the caller decide. That choice is not expressible in
the type declaration, which is the honest limit of what a signature can promise.
Progress
Mark this lesson done and it stays marked in this browser.