Skip to content
beginner Phase 2 · Types, Functions, and Control Flow

Defining Functions

Write functions with typed parameters, defaults, and return values.

40m
0 problems
Topic Progress 0%

Anatomy of a Function: Signature, Scope, and Hoisting

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

echo applyDiscount(100.0, 25);   // 75.0

Four parts, all of which the next two topics expand: the name, the parameters (each optionally typed), the return type after the colon, and the body. Calling it is just the name and an argument list.

Three semantic rules decide how and when a function exists.

Hoisting. An unconditional top-level function declaration is registered at compile time, so it can be called anywhere in the file — including above its own definition:

echo total([10, 20]);        // works: total is known before this line runs

function total(array $xs): int { return array_sum($xs); }

A conditional declaration — inside if, inside another function — is not hoisted; it only exists once execution reaches it, and calling it early is a fatal Call to undefined function. That asymmetry is why library code declares functions at the top level of each file and lets Composer's autoloader load the file, never wrapping declarations in conditionals.

Scope. A function sees its parameters and its own locals — nothing else. An outer variable is invisible without global, and superglobals ($_GET, $_SESSION) are always available. The design answer is not global but parameters in and values out: a function that takes its dependencies as arguments can be tested with fixtures, while a function that reaches for globals needs a full request to test at all.

Arity. Too few arguments is an ArgumentCountError. Too many is not an error for userland functions — the extras are simply ignored unless the function retrieves them with func_get_args(), a legacy pattern for variadic-style APIs that ...$args replaced.

If conditional declarations are the anti-pattern, there is one legitimate use: polyfills. Guarding a function definition with function_exists lets modern code ship a fallback for older runtimes:

if (!function_exists('str_contains')) {
    function str_contains(string $haystack, string $needle): bool {
        return $needle === '' || strpos($haystack, $needle) !== false;
    }
}

str_contains arrived in PHP 8.0; this shim restores it on 7.4 so the same codebase runs everywhere. Because the declaration is conditional, the guard is mandatory — the function exists only once that line has executed.

Two signature features round out the basics. Pass-by-reference is declared in the signature and lets a function modify the caller's variable — sort($arr) reorders $arr in place, which is why its parameter is array &$array; call-time pass-by-reference (f(&$x)) was removed in PHP 5.4 precisely to keep the effect visible in the signature. And since PHP 8.1, a first-class callable turns any function into a Closure with ellipsis syntax:

$trim = trim(...);                 // a Closure you can pass around
echo array_map($trim, $lines);     // instead of a callback that only wraps trim

Naming is convention, not syntax: ecosystem functions are snake_case (array_merge, htmlspecialchars), and PSR-1 asks the same of plain functions while methods in object-oriented code are camelCase. Whatever the casing, a function name should read as a verb or a verb phrase — sendInvoice, parseLog — because in stack traces and logs the name is the only documentation anyone reads under pressure.

Default Values, Named Arguments, and Variadics

A default value makes a parameter optional at every call site:

function paginate(int $page = 1, int $perPage = 20): array
{
    $offset = ($page - 1) * $perPage;
    return ['limit' => $perPage, 'offset' => $offset];
}

paginate();                // 1 / 20
paginate(4);               // 4 / 20
paginate(4, 50);           // 4 / 50

Optional parameters must be trailing — a required parameter cannot follow an optional one in a sane signature. PHP technically allows function f($a = 1, $b), but then f(5) binds $a and starves $b, and there is no positional way to say "default $a, here is $b". Before 8.0 that was a dead end; named arguments removed it.

Defaults themselves must be constant expressions: literals, constants, consts, and arrays of them — not variables, not function calls. The reason is that a default cannot depend on call-time state; it is part of the function's declaration, evaluated from values the compiler already knows. PHP 8.1 widened this slightly — new PDO(...) and arbitrary constant expressions became legal defaults — but $config['timeout'] never will be.

Named arguments (PHP 8.0) let callers skip parameters and pass by name:

paginate(perPage: 50);              // page defaults to 1
paginate(perPage: 50, page: 3);     // order no longer matters

