Skip to content

第 13 章 实测笔记:博客系统完整复盘

实测时间:2026-05-07(约 2 小时) 项目:playground/(Laravel 12.58.0 + SQLite + Tailwind CDN) Boost:laravel/boost@2.4.6 关联大纲:第 6 章(路由)+ 第 7 章(Eloquent)+ 第 8 章(Migration)+ 第 9 章(Controller + Form Request)+ 第 10 章(Blade + 中间件)+ 第 11 章(认证)+ 第 13 章(博客实战)


0. 战果速览

数字看战果

维度数字
总耗时约 2 小时
新增/修改文件17 个
PHP 代码行~530 行
Blade 视图行~330 行
测试用例31 个(12 listing + 19 management)
测试断言67 条
测试结果31 passed (0 failed) in 0.62s
HTTP 路由11 条(7 RESTful + 1 mine + 3 auth)
Policy 方法7 个(覆盖 viewAny/view/create/update/delete/restore/forceDelete)
Eloquent Scope3 个(published / draft / scheduled)
Blade Components2 个(x-layout + x-posts.form-fields
数据库表10 张(9 张 Laravel 默认 + posts
Seed 数据6 users + 20 posts

时间线

T+0:00   一句话需求 → AI 拆任务
T+0:05   AI 调 search-docs 查 Laravel 12 migration 写法
T+0:10   migration / Model / Factory / Seeder 全部生成
T+0:15   php artisan migrate:fresh --seed → 6 users + 20 posts
T+0:25   PostController + Form Request + Policy 生成
T+0:35   AI 调 list-routes 自验路由
T+0:50   5 个 Blade 视图 + 共享 form-fields 组件生成
T+1:00   php artisan serve → 浏览器访问 /posts 200 OK ⭐ 端到端跑通
T+1:15   写 AuthController demo 登录
T+1:25   layout 顶部导航 @auth 改造
T+1:35   登录 → /posts/create → 提交 → 看到自己的新文章 ⭐ 完整闭环
T+1:50   AI 主动写 31 个 Pest 测试(!)
T+2:00   php artisan test → 31 passed (67 assertions)

T+1:50 那一步是这次最 surprise 的瞬间——我并没有在 prompt 里要求写测试,AI 是看到 Boost Skills 里 testing.md 规则后主动补上的。这是 Skills 起作用的最强证据。


1. 起点:一句话需求

给 AI 的原始 prompt

我在做一个博客的 demo。请帮我建一个 posts 表的 migration,字段需求如下:
- 主键 id
- title(字符串,不超过 200 字符,必填)
- slug(字符串,唯一索引,用于 URL)
- body(长文本)
- user_id(外键,关联 users 表,删除用户时级联删除文章)
- published_at(可空时间戳,用于"草稿 vs 已发布"区分)
- created_at / updated_at

生成 migration 文件后告诉我跑哪个命令。**在生成前先用 Boost 的 database-schema
看一下我现在的 users 表,确认外键字段类型对得上**。

AI 拿到这个 prompt 之后做的 5 件事(按顺序)

  1. database-schema 工具(关键!)—— 看到 users.idinteger,决定 migration 用 foreignId('user_id')(自动 unsignedBigInteger 兼容 integer)而不是手写类型
  2. search-docs 查 Laravel 12 migration 语法 —— 拿到 constrained() + cascadeOnDelete() 的最新链式 API
  3. 生成 migration 文件 database/migrations/2026_05_07_152000_create_posts_table.php
  4. 顺手生成了 Post Model + PostFactory —— 没要求做的事,AI 主动做了
  5. 告诉我跑 php artisan migrate

为什么这 5 件事是"老手做派"

  • 第 1 件事:先调工具看现状——AI 没瞎猜 users 表结构(很多 AI 直接默认 bigInteger
  • 第 2 件事:先查最新文档再写代码——避免用 Laravel 9 的旧 migration API
  • 第 4 件事:主动补全相关物——Migration 写完没 Model 用不了,AI 补上了

这 5 件事正是 Boost 价值的最直接体现:让 AI 从"凭训练数据瞎写"转向"先了解项目、先查文档、再动手"。


2. 数据层(30 分钟)

2.1 Migration: foreignId().constrained().cascadeOnDelete()

文件:playground/database/migrations/2026_05_07_152000_create_posts_table.php

php
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title', 200);
        $table->string('slug')->unique();
        $table->longText('body');
        $table->foreignId('user_id')
            ->constrained()
            ->cascadeOnDelete();
        $table->timestamp('published_at')->nullable();
        $table->timestamps();
    });
}

对比"老写法"(Laravel 7 之前)

php
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');

关键差异

  • foreignId('user_id') = unsignedBigInteger + 自动建索引
  • constrained() = 自动按命名约定找 users 表 + 关联 id
  • cascadeOnDelete() = 链式专用方法,比 onDelete('cascade') 字符串参数更可读 + IDE 能补全

ThinkPHP 没有等价 DSL——这是 Laravel"链式方法当 DSL 用"的典型表现。

2.2 Post Model:3 个 scope + 自定义关系名 author

文件:playground/app/Models/Post.php

