PHP 8.1 — First-class callable syntax
Concept
First-class callable syntax, added in PHP 8.1, lets you obtain a Closure from any callable — named function, static method, instance method, or built-in — using the ... (ellipsis) notation without invocation: strlen(...). The result is a proper Closure object with the same signature as the original callable, suitable for passing to array_map, storing in variables, or composing with other higher-order functions.
Before PHP 8.1, converting a named function to a closure required a verbose anonymous wrapper: fn($s) => strlen($s) or Closure::fromCallable('strlen'). Both approaches disconnect the callable from static analysis — the string 'strlen' is opaque to PHPStan, and fn($s) => strlen($s) creates a new scope and hides the original function's signature. First-class callables are transparent: the resulting Closure carries the same parameter types and return type as the original.
The feature works uniformly across all callable forms. $object->method(...) captures the bound instance. ClassName::staticMethod(...) works for static methods. $object->method(...) produces a closure already bound to $object. Built-ins like strlen(...) and array_map(...) work too. The only exclusion is language constructs (echo, isset, empty) which are not real functions and cannot be captured.
Performance-wise, first-class callables avoid the overhead of repeated Closure::fromCallable() calls in tight loops, since the closure is created once and reused. In Laravel, this is visible in pipeline construction, collection operations, and event dispatcher wiring — all places where passing callables cleanly matters.
| Approach | Type-safe | Refactor-safe | Bound to instance |
|---|---|---|---|
'strlen' string | No | No | N/A |
fn($s) => strlen($s) | Partially | No | No |
Closure::fromCallable('strlen') | No | No | N/A |
strlen(...) (PHP 8.1) | Yes | Yes | N/A |
$obj->method(...) (PHP 8.1) | Yes | Yes | Yes |
Code Example
<?php
declare(strict_types=1);
// 1. Named function to closure
$trim = trim(...);
$upper = strtoupper(...);
$pipeline = array_map(
fn(string $s): string => $upper($trim($s)),
[' hello ', ' world '],
);
// ['HELLO', 'WORLD']
// 2. Static method
class Sanitizer
{
public static function slugify(string $input): string
{
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($input)));
}
public function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
}
$slugify = Sanitizer::slugify(...);
$slugs = array_map($slugify, ['Hello World', 'PHP 8.1 Features']);
// ['hello-world', 'php-8-1-features']
// 3. Instance method — closure is bound to the instance
$sanitizer = new Sanitizer();
$normalizeEmail = $sanitizer->normalizeEmail(...);
$emails = array_map($normalizeEmail, [' Alice@Example.COM', 'BOB@EXAMPLE.COM']);
// ['alice@example.com', 'bob@example.com']
// 4. Composing a pipeline (functional style)
/**
* @param list<\Closure> $fns
*/
function pipe(array $fns): \Closure
{
return function (mixed $value) use ($fns): mixed {
return array_reduce($fns, fn($carry, $fn) => $fn($carry), $value);
};
}
$process = pipe([
trim(...),
strtolower(...),
Sanitizer::slugify(...),
]);
echo $process(' My Blog Post Title '); // my-blog-post-title
// 5. Storing built-in callables for reuse
$validators = [
'is_numeric' => is_numeric(...),
'is_string' => is_string(...),
'is_array' => is_array(...),
];
foreach (['42', 42, []] as $value) {
foreach ($validators as $name => $fn) {
echo "{$name}(" . json_encode($value) . "): " . ($fn($value) ? 'true' : 'false') . "\n";
}
}Interview Q&A
Q: What problem does first-class callable syntax solve over using string callables or Closure::fromCallable()?
String callables like 'strlen' are opaque to static analysis — PHPStan cannot verify that the string names a real function or that its signature matches the context. Closure::fromCallable('strlen') resolves this at runtime but still starts with an untyped string. First-class callable syntax (strlen(...)) is parsed by PHP itself: the engine resolves the name at compile time, the resulting Closure carries the original function's reflected signature, and static analyzers treat it as a fully-typed callable. Refactoring tools can also rename the function and update the strlen(...) reference automatically, whereas a string would be missed.
Q: How does $object->method(...) differ from Closure::fromCallable([$object, 'method'])?
Both produce a Closure bound to $object, but the first-class syntax is resolved at parse time, so IDEs and static analyzers can inspect the method signature immediately. Closure::fromCallable with an array callable is semantically equivalent at runtime but requires the engine to reflect at the point of the call. Additionally, $object->method(...) works with readonly properties that hold objects — the expression is evaluated once, and the resulting closure captures the binding without needing the array callable form. Neither approach re-binds $this; both carry the original object as context.
Q: Why can't you use first-class callable syntax with language constructs like isset or empty?
isset, empty, echo, unset, and list are language constructs handled by the parser, not functions registered in the function table. They have special evaluation rules — isset($a) does not throw if $a is undefined, which a true function call cannot replicate. Because they are not callable in PHP's runtime model, there is no Closure to produce. The workaround is to wrap them in a real function, such as fn($v) => isset($v), which loses some of the intent but is the only option available.