Laravel: Phase 2综合练习—ShopMetrics后台CRUD
最后更新:2026-08-26
Phase 2 综合练习是"交付核心功能"——把第 8-13 课的所有知识串起来,构建一个完整可用的后台系统。
1. 你将学到
- 完整迁移文件集:10+ 表含外键与索引
- Eloquent 模型层:关联关系 + Scope + Accessor/Mutator
- 资源控制器 CRUD:列表/创建/编辑/删除含验证
- 数据填充:100+ 租户/用户/订单模拟数据
- N+1 检测与优化:Debugbar + 查询分析
2. Bob 的 Phase 2 验收故事
(1) 痛点:后台功能散落各课,无法形成完整产品
Alice 完成了 Phase 2 所有课程,但迁移在课 8、模型在课 9、验证在课 12——每课单独能跑,但组合起来就出问题:外键约束不匹配、模型关联缺失、验证规则和数据库列类型不一致。
(2) 综合练习的解法
本课从数据库设计到 CRUD 页面,一步步把所有知识串成一个完整系统——每一步都基于上一步的产出,确保无断层。
BASH
# Phase 2 deliverable: A complete admin CRUD system
php artisan migrate:fresh --seed
# → 10+ tables, 100+ records, all CRUD working
(3) 收益
Alice 完成综合练习后,拥有了完整的 ShopMetrics 后台 CRUD 系统——从建表到增删改查到数据填充,一步到位。
3. 数据库设计
(1) ShopMetrics 多租户 ER 图
erDiagram
Tenant ||--o{ User : "has many"
Tenant ||--o| Subscription : "has one"
Tenant ||--o{ Shop : "has many"
Plan ||--o{ Subscription : "has many"
Shop ||--o{ Product : "has many"
Shop ||--o{ Order : "has many"
User ||--o{ Order : "places"
Product }o--o{ Category : "belongs to many"
Order ||--o{ OrderItem : "contains"
Product ||--o{ OrderItem : "included in"
(2) 迁移清单
| # | 迁移文件 | 表名 | 核心字段 |
|---|---|---|---|
| 1 | create_plans_table | plans | name, slug, price, shop_limit, features(json) |
| 2 | create_tenants_table | tenants | name, slug, domain, plan_id(FK), status |
| 3 | create_subscriptions_table | subscriptions | tenant_id(FK), plan_id(FK), stripe_id, status |
| 4 | create_users_table | users | tenant_id(FK), name, email, password, role |
| 5 | create_shops_table | shops | tenant_id(FK), name, slug, status, revenue |
| 6 | create_categories_table | categories | name, slug |
| 7 | create_products_table | products | shop_id(FK), name, sku, price, stock, is_active |
| 8 | create_category_product_table | category_product | category_id(FK), product_id(FK) |
| 9 | create_orders_table | orders | tenant_id(FK), shop_id(FK), user_id(FK), total, status |
| 10 | create_order_items_table | order_items | order_id(FK), product_id(FK), qty, price |
▶ 示例:核心迁移文件
PHP
// database/migrations/2024_01_01_000001_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->decimal('price', 8, 2);
$table->integer('shop_limit')->default(5);
$table->json('features')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000005_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug');
$table->text('description')->nullable();
$table->enum('status', ['active', 'suspended', 'closed'])->default('active');
$table->decimal('revenue', 12, 2)->default(0);
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'slug']);
$table->index(['tenant_id', 'status']);
});
输出:
TEXT
📖 仅展示
// 执行成功
4. Eloquent 模型层
(1) 模型清单与关联
| 模型 | 关联 | Scope | Accessor/Mutator |
|---|---|---|---|
| Tenant | shops(), users(), subscription() | active() | domain_url |
| Shop | products(), orders(), tenant() | active() | revenue_formatted |
| Product | categories(), shop() | active(), inStock() | price_formatted |
| Order | items(), shop(), user() | completed(), thisMonth() | status_badge |
| OrderItem | product(), order() | — | subtotal |
▶ 示例:ShopMetrics 核心模型
PHP
// app/Models/Tenant.php
class Tenant extends Model
{
protected $fillable = ['name', 'slug', 'domain', 'plan_id', 'status'];
protected $casts = ['status' => 'string'];
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function getDomainUrlAttribute(): string
{
return 'https://' . $this->domain;
}
}
// app/Models/Shop.php
class Shop extends Model
{
use SoftDeletes;
protected $fillable = ['tenant_id', 'name', 'slug', 'description', 'status', 'revenue'];
protected $casts = ['revenue' => 'decimal:2', 'deleted_at' => 'datetime'];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function getRevenueFormattedAttribute(): string
{
return '$' . number_format($this->revenue, 2);
}
}
// app/Models/Product.php
class Product extends Model
{
protected $fillable = ['shop_id', 'name', 'sku', 'price', 'stock', 'is_active'];
protected $casts = ['price' => 'decimal:2', 'is_active' => 'boolean'];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)->withTimestamps();
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function scopeInStock(Builder $query): Builder
{
return $query->where('stock', '>', 0);
}
public function getPriceFormattedAttribute(): string
{
return '$' . number_format($this->price, 2);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
5. 资源控制器 CRUD
(1) 控制器与验证类清单
| 控制器 | 方法 | Form Request |
|---|---|---|
| ShopController | index/create/store/show/edit/update/destroy | StoreShopRequest/UpdateShopRequest |
| ProductController | index/create/store/show/edit/update/destroy | StoreProductRequest/UpdateProductRequest |
| OrderController | index/show/update | UpdateOrderRequest |
▶ 示例:ShopController 完整 CRUD
PHP
// app/Http/Controllers/ShopController.php
class ShopController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('tenant.resolve');
}
public function index(Request $request): View
{
$shops = Shop::where('tenant_id', tenant()->id)
->withCount(['products', 'orders as recent_orders' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))])
->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->orderBy($request->sort ?? 'created_at', $request->direction ?? 'desc')
->paginate(15);
return view('shops.index', compact('shops'));
}
public function create(): View
{
return view('shops.create');
}
public function store(StoreShopRequest $request): RedirectResponse
{
$shop = Shop::create(array_merge(
$request->validated(),
['tenant_id' => tenant()->id],
));
return redirect()->route('shops.show', $shop)
->with('success', 'Shop created successfully.');
}
public function show(Shop $shop): View
{
$shop->load(['products' => fn ($q) => $q->active()->latest()->take(10),
'orders' => fn ($q) => $q->with('user')->latest()->take(10)]);
$stats = [
'total_revenue' => $shop->orders()->completed()->sum('total'),
'total_orders' => $shop->orders()->count(),
'total_products' => $shop->products()->active()->count(),
];
return view('shops.show', compact('shop', 'stats'));
}
public function edit(Shop $shop): View
{
return view('shops.edit', compact('shop'));
}
public function update(UpdateShopRequest $request, Shop $shop): RedirectResponse
{
$shop->update($request->validated());
return redirect()->route('shops.show', $shop)
->with('success', 'Shop updated.');
}
public function destroy(Shop $shop): RedirectResponse
{
$shop->delete();
return redirect()->route('shops.index')
->with('success', 'Shop deleted.');
}
}
输出:
TEXT
📖 仅展示
// 执行成功
6. 验证类实现
▶ 示例:ShopMetrics Form Request 类
PHP
// app/Http/Requests/StoreShopRequest.php
class StoreShopRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->user()->role === 'tenant_owner';
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|alpha_dash|unique:shops,slug,NULL,id,tenant_id,' . tenant()->id,
'description' => 'nullable|string|max:5000',
'status' => 'sometimes|in:active,suspended',
];
}
protected function prepareForValidation(): void
{
$this->merge(['slug' => Str::slug($this->slug ?? $this->name)]);
}
}
// app/Http/Requests/StoreProductRequest.php
class StoreProductRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'sku' => 'required|string|unique:products,sku',
'price' => 'required|numeric|min:0.01|max:999999.99',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string|max:5000',
'is_active' => 'boolean',
'categories' => 'sometimes|array',
'categories.*' => 'exists:categories,id',
'image' => 'sometimes|image|max:2048',
];
}
}
输出:
TEXT
📖 仅展示
// 执行成功
7. 数据填充
▶ 示例:ShopMetrics 完整 Seeder
PHP
// database/seeders/ShopMetricsSeeder.php
class ShopMetricsSeeder extends Seeder
{
public function run(): void
{
$starter = Plan::create(['name' => 'Starter', 'slug' => 'starter', 'price' => 29, 'shop_limit' => 5, 'features' => ['basic_analytics']]);
$pro = Plan::create(['name' => 'Pro', 'slug' => 'pro', 'price' => 79, 'shop_limit' => 25, 'features' => ['advanced_analytics', 'api']]);
$enterprise = Plan::create(['name' => 'Enterprise', 'slug' => 'enterprise', 'price' => 199, 'shop_limit' => null, 'features' => ['all']]);
User::factory()->create(['email' => 'admin@shopmetrics.io', 'role' => 'super_admin']);
Category::factory()->count(10)->create();
Tenant::factory()->count(20)->create()->each(function ($tenant) use ($starter, $pro, $enterprise) {
$plan = fake()->randomElement([$starter, $pro, $enterprise]);
Subscription::create(['tenant_id' => $tenant->id, 'plan_id' => $plan->id, 'status' => 'active']);
$owner = User::factory()->for($tenant)->create(['role' => 'tenant_owner']);
$analysts = User::factory()->count(3)->for($tenant)->create(['role' => 'analyst']);
$shopCount = min(fake()->numberBetween(2, 5), $plan->shop_limit ?? 99);
Shop::factory()->count($shopCount)->for($tenant)->create()->each(function ($shop) use ($tenant, $owner, $analysts) {
$products = Product::factory()->count(fake()->numberBetween(10, 30))->for($shop)->create();
$products->each(fn ($p) => $p->categories()->attach(Category::inRandomOrder()->take(rand(1, 3))->pluck('id')));
Order::factory()->count(fake()->numberBetween(20, 60))->for($tenant)->for($shop)->for(fake()->randomElement(array_merge([$owner], $analysts->all())))->create()->each(function ($order) use ($products) {
$items = $products->random(rand(1, 5));
foreach ($items as $product) {
$qty = rand(1, 3);
$order->items()->create(['product_id' => $product->id, 'quantity' => $qty, 'price' => $product->price]);
}
$order->update(['total' => $order->items->sum(fn ($i) => $i->price * $i->quantity)]);
});
});
});
}
}
输出:
TEXT
📖 仅展示
// 执行成功
8. N+1 检测与优化
(1) 安装 Debugbar
BASH
composer require barryvdh/laravel-debugbar --dev
(2) 常见 N+1 场景与修复
| 场景 | N+1 问题 | 修复方案 |
|---|---|---|
| 商店列表 | $shop->products->count() |
withCount('products') |
| 订单列表 | $order->user->name |
with('user') |
| 商品详情 | $product->categories |
with('categories') |
| 仪表盘 | 多个关联同时访问 | with(['user', 'shop.items']) |
▶ 示例:Debugbar 查询分析
PHP
// In development, Debugbar shows query count on each page
// Optimize N+1 step by step:
// Step 1: Identify — Debugbar shows "41 queries" on shops.index
// Step 2: Add eager loading
$shops = Shop::with(['products', 'tenant', 'orders'])->get(); // 4 queries
// Step 3: Use withCount instead of loading full relations
$shops = Shop::withCount('products', 'orders')->with('tenant')->get(); // 3 queries
// Step 4: Verify — Debugbar shows "3 queries"
输出:
TEXT
📖 仅展示
// 执行成功
9. 综合示例:ShopMetrics 后台 CRUD 系统
PHP
// ============================================
// Comprehensive: ShopMetrics Admin CRUD Setup
// Covers: migrations, models, controllers, requests, seeders
// ============================================
// Setup commands (run in order)
// 1. php artisan migrate:fresh --seed
// 2. php artisan storage:link
// 3. php artisan serve
// routes/web.php — Complete admin routes
Route::middleware('auth')->prefix('dashboard')->name('dashboard.')->group(function () {
Route::get('/', [DashboardController::class, 'index'])->name('index');
// Shops CRUD
Route::resource('shops', ShopController::class);
Route::post('/shops/{shop}/logo', [ShopLogoController::class, 'update'])->name('shops.logo');
// Products CRUD (nested under shop)
Route::resource('shops.products', ProductController::class)->shallow();
// Orders (read-only + status update)
Route::resource('orders', OrderController::class)->only(['index', 'show', 'update']);
Route::post('/orders/{order}/export', ExportOrdersController::class)->name('orders.export');
// Categories (simple CRUD)
Route::resource('categories', CategoryController::class)->except('show');
// Analytics
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics');
});
// DashboardController with optimized queries
class DashboardController extends Controller
{
public function index(): View
{
$tenant = tenant();
$stats = Cache::remember("dashboard.{$tenant->id}", 300, function () use ($tenant) {
return [
'total_revenue' => $tenant->shops()->sum('revenue'),
'active_shops' => $tenant->shops()->active()->count(),
'monthly_orders' => $tenant->orders()->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count(),
'total_products' => Product::whereHas('shop', fn ($q) => $q->where('tenant_id', $tenant->id))->active()->count(),
];
});
$recentOrders = Order::where('tenant_id', $tenant->id)
->with(['shop', 'user'])
->latest()
->take(5)
->get();
return view('dashboard.index', compact('stats', 'recentOrders'));
}
}
❓ 常见问题
Q Phase 2 练习需要多少时间?
A 大约 4-6 小时。数据库设计 1h,模型和关联 1.5h,控制器 CRUD 1.5h,数据填充和调试 1h。不要跳步,每一步验证后再做下一步。
Q 如何确保迁移顺序正确?
A 外键依赖的表要先创建。迁移文件名的时间戳决定执行顺序——先创建被引用的表,再创建引用它的表。或使用
php artisan migrate:status 检查顺序。Q CRUD 页面很多代码是重复的,怎么减少?
A 用 Blade 组件封装表单字段(
<x-input>、<x-select>),用 artisan make:controller --resource 生成骨架代码。重复是正常的,先确保功能正确再考虑抽象。Q Debugbar 在生产环境要关闭吗?
A 必须关闭。Debugbar 暴露敏感信息(SQL 查询、内存使用、配置值)。它在 APP_DEBUG=true 时才启用,生产环境 APP_DEBUG=false 会自动禁用。
Q Seeder 数据和测试数据有什么区别?
A Seeder 生成开发环境的基础数据(管理员账号、计划方案),持久保存在数据库中;测试数据在 PHPUnit 中用 Factory 生成,每个测试后自动回滚。
Q 如何验证 N+1 已完全优化?
A Debugbar 显示的查询数应该等于 1(主查询)+ 预加载关联数。如
with(['user', 'shop']) 应该是 3 条查询。如果多于预期,检查是否有懒加载遗漏。📖 小节
- 数据库设计先画 ER 图,再写迁移,确保外键依赖顺序正确
- Eloquent 模型定义关联、Scope、Accessor/Mutator 三层能力
- Form Request 封装验证和授权,控制器保持精简
- 数据填充按依赖顺序:Plan → Tenant → User → Shop → Product → Order
- N+1 用 with() 和 withCount() 优化,Debugbar 监控查询数
- CRUD 系统从资源控制器起步,逐步添加过滤/搜索/分页
📝 作业
-
基础题(⭐):完成 ShopMetrics 后台的 10 张表迁移 + 模型定义,运行
migrate:fresh --seed确认所有数据正确生成,无外键错误。 -
进阶题(⭐⭐):实现 Shop 和 Product 的完整 CRUD(含 Form Request 验证、搜索过滤、分页),确保 Debugbar 显示查询数不超过 5 条/页面。
-
挑战题(⭐⭐⭐):为 Dashboard 实现缓存优化的数据聚合——缓存统计查询 5 分钟,当有新订单时通过模型事件自动清除缓存,确保数据实时性和性能的平衡。