php
class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'slug', 'body', 'user_id', 'published_at'];

    protected function casts(): array
    {
        return ['published_at' => 'datetime'];
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function scopePublished(Builder $query): Builder
    {
        return $query
            ->whereNotNull('published_at')
            ->where('published_at', '<=', now());
    }

    public function scopeDraft(Builder $query): Builder
    {
        return $query->whereNull('published_at');
    }

    public function scopeScheduled(Builder $query): Builder
    {
        return $query->where('published_at', '>', now());
    }
}

4 个老手特征

casts() 方法签名 vs $casts 属性

Laravel 11+ 引入了方法签名版的 casts:

php
protected function casts(): array { return ['published_at' => 'datetime']; }

老写法是属性版

php
protected $casts = ['published_at' => 'datetime'];

Boost Guidelines 锁版本 v12 后,AI 用方法签名版——验证了 Guidelines 真的起作用。

getRouteKeyName() 返回 'slug'

没要求这个!AI 看到 migration 里 slug 是 unique 字段,主动判断"用 slug 当 URL 比 id 友好"。效果:所有路由 posts/{post} 自动用 slug 解析,URL 变成 /posts/my-first-post 而不是 /posts/1

③ 自定义关系名 author + 显式外键

php
public function author(): BelongsTo
{
    return $this->belongsTo(User::class, 'user_id');
}

方法名 author() 不等于字段前缀 user_,所以必须显式传第二个参数 'user_id'——Laravel 默认会找 author_id 字段。

→ 但语义化命名值得这个代价:代码里写 $post->author->name$post->user->name 更接近自然语言。

④ 3 个 Local Scope 把"业务状态"写进 Model

scopePublished / scopeDraft / scopeScheduled——调用时省略 scope 前缀

php
Post::published()->latest()->paginate(10);
Post::draft()->where('user_id', auth()->id());
Post::scheduled()->count();

→ ThinkPHP 通常你会把这些条件散在各处的查询里。Laravel 把业务规则集中到 Model,N 个使用方一致。

2.3 User Model:反向关系 posts()

文件:playground/app/Models/User.php 第 51-54 行

php
public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

关系命名的不对称

方向关系方法名是否需要显式外键
Post → User(多对一)author()必须(因为方法名 ≠ 字段前缀)
User → Post(一对多)posts()不需要(默认匹配 posts.user_id

这是 Laravel 命名约定的"对称性是不对称的"。记住:方法名 = 字段前缀就不用显式传外键。

2.4 PostFactory:state pattern 实现"草稿/scheduled"

文件:playground/database/factories/PostFactory.php

php
public function definition(): array
{
    $title = fake()->sentence(6);
    return [
        'title' => rtrim($title, '.'),
        'slug' => fake()->unique()->slug(),
        'body' => fake()->paragraphs(5, true),
        'user_id' => User::factory(),
        'published_at' => fake()->dateTimeBetween('-1 year', '-1 hour'),
    ];
}

public function draft(): static
{
    return $this->state(fn (array $attributes) => [
        'published_at' => null,
    ]);
}

public function scheduled(): static
{
    return $this->state(fn (array $attributes) => [
        'published_at' => fake()->dateTimeBetween('+1 hour', '+1 month'),
    ]);
}

调用方式

php
Post::factory()->create();              // 已发布
Post::factory()->draft()->create();     // 草稿
Post::factory()->scheduled()->create(); // 定时发布

state pattern = 用方法链表达"变体",比一堆 boolean 参数清晰得多。

2.5 DatabaseSeeder:UNIQUE 踩坑 + 幂等改造

第一版(有坑)

php
public function run(): void
{
    $testUser = User::factory()->create([
        'name' => 'Test User',
        'email' => 'test@example.com',
    ]);
    Post::factory()->count(3)->for($testUser, 'author')->create();
    // ...
}

:第二次跑 php artisan db:seed 时报错:

SQLSTATE[23000]: Integrity constraint violation: 19
UNIQUE constraint failed: users.email

test@example.com 已经被第一次 seed 创建了,再 create 撞唯一约束。

第二版(幂等)—— 用户基于反馈主动改的 ⭐

文件:playground/database/seeders/DatabaseSeeder.php

php
public function run(): void
{
    $testUser = User::firstOrCreate(
        ['email' => 'test@example.com'],
        User::factory()->raw(['name' => 'Test User', 'email' => 'test@example.com'])
    );

    if ($testUser->wasRecentlyCreated) {
        Post::factory()->count(3)->for($testUser, 'author')->create();
        Post::factory()->draft()->for($testUser, 'author')->create();
        Post::factory()->scheduled()->for($testUser, 'author')->create();

        User::factory()
            ->count(5)
            ->has(Post::factory()->count(3), 'posts')
            ->create();
    }
}

3 个改进点

  1. User::firstOrCreate(['email' => 'xxx'], 默认值) —— 如果 email 已存在就取出来用,不存在才新建
  2. User::factory()->raw([...]) —— raw() 返回属性数组写库(给 firstOrCreate 当默认值用)
  3. if ($testUser->wasRecentlyCreated) —— 只在真的新建时才补 Post 数据,避免 Post 也重复

这就是"开发期幂等 seeder"的标准模式。值得记下来。

state + relationship 组合用法

php
Post::factory()->count(3)->for($testUser, 'author')->create();
//                            ↑                ↑
//                        关联到指定 user    关系方法名(不是默认 user)

User::factory()
    ->count(5)
    ->has(Post::factory()->count(3), 'posts')
    //                                ↑
    //                          User 端的关系方法名
    ->create();
  • for($model, $relationName) —— 设置 belongsTo 端("这些 Post 的 author 是 testUser")
  • has(Factory, $relationName) —— 设置 hasMany 端("每个 User 都有 3 个 posts")
  • 第二个参数都是关系方法名,因为我们用了非默认命名(author 而不是 user

2.6 端到端验证:用 database-query 工具看真实数据

sql
SELECT 'users' AS tbl, COUNT(*) AS cnt FROM users
UNION ALL SELECT 'posts', COUNT(*) FROM posts

返回:

json
[{"tbl": "users", "cnt": 6}, {"tbl": "posts", "cnt": 20}]

验算

  • test user: 1
  • factory 5 个 user: 5
  • 合计 users: 6 ✓
  • test user 的 posts: 3 published + 1 draft + 1 scheduled = 5
  • 5 个 user × 3 posts = 15
  • 合计 posts: 20 ✓

3. HTTP 层(45 分钟)

3.1 PostController:Laravel 11+ 的 HasMiddleware 接口

文件:playground/app/Http/Controllers/PostController.php

php
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;

class PostController extends Controller implements HasMiddleware
{
    use AuthorizesRequests;

    public static function middleware(): array
    {
        return [
            new Middleware('auth', except: ['index', 'show']),
        ];
    }

    // ... 7 个 RESTful action + 1 个 mine() ...
}

版本演进的 3 段历史

Laravel 版本写法状态
≤ 10.x在路由文件 Route::middleware('auth')->group(...)仍可用
10.x在 Controller 构造函数 $this->middleware('auth')->except(['index'])Laravel 11 起被移除
11+实现 HasMiddleware 接口 + 静态 middleware() 方法现在的标准

AI 用了第 3 种——证明 Boost Guidelines 的版本锁定起了作用。如果 AI 用第 2 种,跑起来会报 Method does not exist

except: ['index', 'show'] 是 PHP 8 的命名参数语法——可读性比 [..., true, ['index', 'show']] 高得多。

3.2 Form Request:Store + Update 两个分离的验证类

StorePostRequest

文件:playground/app/Http/Requests/StorePostRequest.php

php
public function authorize(): bool
{
    return true;  // 授权交给 Policy 处理
}

public function rules(): array
{
    return [
        'title'        => ['required', 'string', 'max:200'],
        'slug'         => ['required', 'string', 'max:255', 'unique:posts,slug'],
        'body'         => ['required', 'string'],
        'published_at' => ['nullable', 'date'],
    ];
}

UpdatePostRequest(关键差异:unique 要忽略自己)

文件:playground/app/Http/Requests/UpdatePostRequest.php

php
use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        // ...
        'slug' => [
            'required', 'string', 'max:255',
            Rule::unique('posts', 'slug')->ignore($this->route('post')),
        ],
        // ...
    ];
}

