Functions #

A function is the smallest unit of code that can be named, tested, and reused. PHP supports functions in many forms — regular named functions, anonymous closures, one-line arrow functions, and since PHP 8.1 even first-class callable syntax that lets you reference built-in functions like ordinary values. What distinguishes well-written PHP functions from ones that merely work: explicit type declarations, meaningful parameters with sensible defaults, no hidden side effects, and enough documentation via PHPDoc. This article covers all aspects of PHP functions from the basics to modern features — including the traps that often make function code hard to understand and test.

Anatomy of a PHP Function #

A function consists of several parts, each with its own rules and configuration options:

flowchart LR
    A["function"] --> B["name()"]
    B --> C["(parameters)"]
    C --> D[": return_type"]
    D --> E["{ body }"]

    C --> C1["type $name"]
    C --> C2["type $name = default"]
    C --> C3["type ...$name variadic"]
    C --> C4["&$name reference"]

    D --> D1["int | string"]
    D --> D2["?string nullable"]
    D --> D3["void no return"]
    D --> D4["never always throws/exits"]

Defining and Calling Functions #

Functions are defined with the function keyword, a name, optional parameters, and a body. In PHP, functions can be called before they’re defined — the definition is hoisted to the top of the file scope during parsing.

<?php
// A simple function without parameters and return value
function greet(): void
{
    echo "Hello, World!\n";
}

greet(); // Hello, World!

// Functions may be called before their definition in the file
printVersion();

function printVersion(): void
{
    echo "PHP " . PHP_VERSION . "\n";
}
// This is valid — PHP parses the entire file before executing

Function Naming Rules #

<?php
// ✓ Valid function names — camelCase convention for modern PHP
function calculateTotal(): float { /* ... */ }
function findUserById(): ?array { /* ... */ }
function isValid(): bool { /* ... */ }
function formatRupiah(): string { /* ... */ }

// ✓ snake_case is also valid — common in legacy code
function calculate_total(): float { /* ... */ }

// Function names are NOT case-sensitive (but don't exploit this)
function TEST(): void { echo "test"; }
test(); // valid, but avoid — confusing
TEST(); // also valid

// ✗ Names describing the IMPLEMENTATION, not the PURPOSE
function loopArrayAndSum(): float { /* ... */ } // bad
function calculateSubtotal(): float { /* ... */ }  // better

Parameters and Arguments #

Parameters are variables declared in the function definition. Arguments are the actual values sent at the call site.

Type Declarations #

Type declarations make functions safer and self-documenting — PHP will throw a TypeError if the type doesn’t match (when strict_types=1 is active):

<?php
declare(strict_types=1);

// Without type declarations — accepts anything
function addLegacy($a, $b)
{
    return $a + $b;
}

echo addLegacy("3", "4");  // "7" — works because of implicit conversion
echo addLegacy([], true);  // error or unexpected result

// With type declarations — clear and safe
function add(int $a, int $b): int
{
    return $a + $b;
}

echo add(3, 4);      // 7 — OK
// add("3", "4");    // TypeError — string is not int (with strict_types)
// add(1.5, 2.5);    // TypeError — float is not int

// Union type — accepts several types
function formatId(int|string $id): string
{
    return is_int($id) ? "ID-{$id}" : strtoupper($id);
}

echo formatId(42);        // "ID-42"
echo formatId("abc-xyz"); // "ABC-XYZ"

Default Parameters #

Parameters with default values become optional at call time. The default value must be a constant expression — it can’t be a function call or a variable:

<?php
function makeSlug(
    string $text,
    string $separator = '-',
    bool   $lowercase = true,
    int    $maxLength = 100
): string {
    if ($lowercase) {
        $text = mb_strtolower($text);
    }

    // Replace spaces and non-alphanumeric characters with the separator
    $slug = preg_replace('/[^a-z0-9]+/i', $separator, $text);
    $slug = trim($slug, $separator);

    return mb_substr($slug, 0, $maxLength);
}

echo makeSlug("Hello PHP World!");           // "hello-php-world"
echo makeSlug("Hello World", "_");           // "hello_world"
echo makeSlug("Hello World", "-", false);    // "Hello-World"
echo makeSlug("A Long Title", "-", true, 5); // "a-long"

