Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 07 / 15 25 MIN

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.

Lab 25 minutes

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.