主题
分类管理(Category)
介绍
本节将一步一步实现文章分类功能,包括:数据表迁移、模型、表单验证、控制器、资源路由、Blade 视图以及 Seeder。完成后你可以在后台完成分类的新增、编辑、删除与列表展示,并支持父子分类、排序、启用状态过滤以及 slug 唯一生成。
本文所有代码均已在项目中实现,读者可以对照理解;若你是从零开始,可直接照抄本文代码并运行。
创建迁移(Migration)
使用命令创建迁移文件:
shell
php artisan make:migration create_categories_table
该命令会在 database/migrations/
下生成一个以时间戳为前缀的文件,例如:xxxx_xx_xx_xxxxxx_create_categories_table.php
。 打开该文件,写入如下内容(主要看 up 方法):
php
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('分类名称');
$table->string('slug')->unique()->comment('分类唯一标识');
$table->text('description')->nullable()->comment('分类描述');
$table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete()->comment('父分类');
$table->unsignedInteger('sort_order')->default(0)->comment('排序');
$table->boolean('is_active')->default(true)->comment('是否启用');
$table->timestamps();
$table->softDeletes();
// 索引优化
$table->index(['parent_id', 'sort_order']);
$table->index('is_active');
});
运行迁移:
shell
php artisan migrate
占位图(迁移执行结果,命令行输出):
创建模型(Model)
使用命令创建模型文件:
shell
php artisan make:model Category
模型文件路径:app/Models/Category.php
功能要点:
- fillable 白名单与 casts 类型转换
- 软删除 SoftDeletes
- 关系:
posts
、parent
、children
- 作用域:
active
、roots
、ordered
- 在 creating 钩子中根据名称自动生成唯一
slug
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Category extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'slug',
'description',
'parent_id',
'sort_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'parent_id' => 'integer',
'sort_order' => 'integer',
];
protected static function booted(): void
{
static::creating(function (Category $category): void {
if (empty($category->slug) && !empty($category->name)) {
$baseSlug = Str::slug($category->name);
$slug = $baseSlug;
$suffix = 1;
while (static::withTrashed()->where('slug', $slug)->exists()) {
$slug = $baseSlug.'-'.$suffix;
$suffix++;
}
$category->slug = $slug;
}
});
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('id');
}
}
表单验证(FormRequest)
使用命令创建请求验证类:
shell
php artisan make:request CategoryRequest
文件路径:app/Http/Requests/CategoryRequest.php
要点:
- 新增时唯一
slug
;更新时忽略当前记录ignore($id)
parent_id
不能指向自身,使用not_in:$id
- 其余字段类型与长度限制
php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$id = $this->route('category')?->id;
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['nullable', 'string', 'max:120', Rule::unique('categories', 'slug')->ignore($id)],
'description' => ['nullable', 'string'],
'parent_id' => ['nullable', 'integer', 'exists:categories,id', 'not_in:' . ($id ?? '0')],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['nullable', 'boolean'],
];
}
}
资源路由(Routes)
编辑 routes/web.php
,注册资源路由(我们不需要详情页):
php
Route::resource('categories', \App\Http\Controllers\CategoryController::class)->except(['show']);
控制器(Controller)
使用命令创建控制器:
shell
php artisan make:controller CategoryController
控制器文件路径:app/Http/Controllers/CategoryController.php
要点:
index
支持 only_active 筛选、父级预加载、排序、分页create/edit
下拉选择父分类,编辑时去除自身store/update/destroy
使用FormRequest
与闪存成功信息
php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CategoryRequest;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CategoryController extends Controller
{
public function index(Request $request): View
{
$categories = Category::with('parent')
->when($request->boolean('only_active'), fn($q) => $q->active())
->ordered()
->paginate(15)
->withQueryString();
return view('categories.index', compact('categories'));
}
public function create(): View
{
$parents = Category::roots()->ordered()->get();
return view('categories.create', compact('parents'));
}
public function store(CategoryRequest $request): RedirectResponse
{
Category::create($request->validated());
return redirect()->route('categories.index')->with('success', '分类已创建');
}
public function edit(Category $category): View
{
$parents = Category::whereNull('parent_id')
->where('id', '!=', $category->id)
->ordered()->get();
return view('categories.edit', compact('category', 'parents'));
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
$category->update($request->validated());
return redirect()->route('categories.index')->with('success', '分类已更新');
}
public function destroy(Category $category): RedirectResponse
{
$category->delete();
return redirect()->route('categories.index')->with('success', '分类已删除');
}
}
视图(Blade Views)
列表页 resources/views/categories/index.blade.php
使用命令创建视图文件:
shell
php artisan make:view categories/index
php
<x-layout :header="'分类管理'">
<div class="flex justify-between items-center mb-4">
<a href="{{ route('categories.create') }}" class="btn btn-primary">新增分类</a>
<form method="get" class="flex gap-2 items-center">
<label class="cursor-pointer label">
<span class="label-text">仅显示启用</span>
<input type="checkbox" name="only_active" value="1" class="toggle toggle-primary" {{ request('only_active') ? 'checked' : '' }}>
</label>
<button class="btn" type="submit">筛选</button>
</form>
</div>
@if(session('success'))
<div class="mb-4 alert alert-success">{{ session('success') }}</div>
@endif
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>Slug</th>
<th>父分类</th>
<th>排序</th>
<th>启用</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@foreach($categories as $category)
<tr>
<td>{{ $category->id }}</td>
<td>{{ $category->name }}</td>
<td>{{ $category->slug }}</td>
<td>{{ $category->parent?->name ?? '-' }}</td>
<td>{{ $category->sort_order }}</td>
<td>
@if($category->is_active)
<span class="badge badge-success">启用</span>
@else
<span class="badge">停用</span>
@endif
</td>
<td class="flex gap-2">
<a class="btn btn-sm" href="{{ route('categories.edit', $category) }}">编辑</a>
<form method="post" action="{{ route('categories.destroy', $category) }}" onsubmit="return confirm('确定删除该分类吗?')">
@csrf
@method('delete')
<button class="btn btn-sm btn-error" type="submit">删除</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">{{ $categories->links() }}</div>
</x-layout>
访问 categories
路由,会看到如图页面
新增分类
使用命令创建视图文件:
shell
php artisan make:view categories/create
然后找到 resources/views/categories/create.blade.php
, 写入下面的内容
php
<x-layout :header="'新增分类'">
<form class="space-y-4" method="post" action="{{ route('categories.store') }}">
@csrf
<div class="form-control">
<label class="label">名称</label>
<input name="name" class="input input-bordered" value="{{ old('name') }}" required>
@error('name')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">Slug(可留空自动生成)</label>
<input name="slug" class="input input-bordered" value="{{ old('slug') }}">
@error('slug')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">父分类</label>
<select name="parent_id" class="select select-bordered">
<option value="">无</option>
@foreach($parents as $p)
<option value="{{ $p->id }}" @selected(old('parent_id') == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('parent_id')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">排序</label>
<input name="sort_order" type="number" min="0" class="input input-bordered" value="{{ old('sort_order', 0) }}">
@error('sort_order')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">启用</label>
<input type="hidden" name="is_active" value="0">
<input name="is_active" type="checkbox" value="1" class="toggle toggle-primary" @checked(old('is_active', 1))>
@error('is_active')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">描述</label>
<textarea name="description" class="textarea textarea-bordered">{{ old('description') }}</textarea>
@error('description')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="{{ route('categories.index') }}">返回</a>
</div>
</form>
</x-layout>
点击提交之后,会直接回到分类列表页面,你可以看到如图所示,可以看到分类已经创建成功了
编辑页
使用命令创建视图文件:
shell
php artisan make:view categories/edit
然后找到 resources/views/categories/edit.blade.php
, 写入下面的内容
php
<x-layout :header="'编辑分类'">
<form class="space-y-4" method="post" action="{{ route('categories.update', $category) }}">
@csrf
@method('put')
<div class="form-control">
<label class="label">名称</label>
<input name="name" class="input input-bordered" value="{{ old('name', $category->name) }}" required>
@error('name')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">Slug(可留空保留当前或自动生成)</label>
<input name="slug" class="input input-bordered" value="{{ old('slug', $category->slug) }}">
@error('slug')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">父分类</label>
<select name="parent_id" class="select select-bordered">
<option value="">无</option>
@foreach($parents as $p)
<option value="{{ $p->id }}" @selected(old('parent_id', $category->parent_id) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('parent_id')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">排序</label>
<input name="sort_order" type="number" min="0" class="input input-bordered" value="{{ old('sort_order', $category->sort_order) }}">
@error('sort_order')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">启用</label>
<input type="hidden" name="is_active" value="0">
<input name="is_active" type="checkbox" value="1" class="toggle toggle-primary" @checked(old('is_active', $category->is_active))>
@error('is_active')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="form-control">
<label class="label">描述</label>
<textarea name="description" class="textarea textarea-bordered">{{ old('description', $category->description) }}</textarea>
@error('description')<div class="text-sm text-red-600">{{ $message }}</div>@enderror
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="{{ route('categories.index') }}">返回</a>
</div>
</form>
</x-layout>
点击提交之后,会直接回到分类列表页面,你可以看到如图所示,可以看到分类已经更新成功了
数据填充(Seeder)
为了快速准备演示数据,先使用命令创建 Seeder:
shell
php artisan make:seeder CategorySeeder
然后在 database/seeders/CategorySeeder.php
写入递归创建树形分类的逻辑:
关键片段:
php
private function createCategoriesRecursively(array $categories, ?Category $parent = null): void
{
foreach ($categories as $data) {
$children = $data['children'] ?? [];
unset($data['children']);
if ($parent) {
$data['parent_id'] = $parent->id;
}
/** @var Category $category */
$category = Category::create($data);
if (!empty($children)) {
$this->createCategoriesRecursively($children, $category);
}
}
}
运行填充:
shell
php artisan db:seed --class=CategorySeeder
小结
本节完成了分类的从 0 到 1 实现:迁移设计、模型封装、请求验证、控制器编排、资源路由与 Blade 视图,以及 Seeder 的演示数据。代码要点:
- slug 自动生成与唯一性保证
- 父子关系与根分类筛选
- ordered 排序与 only_active 筛选
- 软删除与闪存状态提示