Free Handbook · Every example compiled & verified

Functions

Typed parameters and return types, nullable and union types, default and named arguments, variadics, references, static variables, closures, arrow functions and recursion.

0 / 148 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Declare functions with typed parameters and return types, including ?nullable, union, void and never
  • Call functions with default, named and spread arguments, and accept any number of arguments with ...$args
  • Explain pass-by-value vs pass-by-reference and when a function can change your variable
  • Write closures with use, arrow functions with fn, and pass functions around with strlen(...) and the callable type
  • Trace a recursive function and keep state between calls with a static variable
01

Declaring and calling functions

A function is a named block of code you can call with inputs (parameters) and get a result back with return. A function without return gives back null. Top-level functions are available anywhere in the file, even above their definition, because PHP compiles the whole file before running it. Function names are case-insensitive, but always call them exactly as declared (camelCase is the PSR convention).

phpmain.php
<?php
echo greet("Asha"), "\n";           // works: defined further down

function greet(string $name): string
{
    return "Hello, $name!";
}

function logLine(string $msg): void
{
    echo "[log] $msg\n";            // void: returns nothing
}

logLine("started");
var_dump(logLine("again"));         // a void call evaluates to null

// variables inside a function are local
$count = 10;
function tryToChange(): void
{
    $count = 99;                    // a new local variable
    echo "inside: $count\n";
}
tryToChange();
echo "outside: $count\n";
Outputcompiled & run with real PHP
Hello, Asha!
[log] started
[log] again
NULL
inside: 99
outside: 10

Unlike JavaScript, a PHP function cannot see variables from outside it. Pass what it needs as parameters. (The global keyword exists; modern code avoids it.)

Your turn

Write function square(int $n): int and print square(7).

Error you will hit

ArgumentCountError: Too few arguments

php
<?php
function greet(string $name, string $greeting): string
{
    return "$greeting, $name!";
}

echo greet("Asha");
Fatal error: Uncaught ArgumentCountError: Too few arguments to function greet(), 1 passed in main.php on line 7 and exactly 2 expected in main.php:2
Stack trace:
#0 main.php(7): greet('Asha')
#1 {main}
  thrown in main.php on line 2
Why PHP said that

Every parameter without a default is required. The message tells you both sides: the call on line 7 passed 1 argument, the function on line 2 expects exactly 2. (Passing too many arguments to a user function is silently allowed — the extras are ignored.)

The fix

Pass the missing argument, or give the parameter a default value if most callers use the same one.

php
<?php
function greet(string $name, string $greeting = "Hello"): string
{
    return "$greeting, $name!";
}

echo greet("Asha");
02

Parameter and return types

Type declarations document what a function accepts and returns, and PHP checks them at run time. The scalar types are int, float, string and bool; you can also use array, callable, iterable, object, mixed and any class name. ?string means "string or null". A union type int|string accepts either. Return-only types: void (returns nothing), never (always throws or exits), and static. Add declare(strict_types=1); so PHP stops converting "5" into 5 for you — see Module 01.

phpmain.php
<?php
declare(strict_types=1);

function findUser(int $id): ?string        // may return null
{
    $users = [1 => 'Asha', 2 => 'Ravi'];
    return $users[$id] ?? null;
}

function formatId(int|string $id): string  // union type
{
    return is_int($id) ? sprintf("#%05d", $id) : strtoupper($id);
}

function fail(string $msg): never           // never returns normally
{
    throw new RuntimeException($msg);
}

var_dump(findUser(1), findUser(9));
echo formatId(42), " ", formatId("ab-7"), "\n";
echo findUser(2) ?? 'guest', "\n";

try {
    fail("stop here");
} catch (RuntimeException $e) {
    echo "caught: ", $e->getMessage(), "\n";
}
Outputcompiled & run with real PHP
string(4) "Asha"
NULL
#00042 AB-7
Ravi
caught: stop here

Exceptions and try/catch get a full module later (Module 08); here they only show what never means.

DeclarationAcceptsTypical use
?stringstring or nullA lookup that may find nothing
int|floateither typeNumeric input from several sources
string $x = nulldeprecated since PHP 8.4Write ?string $x = null instead
void (return)no valueFunctions that act: log, save, print
never (return)never returnsHelpers that always throw or exit
mixedanything, including nullLast resort; says "I checked nothing"
Error you will hit

TypeError: Return value must be of type int, float returned

php
<?php
declare(strict_types=1);

function discount(float $price, int $percent): int
{
    return $price * (100 - $percent) / 100;
}

echo discount(99.0, 10);
Fatal error: Uncaught TypeError: discount(): Return value must be of type int, float returned in main.php:6
Stack trace:
#0 main.php(9): discount(99.0, 10)
#1 {main}
  thrown in main.php on line 6
Why PHP said that