Rule::unique()->ignore($model) 是 Laravel 验证里最容易踩的坑之一

  • 不写 ignore() → 编辑文章时只要 slug 没改,校验就报"已存在"(因为它跟自己撞了)
  • $this->route('post') 拿到当前路由参数(已被 model binding 解析为 Post 对象)
  • ignore($model) 自动取 $model->getKey()

→ ThinkPHP 在校验唯一时通常你要手动写 where('id', '<>', $id)——Laravel 把这个常见模式封装进了 Rule。

3.3 PostPolicy:作者唯一原则 + ?User 类型签名

文件:playground/app/Policies/PostPolicy.php

php
public function view(?User $user, Post $post): bool
{
    if ($post->published_at !== null && $post->published_at->isPast()) {
        return true;  // 已发布文章任何人都能看
    }
    return $user !== null && $user->id === $post->user_id;  // 草稿/定时只有作者能看
}

public function update(User $user, Post $post): bool
{
    return $user->id === $post->user_id;
}

public function delete(User $user, Post $post): bool
{
    return $user->id === $post->user_id;
}

?User vs User 的天差地别

php
public function viewAny(?User $user): bool   // 列表页:游客可访问
public function view(?User $user, Post $post): bool  // 详情页:游客可访问已发布的
public function create(User $user): bool     // 创建:必须登录
public function update(User $user, Post $post): bool // 修改:必须登录
public function delete(User $user, Post $post): bool // 删除:必须登录

类型签名直接表达业务规则?User = 允许游客,User = 必须登录。这是 PHP 8 类型系统在领域建模上的应用。

3.4 路由:Route::resource() + 命名顺序的坑

文件:playground/routes/web.php

php
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');

Route::get('/my/posts', [PostController::class, 'mine'])->name('posts.mine');

Route::resource('posts', PostController::class);

一行 = 7 条 RESTful 路由

bash
$ php artisan route:list --path=posts

GET|HEAD     posts                posts.index    PostController@index
POST         posts                posts.store    PostController@store
GET|HEAD     posts/create         posts.create   PostController@create
GET|HEAD     posts/{post}         posts.show     PostController@show
PUT|PATCH    posts/{post}         posts.update   PostController@update
DELETE       posts/{post}         posts.destroy  PostController@destroy
GET|HEAD     posts/{post}/edit    posts.edit     PostController@edit

→ ThinkPHP 也有 Route::resource()但 Laravel 的路由命名 + Action 名匹配更严格——route('posts.show', $post) 必须有对应的 posts.show 路由名 + show() Controller 方法。

为什么 /my/posts 必须写在 Route::resource 前面