This is why the "optional before required" smell stopped mattering (f(b: 5) works), and why long parameter lists stopped needing boolean flag arguments. Two costs library authors must weigh: a typo in a parameter name is a runtime Error, not a silent wrong value, and parameter names become part of your public API — renaming one is a breaking change for every caller using names. Variadics cooperate with the feature: function sum(int ...$nums) collects arguments into $nums, and passing named arguments to it gathers the extras into an associative array.

The other half of optional-ness is at the call site: argument unpacking spreads an array into parameters:

$args = [3, 50];
paginate(...$args);

Combined with func_get_args()-style legacy code and ...$named collection, arrays and signatures convert freely in both directions — the same composability the CLI topic relied on for $argv.

Two design consequences are worth stating plainly. First, boolean flag parameters are usually a design smell that defaults plus named arguments have largely retired: createUser('ada', true, false) tells the reader nothing, while createUser('ada', sendWelcome: true, isAdmin: false) does. Second, PHP 8.1's new in initializers lets a default carry an object, which changed how lightweight dependency injection looks:

class Report {
    public function __construct(private Logger $log = new NullLogger()) {}
}

No container, no null checks — callers without a logger still get a working object. The restriction that matters: the default must be a new expression with no arguments spread from variables; anything dynamic still belongs in the body.

Interview framing for this area is usually prediction, not recitation: given a signature with defaults, name the arguments' values after a call mixing positional and named forms. Practice exactly that — positional arguments bind left to right, named ones bind by name, and a second binding of the same parameter is an Error.

Return Types, Guard Clauses, and Functions That Return Nothing

The return type is the promise a function makes to every caller:

function invoiceTotal(array $lines): float
{
    $total = 0.0;
    foreach ($lines as $line) {
        $total += $line['qty'] * $line['price'];
    }
    return $total;
}

Without a declaration, callers cannot know — or verify — what they get back. With one, the engine enforces the promise: returning the wrong type raises a TypeError at the return, pointing at the line that broke the contract. Under coercive typing (the default) a : string function that returns 42 yields '42'; under strict_types the same return throws — the coercion-versus-strict split from Variables and Scalar Types applies to returns as much as to arguments.

Three declaration forms cover most code:

function findUser(int $id): ?array    // array OR null — absence is part of the contract
function logout(): void               // returns nothing, ever
function fail(string $msg): never     // never returns normally — it always throws/exits

void means "no value": return; alone is allowed, falling off the end is allowed, and return $x; is a TypeError. The declaration also documents intent for readers — a void function exists to change state, and its result is always null to the caller.

never (PHP 8.1) is the sharper tool: the function always throws or exits, so control flow provably does not continue past the call. It is the right type for a abort() helper or an exception-throwing guard, and static analyzers use it to mark subsequent code unreachable.

The structural habit worth adopting is the guard clause, which keeps the happy path flat:

