控制器

简介

与其把所有请求处理逻辑写成路由文件里的闭包,不如用"控制器"类来组织。控制器能把相关功能聚在一个类里——比如 UserController 专门处理用户相关的请求:展示、创建、更新、删除。控制器默认放在 app/Http/Controllers 目录。

编写控制器

基础控制器

用 make:controller 命令快速生成一个新控制器:

php artisan make:controller UserController

来看一个最基础的控制器。控制器里写若干个 public 方法,每个方法响应对应的 HTTP 请求:

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\View\View;

class UserController extends Controller
{
    /**
     * 展示指定用户的个人资料。
     */
    public function show(string $id): View
    {
        return view('user.profile', [
            'user' => User::findOrFail($id)
        ]);
    }
}

写好控制器类和方法后,在路由文件里把它挂上去:

use App\Http\Controllers\UserController;

Route::get('/user/{id}', [UserController::class, 'show']);

当请求匹配到这条路由时,App\Http\Controllers\UserController 的 show() 方法就会被调用,路由参数也会自动传进去。

信息

控制器不强制继承基类。不过继承一个基础控制器类有时很方便——可以把所有控制器共用的方法放在基类里。

单动作控制器

如果一个控制器动作特别复杂,把整个控制器类只用于这一个动作反而更清爽。做法是:在控制器里只定义一个 __invoke() 方法:

<?php

namespace App\Http\Controllers;

class ProvisionServer extends Controller
{
    /**
     * 配置一台新的 Web 服务器。
     */
    public function __invoke()
    {
        // ...
    }
}

注册单动作控制器的路由时不需要指定方法名,直接把控制器类传给路由就行:

use App\Http\Controllers\ProvisionServer;

Route::post('/server', ProvisionServer::class);

你也可以用 --invokable 选项来生成这种控制器:

php artisan make:controller ProvisionServer --invokable

信息

  1. 控制器的模板存根可以通过发布存根来定制。
  2. 个人感觉单动作控制器没多大作用,控制器本身就是轻代码的,都是调用别处的代码

控制器中间件

中间件可以直接在路由文件里挂到控制器路由上:

Route::get('/profile', [UserController::class, 'show'])->middleware('auth');

不过,把中间件声明写在控制器类里面会更直观——一眼就能看出这个控制器有哪些中间件保护。做法是:让控制器实现 HasMiddleware 接口,然后定义一个静态的 middleware() 方法,返回中间件数组:

<?php

namespace App\Http\Controllers;

use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;

class UserController implements HasMiddleware
{
    /**
     * 获取应用于该控制器的中间件。
     */
    public static function middleware(): array
    {
        return [
            'auth',
            new Middleware('log', only: ['index']),
            new Middleware('subscribed', except: ['store']),
        ];
    }

    // ...
}

你也可以返回闭包作为中间件——不用单独写一个中间件类,直接在控制器里定义内联逻辑:

use Closure;
use Illuminate\Http\Request;

/**
 * 获取应用于该控制器的中间件。
 */
public static function middleware(): array
{
    return [
        function (Request $request, Closure $next) {
            return $next($request);
        },
    ];
}

中间件属性

你还可用 PHP 8 的 Attribute(属性)语法来给控制器挂中间件:

<?php

namespace App\Http\Controllers;

use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
#[Middleware('log', only: ['index'])]
#[Middleware('subscribed', except: ['store'])]
class UserController
{
    // ...
}

属性也可以挂在单个方法上。方法上的中间件会和类级别的中间件合并:

<?php

namespace App\Http\Controllers;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
class UserController
{
    #[Middleware('log')]
    #[Middleware('subscribed')]
    public function index()
    {
        // ...
    }

    #[Middleware(static function (Request $request, Closure $next) {
        // ...

        return $next($request);
    })]
    public function store()
    {
        // ...
    }
}

个人意见

不建议把以上案例中的通用中间件放在控制器中,以代码的可维护性来说,把中间件以组的形式放在路由中会更清楚,更方便管理。

授权属性

如果你用策略(Policy)来做控制器授权,可以用 Authorize 属性作为 can 中间件的快捷写法:

<?php

namespace App\Http\Controllers;

use App\Models\Comment;
use App\Models\Post;
use Illuminate\Routing\Attributes\Controllers\Authorize;

class CommentController
{
    #[Authorize('create', [Comment::class, 'post'])]
    public function store(Post $post)
    {
        // ...
    }

    #[Authorize('delete', 'comment')]
    public function destroy(Comment $comment)
    {
        // ...
    }
}

第一个参数是要授权的能力名称,第二个参数是传给策略的模型类、路由参数或参数列表。

资源控制器

关于资源控制器

在国内,建议不要使用资源控制器,资源控制器按照RESTful APi 设计的, 但是以国内的环境,请求的方法只用GET和POST会更稳妥,更方便。

如果把每个 Eloquent 模型看成一种"资源",你会发现对每种资源的操作模式基本一样——创建、读取、更新、删除(CRUD)。Laravel 的资源路由让你一行代码就能生成全套 CRUD 路由。

先用 make:controller 的 --resource 选项快速生成一个资源控制器:

php artisan make:controller PhotoController --resource

这条命令会在 app/Http/Controllers/PhotoController.php 生成一个控制器,里面已经为每种资源操作准备了对应的方法。接着注册一条资源路由指向它:

use App\Http\Controllers\PhotoController;

Route::resource('photos', PhotoController::class);

这一行路由声明等于一次性注册了下面所有的路由:

资源控制器处理的动作

动词 URI 方法 路由名称
GET /photos index photos.index
GET /photos/create create photos.create
POST /photos store photos.store
GET /photos/{photo} show photos.show
GET /photos/{photo}/edit edit photos.edit
PUT/PATCH /photos/{photo} update photos.update
DELETE /photos/{photo} destroy photos.destroy

你也可以用 resources 方法一次注册多个资源控制器:

Route::resources([
    'photos' => PhotoController::class,
    'posts' => PostController::class,
]);

softDeletableResources 方法则一次注册多个资源控制器,并且每个资源的 show、edit、update 路由都会自动加上 withTrashed:

Route::softDeletableResources([
    'photos' => PhotoController::class,
    'posts' => PostController::class,
]);

自定义模型未找到时的行为

通常情况下,隐式绑定的资源模型找不到时,框架会返回 404 响应。你可以用 missing() 方法来自定义这个行为——传一个闭包,当任何资源路由的隐式绑定模型找不到时就会调用它:

use App\Http\Controllers\PhotoController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;

Route::resource('photos', PhotoController::class)
    ->missing(function (Request $request) {
        return Redirect::route('photos.index');
    });

软删除模型

隐式模型绑定默认不会检索已软删除的模型,而是返回 404。如果你想在资源路由中也允许检索软删除的模型,调用 withTrashed() 方法:

use App\Http\Controllers\PhotoController;

Route::resource('photos', PhotoController::class)->withTrashed();

不传参数时,withTrashed() 会对 show、edit、update 三条路由生效。你也可以传一个数组来指定只对哪些路由生效:

Route::resource('photos', PhotoController::class)->withTrashed(['show']);

指定资源模型

如果你用了路由模型绑定,并希望资源控制器的方法参数类型提示为模型实例,可以在生成控制器时加 --model 选项:

php artisan make:controller PhotoController --model=Photo --resource

生成表单验证类

加 --requests 选项可以在生成资源控制器时,同时为 store 和 update 方法生成表单验证类:

php artisan make:controller PhotoController --model=Photo --resource --requests

局部资源路由

声明资源路由时,你可以用 only() 或 except() 只注册其中一部分动作:

use App\Http\Controllers\PhotoController;

Route::resource('photos', PhotoController::class)->only([
    'index', 'show'
]);

Route::resource('photos', PhotoController::class)->except([
    'create', 'store', 'update', 'destroy'
]);

API 资源路由

给 API 用的资源路由一般不需要 create 和 edit 这两个展示 HTML 模板的路由。用 apiResource() 方法,自动排除它们:

use App\Http\Controllers\PhotoController;

Route::apiResource('photos', PhotoController::class);

批量注册 API 资源控制器用 apiResources:

use App\Http\Controllers\PhotoController;
use App\Http\Controllers\PostController;

Route::apiResources([
    'photos' => PhotoController::class,
    'posts' => PostController::class,
]);

生成 API 资源控制器(不含 create 和 edit 方法)用 --api 选项:

php artisan make:controller PhotoController --api

嵌套资源

有时你需要为嵌套资源定义路由。比如一张照片(photo)下面有多条评论(comment)。在路由里用"点号"表示嵌套关系:

use App\Http\Controllers\PhotoCommentController;

Route::resource('photos.comments', PhotoCommentController::class);

这条路由会注册像下面这样的嵌套 URI:

/photos/{photo}/comments/{comment}

限定嵌套资源的查询范围

Laravel 的隐式模型绑定功能可以自动限定嵌套绑定的查询范围——确保解析出的子模型确实属于父模型。在定义嵌套资源时用 scoped() 方法,既能开启自动范围限定,又能指定子资源用哪个字段检索。详见下面的章节。

浅嵌套

很多时候没必要在 URI 里同时带父级和子级的 ID——因为子级 ID 已经是全局唯一的了。如果你的模型用的是自增主键这种唯一标识,可以用"浅嵌套":

use App\Http\Controllers\CommentController;

Route::resource('photos.comments', CommentController::class)->shallow();

这条路由定义会生成以下路由(注意:show、edit、update、destroy 的 URI 里不再带 {photo}):

动词 URI 方法 路由名称
GET /photos/{photo}/comments index photos.comments.index
GET /photos/{photo}/comments/create create photos.comments.create
POST /photos/{photo}/comments store photos.comments.store
GET /comments/{comment} show comments.show
GET /comments/{comment}/edit edit comments.edit
PUT/PATCH /comments/{comment} update comments.update
DELETE /comments/{comment} destroy comments.destroy

命名资源路由

默认情况下,所有资源控制器动作都有对应的路由名称。你可以用 names() 方法覆盖它们:

use App\Http\Controllers\PhotoController;

Route::resource('photos', PhotoController::class)->names([
    'create' => 'photos.build'
]);

命名资源路由参数

Route::resource 默认会根据资源名的单数形式来生成路由参数名。用 parameters() 方法可以按资源逐个覆盖:

use App\Http\Controllers\AdminUserController;

Route::resource('users', AdminUserController::class)->parameters([
    'users' => 'admin_user'
]);

上面的例子中,资源的 show 路由 URI 会变成:

/users/{admin_user}

限定资源路由的查询范围

回到嵌套资源的范围限定——用 scoped() 方法开启自动范围限定,同时指定子资源按什么字段检索:

use App\Http\Controllers\PhotoCommentController;

Route::resource('photos.comments', PhotoCommentController::class)->scoped([
    'comment' => 'slug',
]);

这条路由会注册一个限定范围的嵌套资源,URI 类似:

/photos/{photo}/comments/{comment:slug}

当嵌套路由参数使用了自定义键的隐式绑定时,Laravel 会自动按父模型限定查询范围——它通过约定来推测父模型上的关联方法名。在这个例子里,框架认为 Photo 模型上有一个名为 comments 的关联方法(路由参数名的复数),用它来检索 Comment 模型。

本地化资源 URI

Route::resource 默认用英文动词和复数规则来生成 URI。如果你想本地化 create 和 edit 这两个动词,用 Route::resourceVerbs() 方法。一般在 App\Providers\AppServiceProvider 的 boot() 方法开头调用:

/**
 * 启动应用的任何服务。
 */
public function boot(): void
{
    Route::resourceVerbs([
        'create' => 'crear',
        'edit' => 'editar',
    ]);
}

Laravel 的复数化工具支持多种语言,你可以按需配置。定制完动词和复数化语言后,像 Route::resource('publicacion', PublicacionController::class) 这样的资源路由注册会生成以下 URI:

/publicacion/crear

/publicacion/{publicaciones}/editar

给资源控制器补充额外路由

如果想给资源控制器增加默认动作之外的额外路由,把额外路由写在 Route::resource 之前,否则 resource 方法生成的路由可能会意外覆盖你的自定义路由:

use App\Http\Controller\PhotoController;

Route::get('/photos/popular', [PhotoController::class, 'popular']);
Route::resource('photos', PhotoController::class);

信息

保持控制器聚焦。如果你经常需要标准资源动作之外的方法,考虑把一个控制器拆成两个更小的控制器。

单例资源控制器

有些资源在整个应用里只存在唯一一个实例。比如用户的"个人资料"——可以编辑、更新,但一个用户只能有一份。同理,一张图片也只能有一个"缩略图"。这类资源就是"单例资源"。用 singleton() 方法来注册单例资源控制器:

use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Route::singleton('profile', ProfileController::class);

上面的定义会注册以下路由。注意:单例资源没有"创建"类路由,而且路由 URI 里不带标识符——因为只有一个实例,不需要 ID:

动词 URI 方法 路由名称
GET /profile show profile.show
GET /profile/edit edit profile.edit
PUT/PATCH /profile update profile.update

单例资源也可以嵌套在标准资源里:

Route::singleton('photos.thumbnail', ThumbnailController::class);

在这个例子中,photos 资源会收到所有标准资源路由,而 thumbnail 是一个单例资源,路由如下:

动词 URI 方法 路由名称
GET /photos/{photo}/thumbnail show photos.thumbnail.show
GET /photos/{photo}/thumbnail/edit edit photos.thumbnail.edit
PUT/PATCH /photos/{photo}/thumbnail update photos.thumbnail.update

可创建的单例资源

偶尔你可能也需要为单例资源定义"创建"和"存储"路由。在注册时调用 creatable() 方法:

Route::singleton('photos.thumbnail', ThumbnailController::class)->creatable();

这样会额外注册以下路由(注意:可创建的单例资源还会出现 DELETE 路由):

动词 URI 方法 路由名称
GET /photos/{photo}/thumbnail/create create photos.thumbnail.create
POST /photos/{photo}/thumbnail store photos.thumbnail.store
GET /photos/{photo}/thumbnail show photos.thumbnail.show
GET /photos/{photo}/thumbnail/edit edit photos.thumbnail.edit
PUT/PATCH /photos/{photo}/thumbnail update photos.thumbnail.update
DELETE /photos/{photo}/thumbnail destroy photos.thumbnail.destroy

如果你只想要 DELETE 路由,不需要创建和存储路由,用 destroyable() 方法:

Route::singleton(...)->destroyable();

API 单例资源 {#api-singleton-resources}

apiSingleton() 方法用于注册供 API 使用的单例资源,同样不需要 create 和 edit 路由:

Route::apiSingleton('profile', ProfileController::class);

API 单例资源也可以加上 creatable(),这会额外注册 store 和 destroy 路由:

Route::apiSingleton('photos.thumbnail', ProfileController::class)->creatable();

中间件与资源控制器

Laravel 支持对资源路由的全部方法或特定方法分配中间件,通过 middleware()、middlewareFor() 和 withoutMiddlewareFor() 三个方法实现精细控制。

对所有方法应用中间件

用 middleware() 方法给资源或单例资源的所有路由统一挂中间件:

Route::resource('users', UserController::class)
    ->middleware(['auth', 'verified']);

Route::singleton('profile', ProfileController::class)
    ->middleware('auth');

对特定方法应用中间件

用 middlewareFor() 方法只给一个或多个指定的资源方法挂中间件:

Route::resource('users', UserController::class)
    ->middlewareFor('show', 'auth');

Route::apiResource('users', UserController::class)
    ->middlewareFor(['show', 'update'], 'auth');

Route::resource('users', UserController::class)
    ->middlewareFor('show', 'auth')
    ->middlewareFor('update', 'auth');

Route::apiResource('users', UserController::class)
    ->middlewareFor(['show', 'update'], ['auth', 'verified']);

middlewareFor() 同样适用于单例和 API 单例资源控制器:

Route::singleton('profile', ProfileController::class)
    ->middlewareFor('show', 'auth');

Route::apiSingleton('profile', ProfileController::class)
    ->middlewareFor(['show', 'update'], 'auth');

对特定方法排除中间件

用 withoutMiddlewareFor() 方法从资源控制器的特定方法中排除中间件:

Route::middleware(['auth', 'verified', 'subscribed'])->group(function () {
    Route::resource('users', UserController::class)
        ->withoutMiddlewareFor('index', ['auth', 'verified'])
        ->withoutMiddlewareFor(['create', 'store'], 'verified')
        ->withoutMiddlewareFor('destroy', 'subscribed');
});

依赖注入

构造函数注入

Laravel 的服务容器负责解析所有控制器。因此,你可以在控制器的构造函数里类型提示任何依赖项——声明的依赖会自动被解析并注入到控制器实例中:

<?php

namespace App\Http\Controllers;

use App\Repositories\UserRepository;

class UserController extends Controller
{
    /**
     * 创建一个新的控制器实例。
     */
    public function __construct(
        protected UserRepository $users,
    ) {}
}

方法注入

除了构造函数注入,你也可以在控制器方法的参数上做类型提示。最常见的场景就是注入 Illuminate\Http\Request 实例:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * 存储一个新用户。
     */
    public function store(Request $request): RedirectResponse
    {
        $name = $request->name;

        // 存储用户...

        return redirect('/users');
    }
}

如果你的控制器方法还要接收路由参数,把路由参数放在依赖项之后。比如路由是这样定义的:

use App\Http\Controllers\UserController;

Route::put('/user/{id}', [UserController::class, 'update']);

你依然可以类型提示 Illuminate\Http\Request,同时通过把 $id 参数放在后面来获取它:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * 更新指定用户。
     */
    public function update(Request $request, string $id): RedirectResponse
    {
        // 更新用户...

        return redirect('/users');
    }
}