skip to content

In PHP 8.1+, what does first-class callable syntax like strlen(...) create, and why prefer it over 'strlen' or [$obj, 'method'] callables?

level: middleimportance: should knowfreq 45%

answer

  1. the ... is literal syntax
  2. always returns a Closure
  3. resolved when created, not when called
  4. keeps the scope it was created in
  5. no new Foo(...), no ?->m(...)

basics

~20 s

strlen(...) (PHP 8.1+) creates a Closure from the function or method it names, resolving it right there with the current scope. Typos fail on that line, private methods can be handed out, and IDEs and static analysers can follow it.

solid answer

~40 s

Writing a call with a literal `...` in place of the arguments, `strlen(...)`, `$this->slugify(...)` or `Title::normalize(...)`, does not call anything: it **creates a `Closure`** wrapping that function or method. It has the same semantics as `Closure::fromCallable()`. Compared with string and array callables, three things change. The target is **resolved when the closure is created**, so a misspelt name throws `Error: Call to undefined function` on that line rather than later inside a callback helper. It **keeps the scope** in which it was created, so `$this->privateHelper(...)` can be returned and called from outside, while `[$this, 'privateHelper']` would fail there. And it is **ordinary syntax**, so rename refactorings, go-to-definition and static analysers follow it. Limits: `new Foo(...)` and `$obj?->m(...)` are compile errors, and the result is always a `Closure`.

code

php · 29 lines
php
<?php
declare(strict_types=1);

final class TitleService
{
    public function stringFilter(): array
    {
        return [$this, 'normalize'];  // array callable: no scope attached
    }

    public function closureFilter(): Closure
    {
        return $this->normalize(...); // Closure: keeps TitleService scope
    }

    private function normalize(string $t): string
    {
        return ucfirst(strtolower(trim($t)));
    }
}

$service = new TitleService();
echo ($service->closureFilter())('  HELLO world '), "\n"; // Hello world

try {
    ($service->stringFilter())('  HELLO world ');
} catch (Error $e) {
    echo $e->getMessage(), "\n"; // Call to private method TitleService::normalize() from global scope
}

go deeper

for a junior

Recall that name(...) creates a Closure without calling anything, and that it works for functions, instance methods and static methods.

for a middle

Explain resolution at creation, the scope it keeps, and how that differs from string and array callables, including the new and nullsafe restrictions.

for a senior

Show how migrating callbacks to first-class callables surfaces errors earlier and helps static analysis, and watch for closures that retain $this.

for a principal

Set a convention that callbacks are first-class callables or closures, and decide where string and array callables are still acceptable.

## The syntax PHP 8.1 introduced **first-class callable syntax**: take any expression that could be called and write `(...)` instead of an argument list. The three dots are the syntax itself, not an omission. ```php $len = strlen(...); // function $slug = $this->slugify(...); // instance method $clean = TitleCleaner::normalize(...); // static method $invoke = $normalizer(...); // invokable object $dyn = [$obj, $method](...); // an existing string/array callable, converted ``` Each expression evaluates to a **`Closure`** object. Nothing is called at that point; calling happens later with `$len('title')`. ## What it changes compared with string and array callables | Aspect | `'strlen'` / `[$obj, 'm']` | `strlen(...)` / `$obj->m(...)` | |---|---|---| | Value type | `string` / `array` | `Closure` | | Target looked up | each time it is called | once, when the closure is created | | Misspelt name fails | where it is eventually invoked | on the line that creates it | | Visibility checked from | wherever it is invoked | the scope where it is created | | Namespace resolution | none, must be fully qualified | like a normal call in that file | | Seen by refactoring tools | usually not | yes | These differences follow from one fact: the expression is compiled like a real call, so the compiler and the engine resolve it exactly as they would resolve `strlen($x)`. ## Scope: handing out private methods Because the closure remembers the scope it was created in, a class can give out access to a private method without making it public: ```php public function titleFilter(): Closure { return $this->normalizeTitle(...); // private method, callable by anyone who receives the Closure } ``` Returning `[$this, 'normalizeTitle']` instead would throw `Error: Call to private method` as soon as outside code invoked it. This is also why the manual states that `strlen(...)` has the same semantics as `Closure::fromCallable('strlen')`. ## What it does not support 1. **Object creation.** `new Title(...)` is a compile error, `Cannot create Closure for new expression`, because `new` is not a call. Use `fn (string $s) => new Title($s)`. 2. **The nullsafe operator.** `$obj?->method(...)` is a compile error, `Cannot combine nullsafe operator with Closure creation`. 3. **Partial application.** You cannot fix some arguments: `str_replace(' ', '-', ...)` is not valid. Wrap the call in an arrow function instead. ## Where it pays off - **Callbacks to array helpers and sorting**, where the callback is an existing function or method: `array_map(trim(...), $titles)`. - **The pipe operator** in PHP 8.5, whose right-hand side is typically a first-class callable: `$title |> trim(...) |> strtolower(...)`. - **Storing callbacks in typed properties**, since the result is a `Closure` and `Closure` is a valid property type while `callable` is not. - **Constant expressions** since PHP 8.5, which accept first-class callables, for example in attribute arguments and parameter defaults. ## Migrating existing callbacks Most codebases carry string and array callbacks from before PHP 8.1. The conversions are mechanical: - `'trim'` becomes `trim(...)`, and a namespaced `'App\Text\shout'` becomes `shout(...)` with the usual `use function` import; - `[$this, 'normalize']` becomes `$this->normalize(...)`; - `[self::class, 'build']` or the deprecated `'self::build'` becomes `self::build(...)`, and `static::build(...)` where late static binding is intended; - callbacks stored in configuration arrays can stay strings, but convert them once at load time with `$callable(...)` after an `is_callable()` check, so the rest of the code handles only `Closure` objects. Static analysers then check every converted callback against the real signature. ## Things to keep in mind - Each evaluation creates a new closure object, so in a hot loop create it once and reuse it. The exception is `name(...)` written directly on the right of the 8.5 pipe operator, which the compiler turns into a plain call. - A method closure created from `$this->m(...)` holds a reference to `$this`, which keeps that object alive as long as the closure is stored. - A closure made this way wraps an existing function, so it has that function's exact signature, including parameter names that named arguments can use: `$pad = str_pad(...); $pad('7', 3, pad_type: STR_PAD_LEFT);`.

  • When does PHP report a misspelt function name written as strlenn(...) versus 'strlenn'?
    `strlenn(...)` is compiled like a call, so creating the closure throws `Error: Call to undefined function strlenn()` on that line. The string `'strlenn'` is just a string until something tries to call it; the failure then appears inside whatever received it, for example as a `TypeError` from `array_map()` because the argument is not a valid callback, far from the typo.
  • In PHP, how do you pass a constructor as a callback, given that new Title(...) is a compile error?
    Wrap it: `fn (string $s): Title => new Title($s)`, or a static factory method passed as `Title::fromString(...)`. The compiler rejects `new Title(...)` with `Cannot create Closure for new expression` because object creation is not a function call.

saying these in an interview costs you the question

  • strlen(...) calls strlen with no arguments and returns the result.
  • The three dots mean the arguments were omitted for brevity.
  • strlen(...) returns the string 'strlen' so it works where strings are expected.
  • A misspelt name in name(...) fails only when the closure is invoked.
  • new Title(...) creates a factory closure for the constructor.