Route::resource('posts', ...) 第 4 条是 GET posts/{post} —— 这条会匹配任何 /posts/xxx。如果 /my/posts 写在它后面,Laravel 路由表里 /my/posts 排在 posts/{post} 之后......

等等,其实这里不会冲突——一个是 /my/posts,一个是 /posts/{post},路径前缀都不一样。

真正的坑在另一种场景:如果你写 Route::get('/posts/featured', ...) 想做"精选文章列表",这条必须放在 Route::resource——因为 /posts/featured 会被 posts/{post} 用 slug=featured 匹配掉。

→ 我把这个坑预留在了大纲第 6 章里,第 13 章先放着 mine 路由作为参照。


4. 视图层(30 分钟)

4.1 Blade Components:<x-layout> + <x-posts.form-fields>

主布局 components/layout.blade.php

blade
@props(['title' => null])

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ $title ? $title . ' · ' : '' }}Boost Playground</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col">
    <header class="bg-white shadow-sm">
        <nav class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
            <a href="{{ route('posts.index') }}" class="font-bold text-lg">Blog</a>
            <div class="flex items-center gap-4 text-sm">
                @auth
                    <a href="{{ route('posts.mine') }}">My Posts</a>
                    <a href="{{ route('posts.create') }}">New Post</a>
                    <span class="text-gray-500">{{ auth()->user()->name }}</span>
                    <form method="POST" action="{{ route('logout') }}" class="inline">
                        @csrf
                        <button type="submit" class="text-red-600 hover:underline">Logout</button>
                    </form>
                @else
                    <a href="{{ route('login') }}">Login</a>
                @endauth
            </div>
        </nav>
    </header>

    @if (session('status'))
        <div class="max-w-3xl mx-auto px-6 mt-4">
            <div class="bg-green-100 border border-green-300 text-green-900 px-4 py-2 rounded">
                {{ session('status') }}
            </div>
        </div>
    @endif

    <main class="max-w-3xl mx-auto px-6 py-8 flex-1 w-full">
        {{ $slot }}
    </main>
</body>
</html>

5 个老手细节

细节价值
@props(['title' => null])定义组件参数 + 默认值,调用方 <x-layout title="Edit">
<meta name="csrf-token">给前端 fetch/axios 自动取 token 用
@auth ... @else ... @endauth已登录/游客的导航差异化
session('status') 提示条配合 redirect()->with('status', 'xxx') 显示成功提示
{{ $slot }}子内容插槽

表单字段共享组件 components/posts/form-fields.blade.php

blade
@props(['post' => null])

@php
    $titleValue = old('title', $post?->title);
    $slugValue = old('slug', $post?->slug);
    $bodyValue = old('body', $post?->body);
    $publishedAtValue = old('published_at', $post?->published_at?->format('Y-m-d\TH:i'));
@endphp

<div class="space-y-4">
    <div>
        <label for="title" class="block text-sm font-medium">Title</label>
        <input type="text" id="title" name="title" value="{{ $titleValue }}"
            class="mt-1 w-full border-gray-300 rounded shadow-sm">
        @error('title')
            <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
        @enderror
    </div>
    {{-- ... 其他字段同模式 ... --}}
</div>

3 个关键模式

  1. old('field', $post?->field) —— old() 拿验证失败回填,$post?->field 用 nullsafe 操作符兼容创建(无 post)+ 编辑(有 post)两个场景
  2. $post?->published_at?->format('Y-m-d\TH:i') —— 双 nullsafe,因为 published_at 可空
  3. @error('field') ... @enderror —— Laravel 9.7+ 的标准错误显示语法

→ create 和 edit 两个视图共用这个组件,字段定义只写一次

4.2 5 个视图

文件行数用途
posts/index.blade.php52已发布文章列表 + 分页
posts/show.blade.php~50单篇详情 + 编辑/删除按钮
posts/create.blade.php~25创建表单
posts/edit.blade.php~25编辑表单
posts/mine.blade.php~80"我的文章"三态分组(草稿/定时/已发布)

posts/show.blade.php 关键片段:@can 条件渲染

blade
@can('update', $post)
    <a href="{{ route('posts.edit', $post) }}" class="text-blue-600 hover:underline">
        Edit
    </a>
@endcan

@can('delete', $post)
    <form method="POST" action="{{ route('posts.destroy', $post) }}" class="inline">
        @csrf
        @method('DELETE')
        <button type="submit" class="text-red-600 hover:underline"
            onclick="return confirm('Delete this post?')">
            Delete
        </button>
    </form>
@endcan

注意 3 件事

  • @can('update', $post) —— 直接调 PostPolicy::update(),不需要在 Controller 传任何东西
  • @method('DELETE') —— HTML 表单只支持 GET/POST,Laravel 用隐藏字段模拟 DELETE/PUT
  • @csrf —— 每个 POST/PUT/DELETE 表单都必须带,否则 419 Page Expired

posts/index.blade.php@forelse 优雅处理空状态

blade
@forelse ($posts as $post)
    <article class="border-b py-4">
        {{-- ... --}}
    </article>
@empty
    <p class="text-gray-500">No posts yet.</p>
@endforelse

{{ $posts->links() }}

@forelse@foreach 多了 @empty——空状态的标准化处理。 {{ $posts->links() }}——Laravel 自动渲染分页链接(带 Tailwind 样式,因为 paginator 默认用 tailwind 视图)。

