Community写作与编辑github.com

shaxzodbek-uzb/laravel-guardrails

🛡️ 为 AI 辅助 Laravel 开发提供的生产级护栏技能——在 AI Agent 部署之前阻止跨租户数据泄露、N+1 查询和破坏性迁移。适用于 Claude Code、Cursor 及 40+ 个 Agent。

laravel-guardrails 是什么?

该技能旨在强制 AI Agent(如 Claude Code、Cursor 等)在编写 Laravel 代码时遵循严格的测试纪律和质量护栏。它通过一套 14 条规则,确保 AI 生成的代码不会导致生产环境中的常见问题,如跨租户数据泄露(授权漏洞)、N+1 查询、未测试的功能或脆弱的过度模拟测试。核心原则包括:使用 Pest 框架并采用行为驱动测试(关注结果而非实现细节)、使用工厂(Factory)而非手写插入、伪造外部边界(如 HTTP、队列、邮件)、强制执行授权和异常路径覆盖,以及通过架构测试(Arch测试)和覆盖率阈值设置全局护栏。该技能还提供了具体的“好 vs 坏”代码示例和验证命令,帮助开发者和 AI Agent 避免常见的陷阱。

兼容平台✓Claude Code~Codex CLI✓Cursor
npx skills add shaxzodbek-uzb/laravel-guardrails

Installed? Explore more 写作与编辑 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Pest 测试纪律

功能未经测试就上线,或者使用过度模拟、断言实现细节的“测试”来覆盖,都会在生产环境中悄无声息地崩溃,并在每次重构中腐烂。本技能强制你编写 Pest 3/4 测试,以证明行为,覆盖异常路径和授权,并且能够在重构中存活。

陷阱

两种代价高昂的失败模式:

  1. 没有测试 / 仅快乐路径测试。 功能在演示中运行正常,但在生产环境中,访客命中了端点、某个租户读取了另一个租户的数据、验证漏洞让垃圾数据进入、或者 404 变成了 500。授权漏洞(从未断言的 403/404 路径)是数据泄露流入生产的途径。
  2. 脆弱、过度模拟的测试。 你模拟自己的 UserRepository/服务,使用 shouldReceive('save')->once(),所以即使真实查询是错误的,测试也会通过,并且一旦有人重命名方法,它就会崩溃。套件是绿色的,给你虚假的信心,并在下次重构中因挫败而被删除——让你回到模式 1。

无论哪种方式,解决方法都是相同的纪律:伪造你不拥有的边界,执行你拥有的代码,并断言可观察的结果(数据库行、HTTP 状态、分发的工作/邮件)——包括失败和授权结果。