Parameters with default values must go at the end of the parameter list, after all required parameters. Placing optional parameters before required ones causes a parse error or unexpected behavior.

// ✗ Optional parameter before required — parse error in PHP 8+
function create(string $title = "Untitled", string $content): string { }

// ✓ Required parameters first
function create(string $content, string $title = "Untitled"): string { }

Variadic Parameters #

Variadic parameters (...) accept an unlimited number of arguments as an array:

<?php
// Simple variadic
function sum(int ...$numbers): int
{
    return array_sum($numbers);
}

echo sum(1, 2, 3);          // 6
echo sum(10, 20, 30, 40);   // 100
echo sum();                  // 0 — empty array, sum = 0

// Combining regular and variadic parameters
// The variadic parameter must always be in the last position
function log(string $level, string ...$messages): void
{
    $time = date('Y-m-d H:i:s');
    foreach ($messages as $m) {
        echo "[$time][$level] $m\n";
    }
}

log('INFO', 'Server started', 'Listening on port 8080');
log('ERROR', 'Connection failed');

// Spread operator — the opposite of variadic: array becomes separate arguments
$data = [3, 1, 4, 1, 5, 9, 2, 6];
echo sum(...$data); // same as sum(3,1,4,1,5,9,2,6)
echo max(...$data);       // 9

Pass-by-Reference #

By default PHP passes a copy of the value to functions. With &, the function receives a direct reference to the original variable:

<?php
// Swapping two variables — the classic case for references
function swap(mixed &$a, mixed &$b): void
{
    $temp = $a;
    $a    = $b;
    $b    = $temp;
}

$x = "first";
$y = "second";
swap($x, $y);
echo "$x $y"; // "second first"

// Many PHP built-in functions take arrays by reference
$fruits = ['mango', 'apple', 'orange'];
sort($fruits);       // modifies $fruits directly — by reference
shuffle($fruits);    // same — by reference
array_pop($fruits);  // same

// ANTI-PATTERN: return by reference for simple calculations
// Harder to read and debug than return by value
function &getConfig(): array
{
    static $config = ['debug' => false];
    return $config; // returns a reference to the static $config
}

// CORRECT: return by value — cleaner and easier to understand
function getConfigV2(): array
{
    return ['debug' => false, 'timeout' => 30];
}

Return Values #

Functions return a value with return. Without an explicit return, the function returns null.

Return Types and void #

<?php
declare(strict_types=1);

// Explicit return type — clear and enforced by PHP
function calculateArea(float $length, float $width): float
{
    return $length * $width;
}

// void — the function returns nothing
function printLine(int $length = 40): void
{
    echo str_repeat('-', $length) . "\n";
    // return; is allowed, but return $value; is not
}

// Nullable return type — can return a type or null
function findProduct(int $id): ?array
{
    $data = ['id' => 1, 'name' => 'Laptop'];
    return $data['id'] === $id ? $data : null;
}

$product = findProduct(1);
if ($product !== null) {
    echo $product['name']; // "Laptop"
}

// never — the function never returns normally
function fail(string $message): never
{
    throw new \RuntimeException($message);
}

function redirect(string $url): never
{
    header("Location: $url");
    exit;
}

Returning Multiple Values #

PHP doesn’t support native multiple return values, but there are several patterns in common use:

<?php
// Pattern 1: return an array — the most common
function minMax(array $data): array
{
    return ['min' => min($data), 'max' => max($data)];
}

['min' => $min, 'max' => $max] = minMax([3, 1, 4, 1, 5, 9]);
echo "Min: $min, Max: $max"; // Min: 1, Max: 9

// Pattern 2: return an object (more type-safe, IDE-friendly)
class StatsResult
{
    public function __construct(
        public readonly float $min,
        public readonly float $max,
        public readonly float $avg,
    ) {}
}

function calculateStats(array $data): StatsResult
{
    return new StatsResult(
        min: min($data),
        max: max($data),
        avg: array_sum($data) / count($data),
    );
}

$stats = calculateStats([10, 20, 30, 40, 50]);
echo "Average: {$stats->avg}"; // 30

// Pattern 3: readonly class / DTO (PHP 8.2+)
readonly class PaginationResult
{
    public function __construct(
        public array $data,
        public int   $total,
        public int   $page,
        public int   $perPage,
    ) {}

    public function totalPages(): int
    {
        return (int) ceil($this->total / $this->perPage);
    }
}