4.3 posts/mine.blade.php:三态分组的"工作台"视图

blade
<x-layout title="My Posts">
    <h1 class="text-2xl font-bold mb-6">My Posts</h1>

    <section class="mb-8">
        <h2 class="text-lg font-semibold mb-3">
            草稿 ({{ $drafts->count() }})
        </h2>
        @forelse ($drafts as $post)
            {{-- 草稿条目 --}}
        @empty
            <p class="text-gray-400 text-sm">没有草稿</p>
        @endforelse
    </section>

    <section class="mb-8">
        <h2 class="text-lg font-semibold mb-3">
            定时发布 ({{ $scheduled->count() }})
        </h2>
        {{-- ... --}}
    </section>

    <section>
        <h2 class="text-lg font-semibold mb-3">
            已发布 ({{ $published->count() }})
        </h2>
        {{-- ... --}}
    </section>
</x-layout>

→ Controller 端用 3 个 scope 一次性查出三组:

php
public function mine(Request $request): View
{
    $user = $request->user();
    return view('posts.mine', [
        'published' => $user->posts()->published()->latest('published_at')->get(),
        'drafts'    => $user->posts()->draft()->latest('updated_at')->get(),
        'scheduled' => $user->posts()->scheduled()->orderBy('published_at')->get(),
    ]);
}

$user->posts()->published() —— 关系查询 + scope 链用,这是 Laravel 极其常见的写法。等价于:

sql
SELECT * FROM posts
WHERE user_id = ? 
  AND published_at IS NOT NULL 
  AND published_at <= NOW()
ORDER BY published_at DESC;

4.4 选型决策:Tailwind CDN vs Vite/npm

我刻意没装 Vite + npm。理由:

选项优点缺点
Tailwind CDN(选这个)0 依赖、刷新即生效生产环境 CSS 体积大
Vite + npmCSS 按需打包、生产可用需要 Node 环境、build 流程

demo 项目选 CDN,生产项目选 Vite。AI 一开始想跑 npm install,我打断了,直接 CDN。这种"取舍判断"目前还得人来做。


5. 认证层(15 分钟)

5.1 决策:不装 Breeze,手写 demo 登录

Laravel 官方提供 laravel/breeze 包,跑一行命令出全套登录/注册/邮箱验证脚手架。但我故意没装

  • ✓ 学习目的:手写一遍 Auth::attempt() + session 流程
  • ✗ 跳过的:注册、密码重置、邮箱验证、Email Verification 中间件

演示用户:通过 seeder 直接创建 test@example.com / password

5.2 AuthController 的 4 个安全细节

文件:playground/app/Http/Controllers/AuthController.php

php
public function showLogin(): View
{
    return view('auth.login');
}

public function login(Request $request): RedirectResponse
{
    $credentials = $request->validate([
        'email'    => ['required', 'email'],
        'password' => ['required'],
    ]);

    if (Auth::attempt($credentials, $request->boolean('remember'))) {
        $request->session()->regenerate();
        return redirect()->intended(route('posts.index'));
    }

    return back()
        ->withErrors(['email' => 'The provided credentials do not match our records.'])
        ->onlyInput('email');
}

public function logout(Request $request): RedirectResponse
{
    Auth::logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect()->route('posts.index')->with('status', 'Logged out.');
}
细节防什么
$request->session()->regenerate()Session Fixation 攻击——登录后换 session ID,旧 ID 失效
redirect()->intended(default)用户访问 /posts/create 被踢去登录,登录后自动跳回原页(不是首页)
$request->boolean('remember')转 boolean,防止 'on' 'true' 等字符串混入 cookie
->onlyInput('email')错误回填只回填 email,不回填 password(不要把密码塞进 session)
logout() 三件套logout + invalidate + regenerateToken —— 缺一不可

→ 这 5 件事 ThinkPHP 你都得自己写或自己想到。Laravel 的 Auth::attempt() + session helpers 把这套模式化 了。

5.3 Layout 顶部 @auth 改造

把 layout 顶部从"无脑显示"改成"按登录状态切换":

blade
@auth
    {{-- 已登录:My Posts / New Post / 用户名 / Logout --}}
@else
    {{-- 未登录:Login --}}
@endauth

@auth 默认用 web guard,多 guard 项目可以传:@auth('admin')

5.4 Logout 是 POST 不是 GET

blade
<form method="POST" action="{{ route('logout') }}" class="inline">
    @csrf
    <button type="submit">Logout</button>
</form>

为什么不能 GET

  • GET 可以被恶意网站用 <img src="https://your-site.com/logout"> 触发——访问受害者一看你的图站就被登出
  • POST 自带 CSRF token 校验,跨站构造不出来

→ 这是 web 安全里的"不安全方法 vs 副作用方法"原则:修改服务端状态的请求都不该用 GET


6. 测试层(自带 buff)

6.1 31 个 Pest 测试用例的设计

tests/Feature/PostListingTest.php(12 个)—— 关注"读"

#测试名验证什么
1the index page lists published posts列表显示已发布
2the index page hides draft posts列表隐藏草稿
3the index page hides scheduled posts列表隐藏定时
4the index page paginates posts分页正确
5a guest can view a published post游客能看已发布
6a guest cannot view a draft post游客 403 草稿
7a guest cannot view a scheduled post游客 403 定时
8the author can view their own draft作者能看自己的草稿
9a non-author cannot view someone else's draft非作者 403 别人的草稿
10posts are looked up by slug not id路由用 slug 解析 ✓
11the show page displays the author name详情显示作者
12the show page displays the body详情显示正文

