Callables & Closures
TypePHP provides lazy runtime interception for callbacks, Closures, invokable objects (__invoke), array callables, PHP 8.1+ first-class callables, by-reference callback mutations, and PHPStan static-closure specifications.
How Callback Interception Works (CallableWrapper)
When a function parameter, return value, or local variable is annotated with a callable contract (such as callable(positive-int): non-empty-string), TypePHP wraps the callable in a lazy interceptor proxy:
- Lazy Execution: TypePHP does not execute the callback immediately when passed as an argument.
- Input Validation: When the wrapped callback is invoked, TypePHP validates the arguments passed into the callback against the declared parameter types.
- Output Validation: When the callback returns, TypePHP validates the returned value against the callback's declared return contract.
- Reference Preservation: If the callback takes arguments by reference (
callable(Type &$ref)), TypePHP preserves in-place mutations on the caller's variable. - Zero Overhead on Uncalled Callbacks: If a callback is passed to a function but never invoked in that specific execution branch, zero validation overhead occurs.
Closure Identity & Proxy Wrapping (=== Comparisons)
When TypePHP intercepts a callable or Closure parameter, it wraps the instance in a proxy closure (CallableWrapper) to enforce parameter types, return contracts, and by-reference mutations dynamically at invocation time.
Because a property or variable holds the TypePHP proxy wrapper rather than the raw, unwrapped closure, strict object reference equality (===) between the stored property and the original closure instance evaluates to false:
<?php
declare(strict_types=1);
final class ClosureContainer
{
/**
* @param Closure(positive-int): non-empty-string $factory
*/
public function __construct(public Closure $factory) {}
}
$originalClosure = static fn (int $id): string => "user_{$id}";
$container = new ClosureContainer($originalClosure);
// 1. Invocation is fully protected and type-checked at runtime:
($container->factory)(42); // Valid, returns 'user_42'
// 2. Strict object identity comparison evaluates to false:
$isSame = ($container->factory === $originalClosure); // false (holds TypePHP proxy)Consistency Across Property Syntaxes
Both standard parameter assignments ($this->factory = $factory) and Constructor Property Promotion (public Closure $factory) consistently receive the TypePHP proxy wrapper.
This ensures that invoking $container->factory(...) enforces runtime parameter and return contracts identically regardless of which property declaration syntax is used.
Discarded Returns in void Callback Contracts (callable(): void)
It is important to distinguish between native PHP : void typehint syntax and PHPDoc @return void contracts:
Native PHP : void Syntax (PHP Language Level)
When a function or closure explicitly declares native PHP : void return syntax (function(): void), PHP's Zend Engine strictly forbids returning any value at the language level. Attempting to return a value results in a native PHP compile error:
// Native PHP ': void' syntax
$func = function (int $a): void {
return $a; // Native PHP Fatal Error: A void function must not return a value
};PHPDoc @return void Contracts (TypePHP Level)
For callbacks and closures annotated with PHPDoc @return void (e.g. callable(): void or Closure(): void), TypePHP discards the return value rather than throwing a TypeError.
This design behavior ensures full compatibility with PHP short arrow functions (fn() => $expr), which always implicitly return the evaluated result of their expression:
class User
{
public function save(): bool
{
return true; // Returns boolean
}
}
/**
* @param callable(User): void $callback
*/
function tapUser(User $user, callable $callback): User
{
$callback($user);
return $user;
}
$user = new User();
// Short arrow function implicitly returns boolean true from $u->save()
// TypePHP discards the boolean return value rather than throwing a TypeError
tapUser($user, fn (User $u) => $u->save());Basic Callable Contracts (callable(T1, T2): R)
Declare argument and return types for callbacks using callable(Type1, Type2): ReturnType syntax:
<?php
declare(strict_types=1);
/**
* @param callable(positive-int, non-empty-string): bool $callback
*/
function processUserCallback(callable $callback): bool
{
return $callback(10, 'Alice');
}
// 1. Valid Callback
processUserCallback(function (int $id, string $name): bool {
return $id > 0 && strlen($name) > 0;
});
// 2. Invalid Callback Return (Returns integer 123 instead of bool)
processUserCallback(function (int $id, string $name): int {
return 123;
});
// Throws: TypeError: Callback $callback return value must be of type bool, int (123) given
// 3. Invalid Callback Argument (Passing negative id into callback)
function badInvoker(callable $callback): bool
{
return $callback(-5, 'Alice');
}
// Throws: TypeError: Callback $callback $id must be of type positive-int, negative int (-5) givenBy-Reference Callback Parameters (callable(Type &$ref): void)
When a callback accepts arguments by reference (e.g. callable(positive-int &$val): void), TypePHP uses Signature-Aware Reference Dispatching to preserve caller references in real memory:
- In-Place Mutation: Modifications performed inside the callback mutate the caller's variable in the caller's scope.
- Input Validation on Entry: Incoming by-reference variables are validated before the callback body executes.
- Post-Mutation Validation on Exit: When the callback finishes, TypePHP verifies that the mutated value still satisfies the declared type contract before returning control.
/**
* @param callable(positive-int &$num): void $mutator
*/
function applyBonus(callable $mutator, int &$score): void
{
$mutator($score);
}
$userScore = 10;
// 1. Valid In-Place Mutation
applyBonus(function (int &$num): void {
$num += 50;
}, $userScore);
echo $userScore; // Output: 60 (Mutated in place!)
// 2. Invalid Post-Mutation (Callback sets score to -999 violating positive-int)
applyBonus(function (int &$num): void {
$num = -999;
}, $userScore);
// Throws: TypeError: Callback $mutator $num must be of type positive-int, negative int (-999) givenNative array_walk() Integration
TypePHP matches mixed by-reference and by-value signatures (such as array_walk where the array value is passed by reference and the key is passed by value) with zero PHP reference warnings:
/**
* @param callable(positive-int &$item, array-key $key): void $callback
* @param list<positive-int> &$items
*/
function doubleList(callable $callback, array &$items): void
{
array_walk($items, $callback);
}
$numbers = [1, 2, 3, 4];
doubleList(function (int &$item, mixed $key): void {
$item *= 10;
}, $numbers);
print_r($numbers); // [10, 20, 30, 40]Variadic By-Reference Callables (callable(Type &...$args): void)
/**
* @param callable(positive-int &...$numbers): void $cb
*/
function applyBatch(callable $cb, int &$a, int &$b): void
{
$cb($a, $b);
}
$x = 10;
$y = 20;
applyBatch(function (int &...$numbers): void {
foreach ($numbers as &$n) {
$n += 5;
}
}, $x, $y);
echo "$x, $y"; // Output: 15, 25Generic Callables with Template Substitution (@template T)
When a function uses generic template parameters (@template T), TypePHP dynamically substitutes T into the callable's parameter and return types based on the bound generic type:
/**
* Generic transformer function
*
* @template T
*
* @param callable(T): T $transformer
* @param T $input
*
* @return T
*/
function transformValue(callable $transformer, mixed $input): mixed
{
return $transformer($input);
}
// 1. Valid Execution: Infers T = int, validates callback input (int) and return (int)
$double = fn (int $x): int => $x * 2;
transformValue($double, 21); // Returns 42
// 2. Valid Execution: Infers T = string, validates callback input (string) and return (string)
$shout = fn (string $s): string => strtoupper($s);
transformValue($shout, 'hello'); // Returns 'HELLO'
// 3. Invalid Callback Return: T is inferred as int (from 10), but callback returns string ('invalid')
$badReturn = fn (int $x): string => 'invalid';
transformValue($badReturn, 10);
// Throws: TypeError: Callback $transformer return value must be of type int, string 'invalid' givenGeneric Callables with Class Bounds (@template T of Animal)
Restricting a callback's template parameter to a specific class hierarchy or interface:
/**
* @template T of Animal
*
* @param callable(T): non-empty-string $formatter
* @param T $animal
*
* @return non-empty-string
*/
function formatAnimal(callable $formatter, Animal $animal): string
{
return $formatter($animal);
}
// Valid Execution
formatAnimal(fn (Dog $d) => 'dog_label', new Dog());
// Invalid Return (Callback returns empty string violating non-empty-string)
formatAnimal(fn (Dog $d) => '', new Dog());
// Throws: TypeError: Callback $formatter return value must be of type non-empty-string, empty string ('') givenComplex Parameter & Return Contracts in Callables
Because CallableWrapper delegates argument and return validation directly to TypePHP's central validator engine, all complex types (generics, array shapes, lists, unions, intersections) are fully enforced inside callback signatures:
use App\Generics\Producer;
use App\Models\Dog;
/**
* Callback accepting a generic Producer<Dog> and list<positive-int>, returning an array shape
*
* @param callable(Producer<Dog>, list<positive-int>): array{status: 'success'|'error', count: positive-int} $processor
*/
function executeComplexCallback(callable $processor): void
{
$processor(new Producer(new Dog()), [10, 20]);
}
// Valid Call
executeComplexCallback(function (Producer $producer, array $ids): array {
return ['status' => 'success', 'count' => 2];
});
// Invalid Execution (Callback returns negative count -5 violating positive-int in array shape)
executeComplexCallback(function (Producer $producer, array $ids): array {
return ['status' => 'success', 'count' => -5];
});
// Throws: TypeError: Callback $processor return value['count'] must be of type positive-int, negative int (-5) givenStrict Closure Instance Contracts (Closure(T): R)
When you specify Closure(T): R instead of callable(T): R, TypePHP strictly requires a native \Closure instance, rejecting string function names or invokable objects:
class InvokableService
{
public function __invoke(int $id): string
{
return "user_{$id}";
}
}
/**
* Strictly requires a native \Closure instance
*
* @param Closure(positive-int): non-empty-string $closure
*/
function executeClosureOnly(Closure $closure): string
{
return $closure(42);
}
// 1. Valid Call (Native closure)
executeClosureOnly(fn (int $id) => "user_{$id}");
// 2. Invalid Call (Invokable object passed where \Closure was required)
executeClosureOnly(new InvokableService());
// Throws: TypeError: Argument $closure must be of type Closure, App\InvokableService given
// 3. Invalid Call (String function name 'strlen' passed where \Closure was required)
executeClosureOnly('strlen');
// Throws: TypeError: Argument $closure must be of type Closure, string 'strlen' givenArray & First-Class Callables (PHP 8.1+)
TypePHP seamlessly intercepts instance method array callables, static method callables, and PHP 8.1+ First-Class Callable syntax ($obj->method(...)):
class UserService
{
public function formatUser(int $id): string
{
return "user_{$id}";
}
public static function staticFormat(int $id): string
{
return "static_user_{$id}";
}
}
/**
* @param callable(positive-int): non-empty-string $formatter
*/
function executeFormatter(callable $formatter): string
{
return $formatter(100);
}
$service = new UserService();
// 1. PHP 8.1+ First-Class Callable Syntax on Instance Method
executeFormatter($service->formatUser(...)); // Valid
// 2. PHP 8.1+ First-Class Callable Syntax on Static Method
executeFormatter(UserService::staticFormat(...)); // Valid
// 3. Instance Method Array Callable
executeFormatter([$service, 'formatUser']); // Valid
// 4. Static Method Array Callable
executeFormatter([UserService::class, 'staticFormat']); // ValidHigher-Order & Curried Functions Returning Callables
TypePHP recursively wraps functions that return other callback functions (e.g. factory pipelines, curried validators, and middleware handlers), enforcing argument and return contracts across all invocation stages:
class ValidationPipeline
{
/**
* Method returning a curried validator function
*
* @return callable(positive-int): (callable(non-empty-string): bool)
*/
public function createLengthValidator(): callable
{
return function (int $minLength): callable {
return function (string $text) use ($minLength): bool {
return strlen($text) >= $minLength;
};
};
}
}
$pipeline = new ValidationPipeline();
$factory = $pipeline->createLengthValidator();
// Stage 1: Configure validator with minLength = 5
$minFiveValidator = $factory(5);
// Stage 2: Execute configured validator
$minFiveValidator('Hello World'); // Returns true
$minFiveValidator('Hi'); // Returns false
// Invalid Stage 1 Argument ($minLength = -1 violates positive-int)
$factory(-1);
// Throws: TypeError: Return value Callback argument #1 must be of type positive-int, negative int (-1) given
// Invalid Stage 2 Argument ($text = '' violates non-empty-string)
$minFiveValidator('');
// Throws: TypeError: Return value: Returned callback Callback argument #1 must be of type non-empty-stringAdvanced PHPStan Callable Specifications
TypePHP supports advanced callback specifications including variadic parameters, optional parameters, and static closures:
Variadic Callback Parameters (callable(T ...$items): R)
/**
* @param callable(positive-int ...$ids): void $callback
*/
function processVariadicCallback(callable $callback): void
{
$callback(10, 20, 30);
}
processVariadicCallback(function (int ...$ids) {
// Valid: Every variadic argument is validated against positive-int
});Optional Callback Parameters (callable(T1, T2=): R)
Append an equals sign (T=) to denote optional callback parameters:
/**
* Second callback parameter $name is optional
*
* @param callable(positive-int, non-empty-string=): bool $callback
*/
function processOptionalCallback(callable $callback): bool
{
return $callback(10); // 2nd argument omitted
}Static Closures (static-closure)
Enforce that a closure must be declared as static (not bound to $this):
/**
* @param static-closure(int): string $closure
*/
function processStaticClosure(Closure $closure): string
{
return $closure(100);
}
// 1. Valid (Static closure)
processStaticClosure(static fn (int $id) => "static_{$id}");
// 2. Invalid (Non-static closure bound to $this)
processStaticClosure(fn (int $id) => "bound_{$id}");
// Throws: TypeError: Argument $closure must be a static Closure (not bound to $this)Inline @var Callable Contracts
Enforce argument and return contracts on local variables assigned with inline @var callable docblocks:
/** @var callable(positive-int, non-empty-string): bool $formatter */
$formatter = fn (int $id, string $name) => strlen($name) > 0;
$formatter(10, 'Alice'); // Valid
$formatter(-5, 'Alice');
// Throws: TypeError: Variable $formatter: Callback argument #1 must be of type positive-int, negative int (-5) given