强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

PHP 完全指南 / 第 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 章!

继续探索:回到目录