tests/Feature/PostManagementTest.php(19 个)—— 关注"写"

#分类验证什么
1guest游客访问 /posts/create 被重定向到 /login
2guest游客 POST /posts 被重定向
3guest游客 GET edit 被重定向
4guest游客 PUT/DELETE 被重定向
5create已登录用户能创建文章
6create创建时强制把 user_id 设为当前用户
7validatetitle 必填
8validateslug 必填
9validateslug 唯一
10validatebody 必填
11update作者可以编辑自己的文章
12update非作者编辑他人文章 403
13update编辑时 unique slug 忽略自己
14update编辑也走 PostPolicy::update
15delete作者可以删除自己的文章
16delete非作者删除他人文章 403
17delete数据库里真的没了
18mine/my/posts 显示三态分组
19mine/my/posts 不显示别人的文章

6.2 关键测试 ⭐:forces the authenticated user as the author

这是整个测试套件最有价值的一个:

php
it('forces the authenticated user as the author, ignoring any submitted user_id', function () {
    $user  = User::factory()->create();
    $other = User::factory()->create();

    actingAs($user)->post(route('posts.store'), [
        'title' => 'Hijack Attempt',
        'slug'  => 'hijack-attempt',
        'body'  => 'Trying to forge author',
        'user_id' => $other->id,  // ← 攻击者伪造 author
    ]);

    $post = Post::where('slug', 'hijack-attempt')->firstOrFail();
    expect($post->user_id)->toBe($user->id);  // 实际作者还是当前登录用户
});

为什么这个测试这么重要

  • 它验证 Controller 用了 $request->user()->posts()->create($validated) 而不是 Post::create($request->all())
  • posts()->create($validated) —— 关系方法自动user_id 设为当前 user 的 id,不管前端传什么
  • 如果哪天有人改成 Post::create($validated)——这个测试立刻挂掉

这就是"安全模式"的可执行体现。光靠 code review 看不出来,靠测试钉死。

6.3 测试结果

bash
$ cd playground && php artisan test

   PASS  Tests\Feature\PostListingTest
 the index page lists published posts             0.39s
 the index page hides draft posts                 0.02s
 ...
 posts are looked up by slug not id               0.02s

   PASS  Tests\Feature\PostManagementTest
 ...
 forces the authenticated user as the author      0.04s
 ...

  Tests:    31 passed (67 assertions)
  Duration: 0.62s

0.62 秒跑完 31 个测试——这个速度让"改代码先跑测试"变得不再痛苦。

→ ThinkPHP 项目里你有多少在写测试?这是 Laravel 生态最值得学的部分之一。


7. 复盘 ✓:AI + Boost 在哪些时刻起了关键作用

8 个"老手代码点" → 触发它的 Boost 机制

#老手代码点触发的 Boost 机制
1Migration 用 foreignId().constrained().cascadeOnDelete() 而不是老语法search-docs 工具,AI 现查 Laravel 12 文档
2Model 用 casts() 方法签名而不是 $casts 属性AI Guidelines 锁死 Laravel 12 写法
3Post::published() scope 而不是散落的查询条件AI Guidelines · eloquent.md "scope first"
4getRouteKeyName() = 'slug' 让 URL 友好AI 看到 migration 里 slug 是 unique 字段,主动判断
5Controller 用 HasMiddleware 接口而不是 constructorAI Guidelines · controllers.md + 版本锁定
6Rule::unique()->ignore() 处理编辑时唯一校验AI 知道这是 Laravel "标准用法"
7Policy ?User vs User 的类型签名差异AI Guidelines · auth.md + Laravel 11+ 类型化
8不要求 AI 写测试,AI 主动写了 31 个 ⭐AI Guidelines · testing.md "always write tests"

Boost 工具 vs 普通 AI 的关键差异

场景普通 AIBoost AI
不知道 Laravel 版本用最常见的 v9 写法application-info 工具,准确知道是 v12
不知道 users 表结构假设 bigIntegerdatabase-schema 工具,看到真实类型
不知道 v12 新 API写 Laravel 9 的 APIsearch-docs 工具,拿最新文档
不知道当前测试结果让用户自己跑tinker / 看 application-info
改完代码不验证默认能跑last-error / read-log-entries 自验
写测试不知道用什么默认 PHPUnit看 Guidelines 里说"用 Pest",用 Pest

一个具象时刻:Boost 阻止了一次 N+1 查询

PostController@index 第一版 AI 写的:

php
return view('posts.index', [
    'posts' => Post::published()->latest()->paginate(10),
]);

视图里:

blade
@foreach ($posts as $post)
    By {{ $post->author->name }}  {{-- 每篇文章一次 SELECT * FROM users WHERE id = ? --}}
@endforeach

→ 10 篇文章 = 1 + 10 = 11 次 SQL 查询(典型 N+1)。

AI 后来主动改成:

php
'posts' => Post::published()->with('author')->latest()->paginate(10),

→ 1 + 1 = 2 次 SQL(一次查 posts,一次 WHERE user_id IN (...) 查 authors)。

为什么 AI 主动改了?因为 Boost Guidelines 里明确写了:

