CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Laravel 实战入门/课时/Eloquent ORM
RawBlame
课时目录12 节

Laravel 实战入门

  1. 01Laravel 概览
  2. 02目录结构
  3. 03路由
  4. 04控制器
  5. 05Blade 模板
  6. 06Eloquent ORM
  7. 07认证与授权
  8. 08验证与异常
  9. 09队列与事件
  10. 10运维与工具
  11. 11测试
  12. 12综合练习

Eloquent ORM

关系、Scope 与迁移。

30 分钟

数据库与 Eloquent ORM

这一课你将能做到

  1. 用迁移(migration)版本化地建表改表,用工厂与填充造出测试数据
  2. 定义一对一、一对多、多对多关系,并通过关系进行预加载与跨表查询
  3. 识别并治理 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');
    }
});

order_items 表:

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:定义模型与关系

app/Models/Order.php:

<?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);
    }
}

User 模型里补上:

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);

讲清楚要点

  1. 迁移是团队契约:只增不改旧文件;迁移要能 up 也能 down;外键用 foreignId()->constrained(),删除策略(cascade/restrict)显式声明。
  2. Active Record 的双刃:写业务很爽($order->items()->create()),但模型容易承载过多职责。复杂查询用 scope 或专门的查询类隔离,别让模型变成几千行的上帝类。
  3. N+1 是默认行为:访问关系动态属性就会即时查询;列表场景一律 with() 预加载。可用 preventLazyLoading 与调试栏(Laravel Debugbar)持续盯防。
  4. 时间戳与时区:timestamps() 自动维护;建议库里存 UTC,展示层按用户时区转换,config/app.php 的 timezone 只影响写入。
  5. 软删除:use SoftDeletes 后删除变更新 deleted_at,查询默认自动过滤;需要唯一索引的列(如手机号)要把 deleted_at 纳入唯一键设计,否则删除后无法重建。
  6. 集合不是数组:get() 返回 Collection,可链式 map/filter/groupBy,但聚合在内存进行;几十万行要分批 chunk(500) 或惰性 lazy()。
  7. 防注入靠绑定,不靠拼接:永远不要把变量直接拼进 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->nameN+1,50 单变 51 次查询with('user')
改旧迁移文件修正字段别人的库不会变更新增迁移
循环内单条更新库存慢且无法保证一致事务 + 条件 update
whereRaw("name = '$name'")SQL 注入whereRaw('name = ?', [$name])
一次 get() 几十万行再聚合内存打爆chunk/lazy 或库内 SUM
事务内先调支付再写库回滚后钱已付先落库(状态处理中)→ 事务外支付 → 回调更新

卡点急救

  1. 关系属性返回 null 但数据存在:检查外键命名(默认 order_id)、所属模型的主键、关系方法有没有误加 where;$order->items()(带括号)返回构造器,$order->items 才是集合,别搞混。
  2. 迁移报外键约束失败:被引用的表必须先建(调整迁移时间戳顺序);SQLite 默认外键支持在迁移环境中已开启;字段类型要与引用主键一致(都用 unsignedBigInteger)。
  3. 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 预扣 + 异步落库或分布式锁。

自测三问

  1. 订单列表要同时显示下单人姓名和每件商品名称,你会怎么写查询?生成几次 SQL?
  2. 下单涉及写订单、写明细、扣三处库存,用什么保证一致性?外部支付调用应该放在事务内还是外?
  3. 上线后要给 orders 表加一个字段,正确的操作步骤是什么?为什么不能直接改原来的建表迁移?

小结一句话

Eloquent 用模型 + 关系 + 迁移把数据操作写得像业务语言,但必须用预加载、事务和条件更新守住性能与一致性两条底线。

延伸

  • Eloquent 入门
  • 数据库迁移
  • 查询构造器
  • 数据库事务
  • Spring Data JPA 对照
  • 下一课:认证与授权——学习登录会话、Sanctum API Token、Gate 与 Policy。
加载本节练习…
← 上一课Blade 模板下一课 →认证与授权