PHP 接口返回统一响应封装,让前后端对接更省心
前端最怕的三种接口返回:
有时候返回
{"code":0,"data":{...}},有时候直接返回
{"id":1,"name":"test"},还有时候直接抛一段 HTML 报错。
——前端同学含泪写下第 18 个
if (res.code === 0)的变体。
这篇文章讲一件事:怎么把 PHP 接口的返回格式统一起来,让前端对接不再靠猜。
一、为什么要统一响应格式
先说结论:不是形式主义,是省命。
不统一的代价
// 接口 A
{"code":0,"msg":"success","data":{"list":[]}}
// 接口 B
{"status":"ok","result":{"list":[]}}
// 接口 C
{"list":[]}
// 接口 D(出错时)
"SQLSTATE[42S02]: Table not found"
// 接口 E(框架异常)
<html><body><h1>Whoops!</h1>...
前端面对这种局面,只能:
if (res.code === 0) {
// 处理
} else if (res.status === 'ok') {
// 处理
} else if (res.list) {
// 处理
} else if (typeof res === 'string') {
// ???
}
每个接口一套判断逻辑,对接效率直接腰斩,bug 率翻倍。
统一之后
// 所有接口一个逻辑
if (res.code === 0) {
// 成功,用 res.data
} else {
// 失败,弹 res.msg
}
前端开心,后端省心,联调时间从 3 天缩到半天。
二、核心设计:一个标准响应长什么样
最小可用结构
{
"code": 0,
"msg": "success",
"data": {}
}
三个字段,不多不少:
| 字段 | 类型 | 作用 |
|---|---|---|
code | int | 业务状态码(0 = 成功,非 0 = 失败) |
msg | string | 人类可读消息,前端直接弹 |
data | mixed | 业务数据,成功时才有意义 |
为什么 code 用 int 不用 bool
true/false只能表达"成功/失败"int能表达多种失败原因:401未登录、403无权限、1001余额不足、2001参数错误- 前端可以根据
code做不同处理(跳转登录、弹确认框、标红输入框)
为什么 data 要有,哪怕是空对象
{"code":0,"msg":"success","data":{}}
前端不用判断 res.data 存不存在,直接 res.data.xxx 就行。
三、原生 PHP 实现
1. 封装响应函数
<?php
// src/Helpers/response.php
if (!function_exists('json_response')) {
function json_response(int $code, string $msg, $data = null, int $httpCode = 200): void
{
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
$response = [
'code' => $code,
'msg' => $msg,
];
if ($data !== null) {
$response['data'] = $data;
} else {
$response['data'] = new stdClass(); // 返回 {} 而不是 null
}
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
if (!function_exists('success')) {
function success($data = null, string $msg = 'success'): void
{
json_response(0, $msg, $data);
}
}
if (!function_exists('fail')) {
function fail(string $msg = 'error', int $code = 1, int $httpCode = 200): void
{
json_response($code, $msg, null, $httpCode);
}
}
2. 业务中使用
<?php
require_once 'src/Helpers/response.php';
// 成功
$user = ['id' => 1, 'name' => '张三', 'email' => 'zhang@example.com'];
success($user);
// 失败
if (!$token) {
fail('请先登录', 401, 401);
}
// 参数错误
if (empty($_POST['mobile'])) {
fail('手机号不能为空', 1001, 400);
}
// 列表
$list = $db->query("SELECT * FROM orders LIMIT 20");
success([
'list' => $list,
'total' => $total,
'page' => $page,
]);
3. 全局异常兜底
<?php
// bootstrap.php 或入口文件
set_exception_handler(function (Throwable $e) {
// 区分业务异常和系统异常
if ($e instanceof BusinessException) {
fail($e->getMessage(), $e->getCode(), 200);
} else {
// 系统异常,记录日志但不暴露细节
error_log($e);
fail('系统异常,请稍后重试', 500, 500);
}
});
set_error_handler(function ($level, $message, $file, $line) {
if (!(error_reporting() & $level)) {
return;
}
throw new ErrorException($message, 0, $level, $file, $line);
});
<?php
// src/Exception/BusinessException.php
class BusinessException extends Exception {}
业务代码里直接抛:
if ($balance < $amount) {
throw new BusinessException('余额不足', 1002);
}
四、Laravel 实现
1. 封装响应 Trait
<?php
// app/Traits/ApiResponse.php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse
{
protected function success($data = null, string $msg = 'success', int $code = 0): JsonResponse
{
return response()->json([
'code' => $code,
'msg' => $msg,
'data' => $data ?? (object)[],
], 200);
}
protected function fail(string $msg = 'error', int $code = 1, int $httpCode = 200): JsonResponse
{
return response()->json([
'code' => $code,
'msg' => $msg,
'data' => (object)[],
], $httpCode);
}
// 分页快捷方法
protected function paginated($paginator, string $msg = 'success'): JsonResponse
{
return $this->success([
'list' => $paginator->items(),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
], $msg);
}
}
2. Controller 里用
<?php
class UserController extends Controller
{
use ApiResponse;
public function show($id)
{
$user = User::find($id);
if (!$user) {
return $this->fail('用户不存在', 404, 404);
}
return $this->success($user->only(['id', 'name', 'email']));
}
public function index(Request $request)
{
$list = User::paginate($request->input('size', 15));
return $this->paginated($list);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'email' => 'required|email|unique:users',
]);
$user = User::create($validated);
return $this->success($user, '创建成功');
}
}
3. 全局异常兜底(Laravel 特有)
<?php
// app/Exceptions/Handler.php
public function register(): void
{
$this->renderable(function (Throwable $e, Request $request) {
// 只处理 API 请求
if (!$request->expectsJson()) {
return null;
}
// 验证异常
if ($e instanceof ValidationException) {
return response()->json([
'code' => 1001,
'msg' => $e->errors()[array_key_first($e->errors())][0] ?? '参数错误',
'data' => (object)[],
], 200);
}
// 业务异常
if ($e instanceof BusinessException) {
return response()->json([
'code' => $e->getCode() ?: 1,
'msg' => $e->getMessage(),
'data' => (object)[],
], 200);
}
// 认证异常
if ($e instanceof AuthenticationException) {
return response()->json([
'code' => 401,
'msg' => '请先登录',
'data' => (object)[],
], 401);
}
// 未找到
if ($e instanceof ModelNotFoundException) {
return response()->json([
'code' => 404,
'msg' => '资源不存在',
'data' => (object)[],
], 404);
}
// 系统异常
if (app()->environment('production')) {
Log::error($e);
return response()->json([
'code' => 500,
'msg' => '系统异常,请稍后重试',
'data' => (object)[],
], 500);
}
// 非生产环境暴露详细错误
return response()->json([
'code' => 500,
'msg' => $e->getMessage(),
'data' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace(),
],
], 500);
});
}
五、状态码设计:别乱编,定个规范
推荐分层方案
| 范围 | 含义 | 示例 |
|---|---|---|
0 | 成功 | 0 |
1~99 | 通用错误 | 1 未知错误、2 参数错误 |
100~199 | 参数/校验 | 1001 手机号格式错误、1002 验证码错误 |
200~299 | 认证/授权 | 401 未登录、403 无权限、402 Token 过期 |
300~399 | 业务规则 | 3001 余额不足、3002 库存不足、3003 订单已取消 |
400~499 | 资源 | 404 不存在、409 重复创建 |
500+ | 系统/第三方 | 500 系统异常、501 支付服务不可用 |
定义常量文件
<?php
// src/Constants/ApiCode.php
class ApiCode
{
// 成功
const SUCCESS = 0;
// 通用
const ERROR = 1;
const PARAM_ERROR = 1001;
const CAPTCHA_ERROR = 1002;
// 认证
const UNAUTHORIZED = 401;
const TOKEN_EXPIRED = 402;
const FORBIDDEN = 403;
// 业务
const BALANCE_INSUFFICIENT = 3001;
const STOCK_INSUFFICIENT = 3002;
const ORDER_CANCELLED = 3003;
// 系统
const SYSTEM_ERROR = 500;
const SERVICE_UNAVAILABLE = 501;
public static function getMessage(int $code): string
{
return match($code) {
self::SUCCESS => 'success',
self::ERROR => 'error',
self::PARAM_ERROR => '参数错误',
self::CAPTCHA_ERROR => '验证码错误',
self::UNAUTHORIZED => '请先登录',
self::TOKEN_EXPIRED => '登录已过期',
self::FORBIDDEN => '无权限',
self::BALANCE_INSUFFICIENT => '余额不足',
self::STOCK_INSUFFICIENT => '库存不足',
self::ORDER_CANCELLED => '订单已取消',
self::SYSTEM_ERROR => '系统异常',
self::SERVICE_UNAVAILABLE => '服务暂不可用',
default => 'unknown error',
};
}
}
使用时:
return $this->fail(ApiCode::BALANCE_INSUFFICIENT, 3001);
// 或者
throw new BusinessException(ApiCode::getMessage(ApiCode::BALANCE_INSUFFICIENT), ApiCode::BALANCE_INSUFFICIENT);
六、前端怎么消费这个格式
统一封装 axios(示例)
// src/utils/request.js
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
const request = axios.create({
baseURL: '/api',
timeout: 10000,
});
request.interceptors.response.use(
(response) => {
const res = response.data;
// 统一判断 code
if (res.code === 0) {
return res.data; // 直接返回 data,业务层不用再 .data
}
// 特定 code 处理
if (res.code === 401 || res.code === 402) {
ElMessage.error('登录已过期,请重新登录');
useUserStore().logout();
window.location.href = '/login';
return Promise.reject(res);
}
if (res.code === 403) {
ElMessage.error('无权限操作');
return Promise.reject(res);
}
// 其他错误,弹消息
ElMessage.error(res.msg || '请求失败');
return Promise.reject(res);
},
(error) => {
ElMessage.error('网络异常,请稍后重试');
return Promise.reject(error);
}
);
export default request;
业务层调用:
// 干净利落
const userList = await request.get('/users');
const order = await request.post('/orders', { product_id: 1 });
七、进阶:分页、列表、树形结构怎么统一
分页格式
{
"code": 0,
"msg": "success",
"data": {
"list": [...],
"total": 156,
"page": 1,
"size": 15,
"pages": 11
}
}
树形结构
{
"code": 0,
"msg": "success",
"data": {
"list": [
{
"id": 1,
"name": "一级菜单",
"children": [
{"id": 2, "name": "二级菜单", "children": []}
]
}
]
}
}
选项列表(下拉框)
{
"code": 0,
"msg": "success",
"data": {
"options": [
{"label": "启用", "value": 1},
{"label": "禁用", "value": 0}
]
}
}
八、常见坑
坑 1:data 返回 null
{"code":0,"msg":"success","data":null}
前端 res.data.xxx 直接报错。
✅ 解决:空数据返回 {} 或 []。
坑 2:成功也返回 200,失败也返回 200
这是有意为之。原因:
- HTTP 状态码表达"传输层"状态(200 到达、500 服务器崩了、401 没带 token)
code表达"业务层"状态(余额不足、库存不够)
混用会导致前端拦截器里要同时判断两层,逻辑混乱。
但也有反面观点:RESTful 派认为应该用 HTTP 状态码表达业务语义。
两种都可以,关键是全项目统一,别混着来。
坑 3:msg 返回英文或技术术语
{"code":500,"msg":"SQLSTATE[23000]: Integrity constraint violation"}
前端直接弹给用户?用户看不懂。
✅ 解决:msg 面向用户,技术细节写日志。
坑 4:data 里塞分页又塞别的
{
"code": 0,
"data": {
"list": [...],
"total": 100,
"summary": {"amount": 9999}, // 突然冒出来
"config": {"page_size": 15} // 又冒出来
}
}
前端不知道从哪取什么。
✅ 解决:分页接口 data 里只放 list/total/page/size,汇总数据放 data.summary,配置放 data.config,约定好位置。
九、完整示例:一个标准 CRUD 的返回
列表
GET /api/orders?page=1&size=15
{
"code": 0,
"msg": "success",
"data": {
"list": [
{"id": 1, "no": "ORD202401001", "amount": 299, "status": 1}
],
"total": 156,
"page": 1,
"size": 15,
"pages": 11
}
}
详情
GET /api/orders/1
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"no": "ORD202401001",
"amount": 299,
"status": 1,
"items": [...],
"created_at": "2024-01-15 10:30:00"
}
}
创建
POST /api/orders
→ 201
{
"code": 0,
"msg": "创建成功",
"data": {
"id": 2,
"no": "ORD202401002"
}
}
删除
DELETE /api/orders/2
→ 200
{
"code": 0,
"msg": "删除成功",
"data": {}
}
失败
POST /api/orders
→ 200
{
"code": 3002,
"msg": "库存不足",
"data": {}
}
十、一句话总结
统一响应格式的本质,是给前后端之间签一份"合同"。
合同签好了,前端不用猜,后端不用解释,联调不再靠喊。