第 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 Scope | 3 个(published / draft / scheduled) |
| Blade Components | 2 个(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 件事(按顺序)
- 调
database-schema工具(关键!)—— 看到users.id是integer,决定 migration 用foreignId('user_id')(自动 unsignedBigInteger 兼容 integer)而不是手写类型 - 调
search-docs查 Laravel 12 migration 语法 —— 拿到constrained()+cascadeOnDelete()的最新链式 API - 生成 migration 文件
database/migrations/2026_05_07_152000_create_posts_table.php - 顺手生成了 Post Model + PostFactory —— 没要求做的事,AI 主动做了
- 告诉我跑
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
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 之前):
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');关键差异:
foreignId('user_id')=unsignedBigInteger+ 自动建索引constrained()= 自动按命名约定找users表 + 关联idcascadeOnDelete()= 链式专用方法,比onDelete('cascade')字符串参数更可读 + IDE 能补全
→ ThinkPHP 没有等价 DSL——这是 Laravel"链式方法当 DSL 用"的典型表现。
2.2 Post Model:3 个 scope + 自定义关系名 author
文件:playground/app/Models/Post.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:
protected function casts(): array { return ['published_at' => 'datetime']; }老写法是属性版:
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 + 显式外键
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 前缀:
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 行
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
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'),
]);
}调用方式:
Post::factory()->create(); // 已发布
Post::factory()->draft()->create(); // 草稿
Post::factory()->scheduled()->create(); // 定时发布→ state pattern = 用方法链表达"变体",比一堆 boolean 参数清晰得多。
2.5 DatabaseSeeder:UNIQUE 踩坑 + 幂等改造
第一版(有坑)
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.emailtest@example.com 已经被第一次 seed 创建了,再 create 撞唯一约束。
第二版(幂等)—— 用户基于反馈主动改的 ⭐
文件:playground/database/seeders/DatabaseSeeder.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 个改进点:
User::firstOrCreate(['email' => 'xxx'], 默认值)—— 如果 email 已存在就取出来用,不存在才新建User::factory()->raw([...])——raw()返回属性数组不写库(给 firstOrCreate 当默认值用)if ($testUser->wasRecentlyCreated)—— 只在真的新建时才补 Post 数据,避免 Post 也重复
→ 这就是"开发期幂等 seeder"的标准模式。值得记下来。
state + relationship 组合用法
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 工具看真实数据
SELECT 'users' AS tbl, COUNT(*) AS cnt FROM users
UNION ALL SELECT 'posts', COUNT(*) FROM posts返回:
[{"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
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
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
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
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 的天差地别:
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
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 路由
$ 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
@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
@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 个关键模式:
old('field', $post?->field)——old()拿验证失败回填,$post?->field用 nullsafe 操作符兼容创建(无 post)+ 编辑(有 post)两个场景$post?->published_at?->format('Y-m-d\TH:i')—— 双 nullsafe,因为published_at可空@error('field') ... @enderror—— Laravel 9.7+ 的标准错误显示语法
→ create 和 edit 两个视图共用这个组件,字段定义只写一次。
4.2 5 个视图
| 文件 | 行数 | 用途 |
|---|---|---|
posts/index.blade.php | 52 | 已发布文章列表 + 分页 |
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 条件渲染
@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 优雅处理空状态
@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:三态分组的"工作台"视图
<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 一次性查出三组:
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 极其常见的写法。等价于:
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 + npm | CSS 按需打包、生产可用 | 需要 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
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 顶部从"无脑显示"改成"按登录状态切换":
@auth
{{-- 已登录:My Posts / New Post / 用户名 / Logout --}}
@else
{{-- 未登录:Login --}}
@endauth@auth 默认用 web guard,多 guard 项目可以传:@auth('admin')。
5.4 Logout 是 POST 不是 GET
<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 个)—— 关注"读"
| # | 测试名 | 验证什么 |
|---|---|---|
| 1 | the index page lists published posts | 列表显示已发布 |
| 2 | the index page hides draft posts | 列表隐藏草稿 |
| 3 | the index page hides scheduled posts | 列表隐藏定时 |
| 4 | the index page paginates posts | 分页正确 |
| 5 | a guest can view a published post | 游客能看已发布 |
| 6 | a guest cannot view a draft post | 游客 403 草稿 |
| 7 | a guest cannot view a scheduled post | 游客 403 定时 |
| 8 | the author can view their own draft | 作者能看自己的草稿 |
| 9 | a non-author cannot view someone else's draft | 非作者 403 别人的草稿 |
| 10 | posts are looked up by slug not id | 路由用 slug 解析 ✓ |
| 11 | the show page displays the author name | 详情显示作者 |
| 12 | the show page displays the body | 详情显示正文 |
tests/Feature/PostManagementTest.php(19 个)—— 关注"写"
| # | 分类 | 验证什么 |
|---|---|---|
| 1 | guest | 游客访问 /posts/create 被重定向到 /login |
| 2 | guest | 游客 POST /posts 被重定向 |
| 3 | guest | 游客 GET edit 被重定向 |
| 4 | guest | 游客 PUT/DELETE 被重定向 |
| 5 | create | 已登录用户能创建文章 |
| 6 | create | 创建时强制把 user_id 设为当前用户 ⭐ |
| 7 | validate | title 必填 |
| 8 | validate | slug 必填 |
| 9 | validate | slug 唯一 |
| 10 | validate | body 必填 |
| 11 | update | 作者可以编辑自己的文章 |
| 12 | update | 非作者编辑他人文章 403 |
| 13 | update | 编辑时 unique slug 忽略自己 |
| 14 | update | 编辑也走 PostPolicy::update |
| 15 | delete | 作者可以删除自己的文章 |
| 16 | delete | 非作者删除他人文章 403 |
| 17 | delete | 数据库里真的没了 |
| 18 | mine | /my/posts 显示三态分组 |
| 19 | mine | /my/posts 不显示别人的文章 |
6.2 关键测试 ⭐:forces the authenticated user as the author
这是整个测试套件最有价值的一个:
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 测试结果
$ 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 机制 |
|---|---|---|
| 1 | Migration 用 foreignId().constrained().cascadeOnDelete() 而不是老语法 | search-docs 工具,AI 现查 Laravel 12 文档 |
| 2 | Model 用 casts() 方法签名而不是 $casts 属性 | AI Guidelines 锁死 Laravel 12 写法 |
| 3 | Post::published() scope 而不是散落的查询条件 | AI Guidelines · eloquent.md "scope first" |
| 4 | getRouteKeyName() = 'slug' 让 URL 友好 | AI 看到 migration 里 slug 是 unique 字段,主动判断 |
| 5 | Controller 用 HasMiddleware 接口而不是 constructor | AI Guidelines · controllers.md + 版本锁定 |
| 6 | Rule::unique()->ignore() 处理编辑时唯一校验 | AI 知道这是 Laravel "标准用法" |
| 7 | Policy ?User vs User 的类型签名差异 | AI Guidelines · auth.md + Laravel 11+ 类型化 |
| 8 | 不要求 AI 写测试,AI 主动写了 31 个 ⭐ | AI Guidelines · testing.md "always write tests" |
Boost 工具 vs 普通 AI 的关键差异
| 场景 | 普通 AI | Boost AI |
|---|---|---|
| 不知道 Laravel 版本 | 用最常见的 v9 写法 | 调 application-info 工具,准确知道是 v12 |
| 不知道 users 表结构 | 假设 bigInteger | 调 database-schema 工具,看到真实类型 |
| 不知道 v12 新 API | 写 Laravel 9 的 API | 调 search-docs 工具,拿最新文档 |
| 不知道当前测试结果 | 让用户自己跑 | 调 tinker / 看 application-info |
| 改完代码不验证 | 默认能跑 | 调 last-error / read-log-entries 自验 |
| 写测试不知道用什么 | 默认 PHPUnit | 看 Guidelines 里说"用 Pest",用 Pest |
一个具象时刻:Boost 阻止了一次 N+1 查询
PostController@index 第一版 AI 写的:
return view('posts.index', [
'posts' => Post::published()->latest()->paginate(10),
]);视图里:
@foreach ($posts as $post)
By {{ $post->author->name }} {{-- 每篇文章一次 SELECT * FROM users WHERE id = ? --}}
@endforeach→ 10 篇文章 = 1 + 10 = 11 次 SQL 查询(典型 N+1)。
AI 后来主动改成:
'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 写的(好):
public function __construct()
{
$this->authorizeResource(Post::class, 'post');
}一行搞定 7 个 RESTful action 的授权——Laravel 自动按方法名映射到 Policy(index→viewAny, show→view, store→create, ...)。
AI 后续重构改成(差):
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 第一次想生成视图时跑:
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 写法 | 差异核心 |
|---|---|---|---|
| 1 | Db::name('posts')->where(...)->select() | Post::where(...)->get() | Eloquent 用 Active Record 模式,模型本身就是查询起点 |
| 2 | 模型字段在文档里说明 | $fillable = [...] 必填 | Laravel 默认所有字段都不可批量赋值,必须显式声明 |
| 3 | 业务条件散在 Controller | Eloquent scope 集中到 Model | "状态相关查询"是 Model 的责任 |
| 4 | 验证用 Validate 类放在 validate/ | Form Request 类 = 验证 + 授权 + 自动注入 | Form Request 是"被注入的活动验证器" |
| 5 | 权限手写 if (...) 散在 controller | Policy 类 + @can 指令 + authorize() 方法 | 权限抽象成业务对象,可独立测试 |
| 6 | URL 用模型 ID /posts/1 | 路由模型绑定 /posts/{post} 自动解析 + slug 化 | 类型驱动而不是 ID 驱动 |
| 7 | __construct() 注册中间件 | HasMiddleware 接口 + 静态方法 | Laravel 11+ 用接口表达"可被框架检查的契约" |
| 8 | 写测试很少 | Pest 默认安装,php artisan test 0.6 秒跑完 | 测试是 Laravel 文化的一部分 |
| 9 | Migration 写 SQL 风格的字段 | Schema Builder + 链式 DSL(foreignId()->constrained()) | DSL 隐藏 SQL 方言差异,同代码跑 SQLite/MySQL/PostgreSQL |
| 10 | display('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.phpHTTP 层(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关键命令清单(按出场顺序)
# 启动数据层
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 PostManagementTest11. 下一步可玩的方向
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(
HasMiddleware、casts()方法签名) - 自动 eager-load 防 N+1
- 自动写测试
- 自动加 Policy 而不是散落 if 判断
→ 这些不是 prompt 工程能搞定的,是 Guidelines + Skills + 工具组合的效果。
Q2:Boost 工具最有价值的是哪几个?
TOP 5(实测):
search-docs——避免 AI 用旧版 API(出现在每个新需求开头)database-schema——避免 AI 瞎猜表结构database-query——验证 seed 数据 + 业务查询,比 tinker 可靠application-info——确认版本(让 AI 知道是 Laravel 12 还是 9)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 章"高级实战" + 把这套体验落地到我自己的项目里。