PHP 配置一直有个矛盾:
以前,一旦你需要在"配置类"的地方加逻辑,就会碰壁。PHP 故意把很多结构限制在常量表达式——基本上就是不可变的值。属性参数是最明显的例子:你可以放整数、字符串、标量数组……但不能放闭包。
所以我们用各种变通方案:
"App\\Handler::handle",然后用 call_user_func 调用。PHP 8.5 改变了这个局面:静态闭包和一等可调用对象现在可以出现在常量表达式中,包括:
这听起来像编译器特性。实际上是个"生活质量"升级:让你把配置放在它配置的代码旁边,不用魔术字符串或运行时初始化 hack。
这篇文章会讲"为什么"、具体规则(有重要限制),然后深入实际模式:路由映射、处理器注册表、策略/格式化器注册表。也会讲哪些场景不适合——因为如果不小心,可调用配置确实能搞出一团乱。
PHP 8.5 之前,限制不是你不能创建闭包——而是你不能在某些"配置槽"里用它们。
三个常见痛点:
如果你想写一个接受可选回调的函数,并且想要一个合理的默认回调,通常这样做:
function my_filter(array $items, ?Closure $predicate = null): array
{
$predicate ??= static function ($v): bool { return !empty($v); };
$out = [];
foreach ($items as $item) {
if ($predicate($item)) {
$out[] = $item;
}
}
return $out;
}这能用……但是样板代码,而且不是"声明式"的。
PHP 8.5 的 RFC 明确提到这个用例:允许直接声明默认回调闭包,不用可空参数的变通方案。
属性是表达规则的自然场所:
但属性参数只能是常量表达式,所以人们用字符串或表达式对象。
PHP 8.5 发布公告展示了一个典型的"之前"模式,访问控制属性接受字符串表达式。在 PHP 8.5 中你可以直接传静态闭包。
任何时候你想要从"键"到"处理器"的映射,你可能在运行时构建它:
$handlers = [
'json' => [JsonFormatter::class, 'format'],
'text' => [TextFormatter::class, 'format'],
];这能用,但很脆弱:
PHP 8.5 的常量表达式改进让你可以把这些注册表表达为常量——并且让处理器重构安全。
"常量表达式"是 PHP 内部术语,指在必须不依赖运行时状态就能计算的上下文中允许的表达式——可以理解为"不可变值"。
这些上下文包括:
闭包 RFC 总结旧规则为:常量表达式被限制在实际上是"不可变值"的操作,闭包不包括在内——尽管闭包本质上是编译后的代码(操作码),在约束下可以被视为不可变。
为什么这很重要?
因为这些上下文是你想放配置的地方:
换句话说:常量表达式是 PHP 引导你走向声明式代码的地方。PHP 8.5 扩展了"声明式"的含义。
PHP 8.5 允许常量表达式中的闭包——但有严格约束:
$this)。use(...) 捕获外部变量。这些规则是编译时强制的。
这听起来有限制,但实际上这正是这个特性安全的原因:它防止意外把"运行时状态"偷渡进常量。
这是之前过滤器示例的干净 PHP 8.5 版本:
<?php
declare(strict_types=1);
function my_filter(
array $items,
Closure $predicate = static function ($v): bool { return !empty($v); },
): array {
$out = [];
foreach ($items as $item) {
if ($predicate($item)) {
$out[] = $item;
}
}
return $out;
}这正是闭包 RFC 强调的动机:你可以声明一个真正的默认回调,不需要"可空 + 运行时默认"。
实际上,这也改善了工具支持:
Closure,不是 ?Closurenull 是否有特殊含义你可以在常量或类常量中存储闭包,把它们当作"可调用配置"。
一个简单例子:格式化器注册表。
<?php
declare(strict_types=1);
final class Formatters
{
public const MAP = [
'trim_lower' => static function (string $s): string {
return strtolower(trim($s));
},
'digits_only' => static function (string $s): string {
return preg_replace('/\D+/', '', $s) ?? '';
},
];
}使用:
$input = " +62 (812) 345-678 ";
$normalized = (Formatters::MAP['digits_only'])($input);这读起来像配置,但不是"字符串类型"。它是真正的 PHP,编译过的,有类型的,可重构的。
因为常量表达式中的闭包可以用作属性默认值,你可以在属性声明处定义默认策略——同样不需要运行时初始化。
例子:可配置的规范化器。
<?php
declare(strict_types=1);
final class Normalizer
{
public Closure $normalize = static function (string $s): string {
return trim($s);
};
public function run(string $value): string
{
return ($this->normalize)($value);
}
}重要细节:常量表达式中的闭包必须是静态的,这意味着闭包本身不能用 $this。
这是故意的权衡:常量表达式只求值一次,而 $this 只有在闭包为每个对象实例重新创建时才有意义(这不是常量表达式的行为方式)。
尽管闭包必须是静态的(没有 $this),在这些常量上下文中创建的闭包仍然遵循正常的作用域规则。RFC 说明:
这启用了一个好模式:把复杂逻辑放在私有静态辅助方法中,把闭包作为配置暴露出来。
闭包适合"内联逻辑"。但有时候你不想要内联逻辑——你想指向一个现有的函数或静态方法。
这就是一等可调用对象(FCC)的用武之地。
一等可调用对象看起来像:
strrev(...)
MyClass::myMethod(...)它们产生一个转发到函数/方法的 Closure。
PHP 8.5 现在允许常量表达式中的 FCC 语法,旨在"完善"常量中闭包的特性。
比较这两个:
// 旧方式
public const HANDLERS = [
'reverse' => 'strrev',
'slug' => 'App\\Slugger::slugify',
];对比:
// PHP 8.5
public const HANDLERS = [
'reverse' => strrev(...),
'slug' => Slugger::slugify(...),
];第二个版本更好,因为:
FCC RFC 添加了一些重要限制(除了正常的 FCC 规则):
::)。function_name(...) 和 ClassName::methodName(...) 语法。($fn)(...))、数组([ClassName::class, 'method'](...)),或依赖 __callStatic() 魔术方法。这是好事:它让常量表达式中的 FCC 用法清晰且可分析。
传统方式,路由映射是运行时构建的:
$routes = [
'GET /health' => [HealthController::class, 'check'],
'GET /posts' => [PostsController::class, 'index'],
];这能用,但不是重构安全的。
在 PHP 8.5 中你可以用 FCC 或静态闭包定义路由映射,作为常量:
<?php
declare(strict_types=1);
final class Routes
{
public const MAP = [
'GET /health' => HealthController::check(...),
'GET /posts' => PostsController::index(...),
// 快速端点的内联处理器
'GET /version' => static function (Request $req): Response {
return Response::text('ok');
},
];
}现在你可以实现一个简单的分发器:
final class Dispatcher
{
public function dispatch(Request $req): Response
{
$key = $req->method . ' ' . $req->path;
$handler = Routes::MAP[$key] ?? null;
if ($handler === null) {
return Response::text('Not found', 404);
}
return $handler($req);
}
}这个模式有几个好处:
实际的路由器需要路径参数;但即使这样,"处理器注册表"部分通常保持静态。
想象一个简单的消息总线:消息类映射到处理器。
旧方式:
$handlers = [
UserRegistered::class => 'App\\Handlers\\SendWelcomeEmail::handle',
];现在,用 PHP 8.5 FCC:
final class MessageHandlers
{
public const MAP = [
UserRegistered::class => SendWelcomeEmail::handle(...),
OrderPaid::class => CreateInvoice::handle(...),
];
}分发器:
final class Bus
{
public function __construct(private Container $container) {}
public function handle(object $message): void
{
$handler = MessageHandlers::MAP[$message::class] ?? null;
if ($handler === null) {
throw new RuntimeException('No handler registered for ' . $message::class);
}
// 如果处理器是静态的,它们可以显式接受依赖,
// 或者你可以调整这个模式(见下面的 DI 说明)。
$handler($message, $this->container);
}
}关键概念:常量表达式让你把映射保持在常量中,但你仍然控制依赖如何注入——通过签名设计。
这是我最喜欢的实际用途:替换一个不断增长的 switch。
function format(string $type, mixed $value): string
{
return match ($type) {
'json' => json_encode($value),
'text' => (string) $value,
'upper' => strtoupper((string) $value),
default => throw new InvalidArgumentException('Unknown formatter'),
};
}现在想象这增长到 15-30 个策略。你最终得到一个大 match 和一个 diff 磁铁。
用可调用常量:
final class FormatterRegistry
{
public const FORMATTERS = [
'json' => static function (mixed $v): string {
return json_encode($v, JSON_THROW_ON_ERROR);
},
'text' => static function (mixed $v): string {
return (string) $v;
},
// FCC 到原生函数
'reverse' => strrev(...),
];
public static function format(string $type, mixed $value): string
{
$fn = self::FORMATTERS[$type] ?? null;
if ($fn === null) {
throw new InvalidArgumentException("Unknown formatter: {$type}");
}
return $fn($value);
}
}现在添加策略只需要改一行。
这个特性引导你走向"代码即配置"。这很好——直到你开始把运行时状态注入到应该是静态的东西里。
一个好的心智模型:
这些在常量表达式中是安全的:
你仍然可以通过设计可调用对象显式接受依赖来混合可调用配置和 DI。
例子:注册表返回一个接受 (Message $m, Container $c) 的可调用对象:
final class Handlers
{
public const MAP = [
UserRegistered::class => static function (UserRegistered $m, Container $c): void {
$mailer = $c->get(Mailer::class);
$mailer->sendWelcome($m->email);
},
];
}这保持在约束内,因为闭包是静态的且不捕获状态。"依赖解析"在调用时发生,容器被传入。
这总是理想的吗?不是。但它是一个干净、显式的桥梁。
常量表达式闭包让默认值更干净:
对于测试,你仍然可以通过传递不同的闭包参数或给对象属性赋值不同的策略来覆盖行为(如果该属性设计上是可变的)。主要改进是默认行为在它该在的地方表达,你不需要运行时初始化胶水代码来创建默认闭包。
这个特性给你在"配置上下文"中更多能力。能力带来新的搬起石头砸自己脚的方式。
你不能这样做:
$prefix = "prod_";
const FN = static function (string $s) use ($prefix): string {
return $prefix . $s;
};常量表达式中的闭包不能通过 use(...) 捕获变量。
这是硬性约束,它强迫你采用更好的设计:
类似地,箭头函数在常量表达式中被阻止,因为它们隐式捕获变量。
如果你的"注册表"闭包开始做 IO、访问数据库、读取环境变量等,你就让配置变得不可预测了。
一个好规则:
如果可调用对象做的不只是"计算并返回",考虑把它移到真正的服务中,通过静态方法引用它(或容器接线)。
仅仅因为你能在属性里放代码,不意味着你应该这样做。
如果你的规则可以表达为简单数据——用数据。例子:
可调用配置应该是你的工具,用于数据本身变得笨拙的情况(或者你否则会发明一个字符串表达式 DSL)。
可调用配置仍然是代码。如果你的团队经验水平不一,你需要约定:
SomeClass::somePolicy(...))。这是一套在实际代码库中通常效果不错的实用指南。
在以下情况倾向于纯数据配置:
例子:
final class Limits
{
public const MAX_TITLE_LENGTH = 120;
public const ALLOWED_SORTS = ['newest', 'popular', 'discussed'];
}在以下情况使用可调用配置:
这正是 PHP 8.5 在属性、默认值和常量中启用的。
保持可调用配置安全且可维护:
use(...) 不允许)。function_name(...) 和 ClassName::methodName(...)(不支持数组可调用语法)。如果你想知道这是否"免费",两个 RFC 都提到 opcache 需要调整才能正确地在共享内存中存储这些闭包/可调用对象。
换句话说:这个特性的实现考虑了真实世界的运行时环境(opcache/JIT)。目标不是微优化——而是表达力和安全性。
PHP 8.5 支持常量表达式中的静态闭包和一等可调用对象,这是那种在更新日志上看起来很小、然后悄悄改善你设计 API 方式的特性:
约束就是护栏:不能捕获变量、没有 $this、常量表达式中没有箭头函数。
如果你接受这些护栏,你会得到一个真正更好的"编译时风格"配置层——接线是静态的、可读的、重构更安全。
用它来移除胶水代码,而不是隐藏复杂性。保持可调用配置简短,给重的东西命名,让你的常量描述"发生什么",而不是把它们变成迷你应用程序。