第 28 章 — 实战项目
第 28 章 — 实战项目:Laravel API、CMS、队列与 WebSocket
28.1 Laravel RESTful API 项目
28.1.1 项目结构
# 创建项目
composer create-project laravel/laravel blog-api
cd blog-api
# 安装 API 相关包
composer require laravel/sanctum
php artisan install:api
28.1.2 数据模型
<?php
// app/Models/Article.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Article extends Model
{
use SoftDeletes;
protected $fillable = [
'title', 'slug', 'content', 'excerpt',
'status', 'published_at', 'user_id',
];
protected $casts = [
'published_at' => 'datetime',
];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function isPublished(): bool
{
return $this->status === 'published';
}
}
<?php
// database/migrations/xxxx_create_articles_table.php
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('content');
$table->text('excerpt')->nullable();
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
}
28.1.3 API 资源
<?php
// app/Http/Resources/ArticleResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $request->is('api/articles/*') ? $this->content : null,
'status' => $this->status,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
28.1.4 控制器
<?php
// app/Http/Controllers/Api/ArticleController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreArticleRequest;
use App\Http\Requests\UpdateArticleRequest;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ArticleController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$articles = Article::query()
->with(['author', 'tags'])
->when($request->status, fn($q, $s) => $q->where('status', $s))
->when($request->search, fn($q, $s) => $q->where('title', 'like', "%{$s}%"))
->latest()
->paginate($request->per_page ?? 15);
return ArticleResource::collection($articles);
}
public function store(StoreArticleRequest $request): JsonResponse
{
$article = $request->user()->articles()->create($request->validated());
if ($request->has('tags')) {
$article->tags()->sync($request->tags);
}
return (new ArticleResource($article->load(['author', 'tags'])))
->response()
->setStatusCode(201);
}
public function show(Article $article): ArticleResource
{
$article->load(['author', 'tags']);
return new ArticleResource($article);
}
public function update(UpdateArticleRequest $request, Article $article): ArticleResource
{
$this->authorize('update', $article);
$article->update($request->validated());
if ($request->has('tags')) {
$article->tags()->sync($request->tags);
}
return new ArticleResource($article->fresh()->load(['author', 'tags']));
}
public function destroy(Article $article): JsonResponse
{
$this->authorize('delete', $article);
$article->delete();
return response()->json(['message' => 'Article deleted'], 204);
}
}
28.1.5 表单验证
<?php
// app/Http/Requests/StoreArticleRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreArticleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Article::class);
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string|min:10',
'excerpt' => 'nullable|string|max:500',
'status' => 'in:draft,published',
'published_at' => 'nullable|date',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
];
}
}
28.1.6 路由
<?php
// routes/api.php
use App\Http\Controllers\Api\ArticleController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('articles', ArticleController::class);
});
// 公开路由
Route::get('articles/published', [ArticleController::class, 'published']);
28.2 队列系统
28.2.1 创建 Job
<?php
// app/Jobs/SendArticleNotification.php
namespace App\Jobs;
use App\Models\Article;
use App\Models\User;
use App\Notifications\NewArticleNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendArticleNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public readonly Article $article,
) {}
public function handle(): void
{
$subscribers = User::where('subscribed', true)->cursor();
foreach ($subscribers as $user) {
$user->notify(new NewArticleNotification($this->article));
}
}
public function failed(\Throwable $exception): void
{
\Log::error("Failed to send notifications for article #{$this->article->id}", [
'error' => $exception->getMessage(),
]);
}
}
28.2.2 分发 Job
<?php
// 在控制器中分发
SendArticleNotification::dispatch($article);
// 延迟分发
SendArticleNotification::dispatch($article)->delay(now()->addMinutes(5));
// 指定队列
SendArticleNotification::dispatch($article)->onQueue('notifications');
// 链式 Job
ProcessPodcast::chain([
new OptimizePodcast($podcast),
new ReleasePodcast($podcast),
])->dispatch();
28.2.3 Horizon(队列监控)
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
<?php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'queue' => ['default', 'notifications', 'emails'],
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
'queue' => ['default', 'notifications'],
],
],
],
28.3 简易 CMS 系统
28.3.1 内容模型
<?php
// app/Models/Page.php
class Page extends Model
{
use HasSlug, SoftDeletes, HasMedia;
protected $fillable = [
'title', 'slug', 'body', 'meta_title',
'meta_description', 'template', 'status',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
// 多态关联:一个页面有多个区块
public function blocks(): MorphMany
{
return $this->morphMany(Block::class, 'blockable');
}
// 版本管理
public function revisions(): HasMany
{
return $this->hasMany(PageRevision::class);
}
public function publish(): void
{
$this->update(['status' => 'published', 'published_at' => now()]);
}
}
28.3.2 内容区块
<?php
// app/Models/Block.php
class Block extends Model
{
protected $fillable = ['type', 'content', 'sort_order', 'blockable_type', 'blockable_id'];
protected $casts = [
'content' => 'json',
];
// 区块类型枚举
enum BlockType: string
{
case Text = 'text';
case Image = 'image';
case Gallery = 'gallery';
case Video = 'video';
case Code = 'code';
case Quote = 'quote';
}
}
28.3.3 Blade 模板
{{-- resources/views/blocks/text.blade.php --}}
<div class="block block-text">
{!! $block->content['html'] !!}
</div>
{{-- resources/views/blocks/image.blade.php --}}
<figure class="block block-image">
<img src="{{ $block->content['url'] }}"
alt="{{ $block->content['alt'] ?? '' }}"
loading="lazy">
@if(!empty($block->content['caption']))
<figcaption>{{ $block->content['caption'] }}</figcaption>
@endif
</figure>
{{-- resources/views/page.blade.php --}}
@extends('layouts.app')
@section('content')
<article class="page">
<h1>{{ $page->title }}</h1>
@foreach($page->blocks()->orderBy('sort_order')->get() as $block)
@include("blocks.{$block->type}", ['block' => $block])
@endforeach
</article>
@endsection
28.4 WebSocket 实时通知
28.4.1 使用 Laravel Reverb
composer require laravel/reverb
php artisan reverb:install
php artisan reverb:start
28.4.2 广播事件
<?php
// app/Events/ArticlePublished.php
namespace App\Events;
use App\Models\Article;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ArticlePublished implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Article $article,
) {}
public function broadcastOn(): array
{
return [
new Channel('articles'),
new Channel('user.' . $this->article->user_id),
];
}
public function broadcastAs(): string
{
return 'article.published';
}
public function broadcastWith(): array
{
return [
'id' => $this->article->id,
'title' => $this->article->title,
'slug' => $this->article->slug,
'author'=> $this->article->author->name,
];
}
}
28.4.3 前端监听
// resources/js/app.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
});
// 监听公开频道
echo.channel('articles')
.listen('.article.published', (e) => {
console.log('新文章:', e.title);
showNotification(`新文章: ${e.title}`);
});
// 监听私有频道
echo.private(`user.${userId}`)
.listen('.article.published', (e) => {
console.log('你的文章已发布:', e.title);
});
28.5 API 限流
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// 在路由中使用
Route::middleware(['throttle:api'])->group(function () {
Route::apiResource('articles', ArticleController::class);
});
28.6 部署脚本
#!/bin/bash
# deploy.sh
set -e
echo "🚀 Deploying..."
# 拉取最新代码
git pull origin main
# 安装依赖
composer install --no-dev --optimize-autoloader
# 运行迁移
php artisan migrate --force
# 缓存配置
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 重启队列
php artisan queue:restart
# 重启 PHP-FPM
sudo systemctl reload php8.3-fpm
echo "✅ Deployed successfully!"
28.7 测试策略
<?php
// tests/Feature/ArticleApiTest.php
class ArticleApiTest extends TestCase
{
use RefreshDatabase;
public function test_can_list_articles(): void
{
Article::factory()->count(5)->create();
$response = $this->getJson('/api/articles');
$response->assertOk()
->assertJsonCount(5, 'data');
}
public function test_can_create_article(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/articles', [
'title' => 'Test Article',
'content' => 'This is the test content for the article.',
'status' => 'draft',
]);
$response->assertCreated()
->assertJsonFragment(['title' => 'Test Article']);
$this->assertDatabaseHas('articles', ['title' => 'Test Article']);
}
public function test_cannot_update_others_article(): void
{
$user = User::factory()->create();
$article = Article::factory()->create(); // 其他用户的文章
$response = $this->actingAs($user)
->putJson("/api/articles/{$article->id}", ['title' => 'Updated']);
$response->assertForbidden();
}
}
28.8 扩展阅读
上一章:第 27 章 — 最佳实践
🎉 恭喜完成 PHP 完全指南全部 28 章!
继续探索:回到目录