原文:Symfony 文档贡献者,Databases and the Doctrine ORM。本稿按 2026-10-05 读取的 Symfony 8.1 官方正文翻译、整理。原文连同代码示例采用 CC BY-SA 3.0;本中文改编按相同许可提供。原始归属保留,安全性与版本补充均标为编者说明。
Doctrine 把关系数据库中的记录映射成 PHP 对象,让应用通过实体表达领域数据,通过仓库查询实体,通过 Entity Manager 管理对象变化。本文从一个 Product 实体出发,走完连接数据库、生成迁移、保存、读取、更新、删除以及编写查询的过程。
本文范围是关系数据库 ORM。需要底层 SQL 操作时可阅读 Doctrine DBAL,MongoDB 则使用相应的 DoctrineMongoDBBundle。本文的环境边界为 Symfony 8.1、PHP 8.4+;实际项目还需检查已安装的 Doctrine、驱动与数据库版本。所有命令和 PHP 代码均未执行,仅作静态审核。

一、安装 ORM 与配置数据库
在现有 Symfony 项目中安装 ORM pack,再安装用于生成实体与控制器的开发工具 MakerBundle:
composer require symfony/orm-pack
composer require --dev symfony/maker-bundle
连接信息一般使用 DATABASE_URL。开发环境可以在 .env.local 覆盖默认配置,真实密码不应提交到版本库。下面保留原文的数据库类型示例;用户名、密码、库名和版本号都只是说明写法的占位值,必须替换为目标环境的实际值。旧服务版本不是安装建议。
# .env.local:只演示格式,勿提交真实凭据
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=8.0.37"
# MariaDB:
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=10.5.8-MariaDB"
# Unix 本地 MySQL/MariaDB socket:
# DATABASE_URL="mysql://db_user:db_password@localhost/db_name?serverVersion=8.0.37&unix_socket=/var/run/mysqld/mysqld.sock"
# SQLite:
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
# PostgreSQL(版本必须按实际服务填写):
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=12.19%20%28Debian%2012.19-1.pgdg120%2B1%29&charset=utf8"
# Oracle:
# DATABASE_URL="oci8://db_user:db_password@127.0.0.1:1521/db_name"
这里把原文 PostgreSQL 版本字符串中的空格、括号和加号做了 URL 编码。127.0.0.1 明确使用 IPv4,避免 localhost 被解析为 ::1 而数据库只监听 IPv4 的不一致。Unix socket 写法只适用于对应的类 Unix 本地服务。
用户名、密码、主机名或库名含 URI 保留字符时也必须正确编码,例如 :、/、@、#、& 等。原文允许使用 urlencode 或环境变量处理器,并提醒使用这类环境变量处理时移除 Doctrine URL 配置中的 resolve: 前缀,改成 url: '%env(DATABASE_URL)%'。
也可以完全避免把凭据拼成 URL,使用独立参数。环境文件中的值用单引号括起,可避免 $ 和 # 被解释。不要同时保留一个会覆盖这些值的 URL 配置:
# config/packages/doctrine.yaml
doctrine:
dbal:
user: '%env(DATABASE_USER)%'
password: '%env(DATABASE_PASSWORD)%'
host: '%env(DATABASE_HOST)%'
port: '%env(DATABASE_PORT)%'
dbname: '%env(DATABASE_NAME)%'
driver: pdo_mysql
需要对应 PDO/数据库驱动,以及创建数据库的权限。核对连接指向独立开发库后,才考虑执行:
php bin/console doctrine:database:create
php bin/console list doctrine
server_version 或 URL 中的 serverVersion 会影响 Doctrine 生成的 SQL,不应照抄与实际服务不符的值。生产连接宜使用最小权限账户,建库与结构迁移权限可与日常运行权限分离。
二、定义 Product 实体
运行 php bin/console make:entity,实体名填 Product,先增加 name(string,长度 255,不可为 null)和 price(integer,不可为 null)。Maker 会生成 src/Entity/Product.php 与相应仓库。
实体只是 PHP 类;#[ORM\Entity] 声明它受 ORM 管理,#[ORM\Column] 把属性映射为列,主键属性增加 Id、GeneratedValue。价格使用整数是示例设计,例如把 1999 解释为最小货币单位,可避免二进制浮点直接存金额的舍入问题,但业务仍需记录币种与精度。
稍后新增 description 后,完整实体可整理如下。这里补全访问器和基础业务校验约束,是相对原文省略片段的编者整理;nullable PHP 初始值不等于数据库列允许 null。
<?php
// src/Entity/Product.php
namespace App\Entity;
use App\Repository\ProductRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private ?string $name = null;
#[ORM\Column]
#[Assert\NotNull]
#[Assert\PositiveOrZero]
private ?int $price = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
private ?string $description = null;
public function getId(): ?int { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getPrice(): ?int { return $this->price; }
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getDescription(): ?string { return $this->description; }
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
}
可向 make:entity 传 --with-uuid 或 --with-ulid,把主键生成成相应 Uid 类型。生成器只是辅助工具,字段、方法和映射仍由开发者维护。表名或列名应避免 GROUP、USER 等 SQL 保留字;可通过 #[ORM\Table(name: 'groups')] 或列的 name 参数改名。
原文还保留 MySQL 5.6 及更早版本 InnoDB 767 字节索引前缀限制的历史说明:utf8mb4 下 255 字符的唯一索引列可能超限,示例建议长度 190。这是兼容旧系统的说明,不是建议新项目继续使用该旧版本。
字段类型:枚举、UUID、ULID 与 DatePoint
Doctrine 支持数值、字符串、日期、JSON、二进制等映射。使用 PHP 枚举时,必须是具有标量值的 backed enum,数据库保存的是其标量值:
<?php
// src/Enum/Suit.php
namespace App\Enum;
enum Suit: string
{
case Hearts = 'H';
case Diamonds = 'D';
case Clubs = 'C';
case Spades = 'S';
}
use App\Enum\Suit;
use Doctrine\ORM\Mapping as ORM;
// 位于实体类内:
#[ORM\Column(enumType: Suit::class)]
public Suit $suit;
Symfony 的 UuidType 和 UlidType 在数据库支持时使用原生 GUID 类型,否则使用 16 字节二进制。原文的字段写法分别是:
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\Ulid;
// 实体字段片段;需在构造器或业务流程中初始化。
#[ORM\Column(type: UuidType::NAME)]
private Uuid $sku;
#[ORM\Column(type: UlidType::NAME)]
private Ulid $identifier;
Clock 组件的 DatePoint 有三种映射:date_point 对应 datetime_immutable,day_point 对应 date_immutable,time_point 对应 time_immutable。类型提示为 DatePoint 时可自动推断 date_point,也可显式指定:
use Symfony\Component\Clock\DatePoint;
// 实体字段片段:
#[ORM\Column]
private DatePoint $createdAt;
#[ORM\Column(type: 'date_point')]
private DatePoint $updatedAt;
#[ORM\Column(type: 'day_point')]
public DatePoint $releaseDate;
#[ORM\Column(type: 'time_point')]
public DatePoint $openingTime;
需要 Clock 组件的可测试时间能力时,可选 DatePoint;不需要这些能力时,datetime_immutable 已能表示不可变时间值。以上补充类型并非要求全部加入 Product,使用之前要完成相应字段初始化、依赖和迁移。
三、用迁移同步数据库结构
创建实体不会自动创建数据库表。先生成迁移,再审查生成的 SQL,然后才能对已确认的目标数据库执行:
php bin/console make:migration
php bin/console doctrine:migrations:migrate
make:migration --formatted 可生成格式更整齐的文件。迁移命令只执行尚未应用的迁移;系统用迁移版本记录跟踪进度。应提交迁移文件,让各环境按相同变更序列推进。迁移可能建表、改列或删除数据,本文没有运行这些命令;生产执行前需要备份、审核锁表与回退方案。
后续再次运行 make:entity,选择 Product,增加不可为空的 text 字段 description。Maker 会增加属性、getter 与 setter。再次生成的迁移可能包含:
ALTER TABLE product ADD description LONGTEXT NOT NULL
这是原文用于解释差异的 MySQL 风格 SQL,不是通用跨数据库语句。SQLite 为已有表添加“无默认值的非空列”会报错,原文建议先允许 null。已有数据的其他数据库同样应考虑回填:先增加可空列,按业务规则写入旧行,再在后续迁移收紧为非空。不能把“改成 nullable”当成已经满足业务完整性的永久解决。
手动加属性后,可用 php bin/console make:entity --regenerate 补访问器;加 --overwrite 会重写已有 getter/setter,执行前应检查自定义逻辑与版本差异。
四、持久化:persist 不发 SQL,flush 才写入
Entity Manager 可通过 EntityManagerInterface 自动注入。persist($product) 把新对象纳入管理,此时尚未执行 INSERT;flush() 检查所有受管理对象的变化并提交相应 SQL。新对象需要 INSERT,已载入的对象变更通常需要 UPDATE。
原教程用没有限定 HTTP 方法的 /product 路由演示创建,浏览器访问即可写库。这适合说明 ORM 操作,却不适合作为真实写接口。下面修订为 POST,增加权限与 CSRF 检查,先校验再持久化。它仍以固定示例产品说明流程,不包含面向任意输入的完整产品表单。
<?php
// src/Controller/ProductController.php
namespace App\Controller;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ProductController extends AbstractController
{
#[Route('/product', name: 'create_product', methods: ['POST'])]
public function createProduct(
Request $request,
EntityManagerInterface $entityManager,
ValidatorInterface $validator,
): Response {
$this->denyAccessUnlessGranted('ROLE_PRODUCT_EDITOR');
$token = (string) $request->request->get('_token', '');
if (!$this->isCsrfTokenValid('product_create', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$product = new Product();
$product->setName('Keyboard')
->setPrice(1999)
->setDescription('Ergonomic and stylish!');
$errors = $validator->validate($product);
if (count($errors) > 0) {
return $this->json(['error' => 'Invalid product data'], 400);
}
$entityManager->persist($product);
$entityManager->flush();
return $this->json(['id' => $product->getId()], 201);
}
}
这段修订依赖已配置的安全系统、授权角色以及 CSRF 组件。浏览器表单需提交 csrf_token('product_create') 生成的令牌;模板示例如下,创建产品的固定值仍只用于教学:
<form action="{{ path('create_product') }}" method="post">
<input type="hidden" name="_token" value="{{ csrf_token('product_create') }}">
<button type="submit">创建示例产品</button>
</form>
如果是无 Cookie 身份凭据的 API,应按实际认证方案设计防护,而非不加区分地照抄表单 CSRF。真正从请求读取字段时,还需限制字段、数据类型、长度、数值范围及业务授权,避免任意字段批量绑定。
原文用只读 SQL 验证创建结果:
php bin/console dbal:run-sql 'SELECT * FROM product'
在 Windows 的非 PowerShell shell 中可改用双引号。命令只是验证方法,不表示本文已经插入记录。flush() 可能失败,需按所用 ORM 版本的异常类型与事务约定处理;不能在数据库操作失败后仍返回创建成功,也不能向外暴露数据库连接信息。
五、自动推断校验不等于业务校验
Symfony Validator 可从 Doctrine 元数据推断一部分约束,需要配置 Validator 的 auto_mapping 范围。注意它与后面控制器实体解析器的 auto_mapping 是不同配置。
| Doctrine 元数据 | 可能推断的约束 | 边界 |
|---|---|---|
| nullable=false | NotNull | 需要 PropertyInfo;不等于非空字符串 |
| type | Type | 需要 PropertyInfo;不表达业务值域 |
| unique=true | UniqueEntity | 仍应保留数据库唯一约束处理并发 |
| length | Length | 只限制长度 |
调用 $validator->validate($product) 才执行相应校验流程。Form 组件与 API Platform 因内部使用 Validator,也可受益于自动约束。自动映射不能表达“价格不可为负”“名称不得全为空白”等全部业务规则,因此前面的实体显式补充了约束。
六、通过 Repository 查询对象
查询某类实体时,通常通过它的 Repository。可以从 Entity Manager 获取,也可以直接把 ProductRepository 注入控制器。找不到记录时,应用应返回 404,而不是继续解引用 null。
use App\Repository\ProductRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
// 以下方法放在继承 AbstractController 的控制器中。
#[Route('/product/{id}', name: 'product_show', methods: ['GET'],
requirements: ['id' => '\\d+'])]
public function show(ProductRepository $products, int $id): Response
{
$product = $products->find($id);
if ($product === null) {
throw $this->createNotFoundException('Product not found');
}
return $this->render('product/show.html.twig', ['product' => $product]);
}
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
原文还展示把 $product->getName() 直接拼入 new Response(...) 的写法。如果名称可被用户控制且响应按 HTML 解释,这会引入存储型 XSS 风险。这里改成默认自动转义的 Twig 输出;不要无故追加 |raw。也可按接口需求使用 JSON 响应。
$repository = $entityManager->getRepository(Product::class);
$product = $repository->find($id);
$product = $repository->findOneBy(['name' => 'Keyboard']);
$product = $repository->findOneBy(['name' => 'Keyboard', 'price' => 1999]);
$products = $repository->findBy(
['name' => 'Keyboard'],
['price' => 'ASC'],
);
$allProducts = $repository->findAll();
find() 按主键查一项;findOneBy() 按条件查一项;findBy() 返回匹配列表并可指定排序;findAll() 返回全部。数据量大时不要无界读取全部记录,应分页或限制结果数。
开发环境的调试工具栏会显示 SQL 数量与耗时,点击 Doctrine 项可在 Profiler 查看实际查询。需要时可安装 composer require --dev symfony/profiler-pack。查询数偏多可帮助发现 N+1 等问题,但计数颜色不是性能诊断结论;Profiler 不应向未授权的生产用户开放。
七、让 EntityValueResolver 自动取得实体
控制器参数直接声明 Product $product,且路由有 {id} 时,解析器可按主键调用 find()。没找到会自动产生 404。把参数改为 ?Product $product 则可自己处理缺失情况。
#[Route('/product/{id}', methods: ['GET'])]
public function show(Product $product): Response
{
return $this->render('product/show.html.twig', ['product' => $product]);
}
要按其他字段查找,可使用 {param:argument} 语法,如 /product/{slug:product},含义是把 slug 条件交给 product 参数的解析。这里的 slug 必须先在实体和数据库中真实存在;前面最小 Product 尚未定义它。
也可以通过 MapEntity 显式映射,占位符名是键,实体属性名是值:
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
#[Route('/product/{product_slug}', methods: ['GET'])]
public function show(
#[MapEntity(mapping: ['product_slug' => 'slug'])]
Product $product,
): Response {
return $this->render('product/show.html.twig', ['product' => $product]);
}
全局开启 doctrine.orm.controller_resolver.auto_mapping=true 时,会尝试用所有与实体属性同名的路由通配符做 findOneBy(),忽略不是属性的通配符。原文已不推荐这种较隐式的行为。特定参数可用 #[MapEntity(disabled: true)] 关闭,例如从 #[CurrentUser] 获取的用户不应再次按路由解析。
用表达式或接口定制解析
MapEntity(expr: ...) 可以使用 ExpressionLanguage。表达式中的 repository 是相应实体仓库,路由通配符以及 request 可作为变量:
#[Route('/product/{product_id}', methods: ['GET'])]
public function show(
#[MapEntity(expr: 'repository.find(product_id)')]
Product $product,
): Response {
return $this->render('product/show.html.twig', ['product' => $product]);
}
仓库方法也可返回多个实体,此时参数声明为 iterable,并明确实体 class;原文示例为按作者查最多十篇文章:
#[MapEntity(
class: Post::class,
expr: 'repository.findBy({"author": author_id}, {}, 10)',
)]
iterable $posts
这是控制器参数片段,假定已经定义 Post 实体及 {author_id} 路由。多个参数可分别映射,例如 product 用主键自动取得,comment 用 repository.find(comment_id)。编者补充:仅分别查到两条记录,并不能证明评论属于这个产品,也不能证明当前用户有权读取它们;关联条件和对象级授权需补齐。
原文还用 request.query.get("sort", "DESC") 直接决定 createdAt 的排序方向。它说明表达式能读取请求,但真实接口应先把方向限制为 ASC/DESC,不能把任意外部字符串当作可信查询结构。更适合在仓库方法内完成白名单和产品关联条件,再由表达式调用。
若 Product 实现 ProductInterface,可配置 resolve_target_entities,让控制器依赖接口,并对参数加 #[MapEntity]。这样可把控制器与具体实体实现解耦,但解析器仍需要已配置的实际目标实体。
| MapEntity 选项 | 作用 |
|---|---|
| id | 指定哪个路由参数是主键,如 id: ‘product_id’ |
| mapping | 指定路由参数到实体属性的映射,用于 findOneBy |
| stripNull | 为 true 时,不把 null 条件传给 findOneBy;需注意这可能放宽查询 |
| objectManager | 选择非默认对象管理器,如 ‘foo’ |
| evictCache | 强制从数据库读取而非缓存 |
| disabled | 停止解析该参数 |
| message | 定制开发环境 NotFoundHttpException 消息,生产环境不显示该消息 |
八、更新和删除:对象变化在 flush 时提交
从 Doctrine 查得的对象已经处于管理状态。修改后调用 flush() 即可,不必再 persist()。原文以无方法限制的编辑路由说明三个步骤;这里仅保留持久化核心,并明确要求放在已鉴权、已做对象授权和必要 CSRF 防护的 POST/PATCH 处理器中。
$product = $entityManager->getRepository(Product::class)->find($id);
if ($product === null) {
throw $this->createNotFoundException('Product not found');
}
// 在此之前完成对象级授权、输入解析与校验。
$product->setName('New product name!');
$entityManager->flush();
return $this->redirectToRoute('product_show', [
'id' => $product->getId(),
]);
删除调用 remove() 后再 flush()。前者只是登记删除意图,后者才执行 DELETE。以下是有副作用的原理示例,本文没有执行;真实入口还需核对级联删除、关联约束和恢复要求。
$entityManager->remove($product);
$entityManager->flush();
九、把复杂查询放进仓库
Maker 生成的 ProductRepository 继承 ServiceEntityRepository,构造器通过 ManagerRegistry 与 Product 类建立联系。实体上的 repositoryClass 使 getRepository(Product::class) 返回这个仓库。
例如查询价格高于某值的产品,用 DQL 描述 PHP 实体与属性,而非底层表名:
<?php
// src/Repository/ProductRepository.php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
/** @return Product[] */
public function findAllGreaterThanPrice(int $price): array
{
return $this->getEntityManager()->createQuery(
'SELECT p FROM App\Entity\Product p
WHERE p.price > :price
ORDER BY p.price ASC'
)->setParameter('price', $price)->getResult();
}
}
调用 $repository->findAllGreaterThanPrice(1000) 返回 Product 对象数组。查询中的值通过 :price 与 setParameter() 绑定;不要把请求值直接拼入 DQL 字符串。
Query Builder:根据条件组合查询
查询结构随 PHP 条件改变时,可用 Query Builder。下面是与前面最小实体匹配的版本;相对原文,默认片段移除了尚未定义的 available 条件。
public function findAllGreaterThanPriceWithBuilder(int $price): array
{
return $this->createQueryBuilder('p')
->where('p.price > :price')
->setParameter('price', $price)
->orderBy('p.price', 'ASC')
->getQuery()
->getResult();
}
原文另外提供 $includeUnavailableProducts 分支:为 false 时加 $qb->andWhere('p.available = TRUE')。使用前必须给 Product 增加 boolean 的 available 映射、默认值、访问器和迁移,并处理旧行回填,否则查询引用不存在的字段。需要只取一项时可对 Query 使用 setMaxResults(1)->getOneOrNullResult()。
Query Builder 不会把所有字符串自动变安全。值应参数化;动态字段名、排序方向和其他查询结构须使用开发者定义的白名单,而不是对任意用户字符串调用 orderBy()。
直接 SQL 返回原始行,不会自动变成实体
public function findRowsGreaterThanPrice(int $price): array
{
$connection = $this->getEntityManager()->getConnection();
$sql = 'SELECT * FROM product p
WHERE p.price > :price
ORDER BY p.price ASC';
return $connection->executeQuery($sql, ['price' => $price])
->fetchAllAssociative();
}
这段使用 DBAL Connection 执行参数化 SQL,得到的是关联数组的数组,不是受 Entity Manager 跟踪的 Product 实体。修改返回数组不会自动触发数据库 UPDATE。需要从原生 SQL 映射实体时,应使用 Doctrine 的 NativeQuery 等专门机制;不要混淆 DQL 结果与 SQL 数据行。
十、关联、扩展与测试的下一步
Doctrine 支持 ManyToOne、OneToMany、OneToOne 与 ManyToMany,关系的拥有端、级联与懒加载需要另行设计。原文将这些内容指向关联教程,配置细节指向Doctrine 配置参考,数据库测试指向仓库测试指南。
自动记录 createdAt、翻译等常见需求,可查看 Doctrine 社区扩展,并通过 StofDoctrineExtensionsBundle 集成。需要时进一步阅读 Doctrine 事件、自定义 DQL 函数以及多 Entity Manager/连接配置。它们属于后续主题,本稿没有把这些链接伪装成已执行的测试或完整实现。
本次静态审核确认原文使用参数绑定的 DQL 与 SQL 示例没有直接拼接 price;同时识别了无方法限制的写路由、未转义 HTML 响应、直接使用外部排序参数、缺失 available 字段,以及向已有数据添加非空列的实际问题。示例密码是占位文本,没有发现真实凭据;这不等于完整应用不存在漏洞。本文未连接数据库、未执行迁移、未测试权限或性能。
依据 Symfony 官方文档作中文翻译与改编,包含上述明确标出的安全修改;按 CC BY-SA 3.0 共享,不表示 Symfony 或其贡献者背书。翻译整理、安全性补充及原创示意图:未完纪。原文及代码示例的许可说明见 Symfony 官方文档许可说明;CC BY-SA 3.0 完整法律条款适用于本中文改编及原创示意图。












暂无评论内容