Closures — Anonymous Functions #

A closure is a function without a name. It can be assigned to a variable, passed as an argument, or returned from another function. This makes functions first-class values in PHP.

<?php
// Assign to a variable
$double = function(int $n): int {
    return $n * 2;
};

echo $double(5);  // 10

// As an argument — very common for callbacks
$numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
$evens   = array_filter($numbers, function(int $n): bool {
    return $n % 2 === 0;
});
$tripled = array_map(function(int $n): int {
    return $n * 3;
}, $numbers);

// Returned from a function — creating "configurable functions"
function makeMultiplier(int $factor): Closure
{
    return function(int $n) use ($factor): int {
        return $n * $factor;
    };
}

$multiply5  = makeMultiplier(5);
$multiply10 = makeMultiplier(10);

echo $multiply5(3);   // 15
echo $multiply10(3);  // 30

use — Capturing Variables from the Outer Scope #

A closure doesn’t automatically access variables from its surrounding scope — it must be explicit with use:

<?php
$limit    = 100;
$category = "electronics";

// Without use — $limit isn't available inside the closure
$filter = function(array $product): bool {
    return $product['price'] < $limit; // PHP Warning: Undefined variable $limit
};

// With use — capture variables explicitly
$filter = function(array $product) use ($limit, $category): bool {
    return $product['price'] < $limit
        && $product['category'] === $category;
};

// use by value (default) — a copy of the value when the closure is created
$x  = 10;
$fn = function() use ($x) { echo $x; };
$x  = 99;
$fn(); // 10 — the value of $x when the closure was created, not when called

// use by reference — follows value changes
$counter  = 0;
$increment = function() use (&$counter): void {
    $counter++;
};

$increment();
$increment();
$increment();
echo $counter; // 3 — the original counter changed

Arrow Functions — Concise Closures #

Arrow functions (fn) were introduced in PHP 7.4 as a one-expression closure shorthand. The main difference from regular closures: they automatically capture variables from the outer scope without needing use.

<?php
// Regular closure vs arrow function
$doubleClosure = function(int $n): int { return $n * 2; };
$doubleArrow   = fn(int $n): int => $n * 2;

echo $doubleArrow(5); // 10

// Arrow functions automatically capture outer variables
$vat     = 0.11;
$markup  = 0.2;

$finalPrice = fn(float $price): float => $price * (1 + $vat) * (1 + $markup);
echo $finalPrice(100000); // 133200.0

// Very useful as array callbacks
$products = [
    ['name' => 'Laptop',  'price' => 15000000],
    ['name' => 'Monitor', 'price' => 5000000],
    ['name' => 'Mouse',   'price' => 250000],
];

$productNames = array_map(fn($p) => $p['name'], $products);
$expensive    = array_filter($products, fn($p) => $p['price'] > 1000000);

usort($products, fn($a, $b) => $a['price'] <=> $b['price']);

// Nested arrow functions — capturing from an even outer scope
$discount = 0.1;
$processAll = fn($items) => array_map(
    fn($item) => [
        ...$item,
        'discounted_price' => $item['price'] * (1 - $discount), // $discount is captured
    ],
    $items
);

Closure vs Arrow Function — When to Use Which #

Use an Arrow Function when:
  ✓ The logic is a single expression
  ✓ You need outer-scope variables (without verbose use)
  ✓ As a callback for array_map, array_filter, usort, etc.

Use a regular Closure when:
  ✓ The logic spans more than one line / needs statements
  ✓ You need early returns with conditions
  ✓ You need explicit control over which variables are captured (by value vs reference)
  ✓ The closure is complex enough that named use variables help readability

First-Class Callables (PHP 8.1+) #

Before PHP 8.1, to pass a named function as a callable to array_map or another function, you had to wrap it in a closure or use a string function name. PHP 8.1 introduced the cleaner function_name(...) syntax:

<?php
// The old way — wrapping in an unnecessary closure
$lengths  = array_map(function(string $s): int { return strlen($s); }, $words);
$lengths2 = array_map('strlen', $words); // string — not type-safe, not IDE-friendly

// The new way — first-class callable syntax
$lengths3 = array_map(strlen(...), $words);         // built-in function
$lengths4 = array_map(mb_strlen(...), $words);      // also built-in

