Laravel 是一个成熟的 PHP Web 应用框架,几乎内置了现代应用所需的一切功能。但今天我们不会覆盖所有这些功能,而是专注于一个常常被忽视的话题:Laravel 提供的众多安全功能,它们能够有效地帮助开发者避免痛苦的错误。
本文将讨论以下安全机制:

  1. N+1 问题的预防
  2. 部分加载模型的保护
  3. 属性拼写错误和重命名的列
  4. 批量赋值保护
  5. 严格模型验证
  6. 多态映射强制
  7. 长时间运行事件监控

每种保护机制都是可配置的。我们将推荐如何以及何时使用这些机制。


1. N+1 问题的预防

许多 ORM(包括 Laravel 的 Eloquent)都提供一种功能——**延迟加载(Lazy Loading)**。延迟加载关系非常方便,因为你无需提前考虑需要从数据库中选择哪些关系。但这通常导致一个性能问题,称为 **N+1 问题**。

什么是 N+1 问题?

N+1 问题是使用 ORM 时开发者最常遇到的问题之一,也是一些人拒绝使用 ORM 的主要原因。虽然有些人因此完全避免 ORM,但这是过度纠正,其实我们可以通过简单地禁止延迟加载来解决这个问题!
来看一个博客文章列表的示例。我们希望显示每篇文章的标题及其作者名称:

$posts = Post::all();

foreach($posts as $post) {
    // `author` 是延迟加载的。
    echo $post->title . ' - ' . $post->author->name;
}

上述代码就出现了 **N+1 问题**!第一行查询所有博客文章后,对于每篇文章,会运行一个额外的查询以获取作者信息:

SELECT * FROM posts;
SELECT * FROM users WHERE user_id = 1;
SELECT * FROM users WHERE user_id = 2;
SELECT * FROM users WHERE user_id = 3;
SELECT * FROM users WHERE user_id = 4;
SELECT * FROM users WHERE user_id = 5;

N+1 的含义是:初始查询会运行一次,然后为返回的 n 条记录,分别执行额外的 n 次查询。一共 N+1 次查询。虽然每个查询本身可能很快,但当这些查询累积时就会导致严重的性能问题。而且,由于每个查询单独执行很快,这种问题往往不会出现在慢查询日志(slow query log)中!

如何防止 N+1 问题?

在 Laravel 中,可以使用 Model::preventLazyLoading() 方法禁用延迟加载,从而完全避免 N+1 问题:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventLazyLoading();
}

每次尝试延迟加载关系时,都会抛出 LazyLoadingViolationException 异常。开发者需显式地提前加载关系:

// 显式地提前加载 `author` 关系。
$posts = Post::with('author')->get();

foreach($posts as $post) {
    // `author` 已经加载。
    echo $post->title . ' - ' . $post->author->name;
}

延迟加载只影响 性能 而不是 **应用程序的正确性**。理想情况下,你应该显式加载所有需要的关系。但即使关系未提前加载,Laravel 会通过延迟加载补上。当然,这会导致性能下降。
我们建议在非生产环境中禁止延迟加载,确保所有延迟加载行为能在开发或测试中被捕获。如果某些延迟加载能够进入生产环境,应用程序仍然可以正常运行,只是速度稍慢。
修改后的 AppServiceProvider 配置如下:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 当应用程序不在生产环境时,禁止延迟加载。
    Model::preventLazyLoading(!$this->app->isProduction());
}

记录延迟加载行为

