5 PHP Features You're Probably Not Using (But Should)
These aren't obscure tricks. They're tools that ship with modern PHP — and once you start using them, you'll wonder how you lived without them.
1. Generators — Process 1 Million Rows with Almost Zero Memory
Here's a scenario every backend developer has faced: you need to read a large CSV file, process it row by row, and do something with the data. The instinct is to load everything into an array first.
That instinct will kill your server.
The traditional approach:
function readCsv(string $path): array
{
$rows = [];
$handle = fopen($path, 'r');
while ($row = fgetcsv($handle)) {
$rows[] = $row; // everything lands in RAM
}
fclose($handle);
return $rows;
}
// 1 million rows = ~460MB RAM 💀
foreach (readCsv('big.csv') as $row) {
process($row);
}
With a Generator:
function readCsv(string $path): Generator
{
$handle = fopen($path, 'r');
while ($row = fgetcsv($handle)) {
yield $row; // one row at a time, nothing more
}
fclose($handle);
}
// 1 million rows = ~0.3MB RAM ✨
foreach (readCsv('big.csv') as $row) {
process($row);
}
The only difference is yield instead of $rows[] =. But the impact is dramatic.
Real benchmark — 1 million rows, 42MB file:
| Traditional | Generator | |
|---|---|---|
| Time | 6.5s | 2.67s |
| Memory | 462 MB | ~0.3 MB |
| Result | identical | identical |
The Generator is 2.4× faster and uses virtually no memory. PHP doesn't have to build a massive array in memory before you can start processing — it gives you one row, you process it, it gives you the next.
yield pauses the function, hands a value to the caller, then resumes exactly where it left off when the caller asks for the next value. The file handle stays open between calls — no jumping back to the start.
Beyond CSV: Generators shine anywhere you deal with large sequences — reading database results in chunks, paginating API responses, generating infinite sequences, or streaming log files in real time.
2. Named Arguments — Never Misorder Parameters Again
Quick quiz. What does this code do?
$result = array_slice($items, 1, true, 3);
You probably need to open the docs. Is true the preserve_keys flag? Is 3 the length? What's what?
This is a bug waiting to happen. And it has happened to all of us.
Named Arguments fix this entirely:
// ❌ Before — which true is which?
$result = array_slice($items, 1, 3, true);
// ✅ After — reads like documentation
$result = array_slice(
array: $items,
offset: 1,
length: 3,
preserve_keys: true
);
But the real power isn't just readability. It's skipping optional parameters in the middle.
PHP has many built-in functions with optional parameters buried in the middle of their signatures. With positional arguments, you have to spell out every parameter until you reach the one you want:
// ❌ Before — had to repeat ENT_QUOTES and 'UTF-8' just to reach double_encode
$safe = htmlspecialchars($input, ENT_QUOTES, 'UTF-8', false);
// ✅ After — jump straight to what you care about
$safe = htmlspecialchars(string: $input, double_encode: false);
Named Arguments + Spread = clean dependency injection:
$config = [
'table' => 'orders',
'limit' => 20,
'cache' => true,
'orderBy' => 'total',
];
// spread a named array directly into a constructor
$query = new QueryBuilder(...$config);
And before you ask — no, there's no performance cost. In our benchmark of 1 million calls, named arguments were actually marginally faster than positional ones (PHP 8's internal optimizer handles them efficiently).
3. Fibers — The Foundation Under ReactPHP and Swoole
Fibers are the most misunderstood feature on this list. They're not magic async/await. They won't automatically make your code run in parallel. But they are the primitive that makes modern PHP async frameworks possible.
A Fiber is a function that can pause itself and hand control back to the caller — then be resumed later from exactly where it left off.
$fiber = new Fiber(function(): string {
echo "[Fiber] starting" . PHP_EOL;
$received = Fiber::suspend('first pause'); // pause + send value out
echo "[Fiber] resumed, got: $received" . PHP_EOL;
$received = Fiber::suspend('second pause');
echo "[Fiber] resumed again, got: $received" . PHP_EOL;
return 'done';
});
echo "[Main] starting fiber" . PHP_EOL;
$yielded = $fiber->start(); // runs until first suspend
echo "[Main] fiber paused, sent: $yielded" . PHP_EOL;
$yielded = $fiber->resume('message one'); // resume, get next suspend value
echo "[Main] fiber paused again, sent: $yielded" . PHP_EOL;
$fiber->resume('message two'); // resume to completion
echo "[Main] fiber returned: " . $fiber->getReturn() . PHP_EOL;
Output:
[Main] starting fiber
[Fiber] starting
[Main] fiber paused, sent: first pause
[Fiber] resumed, got: message one
[Main] fiber paused again, sent: second pause
[Fiber] resumed again, got: message two
[Main] fiber returned: done
Notice the two-way communication. This is what separates Fibers from Generators — data flows both in and out. suspend() sends a value to the caller; resume($value) sends a value back into the Fiber.
The key insight: Fibers don't give you parallelism on their own. They give you cooperative multitasking — each Fiber voluntarily yields control at a known point (an I/O wait, a network call, a database query). A scheduler can then run another Fiber while the first one waits.
That scheduler is what ReactPHP, Swoole, and the Revolt event loop provide. PHP 8.1 gave us the primitive; the frameworks built async on top of it.
If you're writing application code, you probably won't create Fibers directly. But understanding them tells you why amphp and ReactPHP work the way they do — and why PHP async is fundamentally different from JavaScript's single-threaded event loop.
4. First-Class Callable Syntax — Functions as Values, Finally Clean
PHP has always let you pass functions as values. But the syntax was painful enough that most people just wrote inline closures instead.
// before PHP 8.1 — verbose wrappers everywhere
$fn = Closure::fromCallable('strlen');
$lengths = array_map(Closure::fromCallable('strtoupper'), $words);
// or inline closures that wrap a single function call
$lengths = array_map(function($w) { return strlen($w); }, $words);
PHP 8.1 introduced a clean syntax: functionName(...) creates a first-class Closure from any callable — built-in functions, static methods, instance methods, all of them.
// ✅ PHP 8.1+
$fn = strlen(...);
$upper = strtoupper(...);
$method = $obj->format(...);
$static = Formatter::clean(...);
$lengths = array_map(strlen(...), $words);
This is where it really shines — pipeline patterns:
function pipeline(mixed $value, callable ...$fns): mixed
{
return array_reduce($fns, fn($carry, $fn) => $fn($carry), $value);
}
$result = pipeline(
" Hello, World! This is PHP. ",
trim(...),
strtolower(...),
fn($s) => str_replace([',', '!', '.'], '', $s),
fn($s) => explode(' ', $s),
array_filter(...),
array_values(...),
);
// ['hello', 'world', 'this', 'is', 'php']
Or validation chains where each rule is just a callable:
function validate(mixed $value, callable ...$rules): array
{
return array_filter(array_map(fn($rule) => $rule($value), $rules));
}
$errors = validate(
$password,
fn($v) => strlen($v) < 8 ? 'Minimum 8 characters' : null,
fn($v) => !preg_match('/\d/', $v) ? 'Must contain a number' : null,
fn($v) => !preg_match('/[A-Z]/', $v) ? 'Must contain uppercase' : null,
);
Clean, composable, testable — each rule is independently unit-testable, and adding a new one is a one-liner.
5. The Underrated Built-ins You Keep Reimplementing
PHP's standard library has some genuinely useful functions that don't get enough attention. These aren't new features — they've been around for a while — but they solve problems that developers keep solving manually.
array_is_list() — PHP 8.1+
// before: manual check
function isList(array $arr): bool {
return $arr === [] || array_keys($arr) === range(0, count($arr) - 1);
}
// now
array_is_list([1, 2, 3]); // true
array_is_list(['a' => 1, 'b' => 2]); // false
array_is_list([0 => 'a', 1 => 'b']); // true
Essential when you're deciding whether to JSON-encode as [] or {}.
str_contains(), str_starts_with(), str_ends_with() — PHP 8.0+
// before
if (strpos($haystack, $needle) !== false) { ... }
// now
if (str_contains($haystack, $needle)) { ... }
if (str_starts_with($url, 'https://')) { ... }
if (str_ends_with($file, '.php')) { ... }
No more !== false comparisons. No more accidentally treating 0 as falsy.
array_any() and array_all() — PHP 8.5+
// before: manual loops or array_filter tricks
$hasAdmin = count(array_filter($users, fn($u) => $u->role === 'admin')) > 0;
// PHP 8.5
$hasAdmin = array_any($users, fn($u) => $u->role === 'admin');
$allActive = array_all($users, fn($u) => $u->isActive());
Putting It Together
None of these features require a new framework or a major refactor. They're available in PHP 8.0–8.1+, which most production environments already run.
The pattern across all five is the same: less boilerplate, clearer intent, better resource usage. Generators keep memory under control. Named Arguments make signatures self-documenting. Fibers power the async ecosystem. First-class callables make functional composition clean. And the built-ins remove manual reimplementations.
Start with whichever one solves a problem you're already facing. Once it clicks, the rest follow naturally.
All benchmarks were run on PHP 8.3 CLI on Ubuntu. Source code for every example is available on GitHub.`