PHP 接口返回统一响应封装,让前后端对接更省心

18 阅读7分钟

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": {}
}

三个字段,不多不少:

字段类型作用
codeint业务状态码(0 = 成功,非 0 = 失败)
msgstring人类可读消息,前端直接弹
datamixed业务数据,成功时才有意义

为什么 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": {}
}

十、一句话总结

统一响应格式的本质,是给前后端之间签一份"合同"。

合同签好了,前端不用猜,后端不用解释,联调不再靠喊。