// Works for all callables: functions, static methods, instance methods
class Formatter
{
    public static function formatRupiah(int $value): string
    {
        return 'Rp ' . number_format($value, 0, ',', '.');
    }

    public function formatPercent(float $value): string
    {
        return number_format($value * 100, 1) . '%';
    }
}

$prices       = [15000000, 5000000, 250000];
$formatter    = new Formatter();

// Static method
$formatted    = array_map(Formatter::formatRupiah(...), $prices);

// Instance method
$percentages  = [0.1, 0.2, 0.15];
$formattedPct = array_map($formatter->formatPercent(...), $percentages);

// Store a reference to a function to call later
$fn = strlen(...);
echo $fn("Hello"); // 5

Recursive Functions #

Recursion is a technique where a function calls itself to break a problem into smaller sub-problems. Every recursive function must have a base case — a condition that stops the recursion.

<?php
// Factorial — the classic recursion example
function factorial(int $n): int
{
    if ($n < 0) {
        throw new \InvalidArgumentException("Factorial is not defined for negatives");
    }

    // Base case — stop the recursion
    if ($n <= 1) {
        return 1;
    }

    // Recursive case — call itself with a smaller input
    return $n * factorial($n - 1);
}

echo factorial(5);  // 120 — 5 × 4 × 3 × 2 × 1
echo factorial(0);  // 1   — 0! = 1 by definition

Recursion with Memoization #

Naive recursion for Fibonacci is very slow because it computes the same values over and over. Memoization stores already-computed results:

<?php
// Naive recursion — very slow for large n
function fibNaive(int $n): int
{
    if ($n <= 1) return $n;
    return fibNaive($n - 1) + fibNaive($n - 2);
    // fibNaive(40) requires ~330 million calls!
}

// With memoization using a static variable
function fibMemo(int $n): int
{
    static $cache = [];

    if ($n <= 1) return $n;
    if (isset($cache[$n])) return $cache[$n];

    $cache[$n] = fibMemo($n - 1) + fibMemo($n - 2);
    return $cache[$n];
}

echo fibMemo(50); // 12586269025 — instantly, not in minutes

// Recursion for directory tree traversal
function scanDirRecursive(string $dir, int $level = 0): void
{
    $indent = str_repeat('  ', $level);
    echo "{$indent}" . basename($dir) . "/\n";

    foreach (scandir($dir) as $entry) {
        if ($entry === '.' || $entry === '..') continue;

        $path = $dir . DIRECTORY_SEPARATOR . $entry;

        if (is_dir($path)) {
            scanDirRecursive($path, $level + 1); // recurse into subdirectories
        } else {
            echo "{$indent}  {$entry}\n";
        }
    }
}

Recursion Stack Limits #

PHP has a recursion depth limit determined by the stack size. Recursion that’s too deep causes a fatal error:

<?php
// ANTI-PATTERN: recursion for operations that could be iterative
function sumRecursive(array $arr, int $i = 0): int
{
    if ($i >= count($arr)) return 0;
    return $arr[$i] + sumRecursive($arr, $i + 1);
    // Fails for large arrays — stack overflow!
}

// CORRECT: use iteration for simple linear operations
function sumIterative(array $arr): int
{
    return array_sum($arr); // or a regular loop
}

// Recursion is most appropriate for: trees, graphs, divide-and-conquer
// (structures that are naturally recursive)

Pure Functions and Side Effects #

The easiest functions to understand, test, and debug are pure functions — functions whose result depends only on their input and that don’t modify any state outside themselves.

<?php
// Pure function — same input → same output, no side effects
function calculateVAT(float $price, float $rate = 0.11): float
{
    return $price * $rate;
}

// Always produces 1100 for input (10000, 0.11) — predictable
// Easy to unit test without any setup

// A function with side effects — depends on external state
class OrderService
{
    private array $log = [];

    // Side effects: writes to $this->log and the database
    public function createOrder(array $data): int
    {
        $orderId = $this->db->insert('orders', $data); // side effect: DB write
        $this->log[] = "Order $orderId created";        // side effect: mutates state
        $this->email->send($data['email'], $orderId);   // side effect: network call
        return $orderId;
    }
}

