Symfony vs Laravel:架构差异与选型实战对比
发布日期: 2026/08/20 阅读总量: 0

一个真实的选择困难症

三个月前,我们技术团队接到一个 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 11Symfony 7
请求入口public/index.phppublic/index.php
内核Illuminate\Foundation\Http\KernelSymfony\Component\HttpKernel\HttpKernel
路由分发Illuminate\Routing\RouterSymfony\Component\Routing\Router
依赖注入容器Illuminate\Container\ContainerSymfony\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.requestkernel.controllerkernel.responsekernel.exceptionkernel.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.31124639.4ms54.2ms16.8MB
Symfony 7.1175328.1ms39.7ms11.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.requestkernel.controllerkernel.responsekernel.view。中间件(或者说 event listener)之间的协调靠的是事件的优先级数字,而不是代码里的调用顺序。这比 Laravel 的管道多了灵活性,但也意味着调试时你得看优先级。我们项目里定了个规矩:所有全局认证统一用 kernel.request priority 10,其他业务监听器一律低于这个数字。

配置管理:一个读数组,一个走编译器

Laravel 的配置是加载 .envconfig/*.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 11Symfony 7差值
RPS12461753+40.7%
平均延迟40.1ms28.5ms-28.9%
TP95 延迟55.3ms40.2ms-27.3%
PHP-FPM 内存峰值16.8MB11.2MB-33.3%
500 并发下的错误率0.02%0%

把测试场景换成 20 个路由 + 5 个中间件(多租户解析、日志、JWT),Symfony 的优势扩大到 48%。原因很简单:路由数量和中间件数量越多,Laravel 的反射和闭包嵌套开销越大,而 Symfony 的编译缓存优势反而扩大。

开发环境(开启 debug)下情况完全不同:Laravel 首次请求 2.4s,Symfony 首次请求 1.2s(因为有 debug 容器和 profiler 特性)。两者日常开发体感差异不大,因为大部分时间消耗在数据库查询上。

选型建议,不是看跑分,是看你团队的容忍度

考量LaravelSymfony
上手成本低,文档友好,中文资源多中高,需要理解概念再动手
轻量级 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=20000opcache.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.yamlarguments: 里,后来为了支持多环境不得不改成 %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 同样可以做出高质量代码,只是它不强制。

不管怎么选,架构纪律比框架本身重要。