引言
Laravel 让 PHP 开发者第一次感受到「框架替我做完一切」的爽快,Symfony 则走了另一条路:它把「一切」拆成几十个可以独立安装的组件,再把它们组装成一个骨架。你既可以直接用 symfony/http-foundation 而完全不碰框架,也可以用完整骨架搭建一套复杂的企业级系统。
这种「组件优先」的设计让 Symfony 成为 PHP 世界的基础设施:Laravel 的底层(HTTP 内核、控制台、路由、事件)大量复用 Symfony 组件,Drupal、Shopware、API Platform 也都构建在它之上。理解 Symfony,等于理解了半个 PHP 生态的公共底座。
但它的学习曲线也是真实的:容器、Bundle、编译器 Pass、事件优先级、Autowiring 的边界……新手常被「为什么我的服务没被注入」卡住半天。本文用可运行的代码把这套机制拆开讲透,最后给出与 Laravel 的选型对比。
关联阅读:https://plumephp.com/php-laravel-internals/ 中讲的服务容器与门面,其底层正是 Symfony 组件;设计模式部分可参考 https://plumephp.com/php-oop-design-patterns/。
目录
- 1. Symfony 的组件化哲学
- 2. 项目骨架与 Flex:从零搭建
- 3. Bundle:可复用的功能单元
- 4. 依赖注入容器:服务的装配中心
- 5. 路由与控制器:请求的入口
- 6. Doctrine 集成:ORM 与数据库
- 7. 事件系统与内核事件
- 8. 配置、环境与密钥管理
- 9. Symfony 与 Laravel 的取舍
- 延伸阅读
1. Symfony 的组件化哲学
Symfony 的 60 多个组件都可以单独 composer require,且不依赖框架骨架:
| 组件 | 独立用途 |
|---|---|
| symfony/http-foundation | 封装 Request/Response/Session,几乎所有 PHP 框架都在用 |
| symfony/console | 写 CLI 工具的事实标准 |
| symfony/event-dispatcher | 通用事件系统(PSR-14 实现) |
| symfony/validator | 独立的数据校验库 |
| symfony/process | 优雅地调用外部进程 |
<?php
use Symfony\Component\Console\Application;
$app = new Application('demo', '1.0.0');
$app->run(); // 只装 symfony/console 就有可用 CLI
完整骨架(symfony/skeleton)做的事情,是把这些组件按约定组装起来,并提供 Kernel 作为装配入口——框架不是黑魔法,而是一份「官方推荐配置」。松耦合带来三个收益:可替换(不满意某组件就换掉)、可复用(业务代码可跑在完整框架或纯组件之上)、可测试(组件边界清晰,单元测试无需启动整个应用)。
2. 项目骨架与 Flex:从零搭建
composer create-project symfony/skeleton:"7.1.*" my-app
cd my-app && composer require webapp
my-app/
├── bin/console # CLI 入口
├── config/
│ ├── packages/ # 各包配置(按环境后缀区分)
│ ├── routes.yaml # 路由导入
│ └── services.yaml # 容器配置
├── public/index.php # Web 唯一入口
├── src/{Controller,Entity,Repository,Kernel.php}
├── templates/ # Twig 模板
├── migrations/ # Doctrine 迁移
└── var/ # 缓存与日志(可写)
Flex 是 Symfony 的「包安装助手」。当你 composer require twig 时,它会自动拉取对应版本的 recipe,写入 config/packages/twig.yaml、更新 bundles.php、必要时追加 .env 变量。
composer recipes
composer recipes:install symfony/framework-bundle --force
symfony server:start -d # 启动带 TLS 的本地服务器
3. Bundle:可复用的功能单元
Bundle 是 Symfony 的「插件」:一个包含控制器、服务、配置、路由的目录,可被复用与分发。应用本身也是一个 Bundle(App\Kernel 所在)。
src/InvoiceBundle/
├── InvoiceBundle.php # Bundle 类(通常为空壳)
├── Controller/
├── DependencyInjection/ # InvoiceExtension.php + Configuration.php
└── Resources/{config,routes}.yaml
<?php
namespace App\InvoiceBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tree = new TreeBuilder('invoice');
$tree->getRootNode()->children()
->scalarNode('currency')->defaultValue('CNY')->end()
->integerNode('tax_rate')->defaultValue(13)->end()
->end();
return $tree;
}
}
用户在 config/packages/invoice.yaml 写 invoice: { currency: CNY },Extension 便能读到并注入服务。
什么时候该抽 Bundle:复用型功能(支付网关、审计日志)抽成 Bundle 便于跨项目复用;应用内业务模块(订单、用户)通常不必抽 Bundle,放在 src/ 下按命名空间组织即可,过度 Bundle 化反而增加认知负担。
4. 依赖注入容器:服务的装配中心
# config/services.yaml
services:
_defaults:
autowire: true # 构造函数依赖自动注入
autoconfigure: true # 自动打 tag(如 EventSubscriber)
public: false # 默认私有,只能被注入
App\:
resource: '../src/'
exclude: '../src/{DependencyInjection,Entity,Kernel.php}'
只要类在 src/ 下且被 resource 覆盖,就自动注册为服务。这与 Laravel 的反射自动解析思路一致,但 Symfony 更进一步:服务定义在编译期生成,运行时几乎零反射开销。
<?php
namespace App\Service;
use App\Repository\OrderRepository;
use Psr\Log\LoggerInterface;
final class OrderService
{
public function __construct(
private readonly OrderRepository $orders,
private readonly LoggerInterface $logger,
) {}
}
显式绑定、参数与环境变量:
services:
App\Service\Payment\GatewayInterface:
alias: App\Service\Payment\StripeGateway
App\Service\InvoiceService:
arguments:
$taxRate: '%env(float:TAX_RATE)%'
$currency: '%invoice.currency%'
%env(...)% 是环境变量处理器语法(float: 前缀做类型转换),%参数名% 引用容器参数。接口之所以能被 autowire 解析,靠的正是 alias 绑定。
编译器 Pass 在编译期改写容器,适合「收集所有带某 tag 的服务」这类全局装配——这正是框架扩展自己的方式:
final class CollectHandlersPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$handlers = [];
foreach ($container->findTaggedServiceIds('app.event_handler') as $id => $tags) {
$handlers[] = $container->getDefinition($id);
}
$container->getDefinition('app.handler_registry')->setArgument(0, $handlers);
}
}
排查「为什么我的服务没被注入」,九成靠 php bin/console debug:autowiring Order(反查类型提示能解析到什么)与 debug:container --tag=... 就能定位。
5. 路由与控制器:请求的入口
<?php
namespace App\Controller;
use App\Entity\Order;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/orders', name: 'api_orders_')]
final class OrderController extends AbstractController
{
#[Route('', name: 'list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$page = $request->query->getInt('page', 1);
return $this->json(['page' => $page]);
}
#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(Order $order): JsonResponse
{
return $this->json($order); // id 自动映射为实体,见下
}
}
Symfony 的 Request 是 symfony/http-foundation 的产物,把 $_GET/$_POST/$_SERVER 封装成面向对象接口——Laravel 的 Illuminate\Http\Request 也是它的子类。响应对象可自由加头,如 $response->headers->set('X-Request-Id', bin2hex(random_bytes(8))) 后再 return $response。
参数转换(ParamConverter):当路由占位符 {id} 与参数类型 Order 匹配时,Doctrine 会自动按主键查询,查不到则抛 404。注意:控制器签名里出现实体类型会触发一次数据库查询,需要严格区分「路由模型绑定」与「手动查询」的语义。
php bin/console debug:router --show-controllers
php bin/console router:match /api/orders/42
6. Doctrine 集成:ORM 与数据库
Doctrine 采用 Data Mapper 模式:实体是纯 PHP 对象,映射用 Attribute 声明,持久化交给 EntityManager。
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: \App\Repository\OrderRepository::class)]
#[ORM\Table(name: 'orders')]
#[ORM\Index(columns: ['created_at'], name: 'idx_orders_created')]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $orderNo;
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
private string $amount;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(string $orderNo, string $amount)
{
$this->orderNo = $orderNo;
$this->amount = $amount;
$this->createdAt = new \DateTimeImmutable();
}
}
仓储继承 ServiceEntityRepository,用 QueryBuilder 或 DQL 查询;写入用工作单元批量提交,事务用 wrapInTransaction 包住:
$em->wrapInTransaction(function () use ($em, $order) {
$em->persist($order);
$em->persist(new AuditLog($order->getOrderNo()));
});
php bin/console make:migration
php bin/console doctrine:migrations:migrate
php bin/console doctrine:schema:validate # CI 中检查实体与库结构漂移
| 问题 | 对策 |
|---|---|
| N+1 查询 | DQL join + addSelect 预抓取 |
| 大量写入逐条 flush | 分批 flush() + clear(),避免工作单元膨胀 |
| 只读列表加载实体 | 用 getArrayResult() 或 DTO 查询省去对象水合 |
Doctrine 迁移的设计哲学(版本化、可回滚、多环境一致)在 https://plumephp.com/php-database-migrations-architecture/ 中有更系统的展开。
7. 事件系统与内核事件
<?php
namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class OrderPlacedEvent extends Event
{
public function __construct(public readonly string $orderNo) {}
}
<?php
namespace App\EventListener;
use App\Event\OrderPlacedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: OrderPlacedEvent::class, priority: 10)]
final class SendOrderMailListener
{
public function __construct(private readonly LoggerInterface $logger) {}
public function __invoke(OrderPlacedEvent $event): void
{
$this->logger->info('订单已下单,准备发邮件', ['orderNo' => $event->orderNo]);
}
}
#[AsEventListener] 由 autoconfigure: true 自动打 tag,无需手写 YAML。
Symfony 的 HTTP 内核本身就是一个事件流:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
| kernel.request | 请求刚进入 | 认证、语言协商、维护模式 |
| kernel.controller | 控制器解析后 | 权限检查、审计 |
| kernel.response | 响应生成后 | 加响应头、CORS、缓存 |
| kernel.exception | 抛异常时 | 统一错误响应 |
| kernel.terminate | 响应发送后 | 异步收尾、写日志 |
监听器里先用 $event->isMainRequest() 过滤子请求,再写业务逻辑。priority 越大越先执行,用 php bin/console debug:event-dispatcher kernel.request 查看顺序。
与 Laravel 事件相比:Symfony 严格遵循 PSR-14 且深度集成请求生命周期,监听器用 Attribute 自动注册、优先级为显式数字;Laravel 则通过 EventServiceProvider 与自动发现机制。
8. 配置、环境与密钥管理
# .env 提交到仓库(只放非敏感默认值)
APP_ENV=dev
DATABASE_URL="mysql://app:pass@127.0.0.1:3306/app?serverVersion=8.0"
# .env.local 不提交(本机覆盖);.env.test / .env.prod 按环境区分
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
密钥保险箱(Secrets Vault) 把敏感值以加密形式存于 config/secrets/<env>/,可安全提交到私有仓库,解密密钥单独分发——比「把 .env 塞进 CI 变量」更适合团队协作:
php bin/console secrets:set DATABASE_PASSWORD
php bin/console secrets:list --reveal
注意:Symfony 的配置在编译期被展开并缓存进 var/cache/<env>/,所以运行期改 services.yaml 不会立即生效——这是很多「改了没生效」问题的根源。用 config:dump-reference framework 查配置树,debug:config framework 看当前生效值。
9. Symfony 与 Laravel 的取舍
| 维度 | Symfony | Laravel |
|---|---|---|
| 设计哲学 | 组件优先、显式配置 | 约定优于配置、开箱即用 |
| 学习曲线 | 陡峭(容器/Bundle 概念多) | 平缓(文档友好) |
| 灵活度 | 高(可替换任一组件) | 中(框架整体性强) |
| 默认 ORM | Doctrine(Data Mapper) | Eloquent(Active Record) |
| 长期维护 | 企业级、向后兼容严格 | 迭代快、大版本有破坏性变更 |
| 生态 | 偏企业、CMS、电商 | 偏创业、SaaS、快速交付 |
- 选 Symfony:系统复杂、生命周期长(5 年以上)、团队规模大、需要严格架构约束,或产品基于 Drupal/Shopware/API Platform。
- 选 Laravel:追求交付速度、团队小、产品需快速验证,且生态组件(Horizon、Nova、Livewire)契合需求。
- 混用:Laravel 应用里直接使用 Symfony 组件(
symfony/console、symfony/process、symfony/serializer)是完全正常的做法。
从 Laravel 迁移到 Symfony 需要四次认知转换:Facade → 构造器注入(没有门面,依赖必须显式声明)、Eloquent → Doctrine(从「模型即表」转向「实体 + 仓储 + 工作单元」)、Artisan → bin/console(命令编写方式几乎一致,都基于 symfony/console)、配置在代码里 → 配置在 YAML 里(服务绑定从 AppServiceProvider 搬到 services.yaml)。
一句话总结:Symfony 是「给你一套可组合的积木和一份装配图纸」,Laravel 是「给你一栋装修好的房子」。前者上限更高、后者起步更快;真正成熟的团队会根据系统寿命与复杂度做选择,而不是凭喜好站队。
延伸阅读
- https://plumephp.com/php-laravel-internals/ — 服务容器与中间件管道,理解 Symfony 组件的上层应用
- https://plumephp.com/php-oop-design-patterns/ — 依赖注入、工厂与策略模式在框架中的落地
- https://plumephp.com/php-database-migrations-architecture/ — Doctrine/Laravel 迁移系统的架构与零停机实践
- https://plumephp.com/php-api-design-rest/ — 在 Symfony 或 Laravel 上构建一致的 REST API
- Symfony 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。