规则

  1. 与功能一起编写测试,而不是“以后”。 代码变更(控制器/工作/命令/策略)在拥有相应的 Pest 测试之前是不完整的。永远不要在没有相应测试断言其行为的情况下报告功能完成。
  2. 使用 Pest 习惯用法。 使用 it('...') 或 test('...') 定义用例;使用流畅的 expect(...) 进行断言;使用 beforeEach() 共享设置。不要编写 PHPUnit class FooTest extends TestCase,除非该文件已经使用该风格。
  3. 选择正确的类型。 HTTP/端到端行为 → 功能测试(启动框架,通过 $this->get/post/... 命中路由)。没有框架/数据库的纯逻辑 → 单元测试。在有疑问时,默认使用功能测试——它能捕获更多问题。
  4. 使用 Trait 重置数据库,通过 uses()。 在 tests/Pest.php 中为整个 Feature 目录应用 RefreshDatabase(事务包装)或 LazilyRefreshDatabase(仅在测试接触数据库时迁移),或者在文件顶部使用 uses(RefreshDatabase::class)。绝不要让数据库测试依赖遗留状态。
  5. 使用工厂构建数据,而不是手写插入。 使用 User::factory()->create() / ->make() 和关系工厂。在测试中手写 DB::table()->insert([...]) 或 new Model([...])->save() 很脆弱,会跳过类型转换/默认值——禁止使用。
  6. 使用数据集去重,而不是复制粘贴。 当相同的断言需要对许多输入(有效/无效载荷、角色矩阵)运行时,请使用 ->with([...])(内联或在 tests/Datasets/ 中命名的数据集),而不是 N 个几乎相同的测试。
  7. 伪造你不拥有的边界。 对于任何外部或异步的内容,在操作之前使用 Facade Fake:Http::fake()、Queue::fake()、Bus::fake()、Mail::fake()、Notification::fake()、Event::fake()、Storage::fake()。对于时间,使用 $this->travel(...)、$this->travelTo(...) 或 $this->freezeTime()——永远不要针对未冻结的 now() 进行断言。
  8. 不要模拟你自己的模型/服务使其变得无意义。 避免在被测试的代码中使用 Mockery::mock(MyService::class)->shouldReceive(...) 和 Model::shouldReceive(...)。让真实代码针对测试数据库和伪造的边界运行。仅在你没有在练习的真正接缝处(例如,一个慢的第三方 SDK 包装器)模拟你自己的类。
  9. 断言结果,而不是内部实现。 优先使用 assertDatabaseHas / assertDatabaseMissing / assertModelExists、assertStatus / assertOk / assertCreated、assertJson / assertJsonPath、assertRedirect、Mail::assertSent、Notification::assertSentTo、Queue::assertPushed、Bus::assertDispatched、Storage::disk()->assertExists。不要将“方法 X 被调用了一次”作为行为的代理进行断言。
  10. 始终测试异常路径和授权。 每个端点/操作至少需要:快乐路径、验证/失败路径以及认证路径——访客 → 401/重定向、错误用户/角色 → 403、缺失/外键资源 → 404。对于任何安全敏感功能,添加一个显式的“另一个用户无法访问/修改此内容”的测试。对于任何涉及所有权或租户的内容,这是不可妥协的。
  11. 添加架构测试作为廉价的全局护栏。 保留一个 tests/Arch.php(或在 Pest.php 中的架构用例),其中包含适合的预设:arch()->preset()->php()(禁止 die/var_dump/dd 风格的调试输出和废弃的 PHP 函数)、arch()->preset()->security()(禁止 eval、extract、unserialize、弱哈希/md5/sha1、不安全的随机性等)以及 arch()->preset()->laravel()(强制执行 Laravel 约定——包括在配置之外不允许 env())。添加项目不变量,如 arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller')。
  12. 每个测试一个行为,描述性名称。 it('阻止访客访问仪表板'),而不是 it('工作正常')。如果测试名称需要“和”,请拆分它。
  13. 在 CI 中门控覆盖率。 运行 ./vendor/bin/pest --coverage --min=NN 强制执行最低值,以及 ./vendor/bin/pest --type-coverage(需要类型覆盖率插件)强制执行类型签名。将新的未测试行视为缺陷。
  14. 谨慎使用浏览器/E2E 测试(Pest 4)。 Pest 4 通过 visit('/')->...->assertSee(...)(基于 Playwright)提供浏览器测试。将其保留用于真正的全栈/JS 流程;不要将 HTTP 层断言重新实现为缓慢的浏览器测试。

好 vs 坏

// tests/Feature/UpdatePostTest.php

use App\Models\Post;
use App\Models\User;

// ❌ 过度模拟:测试了一个方法被调用,而不是发生了什么。
//    即使错误的帖子被更新或认证缺失,测试也会通过。
it('更新帖子', function () {
    $service = Mockery::mock(App\Services\PostService::class);
    $service->shouldReceive('update')->once()->andReturnTrue();
    $this->app->instance(App\Services\PostService::class, $service);

    $this->put('/posts/1', ['title' => '新标题'])->assertOk();
});
// ✅ 针对测试数据库运行真实代码;断言结果和授权。
use App\Models\Post;
use App\Models\User;

it('允许所有者更新他们的帖子', function () {
    $owner = User::factory()->create();
    $post  = Post::factory()->for($owner)->create(['title' => '旧标题']);

    $this->actingAs($owner)
        ->put("/posts/{$post->id}", ['title' => '新标题'])
        ->assertRedirect();

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => '新标题']);
});

it('禁止非所有者更新帖子', function () {
    $post     = Post::factory()->create(['title' => '旧标题']);
    $attacker = User::factory()->create();

    $this->actingAs($attacker)
        ->put("/posts/{$post->id}", ['title' => '被黑'])
        ->assertForbidden(); // 403

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => '旧标题']);
});

it('阻止访客', function () {
    $post = Post::factory()->create();

    $this->put("/posts/{$post->id}", ['title' => '新标题'])
        ->assertRedirect('/login'); // 或者对于 API 使用 assertUnauthorized()
});
// ❌ 命中了真实网络(不稳定、慢、可能泄露密钥);没有断言任何有用的东西。
it('通知 slack', function () {
    $resp = (new App\Services\Slack)->ping('部署成功');
    expect($resp)->toBeArray();
});
// ✅ 伪造你不拥有的边界;断言你发送的请求。
use Illuminate\Support\Facades\Http;