Return types are checked too. The calculation produces the float 89.1, and in strict mode PHP will not quietly truncate it to 89 for an int return type. This time the error points at the return line inside the function, not at the caller.

The fix

Decide what the function should really return. Either declare float, or convert on purpose with (int) round(...).

php
<?php
declare(strict_types=1);

function discount(float $price, int $percent): float
{
    return $price * (100 - $percent) / 100;
}

echo discount(99.0, 10);   // 89.1
03

Default values and named arguments

A parameter with a default (string $role = 'viewer') becomes optional. Put optional parameters after required ones. PHP 8 added named arguments: createUser(name: 'Asha', admin: true) passes values by parameter name, so you can skip optional parameters in the middle and the call documents itself. Positional arguments must come first; after the first named one, all the rest must be named.

phpmain.php
<?php
declare(strict_types=1);

function createUser(
    string $name,
    string $role = 'viewer',
    bool $active = true,
    ?string $team = null,
): string {
    $team ??= 'none';
    return "$name | $role | " . ($active ? 'active' : 'inactive') . " | team $team";
}

echo createUser('Asha'), "\n";
echo createUser('Ravi', 'admin'), "\n";
echo createUser('Kiran', active: false), "\n";          // skip $role
echo createUser(team: 'data', name: 'Meera'), "\n";     // any order

// named arguments make built-in calls readable too
echo str_pad('7', 3, '0', STR_PAD_LEFT), " ", str_pad(string: '7', length: 3, pad_string: '0', pad_type: STR_PAD_LEFT), "\n";
echo json_encode(['a' => 'x/y'], flags: JSON_UNESCAPED_SLASHES), "\n";
Outputcompiled & run with real PHP
Asha | viewer | active | team none
Ravi | admin | active | team none
Kiran | viewer | inactive | team none
Meera | viewer | active | team data
007 007
{"a":"x/y"}

A trailing comma after the last parameter (and after the last argument) is allowed since PHP 8.0 — it keeps diffs clean when you add a parameter.

Your turn

Call createUser with only a name and a team, using named arguments.

Parameter names are now part of your API
Once callers can write createUser(active: false), renaming $active breaks their code. Library authors treat parameter names as public. In your own code, choose clear names from the start.
Error you will hit

Error: Unknown named parameter

php
<?php
function createUser(string $name, string $role = 'viewer'): string
{
    return "$name ($role)";
}

echo createUser(name: 'Asha', roel: 'admin');
Fatal error: Uncaught Error: Unknown named parameter $roel in main.php:7
Stack trace:
#0 {main}
  thrown in main.php on line 7
Why PHP said that

Named arguments are matched by exact parameter name, and there is no $roel. Unlike extra positional arguments, an unknown name is never ignored — which is exactly what you want for a typo.

The fix

Use the real parameter name. Your editor will autocomplete named arguments if you let it.

php
<?php
function createUser(string $name, string $role = 'viewer'): string
{
    return "$name ($role)";
}

echo createUser(name: 'Asha', role: 'admin');
04

Variadic functions and argument unpacking

Put ... before the last parameter to accept any number of arguments: they arrive as an array. The same three dots at a call site do the opposite — spread an array into separate arguments. A variadic parameter can have a type, which applies to every element.

phpmain.php
<?php
declare(strict_types=1);

function total(float ...$amounts): float
{
    return array_sum($amounts);
}

function tag(string $name, string ...$classes): string
{
    $cls = $classes ? ' class="' . implode(' ', $classes) . '"' : '';
    return "<$name$cls>";
}

echo total(), " ", total(5), " ", total(1.5, 2.5, 3), "\n";
echo tag('p'), " ", tag('div', 'card', 'shadow'), "\n";

$prices = [10, 20, 30];
echo total(...$prices), "\n";               // spread a list

$args = ['name' => 'button', 'classes' => 'btn'];
function el(string $name, string $classes = ''): string
{
    return "$name.$classes";
}
echo el(...$args), "\n";                    // string keys spread as named arguments
Outputcompiled & run with real PHP
0 5 7
<p> <div class="card shadow">
60
button.btn
Your turn

Write function maxOf(int $first, int ...$rest): int that returns the largest value. Why does $first make an empty call impossible?

05

Pass by value and pass by reference

By default PHP passes arguments by value: the function gets its own copy, and nothing it does to that copy changes the caller's variable (for arrays this is cheap thanks to copy-on-write, see Module 04). Put & before a parameter to pass by reference: the parameter becomes another name for the caller's variable. Built-ins like sort() and preg_match()'s $matches work this way.

phpmain.php
<?php
function addOneByValue(int $n): void
{
    $n++;
}

function addOneByRef(int &$n): void
{
    $n++;
}