Always eager-load relationships in views. N+1 queries are the most common Laravel performance bug.

这就是 Skills 在工作——它不是简单的 prompt 模板,是实战规则


8. 复盘 ✗:AI + Boost 在哪些时刻表现一般

4 个表现一般的瞬间(也得记下来)

① Quality Regression:authorizeResource() → 散在各方法的 $this->authorize()

第一版 AI 写的(好)

php
public function __construct()
{
    $this->authorizeResource(Post::class, 'post');
}

一行搞定 7 个 RESTful action 的授权——Laravel 自动按方法名映射到 Policy(index→viewAny, show→view, store→create, ...)。

AI 后续重构改成(差)

php
public function index(): View
{
    $this->authorize('viewAny', Post::class);  // 多此一举
    // ...
}

public function store(StorePostRequest $request): RedirectResponse
{
    $this->authorize('create', Post::class);  // 多此一举
    // ...
}
// ... 其他每个方法都加一行 ...

为什么 AI 退化?我加了 HasMiddleware 接口让 AI 改 controller,AI 顺手把构造函数的 authorizeResource() 也"挪到方法里"。它不知道 authorizeResource() 必须留在构造函数里才能起作用。

教训:AI 修改老代码时容易"过度重构"。Code review 不能省。

② AI 初始化时想跑 npm install

我没装 Node。AI 第一次想生成视图时跑:

bash
npm install
npm run build

我打断改用 Tailwind CDN。 → 教训:环境约束("我不要装 Node")必须在 prompt 里说清楚,AI 默认按 Laravel 标准栈走。

③ Tinker 长进程缓存导致"幻觉"

我让 AI 在 tinker 里 App\Models\User::find(1) 验证 seed 数据。AI 报告:

返回 null,看起来 seed 失败了

但我用 database-query 工具直接查 SQL,user 真的存在。

根因:AI 之前在没数据时打开了 tinker,那个 tinker 进程里 Schema 已经被解析、连接池也建立。我后来 seed 了数据,但那个 tinker 进程还连着旧状态

教训:Boost 的 database-query 工具比 tinker 可靠——它每次都开新连接。tinker 适合手感探索自动化验证用 database-query

④ AI 写的迁移文件名时间戳格式

AI 生成的文件名是 2026_05_07_152000_create_posts_table.php,这是未来时间(实际今天是 2026-05-07,但 15:20 是我执行时的时间)。

问题:如果以后我用 php artisan make:migration 自动生成新迁移,文件名时间戳会用当时的时间。如果当时是 2026_05_07_140000(更早),它在排序上会排在前面,先于这个 posts 迁移执行

2026_05_07_140000_xxx.php  ← 后写的,但时间戳早
2026_05_07_152000_create_posts_table.php  ← 先写的,但时间戳晚

migrate 按文件名字典序执行,新写的迁移如果依赖 posts 表,会失败

教训:让 AI 生成 migration 永远用 php artisan make:migration 命令而不是手写文件名。这次为了演示 Boost 我让 AI 直接写文件,是反模式。


9. ThinkPHP 老手转 Laravel 的认知差异点速查

10 个 ThinkPHP 老手最容易吃惊的差异点