it('向 slack 发送部署消息', function () {
    Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);

    (new App\Services\Slack)->ping('部署成功');

    Http::assertSent(fn ($request) =>
        $request->url() === 'https://hooks.slack.com/services/T/B/x'
        && $request['text'] === '部署成功'
    );
});
// ❌ 复制粘贴的几乎相同的验证测试。
it('拒绝空邮箱', function () {
    $this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('拒绝错误邮箱', function () {
    $this->post('/register', ['email' => '错误'])->assertSessionHasErrors('email');
});
// ✅ 一个测试,使用数据集进行表驱动。
it('拒绝无效的邮箱', function (string $email) {
    $this->post('/register', ['email' => $email])
        ->assertSessionHasErrors('email');
})->with([
    '空'       => '',
    '没有 @ 符号'  => '错误',
    '没有域名'   => 'a@',
]);
// ✅ 伪造了异步 + 时间边界;断言分发和副作用。
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

it('在注册时排队提醒并欢迎用户', function () {
    Queue::fake();
    Mail::fake();
    $this->freezeTime();

    $this->post('/register', [
        'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
    ])->assertRedirect();

    Queue::assertPushed(SendReminder::class);
    Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
    $this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ 廉价的全局护栏
arch()->preset()->php();        // 禁止 die/var_dump/dd 风格的调试输出 + 废弃的 PHP 函数
arch()->preset()->security();   // 禁止 eval, extract, unserialize, md5/sha1, 弱随机性等
arch()->preset()->laravel();    // 强制执行 Laravel 约定,包括配置之外不允许 env()

arch('控制器扩展基础控制器')
    ->expect('App\Http\Controllers')
    ->toExtend('App\Http\Controllers\Controller');

arch('模型位于正确的命名空间')
    ->expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model');

如何验证

在声称一个功能及其测试完成之前,运行这些:

# 1. 整个测试套件必须为绿色。
./vendor/bin/pest

# 2. 迭代时只运行新/更改的文件。
./vendor/bin/pest tests/Feature/UpdatePostTest.php

# 3. 强制执行覆盖率下限(需要 Xdebug 或 PCOV)。
./vendor/bin/pest --coverage --min=80

# 4. 类型覆盖率(需要 pestphp/pest-plugin-type-coverage)。
./vendor/bin/pest --type-coverage --min=100

# 5. 风格保持整洁。
./vendor/bin/pint --test

然后审计你没有重新引入陷阱:

# 授权覆盖率:每个受保护的功能测试应该断言一个 401/403/404 路径。
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/

# 过度模拟的味道:模拟你自己的 APP 类是一个危险信号——审查每一个命中。
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/

# 手写插入而不是工厂——替换为 ->factory()。
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/

# 外部边界必须被伪造——确认代码调用外部的地方存在伪造。
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/

# 安全和 PHP 架构预设(廉价的全局护栏)必须存在于某处。
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/

在测试中断言,而不仅仅在代码中:对于每个新端点,确认存在 (a) 一个快乐路径结果断言(assertDatabaseHas/assertOk/assertJsonPath),(b) 至少一个失败/验证断言,以及 (c) 访客和错误用户的授权断言。如果任何一项缺失,功能就不算完成。

何时可以放宽规则

  • 在真正的外部接缝处模拟你自己的代码。 一个围绕付费第三方 SDK(短信、支付网关)的薄包装器,你无法在 HTTP 层伪造,是合法的 Mockery 目标——你是在隔离他们的依赖,而不是伪造正在测试的行为。
  • 在没有数据库的情况下进行纯逻辑的单元测试。 值对象、格式化器和计算器不需要 RefreshDatabase 和工厂——直接实例化它们并 expect() 结果;这样更快且正确。
  • 使用 make() 而非 create() 当测试永远不会持久化时——User::factory()->make() 避免了对纯逻辑断言执行数据库写入。
  • 在遗留代码上设置较低的覆盖率阈值。 设置一个现实的 --min 并随时间提高它,而不是阻止所有工作;绝不要让“100% 或一无所获”成为交付零测试的借口。
  • 对真正由 JS 驱动的流程进行浏览器测试(Livewire/Inertia/Vue 交互、文件选择器)——在那里,visit() 是正确的工具,而不是过度使用。

参考

相关技能