function findUser(PDO $pdo, int $id): ?array
{
    if ($id <= 0) {
        return null;                                  // early exit, no nesting
    }
    $stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?');
    $stmt->execute([$id]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    return $row === false ? null : $row;
}

Compare the alternative — a deeply nested if ($row) { ... } else { ... } — and the trade-off is clear: early returns trade one early exit for an unindented body that reviewers can read in a single pass.

Typed returns also preview the object model: a method may declare : self ("an instance of this class") or, since PHP 8.0, : static ("an instance of whatever class the call started from"). Both appear in fluent APIs, and both interact with inheritance by a rule the engine enforces at compile time — a child class may narrow the return type (parent promises object, child returns User) but never widen it. That covariance rule is how type declarations keep substitution safe without any runtime cost, and it is a reliable follow-up question once you mention return types in an interview.

Two documentation layers finish the story. PHP's own types stop at coarse values: array cannot say which keys and shapes. For that, docblock shapes carry the detail static analyzers consume:

/** @param array<int, array{qty: int, price: float}> $lines */

And naming can encode the contract too: get* and is* promise no side effects, send* and save* promise them. A function whose name, signature, and body all tell the same story is the whole quality bar for this topic.

Quiz

1. A function is called on line 3 and defined unconditionally at the bottom of the same file. What happens?

Question 1 options

2. `function f($a = 1, $b) {}` — how do you call it with only $b?

Question 2 options

3. What does a `: void` return type allow and forbid?

Question 3 options

4. Why must a parameter default be a constant expression rather than `$config['timeout']` or a function call?

Question 4 options

Flashcards

Question

What is function hoisting in PHP, and what is NOT hoisted?

Answer

Unconditional top-level function declarations are registered at compile time, so they can be called anywhere in the file, even above their definition. Functions declared conditionally (inside if, inside another function) are not hoisted and cause 'Call to undefined function' if called before the declaration executes.

Question

Rules for default parameter values in PHP

Answer

Must be trailing (no required parameter after an optional one, or callers cannot bind positionally), and must be constant expressions — literals, constants, arrays of them; PHP 8.1 adds `new`. Variables and function calls are not allowed because defaults belong to the declaration, not to call-time scope.

Question

What do named arguments change for API design?

Answer

Callers can skip optional parameters and pass in any order (paginate(perPage: 50)), and typos become runtime Errors. The cost: parameter names become public API — renaming one breaks every named caller — so library authors treat them as stable identifiers.

Question

void vs never return types

Answer

void: the function returns no value — bare `return;` or falling off the end; `return $x` throws TypeError. never (8.1): the function never returns normally — it always throws or exits — so the engine and analyzers treat code after the call as unreachable.

Question

What does a wrong-type return do with and without strict_types?

Answer

Coercive mode converts when possible (: string returning 42 yields '42'); strict_types=1 (in the calling file) throws a TypeError at the return. Either way the engine enforces the declared return type at runtime — it is a contract, not a comment.

Revision Notes

Key Takeaways

  • 1. Functions are declared with the function keyword; unconditional top-level declarations are hoisted, conditional ones are not
  • 2. Scope is closed by default — pass dependencies as parameters instead of importing globals, and the function stays testable
  • 3. Defaults must be constant expressions and should be trailing; named arguments (8.0) remove the optional-before-required trap
  • 4. Declare return types: concrete for values, ?type for absence, void for no value, never for always-throws — the engine enforces all of them
  • 5. Guard clauses keep bodies flat, and array shapes in docblocks give analyzers what PHP's array type cannot express

Interview Tips

  • • "Why can I call a function defined at the bottom of the file?" — hoisting: unconditional top-level declarations are registered at compile time; contrast with conditional declarations, which are not
  • • "Explain optional parameters and their constraints" — trailing only, constant-expression defaults; then show named arguments as the modern fix for ordering problems
  • • "What is the difference between void and never?" — void returns no value at all; never never returns normally (always throws/exits) and makes subsequent code provably unreachable
  • • "How do you make this function testable?" — replace globals and static calls with parameters (dependency injection); mention that declared types plus docblock array shapes let PHPStan verify the tests' expectations

Cheat Sheet

Defining Functions

Declaration:

function applyDiscount(float $price, int $percent): float {
    return $price * (1 - $percent / 100);
}
  • Unconditional top-level functions are hoisted — callable anywhere in the file; conditional declarations are not.
  • No outer locals are visible; pass dependencies as parameters instead of importing with global.
  • Too few args → ArgumentCountError; extra args are ignored unless collected via variadics.
  • array &$arr modifies the caller's variable (in-place APIs like sort); call-time f(&$x) was removed in 5.4.

Defaults and calling:

function paginate(int $page = 1, int $perPage = 20): array { … }
paginate();                        // defaults
paginate(perPage: 50);             // named arguments (8.0) — order-independent
paginate(...[3, 50]);              // unpack an array into parameters
$trim = trim(...);                 // first-class callable (8.1)
function sum(int ...$nums) {}      // variadic — collects into an array

Defaults must be constant expressions (literals, consts; PHP 8.1 adds new). A parameter name is public API once callers use named arguments.

Returns:

function a(): float   { … }   // enforced: wrong type → TypeError
function b(): ?array  { … }   // array or null — absence in the contract
function c(): void    { … }   // `return;` only; `return $x` is a TypeError
function d(): never   { … }   // always throws/exits — code after the call is unreachable

Use guard clauses for flat bodies; document array shapes for analyzers: @return array{id: int, name: string}.

Continue Learning

Before You Start

Related Reading