浪白的博客の小站
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

Laravel 用了三年,一些变了的看法

写作时间:2026-08-16 20:15:00
# PHP
# Laravel
# 随笔
# 架构

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
    {
        // 纯业务逻辑
    }
}

这样单元测试不用启动框架,跑得飞快;以后要换框架或者拆微服务,这部分代码能直接搬走。

框架应该是工具,不是地基。 我花了一段时间才理解这个区别。

avatar

浪白

邪恶的浪白

RECOMMENDED

Composer 用了这么多年,几个我认为值得坚持的习惯

2026-04-27 19:15:00

context 传参这件事,我踩过的三个坑

2026-05-06 20:05:00

Go 的 error 处理,用久了反而觉得还行

2026-04-19 22:15:00

Table of Contents