Webhook回调未进PHP脚本,首要排查请求是否到达服务器:查Nginx/Apache访问日志,若无记录则检查网络链路;若有日志但$_POST为空,需用file_get_contents('php://input')读取JSON;务必验签、幂等、解耦至队列或Swoole协程处理。

Webhook回调没进PHP脚本,先查访问日志和基础响应
绝大多数“回调没触发”问题,根本不是PHP代码逻辑问题,而是请求压根没到你的服务器。Nginx/Apache访问日志里搜不到对应路径的POST记录,就别急着改call_user_func——先确认网络链路通不通。
在入口脚本第一行加一行日志:file_put_contents('/tmp/webhook.log', date('c') . " " . print_r($_SERVER, true) . "\n", FILE_APPEND);。如果这个日志没生成,说明请求被反向代理、防火墙、CDN或SSL重定向拦截了;如果生成了但$_POST或file_get_contents('php://input')为空,大概率是GitLab/Stripe等服务发的是application/json,而你用$_POST去读——必须用json_decode(file_get_contents('php://input'), true)。
常见卡点:
- GitLab Webhook测试页点“Test”只发
push_events,但你代码只处理merge_request事件,自然不进分支 - 微信支付回调要求必须返回纯文本
"success"且HTTP状态码200,多一个空格或换行都会被当成失败重试 - 本地调试用
ngrok时,URL带https://,但PHP里硬编码了http://回调地址,导致通知发错地方
PHP-FPM环境下回调函数执行一半就中断
传统PHP-FPM是同步阻塞模型,HTTP响应一发完,worker进程就可能回收脚本内存。你写的sleep(5); do_something();在fastcgi_finish_request()之后执行,但若没配ignore_user_abort(true)和set_time_limit(0),脚本仍会被超时杀掉。
立即学习“PHP免费学习笔记(深入)”;
正确保底写法:
ignore_user_abort(true); set_time_limit(0); // 处理原始请求(验签、入库、返回200) echo 'OK'; fastcgi_finish_request(); // 以下代码在客户端断开后继续运行 handle_webhook_logic($payload); // 这里才真正调用你的回调函数
注意:fastcgi_finish_request()仅对FastCGI有效(Nginx+PHP-FPM),Apache mod_php不支持;Swoole协程环境完全不用这套,直接go(function() { ... })即可。
容易踩的坑:
- 在
fastcgi_finish_request()后尝试写$_SESSION或依赖$GLOBALS,因为会话和全局变量可能已被销毁 - 错误地把耗时操作(如发送邮件、调第三方API)留在主响应流程里,导致Webhook超时失败
- 没做幂等校验,同一笔支付通知因重试机制连打3次,
order_status被重复更新
用消息队列解耦回调逻辑,避免阻塞Web请求
真要稳定处理Webhook,别让HTTP请求承载业务逻辑。收到请求后,只做最轻量的事:验签 → 解析 → 推入Redis队列 → 返回200。后续由独立Worker进程消费,失败可重试、可监控、可限流。
示例关键步骤:
- Web入口推任务:
$redis->rPush('webhook_queue', json_encode(['event'=>'payment.success', 'data'=>$payload])); - Worker脚本循环取任务:
$task = $redis->lPop('webhook_queue');,失败则$redis->lPush('webhook_queue', $task)回队首 - 回调函数实际在Worker里执行:
process_payment_callback($task['data']);,而非在Web请求中
这种模式下,“回调机制”本质是任务分发+状态通知。你甚至可以换回调方式:数据库字段更新、WebSocket广播、或再发一次HTTP到内部管理后台,而不必死守“原路返回”。
Swoole协程中实现真正的异步回调,绕过FPM限制
如果你已用Swoole HTTP Server,就别再模拟异步了。协程天然支持非阻塞IO,go(function() { ... })启动的协程与主请求生命周期无关,也不受set_time_limit影响。
典型结构:
$server = new Swoole\Http\Server('0.0.0.0', 9501);
$server->on('request', function ($request, $response) {
$payload = json_decode($request->rawContent(), true);
// 立即响应,不等待
$response->header('Content-Type', 'application/json');
$response->end(json_encode(['status' => 'accepted']));
// 协程内执行回调逻辑,不影响其他请求
go(function () use ($payload) {
// 这里可安全调用MySQL协程客户端、HTTP请求、文件写入
$db = new Swoole\Coroutine\MySQL();
$db->connect(['host' => 'localhost', 'user' => 'root']);
$db->query("INSERT INTO logs VALUES ('" . json_encode($payload) . "')");
// 完成后还可触发另一个HTTP回调
$http = new Swoole\Coroutine\Http\Client('api.example.com', 443, true);
$http->post('/notify', ['data' => $payload]);
});
});
关键提醒:Swoole协程函数(如Swoole\Coroutine\MySQL)不能和传统mysqli混用;所有IO操作必须用协程版驱动,否则会阻塞整个EventLoop。
复杂点在于——协程上下文无法跨请求共享,比如你在协程里想读$_COOKIE或$_SESSION,得自己从$request里解析并透传,没有FPM那种“开箱即用”的全局变量。



