如果希望在生产环境中记录延迟加载,而不是抛出异常,可以通过 Model::handleLazyLoadingViolationUsing() 方法注册一个自定义记录处理程序。例如:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 始终禁止延迟加载。
    Model::preventLazyLoading();

    if ($this->app->isProduction()) {
        // 在生产环境记录延迟加载行为。
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

2. 部分加载模型的保护

SQL 性能优化的一个常见建议是:**仅选择你需要的列**。这是个好建议!你应该仅让数据库返回需要使用的数据,其余数据将被无意义地丢弃。
Laravel 的 Eloquent 模型采用了 Active Record 模式,每个模型实例对应数据库中的一行数据。比如通过 User::find() 方法查询用户时,会运行以下 SQL:

SELECT * FROM users WHERE id = 1;

此时模型会完全加载,数据库中的所有列都存储在内存中的模型实例中:

$user = User::find(1);

// 完全加载模型,所有字段均可用:
// App\User {
//   id: 1,
//   name: "Aaron",
//   email: "aaron@example.com",
//   created_at: "2022-10-19",
// }

虽然完全加载模型在简单情况下是可以接受的,但如果表结构非常宽泛(例如有 LONGTEXTBLOB 列),或者需要查询多个记录,建议只加载所需的列,以提高性能:

$user = User::select('id', 'name')->find(1);
// 仅加载 `id` 和 `name` 两列。

隐患:部分加载数据导致意外行为

通过 select() 方法部分加载数据时,未加载的字段不会抛出错误,而是返回 **null**。这可能会导致应用程序产生错误。例如:

$user = User::select('id', 'name')->find(1);

// 假设 `is_blocked` 未加载,它总是返回 `null`。
if ($user->is_blocked) {
    // 所有被阻止的用户都会被错误地认为未阻止。
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

如何避免意外行为?

Laravel 9.35.0 引入了一个新机制:Model::preventAccessingMissingAttributes()。启用它后,访问未加载的字段将抛出异常,而不返回 null

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventAccessingMissingAttributes();
}

启用此机制后,生产环境和开发环境都可有效避免由于模型数据未加载而导致的不可预见行为。

3. 属性拼写错误与重命名列

上述部分提到 Model::preventAccessingMissingAttributes() 可保护模型免受部分加载的影响。但除此之外,它还可以帮助处理两个经常被忽视的场景:**属性拼写错误**和**重命名的列**。

属性拼写错误

假如我们有一个正确加载的用户模型,但由于拼写错误,访问了一个不存在的字段。Laravel 默认会返回 null,而不是显式地告知错误。以下是一个示例:

// 完全加载模型。
$user = User::find(1);

// 拼写错误:将 `is_blocked` 错写成 `is_blokced`。
if ($user->is_blokced) {
    // 由于拼写错误,系统会错误地认为所有用户都未被阻止。
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

在简单场景下,这个错误可能会通过测试发现,但在复杂场景中,你可能完全没有意识到拼写错误的存在。

重命名列

另一种常见场景是数据库列的重命名。例如,原始列名称为 blocked,后来你将其重命名为 is_blocked。如果代码中尚未更新所有 blocked 的引用,Laravel 默认会返回 null,而不会提醒开发者。以下是示例:

// 完全加载模型。
$user = User::find(1);

// 使用了旧的列名 `blocked`。
if ($user->blocked) {
    // 由于列名未更新,系统会错误地认为所有用户未被阻止。
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

如何防止这些错误?

启用 Model::preventAccessingMissingAttributes() 可以避免上述场景中的意外行为。未加载的或拼写错误的属性会导致抛出异常,使开发者意识到问题所在,明确纠正错误。

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 不允许访问缺失的属性。
    Model::preventAccessingMissingAttributes();
}

此保护机制让模型行为更加显式,而非隐式。即使是在生产环境中,我们建议启用此机制,以避免潜在的数据错误和不一致。


4. 批量赋值保护

批量赋值(Mass Assignment)是一种漏洞,使用户能够设置不该被允许设置的属性。例如,假如你的用户模型有一个 is_admin 属性,你肯定不希望一个普通用户能够随意将自己升级为管理员。

User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'is_admin' => true, // 用户将自己提升为管理员!
]);

批量赋值保护:fillable 属性

Laravel 默认保护模型中的批量赋值行为。开发者必须显式列出哪些属性可以被赋值。例如:

class User extends Model
{
    protected $fillable = [
        'name',
        'email',
    ];
}

在上述示例中,只有 nameemail 属性可以被赋值。无论用户尝试赋值多少属性,只有 fillable 中列出的属性会被更新:

// 无论用户传入多少属性,只有 `name` 和 `email` 被更新。
User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'is_admin' => true, // 被自动忽略。
]);

禁用批量赋值保护:unguard

有些开发者可能会选择完全关闭批量赋值保护,并依赖请求验证来过滤属性。这也是合理的,前提是你绝对不能将未经处理的全部请求数据传递给模型更新方法。
关闭批量赋值保护的方法如下:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 关闭批量赋值保护。
    Model::unguard();
}

尽管禁用批量赋值保护是可以接受的,但开发者必须确保传递给模型的数据是经过控制的。例如:

// 仅更新 `name` 和 `email`。
User::find(1)->update($request->only(['name', 'email']));

