CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
Eloquent ORM 关系、Scope 与迁移。
数据库与 Eloquent ORM
这一课你将能做到
用迁移(migration)版本化地建表改表,用工厂与填充造出测试数据
定义一对一、一对多、多对多关系,并通过关系进行预加载与跨表查询
识别并治理 N+1 查询,用事务保证一致性,知道何时回落到查询构造器
为什么要学
数据层是后端的半壁江山。Spring 阵营有 JPA/Hibernate 与 MyBatis 两派:前者面向对象、自动映射,后者 SQL 可控。Eloquent 属于前者——Active Record 模式,一个模型类对应一张表,实例即一行记录,关系用方法表达。它的开发效率非常高,配合迁移可以让整个团队的表结构演进像代码一样可追踪、可回滚。
但任何 ORM 都有代价:隐藏 SQL 后容易写出低效查询。真实项目里最常见的性能事故就是 N+1 和隐式全表扫描。本课不仅讲「怎么写」,还讲「生成的 SQL 是什么」「怎么避免坑」,这是从会用框架到能扛项目的分界线。
先建立图像
一个简化电商数据模型:
users 1───N orders N───N products(通过 order_items / 或 product_order 透视表)
orders N───1 addresses
products N───1 categories
Eloquent 关系对照:
关系 Laravel 方法 JPA 等价 一对一 hasOne / belongsTo@OneToOne一对多 hasMany / belongsTo@OneToMany / @ManyToOne多对多 belongsToMany(透视表)@ManyToMany远层一对多 hasManyThrough两次 join 手写 多态 morphMany / morphTo@Any…(较绕)
跟着做
步骤 1:写迁移建表
php artisan make:migration create_orders_table
php artisan make:migration create_order_items_table
php artisan make:model Order -m # 一步生成模型 + 迁移
php artisan make:model OrderItem -m
database/migrations/xxxx_create_orders_table.php:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('order_no', 32)->unique();
$table->unsignedBigInteger('amount')->comment('订单总额,单位:分');
$table->string('status', 16)->default('pending')->index();
$table->string('channel', 16)->default('mock');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->string('sku', 32);
$table->unsignedInteger('price'); // 单价(分)
$table->unsignedInteger('quantity');
$table->timestamps();
});
php artisan migrate # 执行未运行的迁移
php artisan migrate:rollback --step=1
php artisan migrate:fresh # 删全部表重跑(开发用,生产严禁)
修改已有表用新迁移而不是改旧文件(旧文件可能已在别人的环境执行过):
php artisan make:migration add_remark_to_orders_table --table=orders
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('remark', 200)->nullable()->after('channel');
});
}
步骤 2:定义模型与关系 <?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
// 允许批量赋值的字段($request->validated() 写入时需要)
protected $fillable = ['order_no', 'amount', 'status', 'channel', 'remark', 'paid_at'];
// 类型转换:从库中取出时自动转为 Carbon / bool 等
protected $casts = [
'paid_at' => 'datetime',
'amount' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
// 本地作用域:复用查询条件
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
}
app/Models/OrderItem.php:
class OrderItem extends Model
{
protected $fillable = ['product_id', 'sku', 'price', 'quantity'];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
步骤 3:基本 CRUD use App\Models\Order;
// 新增
$order = Order::create([
'order_no' => 'NO'.now()->format('YmdHis').str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT),
'amount' => 19900,
'status' => 'pending',
'channel' => 'alipay',
]);
// 查询
$order = Order::findOrFail(1);
$paid = Order::query()->paid()->where('user_id', $id)->orderByDesc('id')->get();
$one = Order::where('order_no', $no)->firstOrFail();
// 更新
$order->update(['status' => 'paid', 'paid_at' => now()]);
// 删除
$order->delete();
create() 依赖 $fillable 白名单(防批量赋值攻击);不想逐个列白名单可用 $guarded = [](等于关闭防护,仅内部系统谨慎使用)。
步骤 4:通过关系读写 // 创建订单时一并写明细(关系的 save / createMany)
$order = User::findOrFail($userId)->orders()->create([
'order_no' => $orderNo,
'amount' => $total,
'status' => 'pending',
'channel' => $channel,
]);
$order->items()->createMany([
['product_id' => 1, 'sku' => 'SKU-1', 'price' => 9900, 'quantity' => 2],
['product_id' => 2, 'sku' => 'SKU-2', 'price' => 100, 'quantity' => 1],
]);
// 动态属性(触发一次查询)
foreach ($order->items as $item) {
echo $item->sku;
}
// 跨关系查询:找出买过某 SKU 的用户
$users = User::whereHas('orders.items', function ($q) {
$q->where('sku', 'SKU-1');
})->get();
步骤 5:预加载,消灭 N+1 错误写法(1 次查订单 + N 次查每个订单的用户):
$orders = Order::latest()->take(50)->get();
foreach ($orders as $order) {
echo $order->user->name; // 每次循环都查库
}
$orders = Order::with(['user', 'items.product'])->latest()->paginate(20);
foreach ($orders as $order) {
echo $order->user->name; // 不额外查库
echo $order->items->count();
}
// 只在某些条件下加载
$orders = Order::with(['items' => fn ($q) => $q->where('quantity', '>', 1)])->get();
// 统计关联数量而不加载集合
$users = User::withCount('orders')->having('orders_count', '>', 0)->get();
开发期可以开启严格的 N+1 监控(AppServiceProvider::boot()):
Model::preventLazyLoading(! app()->isProduction());
步骤 6:事务保证一致性 下单要同时写 orders、order_items、扣库存,必须同生共死:
use Illuminate\Support\Facades\DB;
$order = DB::transaction(function () use ($payload, $items) {
$order = Order::create($payload);
$order->items()->createMany($items);
foreach ($items as $item) {
$affected = DB::table('products')
->where('id', $item['product_id'])
->where('stock', '>=', $item['quantity']) // 乐观条件,防超卖
->decrement('stock', $item['quantity']);
if ($affected === 0) {
throw new \RuntimeException('库存不足');
}
}
return $order;
}, attempts: 3); // 死锁时自动重试 3 次
注意:抛出异常时事务自动回滚;事务内调用外部 HTTP(支付接口)要谨慎——外部调用不会随数据库回滚,应在事务提交后再发起。
步骤 7:查询构造器与分页 Eloquent 表达不了的复杂报表可以直接用查询构造器(仍然自动绑定参数,防注入):
$stats = DB::table('orders')
->selectRaw('DATE(created_at) AS day, COUNT(*) AS cnt, SUM(amount) AS gmv')
->where('status', 'paid')
->groupByRaw('DATE(created_at)')
->orderByDesc('day')
->limit(30)
->get();
// 分页
$orders = Order::with('items')->where('user_id', $uid)->paginate(20);
// 游标分页(大表深翻页性能更好,不产生 OFFSET)
$orders = Order::where('id', $cursor)->cursorPaginate(20);
讲清楚要点
迁移是团队契约 :只增不改旧文件;迁移要能 up 也能 down;外键用 foreignId()->constrained(),删除策略(cascade/restrict)显式声明。
Active Record 的双刃 :写业务很爽($order->items()->create()),但模型容易承载过多职责。复杂查询用 scope 或专门的查询类隔离,别让模型变成几千行的上帝类。
N+1 是默认行为 :访问关系动态属性就会即时查询;列表场景一律 with() 预加载。可用 preventLazyLoading 与调试栏(Laravel Debugbar)持续盯防。
时间戳与时区 :timestamps() 自动维护;建议库里存 UTC,展示层按用户时区转换,config/app.php 的 timezone 只影响写入。
软删除 :use SoftDeletes 后删除变更新 deleted_at,查询默认自动过滤;需要唯一索引的列(如手机号)要把 deleted_at 纳入唯一键设计,否则删除后无法重建。
集合不是数组 :get() 返回 Collection,可链式 map/filter/groupBy,但聚合在内存进行;几十万行要分批 chunk(500) 或惰性 lazy()。
防注入靠绑定,不靠拼接 :永远不要把变量直接拼进 whereRaw;必须拼的用 ? 占位。
你可能会懵的地方
hasOne 和 belongsTo 到底放哪边?
外键在谁的表上,谁就是 belongsTo。order_items 表有 order_id,所以 OrderItem belongsTo(Order);Order 没有 order_item_id,所以它 hasMany(OrderItem)。
with() 生成的 SQL 长什么样?
不是 JOIN,而是两条语句:先查主表,再 where ... in (主表 id) 一次性查关联,PHP 侧按外键装配。多对多时自动带透视表字段。
迁移跑错了怎么办?
单步回滚 migrate:rollback --step=1;迁移已推生产且 down 会丢数据时,写一个新的补偿迁移而不是回滚。
多对多的额外字段(如购买时单价)存哪?
放透视表,定义关系时 ->withPivot('price', 'quantity');读写用 $model->pivot->price 或 attach($id, ['price' => 100])。
Eloquent 和 MyBatis 能共存吗?
技术上可以混用连接;但一个项目里两种数据访问风格会增加认知成本,通常按「常规 CRUD 用 Eloquent,复杂报表用查询构造器/视图」分工即可。
错法对照 错法 问题 正解 列表里直接访问 $order->user->name N+1,50 单变 51 次查询 with('user')改旧迁移文件修正字段 别人的库不会变更 新增迁移 循环内单条更新库存 慢且无法保证一致 事务 + 条件 update whereRaw("name = '$name'")SQL 注入 whereRaw('name = ?', [$name])一次 get() 几十万行再聚合 内存打爆 chunk/lazy 或库内 SUM事务内先调支付再写库 回滚后钱已付 先落库(状态处理中)→ 事务外支付 → 回调更新
卡点急救
关系属性返回 null 但数据存在 :检查外键命名(默认 order_id)、所属模型的主键、关系方法有没有误加 where;$order->items()(带括号)返回构造器,$order->items 才是集合,别搞混。
迁移报外键约束失败 :被引用的表必须先建(调整迁移时间戳顺序);SQLite 默认外键支持在迁移环境中已开启;字段类型要与引用主键一致(都用 unsignedBigInteger)。
create() 什么都没写进去 :字段不在 $fillable。用 $model->getFillable() 检查,或改用 $model->forceFill()(仅限已过滤的可信数据)。
面试常问
什么是 N+1?如何发现和解决?
查 N 条主记录后,遍历中每条再触发一次关联查询,总查询数 1+N。解决:预加载 with()(IN 查询)、loadCount 聚合、或 JOIN 手写;发现靠 Debugbar、慢查询日志与开发期 preventLazyLoading。能说出「IN 方式预加载 + 监控手段」即为好答案。
fillable 与 guarded 的作用,为什么需要它们?
防批量赋值攻击:攻击者在表单里夹带 is_admin=1,若代码 User::create($request->all()) 就会提权。白名单 fillable 显式开放可写字段,是更安全的默认;guarded 是黑名单兜底。对应 Jackson 里禁用意外字段反序列化的同类防护。
库存扣减如何防超卖?
不依赖「先查后改」(有并发窗口),用条件更新:UPDATE products SET stock=stock-? WHERE id=? AND stock>=?,根据影响行数判断成败,失败即回滚事务并提示;高并发场景再上 Redis 预扣 + 异步落库或分布式锁。
自测三问
订单列表要同时显示下单人姓名和每件商品名称,你会怎么写查询?生成几次 SQL?
下单涉及写订单、写明细、扣三处库存,用什么保证一致性?外部支付调用应该放在事务内还是外?
上线后要给 orders 表加一个字段,正确的操作步骤是什么?为什么不能直接改原来的建表迁移?
小结一句话 Eloquent 用模型 + 关系 + 迁移把数据操作写得像业务语言,但必须用预加载、事务和条件更新守住性能与一致性两条底线。
延伸