$x = 5;
addOneByValue($x);
echo "after by value: $x\n";
addOneByRef($x);
echo "after by reference: $x\n";

function addItem(array &$cart, string $item): void
{
    $cart[] = $item;
}
$cart = [];
addItem($cart, 'pen');
addItem($cart, 'ink');
echo implode(", ", $cart), "\n";

// usually clearer: return the new value instead
function withItem(array $cart, string $item): array
{
    return [...$cart, $item];
}
$cart = withItem($cart, 'paper');
echo implode(", ", $cart), "\n";
Outputcompiled & run with real PHP
after by value: 5
after by reference: 6
pen, ink
pen, ink, paper

Objects are a special case: the function receives a copy of the handle, so it can change the object's properties without &. Module 06 explains why.

In real jobs
Reference parameters are rare in modern PHP code and a code-review flag when the function could just return a value. A function that returns its result is easier to test and cannot surprise the caller. Keep & for the cases the standard library chose it: sorting in place and "output" parameters.
Error you will hit

Error: Argument could not be passed by reference

php
<?php
function addTax(array &$prices): void
{
    foreach ($prices as &$p) {
        $p *= 1.18;
    }
}
addTax([100, 200]);
Fatal error: Uncaught Error: addTax(): Argument #1 ($prices) could not be passed by reference in main.php:8
Stack trace:
#0 {main}
  thrown in main.php on line 8
Why PHP said that

