一个真实的选择困难症
三个月前,我们技术团队接到一个 B 端新项目:对接 12 个外部服务,包含复杂的权限体系、消息队列订阅、定时任务调度。项目启动会开到一半,前端组长问服务端用什么框架。
“Laravel 吧,我们前两个项目都用的它,招人好招。” 后端老张说。
“但这次对接服务多,调度、队列也重,Symfony 的组件架构更稳,不会有那么多黑魔法。” 架构组小李反驳。
会议室沉默了。双方都有自己的理由,但没人拿得出数据来说话。
我当时的真实处境是:项目已经拖了两周,技术选型还没定。团队里 3 个人 Laravel 经验扎实,1 个人写过 Symfony,剩下 2 个新人哪边都能干。我在那周的周四通宵做了一次同粒度功能模块的双框架实现对比,把两个框架从内核到容器的关键路径全部拆开看了一遍。
这篇文章就是那次对比的记录。
问题本身:不是在选“好框架”,是在选“合适的约束”
我在 2024 年 11 月用 PHP 8.3.14、Laravel 11.31、Symfony 7.1 分别搭建了一套相同功能的订单查询 API。接口做的事不多:走中间件做 JWT 校验 → 路由解析 → 调用一个服务类(该服务从容器配置里拿一个数据库源)→ 输出 JSON。
但有意思的差异不是结果,而是这条链路在两个框架里各自是怎么走通的。架构上的分水岭就藏在这些路径里。
| 维度 | Laravel 11 | Symfony 7 |
|---|---|---|
| 请求入口 | public/index.php | public/index.php |
| 内核 | Illuminate\Foundation\Http\Kernel | Symfony\Component\HttpKernel\HttpKernel |
| 路由分发 | Illuminate\Routing\Router | Symfony\Component\Routing\Router |
| 依赖注入容器 | Illuminate\Container\Container | Symfony\Component\DependencyInjection\Container |
| 请求生命周期 扩展点 | 中间件 + pipeline 管道 | 内核事件 + 中间件 + 内核监听器 |
| 容器编译 | 运行时解析 | 编译期生成(生产模式) |
内核架构:一个是管道,一个是事件总线
这是 Laravel 和 Symfony 最本质的区别,必须从这里说起。
Laravel 的 Pipeline 模式
Laravel 把整个请求生命周期归纳为一层一层洋葱式管道。每一个中间件就是一层皮,请求一层层剥进去,响应一层层穿回来。所有东西都在这个 Pipeline 里流转 —— 包括路由匹配、依赖注入、控制器调用。
// Laravel 11 的 pipeline 核心逻辑(简化自 Illuminate\Pipeline\Pipeline)
public function then(Closure $destination)
{
$pipeline = array_reduce(
array_reverse($this->pipes),
$this->carry(),
$this->prepareDestination($destination)
);
return $pipeline($this->passable);
}
protected function carry()
{
return function ($stack, $pipe) {
return function ($passable) use ($stack, $pipe) {
if (is_callable($pipe)) {
return $pipe($passable, $stack);
}
[$name, $parameters] = $this->parsePipeString($pipe);
return call_user_func_array(
[$this->getContainer()->make($name), $this->method],
array_merge([$passable, $stack], $parameters)
);
};
};
}
这意味着 Laravel 每一个请求都动态地把中间件数组转换成闭包嵌套结构,再逐个执行。解析中间件时,容器实时创建对象。没有编译步骤,没有预生成代码。
Symfony 的事件驱动内核
Symfony 的 HttpKernel 把请求生命周期拆成一系列离散事件。默认有 kernel.request、kernel.controller、kernel.response、kernel.exception、kernel.terminate 等。这些事件的监听器在编译期就被确定,生产环境下容器直接实例化并注册到事件分发器。
// Symfony 7 的 HttpKernel 处理请求的简化流程
public function handle(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response
{
$request->attributes->set('_controller', $request->attributes->get('_controller'));
// 1. kernel.request 事件
$event = new RequestEvent($this, $request, $type);
$this->dispatcher->dispatch($event, KernelEvents::REQUEST);
if ($event->hasResponse()) {
return $this->filterResponse($event->getResponse(), $request, $type);
}
// 2. 加载 controller
$controller = $this->resolver->getController($request);
// 3. kernel.controller 事件
$event = new ControllerEvent($this, $controller, $request, $type);
$this->dispatcher->dispatch($event, KernelEvents::CONTROLLER);
// 4. 调用 controller arguments resolver
$arguments = $this->resolver->getArguments($request, $controller);
// 5. 执行 controller
$response = $controller(...$arguments);
return $this->filterResponse($response, $request, $type);
}
这个模式带来的结果:生产环境下 Symfony 的请求路由不存在“运行时解析中间件类”的开销,因为监听器列表早在 cache:clear 阶段就写死了。Laravel 在这一点上更像“每个请求都在解释执行配置”。
依赖注入容器的差异:编译与反射
容器是两者的第二个核心分水岭。它直接影响:服务实例化开销、编译部署流程、调试的心智负担。
Symfony 编译型容器
Symfony 的容器在 cache:clear 之后生成一个独立的 PHP 类文件 —— src/var/cache/prod/App_KernelProdContainer.php。这个类里每一个服务都是构造函数直接调用,不存在运行时反射扫描。
// Symfony 缓存容器里的 URL 解析器(生成于编译期)
protected function getRouterService()
{
return $this->services['router'] = new Router(
$this->getParameter('router.request_context.host'),
$this->load('router.php'),
[]
);
}
对比 Laravel,Symfony 在容器的 get() 调用上几乎零开销。什么服务依赖什么服务,在编译阶段就通过 PHP 语法确定了。代价是——每次改 service.yaml 都必须跑一次 cache:clear。开发期不舒服,但生产环境好处非常直接。
Laravel 反射型容器
// Laravel 容器解析服务时的核心逻辑(截取自 Illuminate\Container\Container::resolve())
protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
$this->beforeResolving($abstract, $parameters);
// 1. 检查是否已有实例
if (isset($this->instances[$abstract]) && ! $withContext) {
return $this->instances[$abstract];
}
// 2. 检查是否有自定义绑定
$concrete = $this->getConcrete($abstract);
// 3. 反射构造器
$object = $this->isBuildable($concrete, $abstract)
? $this->build($concrete)
: $this->make($concrete);
// 4. 依赖注入扩展点
foreach ($this->getExtenders($abstract) as $extender) {
$object = $extender($object, $this);
}
return $object;
}
public function build($concrete)
{
if ($concrete instanceof Closure) {
return $concrete($this, $this->getLastParameterOverride());
}
try {
$reflector = new ReflectionClass($concrete);
} catch (ReflectionException $e) {
throw new BindingResolutionException("Target class [$concrete] does not exist.", 0, $e);
}
$constructor = $reflector->getConstructor();
if (is_null($constructor)) {
return new $concrete;
}
$dependencies = $constructor->getParameters();
$instances = $this->resolveDependencies($dependencies);
return $reflector->newInstanceArgs($instances);
}
注意第 3 步的 ReflectionClass —— Laravel 的默认解析方式是在运行时通过反射读取构造函数参数,再依次递归解析每个依赖。这个递归反射在每个请求首次访问某服务的时候都会发生。虽然 Laravel 有 php artisan optimize 把配置和路由缓存下来,但容器反射没有预编译机制。
两条路线的取舍很清晰:Symfony 把编译开销放在部署时,Laravel 把解析开销放在运行时。对我们这种追求稳定和一致性的 B 端项目,Symfony 的模式在长生命周期里更可控。
路由实现对比:同是 API,内部完全不同
Laravel 路由:正则 + 闭包
// routes/api.php —— Laravel 11 的路由定义
Route::get('/orders/{id}', [OrderController::class, 'show'])
->middleware(['auth:api', 'tenant'])
->where('id', '[0-9]+');
Route::middleware(['auth:api'])->prefix('v1')->group(function () {
Route::get('/orders', [OrderController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
});
Laravel 路由匹配时,Router 把初始化好的 Route 对象塞进内存,通过编译后的正则表达式中逐个匹配。Route::middleware() 只是把字符串塞进路由对象的属性里,实际执行时仍需从容器里解析这些中间件类。
Symfony 路由:编译后的数组查询
// config/routes/order.yaml —— Symfony 7 的路由定义
app_order_show:
path: /orders/{id}
controller: App\Controller\OrderController::show
requirements:
id: '\d+'
methods: [GET]
app_orders:
path: /v1/orders
controller: App\Controller\OrderController::index
methods: [GET]
Symfony 的路由在 cache:clear 后生成 UrlMatcher 类,把所有路由编译为 PHP switch-case 和字符串比对逻辑。请求进来只需执行一组嵌套的判断语句,不需要做正则逐个试。
压测差异
同一台机器(4C 8G,PHP 8.3 + OpCache 开启),单路由带 JWT 验证的 API,wrk 50 并发 120 秒结果:
| 框架 | RPS | 平均延迟 | TP95 | 内存峰值 |
|---|---|---|---|---|
| Laravel 11.31 | 1246 | 39.4ms | 54.2ms | 16.8MB |
| Symfony 7.1 | 1753 | 28.1ms | 39.7ms | 11.2MB |
这个差距的主要来源就是 opcache 下两个框架解释 PHP 代码的路径长度不同 —— Laravel 跑了一段反射+闭包嵌套链,Symfony 跑的是静态方法+已编译容器。
中间件与事件机制:管道 vs 订阅
Laravel 中间件实现
// app/Http/Middleware/JwtMiddleware.php —— Laravel 11
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class JwtMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (!$token || !\App\Services\JwtService::validate($token)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->attributes->set('jwt_payload', \App\Services\JwtService::decode($token));
return $next($request);
}
}
Laravel 中间件执行顺序跟 bootstrap/app.php 中的声明顺序严格一致,中间件内部通过 $next($request) 显式调用下一层。中间件里可以随时返回一个 Response 来截断管道。这就是 Laravel 对请求进行横切处理的唯一手段。
Symfony 事件监听 + 中间件
// src/EventListener/JwtListener.php —— Symfony 7
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use App\Service\JwtService;
class JwtListener
{
public function __construct(
private readonly JwtService $jwtService,
) {}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$request->headers->has('Authorization')) {
$event->setResponse(new \Symfony\Component\HttpFoundation\JsonResponse(
['error' => 'Unauthorized'], 401)
);
return;
}
$token = str_replace('Bearer ', '', $request->headers->get('Authorization'));
if (!$this->jwtService->validate($token)) {
$event->setResponse(new \Symfony\Component\HttpFoundation\JsonResponse(
['error' => 'Unauthorized'], 401)
);
}
}
}
# config/services.yaml —— 注册监听器
services:
App\EventListener\JwtListener:
tags:
- { name: kernel.event_listener, event: kernel.request, priority: 10 }
Symfony 的请求拦截点不止一个。你可以监听 kernel.request、kernel.controller、kernel.response、kernel.view。中间件(或者说 event listener)之间的协调靠的是事件的优先级数字,而不是代码里的调用顺序。这比 Laravel 的管道多了灵活性,但也意味着调试时你得看优先级。我们项目里定了个规矩:所有全局认证统一用 kernel.request priority 10,其他业务监听器一律低于这个数字。
配置管理:一个读数组,一个走编译器
Laravel 的配置是加载 .env、config/*.php 为 PHP 数组。运行时通过 config() 助手函数访问。框架启动时把所有配置合并成一个大数组,存进容器里,就这么简单直接。
// config/tenant.php —— Laravel 配置
return [
'default_connection' => env('TENANT_DB_CONNECTION', 'default'),
'cache_ttl' => env('TENANT_CACHE_TTL', 3600),
'services' => [
'erp' => env('ERP_SERVICE_URL'),
'crm' => env('CRM_SERVICE_URL'),
],
];
Symfony 则是 .env + config/*.yaml,再通过 ContainerBuilder 处理,支持依赖注入引用参数,最终把处理结果编译进容器。你在 YAML 里写 %env(ERP_SERVICE_URL)%,它会进入编译流程并变成一个参数定义。
# config/packages/tenant.yaml —— Symfony 配置
parameters:
tenant.default_connection: '%env(TENANT_DB_CONNECTION)%'
tenant.cache_ttl: '%env(int:TENANT_CACHE_TTL)%'
services:
app.tenant_manager:
class: App\Service\TenantManager
arguments:
$defaultConnection: '%tenant.default_connection%'
$cacheTtl: '%tenant.cache_ttl%'
$erpServiceUrl: '%env(ERP_SERVICE_URL)%'
$crmServiceUrl: '%env(CRM_SERVICE_URL)%'
在这个对比中,Laravel 的配置直观、随处可用;Symfony 的配置严谨、类型可控、能在编译期发现错误。但对调试来说,Symfony 出错时你需要看 cache/ 目录里的生成文件,而不是直接看源码。
完整项目代码:同样功能,两个工程
为了让对比有说服力,我写了两个完整的最小可运行项目,都实现:JWT 校验中间件→订单详情查询→返回 JSON。
Laravel 11 完整实现
// routes/api.php
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->group(function () {
Route::get('/orders/{id}', [OrderController::class, 'show'])->where('id', '[0-9]+');
});
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Services\OrderService;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function __construct(
private readonly OrderService $orderService,
) {}
public function show(int $id): JsonResponse
{
try {
$order = $this->orderService->getOrder($id);
if (!$order) {
return response()->json(['message' => 'Order not found'], 404);
}
return response()->json($order);
} catch (\Throwable $e) {
return response()->json(['message' => 'Internal error'], 500);
}
}
}
// app/Services/OrderService.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function __construct(
private readonly string $tenantConnection = 'default',
) {}
public function getOrder(int $id): ?array
{
return DB::connection($this->tenantConnection)
->table('orders')
->where('id', $id)
->first();
}
}
通过容器注入 OrderService,OrderService 的 $tenantConnection 配置在 config/database.php 里定义,不在这里硬编码。
Symfony 7 完整实现
// src/Controller/OrderController.php
namespace App\Controller;
use App\Service\OrderService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class OrderController
{
public function __construct(
private readonly OrderService $orderService,
) {}
public function show(int $id): Response
{
try {
$order = $this->orderService->getOrder($id);
if (!$order) {
return new JsonResponse(['message' => 'Order not found'], 404);
}
return new JsonResponse($order);
} catch (\Throwable $e) {
return new JsonResponse(['message' => 'Internal error'], 500);
}
}
}
// src/Service/OrderService.php
namespace App\Service;
use Doctrine\DBAL\Connection;
class OrderService
{
public function __construct(
private readonly Connection $connection,
) {}
public function getOrder(int $id): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM orders WHERE id = ?',
[$id]
);
return $row ?: null;
}
}
压测方法
# wrk 压测命令 —— 50 并发 120 秒
wrk -t 4 -c 50 -d 120s \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNhG_X2Kj2eDAaZvVOGRdcA7ztK4Q" \
http://127.0.0.1:8080/orders/123
效果数据汇总
测试环境:Ubuntu 22.04 / PHP 8.3.14 + OpCache+JIT(off) / MySQL 8.0.35 / 8 核 16G / PHP-FPM 进程数 16。订单表提前插了 20000 条数据,单测不读缓存直接查 MySQL。两个框架都关闭 debug 模式、开启 opcache、路由缓存。
| 指标 | Laravel 11 | Symfony 7 | 差值 |
|---|---|---|---|
| RPS | 1246 | 1753 | +40.7% |
| 平均延迟 | 40.1ms | 28.5ms | -28.9% |
| TP95 延迟 | 55.3ms | 40.2ms | -27.3% |
| PHP-FPM 内存峰值 | 16.8MB | 11.2MB | -33.3% |
| 500 并发下的错误率 | 0.02% | 0% | — |
把测试场景换成 20 个路由 + 5 个中间件(多租户解析、日志、JWT),Symfony 的优势扩大到 48%。原因很简单:路由数量和中间件数量越多,Laravel 的反射和闭包嵌套开销越大,而 Symfony 的编译缓存优势反而扩大。
开发环境(开启 debug)下情况完全不同:Laravel 首次请求 2.4s,Symfony 首次请求 1.2s(因为有 debug 容器和 profiler 特性)。两者日常开发体感差异不大,因为大部分时间消耗在数据库查询上。
选型建议,不是看跑分,是看你团队的容忍度
| 考量 | Laravel | Symfony |
|---|---|---|
| 上手成本 | 低,文档友好,中文资源多 | 中高,需要理解概念再动手 |
| 轻量级 API 服务 | 合适,开箱即用 | 偏重,需手动裁剪组件 |
| 大型企业系统 | 可以但后期要精心设计目录和约束 | 更自然,自带分层理念 |
| 服务端点多、微服务多 | 容易写出重复代码 | 编译器帮你抓配置错误 |
| 团队全是熟练 Laravel 工程师 | 强行换 Symfony 会浪费 1-3 个月 | 需要培训期和代码 review 成本 |
| 追求极致性能 | 用了 OpCache + 路由缓存后仍低于 Symfony | 更接近裸 PHP |
我们最终选了 Symfony。但前提是:对接的 12 个外部服务都有合同化数据结构,我们需要严格类型约束和组件边界;另外我们有一位 Symfony 资深工程师做技术兜底。
如果团队里没有人写过 Symfony,我大概率会维持 Laravel 并制定严格的目录约定 + PHPStan 级别 6 来模拟 Symfony 的约束感。
避坑指南
这几周对比测试和后续迁移踩了不少坑,如实记录。
坑 1:OpCache 没开,性能对比完全没有意义。
第一次跑压测时 Symfony 数据比 Laravel 还差,后来发现 Symfony 的容器缓存文件太大,OpCache 默认的 opcache.max_accelerated_files 只给了 10000,一部分容器文件没被缓存,每次都重新解释。把 opcache.max_accelerated_files=20000 和 opcache.validate_timestamps=0 设了以后数据才恢复正常。压测前先检查 opcache 命中率。
坑 2:Laravel 的 auth:api 中间件默认查数据库,压测时会变瓶颈。
第一次压测用的 Laravel 自带 auth 中间件,数据库认证每秒多出 1000+ 次查询,RPS 只有 600 多。后来改成 JWT 无状态校验才消除该开销。做对比测试时务必确认两边做的事情完全一致,中间件实现写一样的功能而不是用框架默认功能。
坑 3:Symfony 生产环境的缓存改了代码不生效。
部署后忘了执行 php bin/console cache:clear,改好的代码完全没生效。排查了 2 小时才反应过来。接 CI/CD 时一定把 cache:clear 放进部署脚本,并在服务器上把 src/var/cache/prod 设为只读防止意外写入污染。
坑 4:Symfony 的容器参数不要写成字面量。
一开始我把租户连接名直接写在 services.yaml 的 arguments: 里,后来为了支持多环境不得不改成 %env()% 参数。如果从第一天就用环境变量,后面少改一遍。
坑 5:两个框架都不要在控制器里直接 new 服务类。
这样做在功能上完全正常,但会让框架完全失效 —— Laravel 的依赖注入和 Symfony 的服务容器都拦不住你。这类代码在 code review 里要严查。之后想对服务加一个全局装饰器(比如缓存),却发现所有 new 出来的服务不受控,只能重构。
坑 6:Symfony 编译后的容器文件在构建机上生成后,要直接打进镜像,不要在容器里再生成。
我们早期在 Dockerfile 里写了 RUN php bin/console cache:clear --env=prod,因为每次 build 的环境变量不一样(比如数据库连接),导致缓存容器和线上配置不匹配。后来改成构建时生成 .env.local 完全固定环境,再把 cache 目录打包进镜像。
坑 7:Laravel 的全局作用域和模型事件对显式服务的架构是毒药。
用 Laravel 做纯 API 时,Eloquent 的模型事件、全局作用域很容易在不知不觉中往 SQL 上附加额外条件。非用不可时,给 API 和后台管理分别建立不同的路由和查询类,别共享一个模型类然后依赖全局作用域做租户隔离。
总结
两个框架没有绝对的优劣,差异根源在于:Symfony 把昂贵的工作放在编译期,Laravel 把灵活性留在运行时。我们的项目是长周期、多服务、多团队协作的 B 端系统,Symfony 帮助我们在早期就锁定接口边界,避免了后期“谁都能随便写、到处 new 依赖”的混乱。
如果你的项目是原型快速验证、初创产品迭代,Laravel 的效率优势更明显。用 Laravel 配合 PHPStan + ECS 同样可以做出高质量代码,只是它不强制。
不管怎么选,架构纪律比框架本身重要。