防止忽略属性:preventSilentlyDiscardingAttributes

假如尝试更新一个不在 fillable 列表中的属性,Laravel 默认会静默丢弃这些属性。例如:

User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'birthday' => '1989-02-14', // `birthday` 属性被丢弃。
]);

虽然这是一种保护,但由于其行为是静默的,可能会导致混淆。如果你希望这种错误显式地被暴露,可以启用 Model::preventSilentlyDiscardingAttributes()

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 禁止静默丢弃不允许赋值的属性。
    Model::preventSilentlyDiscardingAttributes();
}

此配置会抛出 MassAssignmentException 异常,提醒你哪些属性无法被更新。这种保护机制优先保障应用程序的 **正确性**,而不是简单地静默处理错误。

推荐配置

我们建议始终启用批量赋值保护。以下是推荐的配置示例:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 启用所有环境的保护机制。
    Model::preventSilentlyDiscardingAttributes();
}

5. 严格模型验证

Laravel 9.35.0 提供了一种新的方法——Model::shouldBeStrict(),它能够集中控制以下三个严格性设置:

  • Model::preventLazyLoading() (禁用延迟加载)
  • Model::preventSilentlyDiscardingAttributes() (防止静默丢弃属性)
  • Model::preventAccessingMissingAttributes() (防止访问缺失的属性)

这个方法允许开发者在 AppServiceProvider 中通过一个调用开启或关闭所有严格性验证。例如:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 开启所有严格性验证。
    Model::shouldBeStrict();
}

我们对严格性设置的建议如下:

  • preventLazyLoading:禁用延迟加载,提高性能。在非生产环境启用,生产环境可选择记录违规行为。
  • preventSilentlyDiscardingAttributes:优先保障模型正确性,应在所有环境中启用。
  • preventAccessingMissingAttributes:优先保障模型正确性,应在所有环境中启用。

如果你计划在生产环境中记录延迟加载违规行为,推荐如下配置:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 启用严格模式。
    Model::shouldBeStrict();

    // 在生产环境记录延迟加载违规。
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            $class = get_class($model);
            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

如果你不打算记录违规行为,而是直接禁用延迟加载,可以通过以下配置:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // 优先保障正确性,始终启用这些机制。
    Model::preventAccessingMissingAttributes();
    Model::preventSilentlyDiscardingAttributes();

    // 延迟加载是性能问题,仅在非生产环境禁用。
    Model::preventLazyLoading(!$this->app->isProduction());
}

6. 多态映射强制(Polymorphic Mapping Enforcement)

多态关系是一种特殊的关系类型,它允许多个父模型共享一个子模型。例如,一个博客文章 (Post) 和一个用户 (User) 都可以有多个图片 (Image)。而不是为每个父模型分别创建图片模型,可以通过多态关系使用一个通用的 Image 模型,同时服务于两个父模型。
在多态关系中,数据库通常通过两个字段来定位父模型:

  • imageable_type:保存父模型的类型,默认为父模型的完全限定类名(Fully Qualified Class Name,FQCN)。
  • imageable_id:保存父模型的主键 ID。

例如,示例数据表 images

idimageable_idimageable_typeurl
11App\Posthttps://example.com/1001.jpg
22App\Posthttps://example.com/1002.jpg
33App\Posthttps://example.com/1003.jpg
422001App\Userhttps://example.com/1004.jpg

虽然默认情况下,Laravel 会将父模型的 FQCN 存储到数据库,但这种做法非常脆弱。如果父模型的类名称发生变化(例如由于代码重构),存储的 FQCN 就会导致数据的不可预测性。

使用 morphMap 映射

为了解决这个问题,Laravel 提供了 Relation::morphMap 方法,用来控制存储到数据库中的值。通过 morphMap,可以为每个多态模型定义唯一的键(而不是 FQCN),从而保持数据的独立性。
示例如下:

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    Relation::morphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

现在,数据库中不再存储完全限定类名,而是存储自定义定义的键。例如,App\Post 将存储为 postApp\User 将存储为 user

强制映射:enforceMorphMap

将 FQCN 替换为唯一键后,我们依然面临一个问题:如果新增了多态模型(例如 Comment),但开发者忘记将其映射到 morphMap,Laravel 会默认将其 FQCN 存储到数据库,这使得映射不完整。
为避免这个问题,Laravel 提供了 Relation::enforceMorphMap 方法,强制为每个多态关系定义映射。启用此保护后,无映射模型不会被存储到数据库,代码会抛出 ClassMorphViolationException 异常。
示例如下:

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    Relation::enforceMorphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