A reference parameter needs a variable to point at, so the function can write back into it. A literal array (or a function's return value) has nowhere to write to.

The fix

Store the value in a variable first — or, better, make the function return the new array.

php
<?php
function addTax(array $prices): array
{
    return array_map(fn($p) => $p * 1.18, $prices);
}
print_r(addTax([100, 200]));
06

Static variables

A normal local variable is created fresh on every call. A static local variable is created once and keeps its value between calls to the same function. It is useful for counters and simple caches; for anything bigger, a class property (Module 06) is clearer.

phpmain.php
<?php
function nextId(): int
{
    static $id = 0;          // initialised once
    $id++;
    return $id;
}

echo nextId(), nextId(), nextId(), "\n";

function slowSquare(int $n): int
{
    static $cache = [];
    if (isset($cache[$n])) {
        echo "(cached $n) ";
        return $cache[$n];
    }
    echo "(computing $n) ";
    return $cache[$n] = $n * $n;
}

echo slowSquare(4), "\n";
echo slowSquare(4), "\n";
echo slowSquare(5), "\n";
Outputcompiled & run with real PHP
123
(computing 4) 16
(cached 4) 16
(computing 5) 25

In a web request the static lives only until the request ends; every request starts from zero. It is a per-request cache, not a shared one.

07

Closures, arrow functions and callables

A closure (anonymous function) is a function without a name that you can store in a variable and pass around: $double = function ($x) { return $x * 2; };. It cannot see outer variables unless you list them in use (...), which copies their value at the moment the closure is created. use (&$total) captures by reference instead. Arrow functions fn($x) => expr (PHP 7.4) are one-expression closures that capture outer variables automatically, by value.

phpmain.php
<?php
$rate = 1.18;

$withTax = function (float $p) use ($rate): float {
    return round($p * $rate, 2);
};
echo $withTax(100), "\n";

$rate = 2.0;                               // too late: use copied 1.18
echo $withTax(100), "\n";

$arrow = fn(float $p): float => round($p * $rate, 2); // captures 2.0 now
echo $arrow(100), "\n";

$total = 0;
$add = function (int $n) use (&$total): void {
    $total += $n;                          // writes the outer $total
};
$add(5);
$add(7);
echo "total $total\n";

// closures are values: return them from functions
function multiplier(int $factor): Closure
{
    return fn(int $x): int => $x * $factor;
}
$triple = multiplier(3);
echo implode(" ", array_map($triple, [1, 2, 3])), "\n";
Outputcompiled & run with real PHP
118
118
200
total 12
3 6 9
Your turn

Change $add to an arrow function fn(int $n) => $total += $n and call it twice. Why does $total stay 12?

Passing functions around: callable and first-class callable syntax

Anything PHP can call is a callable: a closure, a function name as a string ('strtoupper'), or an array for a method ([$obj, 'method']). PHP 8.1 added first-class callable syntax: strlen(...) turns a function into a Closure object. It is checked when it is created (a typo fails immediately) and your IDE can follow it, unlike a string.

phpmain.php
<?php
declare(strict_types=1);

function applyTwice(callable $fn, string $value): string
{
    return $fn($fn($value));
}

$words = ['php', 'is', 'fun'];
echo implode(" ", array_map(strtoupper(...), $words)), "\n";
echo implode(",", array_map(strlen(...), $words)), "\n";

$shout = strtoupper(...);
var_dump($shout instanceof Closure);

echo applyTwice(fn($s) => "[$s]", "x"), "\n";
echo applyTwice('ucfirst', "hello"), "\n";   // a string callable still works

usort($words, fn(string $a, string $b): int => strlen($a) <=> strlen($b));
echo implode(" ", $words), "\n";
Outputcompiled & run with real PHP
PHP IS FUN
3,2,3
bool(true)
[[x]]
Hello
is php fun

function () use (...) { }

  • Any number of statements
  • Captures only what you list in use
  • Can capture by reference: use (&$x)
  • Needs an explicit return

fn() => expr

  • Exactly one expression
  • Captures every outer variable it uses, automatically
  • By value only — it can never change an outer variable
  • Returns the expression's value
08

Recursion

A recursive function calls itself on a smaller piece of the problem. It needs a base case that returns without recursing, and every recursive call must move towards it. Recursion fits naturally nested data — category trees, folders, JSON, menus. PHP has no tail-call optimisation, so very deep recursion (tens of thousands of levels) will exhaust memory; use a loop for long linear work.

Visualizefactorial(3), call by callStep 1 / 8
<?php
function factorial(int $n): int
{
if ($n <= 1) {
return 1;
}
return $n * factorial($n - 1);
}
echo factorial(3);
Line 9

The main script calls factorial(3).

Variables now

nothing yet

All 8 steps as a table
StepLineWhat happenedVariables now
19The main script calls factorial(3).
24In call #1, $n is 3. Not the base case.$n (call 1) = 3
37Call #1 needs factorial(2) before it can multiply, so it waits and makes call #2.$n (call 1) = 3 $n (call 2) = 2
47Call #2 is not the base case either; it waits for factorial(1).$n (call 2) = 2 $n (call 3) = 1
55Call #3 hits the base case and returns 1. Now the waiting calls resume, newest first.$n (call 3) = 1
67Call #2 computes 2 * 1 and returns 2.$n (call 2) = 2
77Call #1 computes 3 * 2 and returns 6.$n (call 1) = 3
89The result reaches echo.
phpmain.php
<?php
declare(strict_types=1);

function factorial(int $n): int
{
    return $n <= 1 ? 1 : $n * factorial($n - 1);
}

// nested data: print a menu tree at any depth
function printMenu(array $items, int $depth = 0): void
{
    foreach ($items as $label => $children) {
        echo str_repeat("  ", $depth), "- $label\n";
        printMenu($children, $depth + 1);   // base case: an empty array
    }
}

function sumNested(array $data): int
{
    $sum = 0;
    foreach ($data as $v) {
        $sum += is_array($v) ? sumNested($v) : $v;
    }
    return $sum;
}

echo factorial(5), "\n";
printMenu(['Home' => [], 'Shop' => ['Books' => ['Fiction' => []], 'Pens' => []], 'Contact' => []]);
echo sumNested([1, [2, 3], [4, [5, [6]]]]), "\n";
Outputcompiled & run with real PHP
120
- Home
- Shop
  - Books
    - Fiction
  - Pens
- Contact
21
Your turn

Write a recursive countLeaves(array $tree): int that counts the menu items with no children (the answer for the menu above is 4).

Parameter / argument
A parameter is the variable in the declaration ($name); an argument is the value passed in a call.
Return type
The declared type after ):, checked when the function returns. void means no value.
Nullable type
?T — the type T or null.
Union type
A|B — a value of either type.
Named argument
Passing a value by parameter name: f(active: false). PHP 8.0+.
Variadic parameter
...$args — collects the remaining arguments into an array.
Pass by reference
A &$param is an alias of the caller's variable; writes to it change that variable.
Closure
An anonymous function value, an instance of the Closure class; captures outer variables with use.
Arrow function
fn($x) => expr — a one-expression closure that captures outer variables by value automatically.
First-class callable
strlen(...) — creates a Closure from a function or method. PHP 8.1+.
Quick check

What does this print? $n = 1; $f = fn() => $n + 1; $n = 10; echo $f();

Frequently asked questions

What is the difference between a closure and an arrow function in PHP?
Both are anonymous functions. A closure (function () use ($x) { ... }) can contain many statements and captures only the variables listed in use, optionally by reference. An arrow function (fn($y) => $x + $y) is a single expression that captures every outer variable it uses automatically, always by value.
Are PHP arrays passed by reference to functions?
No. Arrays are passed by value, so the function works on its own copy and the caller's array is unchanged. Copy-on-write makes this cheap: memory is only copied if the function modifies the array. Add & to the parameter only when the function must change the caller's variable.
Should I use named arguments in PHP 8?
Use them when a call has several optional parameters or boolean flags, because f(active: false) is clearer than f('viewer', false) and lets you skip defaults. Remember that parameter names become part of a function's public API once callers use them.

Finish the PHP handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.