Skip to content
Laravel 技术公众号,欢迎关注

分类管理(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
  • 关系:postsparentchildren
  • 作用域:activerootsordered
  • 在 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>

laravel 入门教程-新增分类

点击提交之后,会直接回到分类列表页面,你可以看到如图所示,可以看到分类已经创建成功了

laravel 入门教程-新增分类成功

编辑页

使用命令创建视图文件:

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>

Laravel入门教程-编辑分类 点击提交之后,会直接回到分类列表页面,你可以看到如图所示,可以看到分类已经更新成功了 laravel 入门教程-新增更新成功

数据填充(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 筛选
  • 软删除与闪存状态提示