Laravel 是我用得最久的框架。三年下来,有些当初觉得很棒的东西现在有保留,有些当初嫌弃的现在觉得挺对。记录一下。
Eloquent:从爱到谨慎
当初,Eloquent 是最吸引我的地方。$user->posts()->where('published', true)->get() 这种写法太舒服了。
现在,我在核心业务里尽量少用它。
不是因为它不好,而是因为它隐藏了太多东西。
问题一:N+1 太容易发生
$posts = Post::where('status', 'published')->get();
foreach ($posts as $post) {
echo $post->author->name; // 每次循环查一次 users 表
echo $post->category->title; // 又一次
}
100 篇文章 → 201 次查询。开发环境数据少,看不出问题;上线就炸。
预防手段:
$posts = Post::with(['author:id,name', 'category:id,title'])
->where('status', 'published')
->get();
但得记得写。Laravel 11 加了 Model::preventLazyLoading(),在开发环境里延迟加载直接抛异常,这个很有用:
// AppServiceProvider::boot()
Model::preventLazyLoading(!app()->isProduction());
Model::preventSilentlyDiscardingAttributes(!app()->isProduction());
我现在所有项目都开这两个。
问题二:toArray() 的行为不可预测
$user->toArray();
这个方法会:
- 应用
$hidden(藏掉 password) - 应用
$visible(如果设了) - 应用
$appends(追加访问器) - 递归处理关联(已加载的才包含)
- 转换日期格式
- 处理类型转换
结果就是:同一个模型,加载了不同关联,toArray 结果不一样。前端拿到的 JSON 结构会变。
我们线上出过一次:某个接口前端报"字段不存在",排查发现是某次重构去掉了 with('profile'),导致 toArray 里少了 profile。
改法:API 输出用专门的 Resource 类,显式列出字段。
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'avatar' => $this->avatar_url,
'profile' => new ProfileResource($this->whenLoaded('profile')),
];
}
}
啰嗦,但输出结构是显式的,改代码的时候能一眼看到会影响什么。
问题三:复杂查询很难读
简单的 Eloquent 很清晰。但一旦有多个关联、子查询、聚合:
$stats = Order::query()
->whereHas('user', fn($q) => $q->where('level', 'vip'))
->whereDoesntHave('refunds')
->withSum('items as total_amount', 'price')
->withCount(['items as item_count'])
->groupBy('user_id')
->having('total_amount', '>', 10000)
->get();
这段代码能跑,但调试的时候完全不知道生成的 SQL 是什么。得用 ->toSql() 或者开 query log。
而如果写成原生 SQL:
SELECT o.user_id,
SUM(i.price) AS total_amount,
COUNT(i.id) AS item_count
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN users u ON u.id = o.user_id
WHERE u.level = 'vip'
AND NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)
GROUP BY o.user_id
HAVING SUM(i.price) > 10000
一眼就能看懂,而且能直接扔进数据库客户端跑、加 EXPLAIN、让 DBA 看。
我的原则现在是:涉及聚合、多表关联、子查询的,直接写 SQL(用 DB::select 或者 Query Builder 的 selectRaw)。简单的 CRUD 用 Eloquent。
关于"魔术方法"的反思
Laravel 用了大量魔术方法:__get、__call、__callStatic。
好处是写起来短。坏处是:
一、IDE 支持有限。 虽然有 laravel-ide-helper 生成注解,但生成的 _ide_helper.php 经常跟上真实代码不同步。
二、静态分析难做。 我之前想把 PHPStan 上到 level 8,卡在 Eloquent 的动态属性上很久。虽然现在有 larastan 好多了,但一些动态调用的地方还是抓不到。
三、调试时调用栈变长。 一个 $user->posts 可能经过四五层框架内部调用,报错信息里全是框架代码。
我现在更喜欢显式的东西。 比如仓储层:
interface UserRepository
{
public function findById(int $id): ?User;
/** @return User[] */
public function findActiveByLevel(UserLevel $level): array;
}
实现用 Eloquent 还是原生 SQL 是内部细节,调用方不用关心。这样换个 ORM 或者优化某个查询都不用改业务代码。
Facade 和依赖注入
Laravel 的 Facade(Cache::get()、Log::info())用起来方便,但让依赖变成隐式的。
class OrderService
{
public function create(array $data)
{
Log::info('创建订单'); // 依赖了 Log
Cache::forget('orders'); // 依赖了 Cache
Mail::to($data['email'])->send(...); // 依赖了 Mail
}
}
从签名上看不出这个类依赖了什么。单元测试的时候,得 Log::shouldReceive 之类地 mock 一堆 facade。
改成显式注入:
class OrderService
{
public function __construct(
private LoggerInterface $logger,
private CacheInterface $cache,
private MailerInterface $mailer,
) {}
}
创建的时候多打几个字,但测试和维护省很多事。 依赖关系在构造函数上一目了然。
我的规则:业务代码不用 Facade,控制器和命令行入口随便。
队列:好用但有前提
ProcessPodcast::dispatch($podcast)->onQueue('processing');
一行代码就把任务丢后台了。但有几个坑:
一、任务里不要传大对象。 序列化一个 Eloquent 模型,队列里存的是完整快照;如果模型很大,队列存储会爆。而且队列消费时数据可能已经变了。
// 不好
ProcessOrder::dispatch($order);
// 好
ProcessOrder::dispatch($order->id);
// 任务里重新查,拿到最新状态
二、要幂等。 队列可能重复投递(重试、超时后 worker 被 kill)。任务必须能安全地重复执行。
三、失败要能重试且有上限。 默认重试 1 次太少,无限重试会堵塞队列。
class ProcessOrder implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 30; // 每次重试间隔
public function retryUntil(): DateTime
{
return now()->addHours(2); // 2 小时后不再重试
}
public function failed(Throwable $e): void
{
// 最终失败的处理:告警、记录
}
}
failed() 方法一定要实现。 不然任务失败就消失了,没人知道。
迁移和模型的关系
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email')->unique();
$table->timestamps();
});
Laravel 的迁移很方便。但我踩过一个坑:$table->string('name') 默认长度是 255,而 MySQL 的 utf8mb4 索引有长度限制,多个 string 字段建联合唯一索引会失败。
还有个相关的问题:->timestamps() 创建的是 created_at/updated_at,但 Eloquent 默认就是找这两个字段。如果表里没有(比如用了别的命名),模型要设 public $timestamps = false 或者 const CREATED_AT = 'create_time'。忘了设会报错。
建议:迁移里显式写字段长度和索引名。
$table->string('email', 191)->unique(); // 191 是 utf8mb4 下安全的值
我最欣赏的几个设计
说了这么多保留意见,也说说觉得好的。
一、artisan make:* 生成器。 统一了文件组织方式,新人接手不会迷路。
二、测试支持做得很好。
public function test_order_creation(): void
{
$this->postJson('/api/orders', [...])
->assertStatus(201)
->assertJsonPath('data.status', 'pending');
$this->assertDatabaseHas('orders', ['status' => 'pending']);
}
RefreshDatabase trait 自动管理测试数据库,assertDatabaseHas 直接查库验证。这些开箱即用。
三、队列、调度、缓存的接口统一。 从 Redis 换到数据库队列,改配置就行。
四、文档齐全。 这是 Laravel 最大的优势,遇到问题基本都能搜到答案。
现在我的用法
用框架的部分:路由、中间件、请求验证、队列、调度、Migrations、测试工具。
绕开框架的部分:复杂查询(写 SQL)、API 输出(用 Resource)、领域逻辑(独立于框架的类)、依赖注入(显式构造函数)。
核心思路是:让业务逻辑不依赖框架。
// 这个类不 import 任何 Laravel 的东西
final class OrderPricing
{
public function calculate(Order $order, Coupon $coupon): Money
{
// 纯业务逻辑
}
}
这样单元测试不用启动框架,跑得飞快;以后要换框架或者拆微服务,这部分代码能直接搬走。
框架应该是工具,不是地基。 我花了一段时间才理解这个区别。