Part 1 covered the everyday syntax shifts that came with PHP 8.0 through 8.2, and part 2 focused on the type system improvements and the property hooks that arrived in 8.4. This final installment turns to the standard library — the new functions, classes, and attributes that have quietly accumulated and that you'll reach for in real code far more often than you might expect.
#1. New Array Search Functions
PHP 8.4 added four functions that fill a long-standing gap: searching an array by predicate. Before, you had to combine array_filter with array_keys or current, which always felt clumsy for what is conceptually a simple operation.
1$users = [
2 ['id' => 1, 'name' => 'Ada', 'active' => true],
3 ['id' => 2, 'name' => 'Grace', 'active' => false],
4 ['id' => 3, 'name' => 'Linus', 'active' => true],
5];
6
7// Find the first active user
8$active = array_find($users, fn ($u) => $u['active']);
9// ['id' => 1, 'name' => 'Ada', 'active' => true]
10
11// Find the key of the first inactive user
12$key = array_find_key($users, fn ($u) => !$u['active']);
13// 1
14
15// Is there any inactive user?
16$hasInactive = array_any($users, fn ($u) => !$u['active']);
17// true
18
19// Are all users named?
20$allNamed = array_all($users, fn ($u) => $u['name'] !== '');
21// true
array_find and array_find_key return null when nothing matches, which composes nicely with the null-handling operators from part 1. The predicate receives the value first and the key second, matching the signature of array_filter with ARRAY_FILTER_USE_BOTH.
Compared to the old idiom of array_filter followed by reset or array_key_first, these functions are not only shorter but also short-circuit on the first match, which matters when the array is large.
#2. array_is_list
PHP 8.1 added a tiny but useful helper for distinguishing list-shaped arrays (sequential integer keys starting at zero) from associative ones:
1array_is_list([]); // true
2array_is_list(['a', 'b', 'c']); // true
3array_is_list([0 => 'a', 1 => 'b']); // true
4
5array_is_list([1 => 'a']); // false — doesn't start at 0
6array_is_list(['key' => 'value']); // false — string key
7array_is_list([0 => 'a', 2 => 'b']); // false — gap in keys
This matters most when serializing to JSON or interfacing with strongly-typed systems where the distinction between a list and an object is meaningful. It's also the cleanest way to validate input at API boundaries.
#3. The Random Extension
PHP 8.2 introduced a proper object-oriented random number API in the Random namespace, replacing the grab-bag of global functions (rand, mt_rand, random_int, shuffle, array_rand) that had grown over the years.
The centerpiece is Random\Randomizer, which wraps an engine — a pluggable source of randomness:
1use Random\Randomizer;
2use Random\Engine\Mt19937;
3use Random\Engine\Secure;
4
5// Cryptographically secure — replaces random_int / random_bytes
6$secure = new Randomizer(new Secure());
7$token = bin2hex($secure->getBytes(16));
8$pin = $secure->getInt(1000, 9999);
9
10// Seedable for reproducibility — useful in tests and simulations
11$seeded = new Randomizer(new Mt19937(seed: 42));
12$rolls = [];
13for ($i = 0; $i < 5; $i++) {
14 $rolls[] = $seeded->getInt(1, 6);
15}
16// Same seed always produces the same sequence
The Randomizer class also provides higher-level helpers that previously required hand-rolled code:
1$r = new Randomizer();
2
3$shuffled = $r->shuffleArray(['a', 'b', 'c', 'd']);
4$bytes = $r->shuffleBytes('abcdef');
5$sample = $r->pickArrayKeys($users, 2); // pick 2 random keys
6$word = $r->getBytesFromString('ACGT', 10); // random DNA-like string
The big advantage over the old globals is testability. You can inject a seeded Randomizer into a class and get deterministic behavior in tests, then swap in Secure in production — no monkey-patching globals, no resetting hidden state between tests.
#4. json_validate
PHP 8.3 added json_validate, which checks whether a string is valid JSON without actually decoding it. The old idiom — json_decode followed by a json_last_error check — allocates the full PHP value graph just to throw it away if you only wanted to know whether the input parsed.
1$input = file_get_contents('php://input');
2
3// Old way — decodes the whole thing
4$data = json_decode($input);
5if (json_last_error() !== JSON_ERROR_NONE) {
6 abort('Invalid JSON');
7}
8
9// New way — validates without allocating
10if (!json_validate($input)) {
11 abort('Invalid JSON');
12}
13$data = json_decode($input);
For large payloads the memory savings are real. The function also accepts the same depth and flags arguments as json_decode, so you can validate that nesting stays within a limit:
1if (!json_validate($input, depth: 10)) {
2 abort('JSON too deeply nested');
3}
A common pattern: use json_validate at the edge to fail fast on bad input, then decode normally once you know the string parses.
#5. Date and Time Improvements
The DateTime API has been slowly modernizing across recent releases. PHP 8.4 added two changes that close real gaps.
DateTimeImmutable::createFromTimestamp finally provides a direct constructor for Unix timestamps, including fractional ones:
1// Before — workable but ceremonial
2$dt = (new DateTimeImmutable())->setTimestamp(1_700_000_000);
3
4// After
5$dt = DateTimeImmutable::createFromTimestamp(1_700_000_000);
6
7// Fractional timestamps are supported
8$precise = DateTimeImmutable::createFromTimestamp(1_700_000_000.123456);
Microsecond accessors round out the API, which previously forced you to format-and-parse to get at sub-second precision:
1$now = new DateTimeImmutable();
2
3$micros = $now->getMicrosecond(); // 0–999999
4$next = $now->setMicrosecond(500_000); // returns a new immutable instance
These additions are small individually but they remove the awkward edge cases that used to push people toward Carbon or other wrappers for what should have been built-in.
#6. Multibyte String Helpers
PHP 8.3 and 8.4 closed several gaps in the mb_* family, where common ASCII-safe operations had no Unicode-aware counterpart:
1// 8.3 — multibyte padding
2mb_str_pad('café', 10, '·', STR_PAD_RIGHT); // "café······"
3
4// 8.4 — case conversion on the first character
5mb_ucfirst('über'); // "Über"
6mb_lcfirst('ÜBER'); // "üBER"
7
8// 8.4 — Unicode-aware trimming
9mb_trim(" café "); // "café"
10mb_ltrim('···heading', '·'); // "heading"
11mb_rtrim('heading···', '·'); // "heading"
The headline case for mb_trim is whitespace that the ASCII trim doesn't recognize — things like non-breaking spaces, ideographic spaces, and zero-width characters that show up routinely in copy-pasted user input. Reaching for mb_trim by default is the safe choice for anything that could contain non-ASCII text.
#7. The #[\Override] Attribute
PHP 8.3 added the #[\Override] attribute, borrowed in spirit from Java's @Override and C#'s override keyword. Marking a method with it tells the engine to verify that the method actually overrides something from a parent class or interface.
1abstract class Repository
2{
3 abstract public function find(int $id): ?object;
4}
5
6class UserRepository extends Repository
7{
8 #[\Override]
9 public function find(int $id): ?User { /* ... */ }
10
11 #[\Override]
12 public function fnid(int $id): ?User { /* ... */ }
13 // Fatal error: UserRepository::fnid() has #[\Override] attribute,
14 // but no matching parent method exists
15}
The attribute catches two common bugs that are otherwise silent: typos in the method name, and parent methods that get renamed or removed without the child being updated. Without it, the child class just becomes a normal method that nobody ever calls — a particularly nasty kind of dead code.
Sprinkling #[\Override] on every overriding method is cheap insurance, and most modern linters will suggest it for you automatically.
#8. The #[\Deprecated] Attribute
PHP 8.4 added a first-class way to mark methods, functions, constants, and enum cases as deprecated:
1class Cache
2{
3 #[\Deprecated(message: "Use Cache::remember() instead", since: "2.0")]
4 public function fetchOrStore(string $key, callable $factory): mixed
5 {
6 return $this->remember($key, $factory);
7 }
8
9 public function remember(string $key, callable $factory): mixed { /* ... */ }
10}
11
12$cache->fetchOrStore('user:1', fn () => loadUser(1));
13// Deprecated: Method Cache::fetchOrStore() is deprecated,
14// Use Cache::remember() instead since 2.0
Until now, library authors used PHPDoc @deprecated tags that were invisible at runtime, or hand-rolled trigger_error calls inside each deprecated method. The attribute makes the intent declarative and lets IDEs, static analyzers, and the engine itself surface the warning in a uniform way.
#9. Lazy Objects
PHP 8.4 introduced lazy objects through the new ReflectionClass::newLazyGhost and newLazyProxy methods. The idea is to create an object whose initialization is deferred until something actually accesses it — useful for ORMs, dependency injection containers, and any case where construction is expensive but might not be needed.
1class HeavyReport
2{
3 public array $rows;
4
5 public function __construct(int $reportId)
6 {
7 // Imagine a slow database query here
8 $this->rows = Database::loadReport($reportId);
9 }
10}
11
12$reflector = new ReflectionClass(HeavyReport::class);
13
14$report = $reflector->newLazyGhost(function (HeavyReport $report) {
15 $report->__construct(42); // runs only when needed
16});
17
18// Nothing has happened yet — no database query.
19// The first property access triggers initialization:
20$rowCount = count($report->rows);
For most application code you'll never call these methods directly — they're tools for framework authors. But knowing they exist explains why frameworks like Doctrine and Symfony have been quietly removing their custom proxy class generators in recent releases: the engine now handles it natively, which means smaller dependencies and fewer surprises in stack traces.
#Wrapping Up the Series
Looking back across all three parts, modern PHP feels like a different language than it did at the start of the 8.x cycle. Constructor promotion and readonly turned value objects into one-liners. Enums and match replaced sprawling constant tables and switch statements. The type system gained intersection types, DNF, never, and standalone true/false. Property hooks and asymmetric visibility made accessor methods obsolete for most use cases. And the standard library finally caught up with the everyday needs — array search predicates, a real random API, JSON validation, Unicode-aware string helpers, and attributes for overriding and deprecation.
None of these features are revolutionary on their own. What's revolutionary is the cumulative effect: a class that used to need 50 lines of property declarations, constructor assignments, validation methods, and getter/setter pairs now fits in 10 lines that read more clearly than the original. PHP didn't become a different language to get there — it became a more honest version of itself, where the patterns developers were already using by convention are now expressible directly in the syntax.
If you've been writing PHP the same way you did in 2018, the upgrade path is gentler than it looks. Pick a single feature from this series — readonly, or enums, or property hooks — and start using it in new code. The rest tends to follow naturally once you see how the pieces compose.