#ThinkPHP 习惯Laravel 写法差异核心
1Db::name('posts')->where(...)->select()Post::where(...)->get()Eloquent 用 Active Record 模式,模型本身就是查询起点
2模型字段在文档里说明$fillable = [...] 必填Laravel 默认所有字段都不可批量赋值,必须显式声明
3业务条件散在 ControllerEloquent scope 集中到 Model"状态相关查询"是 Model 的责任
4验证用 Validate 类放在 validate/Form Request 类 = 验证 + 授权 + 自动注入Form Request 是"被注入的活动验证器"
5权限手写 if (...) 散在 controllerPolicy 类 + @can 指令 + authorize() 方法权限抽象成业务对象,可独立测试
6URL 用模型 ID /posts/1路由模型绑定 /posts/{post} 自动解析 + slug 化类型驱动而不是 ID 驱动
7__construct() 注册中间件HasMiddleware 接口 + 静态方法Laravel 11+ 用接口表达"可被框架检查的契约"
8写测试很少Pest 默认安装php artisan test 0.6 秒跑完测试是 Laravel 文化的一部分
9Migration 写 SQL 风格的字段Schema Builder + 链式 DSL(foreignId()->constrained()DSL 隐藏 SQL 方言差异,同代码跑 SQLite/MySQL/PostgreSQL
10display('xxx') 渲染视图view('posts.show', compact('post')) + Blade 组件Blade 组件 = 可复用的视图单元,比 partials 强

一句话总结差异

ThinkPHP 是"PHP 框架",Laravel 是"用 PHP 写的 Ruby on Rails"。

  • ThinkPHP 的设计目标:让 PHP 工程师习惯的方式工作(数组、Db::、view)
  • Laravel 的设计目标:用 PHP 实现 Rails 的所有最佳实践(Active Record、约定优先、DSL、强类型)

→ 你不会觉得 ThinkPHP "magic",但 Laravel 处处是 magic。这些 magic 是约定,记住约定后比"显式"快得多。


10. 完整代码索引

17 个文件的索引(按层次)

数据层(5 个)

playground/database/migrations/2026_05_07_152000_create_posts_table.php
playground/app/Models/Post.php
playground/app/Models/User.php  (修改:加 posts() 关系)
playground/database/factories/PostFactory.php
playground/database/seeders/DatabaseSeeder.php

HTTP 层(5 个)

playground/app/Http/Controllers/PostController.php
playground/app/Http/Controllers/AuthController.php
playground/app/Http/Requests/StorePostRequest.php
playground/app/Http/Requests/UpdatePostRequest.php
playground/app/Policies/PostPolicy.php

路由(1 个)

playground/routes/web.php

视图层(7 个)

playground/resources/views/components/layout.blade.php
playground/resources/views/components/posts/form-fields.blade.php
playground/resources/views/posts/index.blade.php
playground/resources/views/posts/show.blade.php
playground/resources/views/posts/create.blade.php
playground/resources/views/posts/edit.blade.php
playground/resources/views/posts/mine.blade.php
playground/resources/views/auth/login.blade.php

测试(2 个)

playground/tests/Feature/PostListingTest.php
playground/tests/Feature/PostManagementTest.php

关键命令清单(按出场顺序)

bash
# 启动数据层
cd playground
php artisan make:migration create_posts_table  # (AI 跳过了这步,手写文件名,反模式)
php artisan migrate:fresh --seed

# 验证数据层
php artisan tinker
>>> App\Models\Post::published()->count()
>>> App\Models\Post::draft()->count()

# 启动 HTTP 层
php artisan route:list --path=posts

# 启动视图层
php artisan serve  # 默认 :8000
# 浏览器访问 http://127.0.0.1:8000/posts

# 测试
php artisan test
php artisan test --filter PostManagementTest

11. 下一步可玩的方向

9 个可以继续玩的方向(按难度递增)

#方向难度学到什么
1加评论功能(Comment 模型 + posts.comments 关系)hasMany / belongsTo 双向关系
2加 Markdown 解析(body 用 Markdown)第三方包集成(league/commonmark
3加图片上传(封面图)⭐⭐Storage facade + image upload
4加全文搜索(用 Laravel Scout + Meilisearch 或 sqlite FTS5)⭐⭐Scout 包 + 搜索引擎集成
5加 RSS feed⭐⭐XML 响应、HTTP 头
6加邮件订阅(订阅者用 Notification 收新文章邮件)⭐⭐⭐Notification + Queue + Mail
7加管理后台(用 Filament 或自己写)⭐⭐⭐后台脚手架方案对比
8加 API(Route::apiResource + Sanctum 认证)⭐⭐⭐API Resource + Token 认证
9部署到服务器(Forge / 自建 + Octane)⭐⭐⭐⭐生产部署 + 性能优化

哪几个适合用 Boost 演示

推荐度方向推荐理由
🔥 高4. 全文搜索加包 + 索引迁移 + 重新跑 query,多个 Boost 工具协作
🔥 高6. 邮件订阅Queue + Mail,Boost 的 read-log-entries 在调试 queue 时极有用
🔥 高8. API 化API Resource + Sanctum,Boost 的 search-docs 查 API 设计文档
3. 图片上传Storage 比较直接

我的下一步选择

打算做 8. API 化——把现有 RESTful Web 路由扩展成 API + Sanctum,对照 ThinkPHP 的 API 写法

这正好对应大纲第 14 章"高级实战"的内容。


复盘总结

这次实战回答了 3 个问题

Q1:Boost 让 AI 在 Laravel 项目里靠谱多少?

A:从"写出来能跑就不错了"提升到"写出来接近老手代码"。具体证据:

  • 自动用 Laravel 12 的最新 API(HasMiddlewarecasts() 方法签名)
  • 自动 eager-load 防 N+1
  • 自动写测试
  • 自动加 Policy 而不是散落 if 判断

→ 这些不是 prompt 工程能搞定的,是 Guidelines + Skills + 工具组合的效果。

Q2:Boost 工具最有价值的是哪几个?

TOP 5(实测)

  1. search-docs——避免 AI 用旧版 API(出现在每个新需求开头)
  2. database-schema——避免 AI 瞎猜表结构
  3. database-query——验证 seed 数据 + 业务查询,比 tinker 可靠
  4. application-info——确认版本(让 AI 知道是 Laravel 12 还是 9)
  5. tinker——快速试 model 方法、scope、关系

→ 详见 docs/12-Boost工具实战大全.md

Q3:ThinkPHP 老手值不值得花一个月学 Laravel?

A:**值得。**特别是要做以下事的人:

  • 做 SaaS 产品(Laravel 生态有 Cashier、Horizon、Telescope)
  • 做 API 优先的产品(Sanctum + API Resource 很优雅)
  • 写 PHP 但想要类型 + 测试 + DSL(PHP 8.4 + Laravel 12 是当前 PHP 最强组合)
  • 跟 AI 协作做项目(Boost 大幅提升 AI 在 Laravel 上的产出质量)

不值得的场景:

  • 你已经有大型 ThinkPHP 项目,迁移成本远大于收益
  • 团队全是 ThinkPHP 老手,没人愿意学
  • 项目极简单(一个表单 + 几条 SQL),用什么都行

最终战果:2 小时 → 一个能用、有测试、有授权、有"我的工作台"的博客系统。 Boost 的角色:不是替我写代码,是让我写代码的速度提高 3 倍下一步:写第 14 章"高级实战" + 把这套体验落地到我自己的项目里。

基于 MIT 许可 发布