// A cleaner pattern: separate pure calculations from side effects
class OrderCalculator
{
    // Pure — testable without DB or email
    public function calculateTotal(array $items, float $discount): float
    {
        $subtotal = array_reduce(
            $items,
            fn($carry, $item) => $carry + ($item['price'] * $item['qty']),
            0.0
        );
        return $subtotal * (1 - $discount) * 1.11; // including VAT
    }
}

Named Arguments (PHP 8.0+) #

Named arguments allow you to call a function by explicitly naming its parameters, regardless of order:

<?php
function createUser(
    string $name,
    string $email,
    string $role     = 'viewer',
    bool   $active   = true,
    ?string $avatar  = null,
): array {
    return compact('name', 'email', 'role', 'active', 'avatar');
}

// The old way — must remember the order, all arguments written
$user = createUser('Budi', '[email protected]', 'viewer', true, null);

// Named arguments — only write what you need, order is free
$user = createUser(
    name:  'Budi',
    email: '[email protected]',
    role:  'admin',   // skips $active and $avatar, which have defaults
);

// Very useful for PHP built-in functions with many optional parameters
$result = array_slice(
    array:         [1, 2, 3, 4, 5],
    offset:        1,
    length:        3,
    preserve_keys: true,
);

// htmlspecialchars with named args — clearer than positional order
$safe = htmlspecialchars(
    string:   $input,
    flags:    ENT_QUOTES | ENT_HTML5,
    encoding: 'UTF-8',
);

Common Function Anti-Patterns #

<?php
// ✗ Anti-pattern 1: a function that does too much
function processUserData(array $data): void
{
    // validate input
    // save to database
    // send email
    // update cache
    // write logs
    // send Slack notification
    // ... 150 lines of code
}
// Hard to test, hard to debug, hard to change without breaking something else

// ✓ One function, one responsibility
function validateUserData(array $data): array { /* ... */ }
function saveUser(array $data): int            { /* ... */ }
function sendWelcomeEmail(int $id): void       { /* ... */ }

// ✗ Anti-pattern 2: boolean parameters that change function behavior
function getUser(int $id, bool $withRelations): array
{
    if ($withRelations) {
        // query with JOINs
    } else {
        // simple query
    }
}
// getUser(1, true) — what does true mean? Not clear from the caller's side

// ✓ Two clearer separate functions
function getUser(int $id): array                { /* ... */ }
function getUserWithRelations(int $id): array   { /* ... */ }

// ✗ Anti-pattern 3: output via echo inside business functions
function calculateDiscount(float $price, float $percent): float
{
    echo "Calculating discount..."; // hidden side effect!
    return $price * $percent;
}
// Can't be used in contexts that don't want output to the screen

// ✓ The function only returns a value — the caller decides what to do
function calculateDiscountV2(float $price, float $percent): float
{
    return $price * $percent;
}

// ✗ Anti-pattern 4: using global variables as "hidden parameters"
$dbConnection = new PDO(/* ... */);

function fetchProducts(int $id): array
{
    global $dbConnection; // hidden dependency
    // ...
}

// ✓ Dependency injection — pass via parameters
function fetchProductsV2(PDO $db, int $id): array
{
    // easy to test with a mock DB
}

Summary #

  • Type declarations on parameters and return types make functions self-documenting and let PHP and IDEs catch bugs earlier. Enable declare(strict_types=1) to enforce types strictly.
  • Default parameters must be constant expressions and placed after all required parameters. Irrelevant optional parameters at call time can be skipped with named arguments.
  • Named arguments (PHP 8.0+) let you name parameters when calling a function — very useful for functions with many optional parameters.
  • Closures capture variables from the outer scope with use — by value by default, or by reference with use (&$var). Arrow functions (fn) capture outer variables automatically and are best for one-line expressions.
  • First-class callables (function(...)) are the modern way to reference a named function as a value — cleaner and more type-safe than string function names.
  • Recursion is best for naturally recursive structures (trees, graphs, divide-and-conquer). Use memoization to avoid recomputation, and prefer iteration for simple linear operations.
  • Pure functions — whose results depend only on input and don’t modify external state — are easier to understand, test, and debug. Separate pure calculations from side effects (I/O, DB, email).
  • One function, one responsibility — if a function needs the word “and” to describe its job, it probably needs to be split.

← Previous: Loops   Next: Classes →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact