CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
控制器、中间件与请求响应
这一课你将能做到
用 artisan 创建控制器,在方法中通过依赖注入拿到 Request,并返回 JSON / 重定向 / 视图
自己编写一个带参数的中间件并注册别名,理解前置、后置、终止三个阶段
用 API Resource 统一响应字段,用状态码、Header、Cookie 正确表达 HTTP 语义
为什么要学
控制器是路由的落点,是「这一个请求要做什么」的入口。Spring Boot 里你写 @RestController + @RequestBody,Laravel 控制器做的事一模一样:解析输入、调用业务、组装响应。差别在于 Laravel 通过服务容器自动注入方法参数——你需要什么就声明什么,没有的一个不多给。
中间件则解决横切问题:认证、限流、CORS、日志、请求 ID 注入。这些逻辑如果散落在每个控制器方法里,会出现大量复制粘贴且容易漏写。把它们抽成中间件后,一条路由声明就能挂上,这与 Servlet Filter 是同一个思想。
先建立图像
Request
│
▼
Middleware::handle() 前置
│
▼
Controller::method(Request $request, Order $order) ← 容器自动注入
│ ├─ $request->input() / query() / header()
│ ├─ 调用 Service / Model
│ └─ 返回 Resource / JsonResponse / Redirect / View
▼
Middleware::handle() 后置
│
▼
Middleware::terminate()(响应发出后,适合慢日志)
HTTP 语义对照:
场景 状态码 Laravel 写法 Spring 写法 创建成功 201 response()->json($data, 201)ResponseEntity.status(201)参数错误 422 验证器自动抛 BindException 处理未登录 401 中间件拦截 认证入口点 无权限 403 abort(403)AccessDeniedException资源不存在 404 abort(404) / 绑定自动ResponseStatusException限流 429 throttle 中间件 网关层
跟着做
步骤 1:创建控制器
# 普通控制器
php artisan make:controller Api/OrderController
# 带 CRUD 方法骨架的资源控制器
php artisan make:controller Api/ProductController --resource
# 纯 API(不含 create/edit)
php artisan make:controller Api/ProductController --api
# 嵌套控制器(处理某资源下的子资源)
php artisan make:controller Api/OrderItemController --parent=Order
控制器基类 App\Http\Controllers\Controller 在 Laravel 11 是极简空类,需要统一能力(如统一响应方法)时自己加。
步骤 2:读取请求数据
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
// 单个字段(带默认值)
$remark = $request->input('remark', '');
// 只取白名单字段,防止 mass assignment 之外的脏数据
$payload = $request->only(['items', 'address_id', 'channel']);
// 查询字符串 / JSON Body / 路由参数
$page = $request->query('page', '1');
$channel = $request->input('channel'); // application/json 也能读
$traceId = $request->header('X-Trace-Id');
// 判断
if ($request->isMethod('post') && $request->expectsJson()) {
// ...
}
// 数组式输入:items[0][sku]
$firstSku = $request->input('items.0.sku');
return response()->json([
'received' => $payload,
'page' => (int) $page,
'trace_id' => $traceId,
'first_sku'=> $firstSku,
], 201);
}
}
curl -X POST http://127.0.0.1:8000/api/orders \
-H "Content-Type: application/json" \
-H "X-Trace-Id: trace-001" \
-d '{"items":[{"sku":"SKU-1"}],"address_id":3,"channel":"alipay","remark":"尽快"}'
步骤 3:依赖注入与构造注入 <?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\OrderService;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function __construct(
private readonly OrderService $orders,
) {}
public function store(Request $request)
{
$order = $this->orders->create(
userId: $request->user()->id,
payload: $request->only(['items', 'address_id', 'channel']),
);
return response()->json($order, 201);
}
}
这与 Spring 的构造器注入(推荐 @RequiredArgsConstructor 的 final 字段)完全同构。readonly 是 PHP 8.1+ 的 promoted constructor property 修饰符,注入后不可变。
步骤 4:编写带参数的中间件 php artisan make:middleware EnsureChannel
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureChannel
{
// 参数在 $next 之后声明,可多个
public function handle(Request $request, Closure $next, string ...$channels): Response
{
if (! in_array($request->input('channel'), $channels, true)) {
abort(403, '支付渠道不被允许');
}
return $next($request);
}
// terminate 在响应发送后执行,适合耗时统计(先注册为 terminable)
public function terminate(Request $request, Response $response): void
{
// 例如记录超过 500ms 的慢请求
}
}
在 bootstrap/app.php 注册别名:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'channel' => \App\Http\Middleware\EnsureChannel::class,
]);
})
Route::post('/orders', [OrderController::class, 'store'])
->middleware('channel:alipay,wechat');
步骤 5:用 API Resource 统一响应 直接返回模型会暴露全部字段(包括你不想公开的内部标记)。Resource 是响应层的整形器,等价于 VO/DTO 转换:
php artisan make:resource OrderResource
php artisan make:resource OrderItemResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_no' => $this->order_no,
'status' => $this->status,
'amount' => [
'value' => $this->amount, // 单位:分
'formatted' => number_format($this->amount / 100, 2),
],
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
use App\Http\Resources\OrderResource;
public function show(\App\Models\Order $order)
{
return new OrderResource($order);
}
public function index(Request $request)
{
$orders = \App\Models\Order::query()
->where('user_id', $request->user()->id)
->with('items')
->orderByDesc('id')
->paginate(20);
return OrderResource::collection($orders); // 自动带分页 meta
}
步骤 6:响应的其他形式 // 下载文件(等价 Spring 的 ResponseEntity<Resource>)
return response()->download(storage_path('app/reports/daily.csv'), '日报.csv');
// 内联显示(如图片、PDF 预览)
return response()->file($path);
// 流式响应(导出大文件,避免内存爆炸)
return response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['订单号', '金额']);
\App\Models\Order::chunk(500, fn ($orders) => collect($orders)->each(
fn ($o) => fputcsv($handle, [$o->order_no, $o->amount])
));
fclose($handle);
}, 'orders.csv');
// 设置 Cookie(自动加密)
return response()->json(['ok' => true])
->cookie('campaign', 'double11', minutes: 60, httpOnly: true);
// 重定向并闪存数据
return redirect()->route('orders.index')->with('success', '下单成功');
// 自定义 Header(如请求追踪)
return response()->json($data)->header('X-Trace-Id', $traceId);
讲清楚要点
控制器保持薄 :它的职责是「翻译 HTTP ↔ 业务」——取输入、调服务、塑响应。跨模型的业务规则放 Service,数据查询规则放模型或 Repository。
Request 对象是请求信息的唯一入口 :不要碰 $_GET/$_POST/getallheaders(),框架已经统一处理了 JSON、文件、代理 IP 信任(TrustProxies)等边界。
中间件参数与别名 :参数通过 alias:arg1,arg2 传入;中间件默认单例复用,不要在属性里存请求相关状态。
terminate 与 handle 后置的区别 :handle 后置发生在响应发送之前,可以修改响应;terminate 在响应发出之后执行,PHP-FPM 下 fastcgi_finish_request 后客户端已断开,适合慢日志等收尾。
Resource 是可嵌套的整形层 :whenLoaded 避免 N+1——只有预加载了关联才输出它。对外契约应稳定,加字段比删字段安全。
JSON 判断有两个方法 :$request->isJson() 看 Content-Type;$request->expectsJson() 看 Accept 头 / X-Requested-With。决定返回 JSON 还是跳转通常用后者。
CORS 已内建 :Laravel 11 的全局 CORS 由框架默认处理,配置发布用 php artisan config:publish cors,无需手写中间件。
你可能会懵的地方
Request 里 get()、input()、query()、post() 有什么区别?
input() 合并路由参数、Body、查询串(最常用);query() 只看查询串;post() 只看表单 Body;get() 是 Symfony 层方法,不含 JSON Body 解析。记住 input() 一个基本够用。
为什么我返回数组/模型时中文被转成了 \uXXXX?
那是 json_encode 默认转义。Laravel 的 JsonResponse 默认会处理部分选项;需要不转义中文、斜杠时可 response()->json($data, 200, [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)。
中间件顺序错了会怎样?
认证依赖 Session,所以 StartSession 必须在认证之前;CORS 必须尽早执行,预检请求才不会被业务中间件拦截。自定义中间件放在 bootstrap/app.php 回调中的位置决定全局顺序,路由中间件按声明顺序执行。
abort(403) 和 throw new AccessDeniedHttpException 选哪个?
abort() 是助手函数,底层就是抛 HttpException。在业务代码里用哪个都行;统一风格即可,Service 层里直接抛领域异常有时更清晰,再用全局异常渲染器转 HTTP 响应(等价 @ControllerAdvice)。
文件上传怎么拿、存哪?
$file = $request->file('avatar'); $path = $file->store('avatars', 'public'); 存到 storage/app/public,并执行过 php artisan storage:link 才能通过 URL 访问。不要信任客户端给的文件名与 MIME,用 guessExtension()。
错法对照 错法 问题 正解 控制器里直接写 DB::table 拼大 SQL 职责膨胀、难以测试 模型查询 + Service 编排 return $order 直接吐模型字段泄露、格式不可控 API Resource 整形 中间件属性里保存当前请求数据 单例复用导致串请求 数据放 Request attribute 所有错误都返回 200 + code 字段 破坏 HTTP 语义,网关无法识别 4xx/5xx 状态码 + 统一错误体 导出大报表 Excel::create 一次加载全量 内存溢出 streamDownload / chunk 游标 用 md5($pwd) 自己造认证 安全漏洞 用 Laravel 内置 Hash 与 auth(第 7 课)
卡点急救
$request->input() 读不到 JSON Body :确认请求头 Content-Type: application/json;表单数组字段名要包含方括号;可用 $request->all() 查看实际解析结果。
中间件别名报 Target class [xxx] does not exist :先在 bootstrap/app.php 完成 alias;检查类名命名空间;跑过缓存则 php artisan optimize:clear。
文件下载或流式输出乱码/提前截断 :确认没有中间件在响应上做 GZIP 冲突;ob 缓冲可用 ob_end_clean() 清栈;文件路径用 storage_path() 拼绝对路径。
面试常问
Laravel 的依赖注入是怎么解析的?循环依赖怎么办?
容器通过反射读构造函数/方法的类型提示,递归解析未显式绑定的类(autowiring);接口需在 Provider 中 bind 到实现。循环依赖会抛解析异常,应通过事件、中介者或重构打破环,而不是强行注入。
中间件和过滤器(Filter)有什么异同?
同:洋葱模型、前置后置、可短路。异:Laravel 中间件是应用层组件,能方便地拿到路由信息与容器服务,支持别名传参与 terminate;Servlet Filter 更底层,作用于容器级。二者在典型项目中的使用场景几乎一一对应。
如何设计统一的 API 响应结构?
成功用 Resource(列表用 collection + 分页 meta),失败通过自定义异常渲染器或在 withExceptions 中统一输出 {message, errors(字段级)},配合正确状态码;不要在每个控制器手写包装数组。再辅以 Accept: application/json 协商,网页请求与 API 请求可分别渲染。
自测三问
要从 POST 的 JSON 中取 items 数组第一个元素的 sku,用 Request 的什么写法?
自己写一个「只允许指定渠道」的中间件需要哪三步?参数在哪声明?
为什么不建议直接 return Order::find(1)?用 Resource 后对 API 版本演进有什么好处?
小结一句话 控制器是 HTTP 与业务之间的翻译层,Request 读取、容器注入、Resource 整形、中间件横切——四件事各司其职,后端接口就既有语义又可维护。
延伸