启用后,忘记映射的模型(例如 App\Comment)会触发显式异常,防止错误数据被存储。 这种保护机制非常重要,因为最危险的失效往往是那些 “静默失败” 的情况。


7. 防止异常的 HTTP 请求

在测试应用程序时,通常需要模拟发出的外部 HTTP 请求,以控制测试场景并避免真正向第三方发送请求。Laravel 通过 Http::fake() 方法实现模拟 HTTP 请求的功能。例如,仅模拟向 GitHub 的请求:

use Illuminate\Support\Facades\Http;

// 仅模拟发送到 GitHub 的请求。
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

上述代码中,所有发送到 GitHub 的请求均返回指定的响应,而向其他域发送的请求仍像普通 HTTP 请求一样实际发出。这可能会导致测试变慢,甚至因发送真实请求触发外部 API 的速率限制。

使用 preventStrayRequests 防止错误请求

Laravel 9.12.0 引入了 Http::preventStrayRequests 方法,可防止所有未模拟的请求发出警告或抛出异常。

use Illuminate\Support\Facades\Http;

// 禁止任何未模拟的请求发出。
Http::preventStrayRequests();

// 模拟 GitHub 请求。
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

// 由于未模拟,以下请求会抛出异常。
Http::get('https://planetscale.com');

集成到测试用例

建议将此保护机制集成到基础测试类的 setUp 方法中,确保测试期间不会发生错误的请求:

protected function setUp(): void
{
    parent::setUp();

    Http::preventStrayRequests();
}

对于某些测试需要允许外部请求的场景,可以通过调用 Http::allowStrayRequests() 临时启用真实请求。


长时间运行事件监控

以上方法主要针对单一错误行为,而接下来我们将讨论一些能够监控**全局性能**的方法,这些方法特别适合没有应用性能监控工具的开发者。

数据库查询监控

Laravel 9.18.0 引入了 DB::whenQueryingForLongerThan() 方法,用来捕获和处理所有累计查询运行时间超过指定阈值的情况。例如,以下回调在总查询时间超过 2 秒时记录日志:

use Illuminate\Support\Facades\DB;

public function boot()
{
    DB::whenQueryingForLongerThan(2000, function ($connection) {
        Log::warning("Database queries exceeded 2 seconds on {$connection->getName()}");
    });
}

如果希望监听单次查询的运行时间,可以通过 DB::listen 回调实现:

use Illuminate\Support\Facades\DB;

public function boot()
{
    DB::listen(function ($query) {
        if ($query->time > 1000) {
            Log::warning("An individual database query exceeded 1 second.", [
                'sql' => $query->sql,
            ]);
        }
    });
}

这些方法非常适合没有专业性能监控工具(如 PlanetScale 的 Query Insights)的场景。


请求和命令生命周期监控

类似于数据库查询监控,Laravel 9.31.0 提供了监控 HTTP 请求和控制台命令生命周期的方法,当运行时间超过指定阈值时触发回调:

use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;

public function boot()
{
    if ($this->app->runningInConsole()) {
        // 记录运行时间超过 5 秒的命令。
        $this->app[ConsoleKernel::class]->whenCommandLifecycleIsLongerThan(
            5000,
            function ($startedAt, $input, $status) {
                Log::warning("A command took longer than 5 seconds.");
            }
        );
    } else {
        // 记录运行时间超过 5 秒的请求。
        $this->app[HttpKernel::class]->whenRequestLifecycleIsLongerThan(
            5000,
            function ($startedAt, $request, $response) {
                Log::warning("A request took longer than 5 seconds.");
            }
        );
    }
}

总结:将隐式行为显式化

Laravel 的许多安全特性将隐式行为转化为显式异常。从项目初期到后期,隐式行为可能远远超出开发者的预期,进而引发不符合期望的问题。这些保护机制可以帮助开发者摆脱对隐式行为的担忧,将更多注意力放在核心业务逻辑上。
通过启用上述安全机制,您可以更好地防止数据错误、性能问题以及不可预测的应用行为,为 Laravel 应用提供更高的稳定性和可靠性。



Laravel 的安全机制插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://www.choupangxia.com/2025/09/10/laravel-safety-mechanisms/