What a Function Buys You
A function gives a name to a piece of work so you can do it from several places without repeating yourself. That is the obvious benefit. The less obvious ones matter more as a project grows.
Consider a small college site with a contact form, a feedback form and a registration form. Each needs to check that an email address is valid, trim it, and lowercase it. Written inline, that logic exists in three places — and when you discover that you also need to reject addresses over 254 characters, you have to remember all three. Written as normaliseEmail(), you change one function and every form is fixed at once.
A function also creates a boundary you can reason about. Because PHP functions cannot see outside variables, you can read a function on its own and know exactly what it depends on. That makes it testable: you can call normaliseEmail(' X@Example.COM ') from a test and check the answer without setting up a web request at all.
Two PHP-specific details to note early. Function names are case-insensitive — greetUser() and GREETUSER() call the same function — which is the opposite of variable names, and a genuine oddity of the language. And a function can only be declared once; a second declaration with the same name is a fatal error, which is the usual reason including the same file twice breaks a script. That is exactly what require_once exists to prevent.
<?php
// Repetition: three places to fix when the rule changes
$email1 = strtolower(trim($_POST['email'] ?? ''));
// ... and again in feedback.php, and again in register.php
// One name, one definition
function normaliseEmail(string $raw): string {
return strtolower(trim($raw));
}
function isValidEmail(string $email): bool {
return $email !== ''
&& mb_strlen($email) <= 254
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
$email = normaliseEmail($_POST['email'] ?? '');
if (!isValidEmail($email)) {
$errors[] = 'Please enter a valid email address.';
}
// A function reads as a sentence when it is named well
function formatRupees(int $paise): string {
return 'Rs ' . number_format($paise / 100, 2);
}
echo formatRupees(499950); // Rs 4,999.50 - A good rule of thumb: if you find yourself copying three or more lines for the second time, or if you need a comment to explain what a block does, that block wants to be a function. The comment then becomes the function's name.
Parameters, Defaults and Named Arguments
Parameters are the inputs a function declares. Give one a default value and callers may omit it, which lets a single function serve both the common case and the customised one.
There is an ordering rule: parameters with defaults must come after all required ones. Putting a required parameter after an optional one has been deprecated since PHP 8.0, because it makes the default unusable — you cannot skip an argument in the middle when passing them positionally.
That last limitation is exactly what named arguments, introduced in PHP 8, remove. Instead of passing values in order, you pass them by parameter name, which lets you skip any defaults you are happy with and set only the one you care about. It also makes call sites self-documenting: sendMail($to, $subject, $body, true, false, true) tells a reader nothing, while sendMail(to: $to, subject: $s, body: $b, isHtml: true) tells them everything.
One consequence worth planning for: once callers use named arguments, your parameter names become part of your function's public contract. Renaming a parameter can break calling code just as surely as removing it. That is a reason to choose parameter names thoughtfully rather than a reason to avoid the feature.
A related design point: when a function starts needing five or six parameters, especially several booleans, it is usually telling you something. Accepting a single associative array of options, or a small object, is easier to read and much easier to extend.
<?php
function paginate(array $items, int $perPage = 20, int $page = 1, string $order = 'asc'): array {
$offset = ($page - 1) * $perPage;
$slice = array_slice($items, $offset, $perPage);
return $order === 'desc' ? array_reverse($slice) : $slice;
}
$all = range(1, 100);
paginate($all); // all defaults
paginate($all, 10); // 10 per page
paginate($all, 10, 3); // page 3
// Named arguments: skip the middle, set only what matters
paginate($all, order: 'desc');
paginate(items: $all, page: 5);
// Self-documenting call sites
function sendMail(string $to, string $subject, string $body,
bool $isHtml = false, bool $urgent = false): bool {
// ...
return true;
}
sendMail($to, $subject, $body, false, true); // what are those booleans?
sendMail($to, $subject, $body, urgent: true); // obvious
// Defaults must be a fixed value, not a computed one
function log(string $msg, string $level = 'info'): void { /* ... */ }
// function log($msg, $level = strtoupper('info')) {} // not allowed - Positional and named arguments can be mixed in one call, but every positional argument must come first.
paginate($all, page: 5)is fine;paginate(items: $all, 5)is a syntax error.
Type Declarations and strict_types
You can declare what type each parameter accepts and what type the function returns. This is optional in PHP, and skipping it is one of the biggest missed opportunities for beginners, because types turn a whole class of silent bugs into immediate, obvious errors.
Compare the two failure modes. Without types, passing a string where a number was expected might produce 0, and that 0 flows through your calculation and lands in the database as a wrong total that nobody notices for a month. With int $marks declared, PHP throws a TypeError at the moment of the bad call, pointing at the exact line.
PHP 8 gives you a good range: simple types like int and string, nullable types written ?string, union types written int|string, array, class names, void for functions that return nothing, and mixed for genuinely anything. Return types go after the closing bracket, following a colon.
By default PHP is in coercive mode: a function declaring int $qty will accept the string "5" and convert it, which is convenient given that everything from a form is a string. It still rejects "abc". Adding declare(strict_types=1); as the very first statement of a file switches that file to strict mode, where "5" is rejected too and only a real integer is accepted.
Strict mode is worth adopting, with one detail that confuses people: the declaration affects calls made from that file, not the functions declared in it. So you put declare(strict_types=1); at the top of every file, and it must be the first statement — before any HTML output, before any require, before anything except a comment.
<?php
declare(strict_types=1); // must be the very first statement
function average(array $marks): float {
if ($marks === []) {
return 0.0;
}
return array_sum($marks) / count($marks);
}
// Nullable: an array, or null when nothing was found
function findStudent(int $id): ?array {
// return $row ?: null;
return null;
}
// Union types (PHP 8)
function formatId(int|string $id): string {
return 'ID-' . $id;
}
// void: returns nothing at all
function logLine(string $message): void {
error_log($message);
// "return;" is allowed; "return $x;" is not
}
// The error arrives immediately instead of silently
function addMarks(int $a, int $b): int {
return $a + $b;
}
// addMarks("90", 5); // strict mode: TypeError right here
// Without strict_types this would quietly become 95
// Form input is always a string, so convert deliberately
$qty = filter_var($_POST['qty'] ?? '', FILTER_VALIDATE_INT);
if ($qty === false) {
$errors[] = 'Quantity must be a whole number.';
} else {
$total = addMarks($qty, 0);
} declare(strict_types=1);before any HTML matters for a second reason: any output before it — even a blank line above<?php— makes it not the first statement and produces a fatal error. This is another argument for keeping logic files free of stray whitespace and closing?>tags.
Return a Value, Do Not Print One
A function that echos its result can only ever be used one way: to print, right now, wherever it is called. A function that returns its result can be printed, stored in a variable, put in an array, sent as JSON, written to a file, or tested. Returning is almost always the better choice, and the habit is easy to form early and painful to change later.
This matters most in the situation every beginner meets: you write showTotal() that echoes, and then you need the same total in an email. Now you either duplicate the logic or fight with output buffering. Had it returned a number, both uses would be one line each.
return also ends the function immediately. That is what makes guard clauses work — check the failure conditions at the top and return early, so the main path is not buried inside nested braces. A function with four early returns is usually clearer than one with a single return at the bottom of four nested if blocks.
PHP returns exactly one value, but that value can be an array, so returning several results at once is easy. Returning an associative array with named keys is far more readable than a positional list, especially at the call site where destructuring can pull out only what the caller needs. For anything that outgrows a couple of fields, a small class is better still.
<?php
// Locked into one use
function showTotalBad(array $items): void {
echo 'Rs ' . number_format(array_sum($items), 2);
}
// Usable anywhere
function calculateTotal(array $items): float {
return array_sum($items);
}
$total = calculateTotal([1200, 899.50, 450]);
echo 'Rs ' . number_format($total, 2); // on the page
// mail($to, 'Your order', "Total: $total"); // in an email
// json_encode(['total' => $total]); // as an API response
// Early returns keep the main path flat
function validateComment(string $body): array {
$body = trim($body);
if ($body === '') {
return ['ok' => false, 'error' => 'Comment cannot be empty.'];
}
if (mb_strlen($body) > 500) {
return ['ok' => false, 'error' => 'Comment is too long.'];
}
return ['ok' => true, 'body' => $body];
}
$result = validateComment($_POST['comment'] ?? '');
if (!$result['ok']) {
echo htmlspecialchars($result['error']);
}
// Several values via an array, unpacked at the call site
function splitName(string $full): array {
$parts = preg_split('/\s+/', trim($full), 2);
return ['first' => $parts[0] ?? '', 'last' => $parts[1] ?? ''];
}
['first' => $first, 'last' => $last] = splitName('Ananya Sharma'); - A function with no explicit
returnreturnsnull. That is fine for a function declared: void, and a bug waiting to happen anywhere else — a code path that forgets to return sendsnulldownstream, where it usually fails somewhere far from the real cause.
By Value, By Reference, and How Objects Differ
By default PHP passes arguments by value: the function receives a copy, and changes to a parameter do not affect the caller's variable. This is the safe default, and it is why you can call a function without worrying that it will quietly rearrange your data.
Adding & before a parameter switches it to by reference, so the function operates on the caller's actual variable. PHP's own sort() works this way, which is why you call sort($arr) rather than assigning its result. In your own code, reach for it rarely: a function that silently modifies its arguments is hard to reason about, and returning a new value is usually clearer.
Objects behave differently, and this trips people up because it looks like an exception to the rule. When you pass an object, PHP copies the handle that points to it, not the object itself. So modifying a property inside the function does affect the caller's object — there is only one object. But reassigning the parameter to a completely different object does not, because you only replaced your local copy of the handle.
The practical takeaway: passing an array to a function is safe, and passing an object is not. If a function accepts an object and you do not want it changed, either say so in the name, or return a modified copy using clone. Modern PHP also offers readonly properties, added in 8.1, which prevent modification after construction.
<?php
// By value: the caller is untouched
function addOne(int $n): int {
$n = $n + 1;
return $n;
}
$x = 5;
addOne($x);
echo $x; // still 5
// Arrays are copied too
function clearList(array $list): array {
$list = [];
return $list;
}
$items = ['a', 'b'];
clearList($items);
print_r($items); // still ['a', 'b']
// By reference: modifies the caller's variable
function appendTag(array &$tags, string $tag): void {
$tags[] = $tag;
}
$tags = ['php'];
appendTag($tags, 'mysql');
print_r($tags); // ['php', 'mysql']
// Objects: the handle is copied, the object is shared
class Cart {
public array $items = [];
}
function addItem(Cart $cart, string $item): void {
$cart->items[] = $item; // affects the caller's object
}
function replaceCart(Cart $cart): void {
$cart = new Cart(); // only replaces the local handle
}
$cart = new Cart();
addItem($cart, 'Notebook');
replaceCart($cart);
print_r($cart->items); // ['Notebook'] - the new Cart was discarded clonemakes a copy of an object, but it is a shallow copy: any objects held inside properties are still shared between the original and the copy. For a nested structure you need a__clone()method that clones those inner objects too.
Anonymous Functions, Closures and Arrow Functions
Sometimes a function is needed only once, as an argument to something else — a sort comparison, a filter test, a callback. Declaring a named function for that clutters your file. An anonymous function lets you write it inline and assign it to a variable or pass it directly.
Anonymous functions follow PHP's usual scope rule: they cannot see the surrounding variables. To let one in, list it in a use clause. The value is captured at the moment the function is defined, not when it is called, so changing the outer variable afterwards has no effect on what the closure sees. Writing use (&$x) captures by reference instead, which does keep them linked — useful for accumulating a total, and easy to misuse.
Arrow functions, written fn($x) => expression, are the compact form. They capture outer variables automatically by value, with no use clause needed, but they are limited to a single expression and cannot modify the captured values. For the one-line callbacks that array_map() and usort() want, they are almost always the right choice.
PHP accepts several things as a "callable": a closure, a string holding a function name such as 'strtoupper', or an array of object and method name. PHP 8.1 added a cleaner form for turning an existing function into a closure — strlen(...), with a literal three dots — which is clearer than the string version and can be checked by your editor.
Finally, variadic parameters. Writing ...$numbers collects any number of remaining arguments into an array, and it must be the last parameter. The same three dots at a call site do the reverse, spreading an array out into separate arguments.
<?php
// Anonymous function assigned to a variable
$greet = function (string $name): string {
return "Hi, $name!";
};
echo $greet('Ananya');
// use: capture by value, at definition time
$gst = 18;
$withTax = function (float $amount) use ($gst): float {
return $amount + ($amount * $gst / 100);
};
$gst = 5; // changed AFTER the closure was created
echo $withTax(1000); // 1180 - still using 18
// use by reference: stays linked
$total = 0;
$add = function (float $n) use (&$total): void {
$total += $n;
};
$add(100); $add(250);
echo $total; // 350
// Arrow functions: automatic capture, single expression
$rate = 1.18;
$prices = [1000, 2500, 400];
$withGst = array_map(fn($p) => round($p * $rate, 2), $prices);
usort($prices, fn($a, $b) => $a <=> $b);
// Other callable forms
$upper = array_map('strtoupper', ['a', 'b']);
$lens = array_map(strlen(...), ['abc', 'de']); // PHP 8.1
// Variadic: any number of arguments
function totalMarks(int ...$marks): int {
return array_sum($marks);
}
echo totalMarks(91, 78, 88); // 257
$fromDb = [91, 78, 88];
echo totalMarks(...$fromDb); // spread an array back out - The by-value capture of arrow functions is a safety feature, not a limitation. Because
fn()cannot modify anything outside itself, a reader can be certain it has no side effects — which is exactly the property you want in a sort comparison or a filter test.
