简介PHP微信支付V3完整实例是一套面向PHP开发者的微信支付集成方案适合需要在商城、小程序或业务系统中快速接入最新版微信支付接口的初中级开发者。资源围绕V3版统一下单、前端调起支付、异步回调通知、订单查询确认及退款等核心流程展开并重点讲解了新版API签名规则与证书管理方式同时给出沙箱测试与安全性方面的注意事项。压缩包共16个文件以PHP脚本、ASP辅助文件、PEM证书、Access数据库及说明文档为主整体仅61KB轻量便于部署调试。示例代码中保留了证书配置、支付接口、订单回调及测试用数据库配合txt说明可对照理解完整调用逻辑与配置文件结构。该资源已有4621人学习下载适合想快速上手微信支付V3并落地到实际项目的PHP开发者参考。1. PHP微信支付v3 完整实例为什么官方文档读懂了还会翻车「PHP微信支付v3 完整实例」最容易被误读的地方在于它不是一个官方demo的搬运。很多PHP项目至今还停在v2的支付类写法改个URL和字段名就指望跑通v3结果第一次下单就被「签名错误」「证书序列号无效」卡住。v3把安全模型整个换掉了——请求签名从MD5变成SHA256withRSA回调解密从「一把密钥走天下」变成「商户证书平台证书AES-256-GCM」三件套接口路径也全部带上了/v3前缀。这篇按我接单的真实流程走一遍商户平台该准备哪四样配置PHP签名工具类怎么写Native和JSAPI的下单差别回调验签解密后如何安全落库再单独列几个花过真金白银踩到的坑。适合正在用Laravel、ThinkPHP或原生PHP接微信支付v3的开发者。2. 先搞懂v3的签名与证书体系v2和v3差在哪密钥从哪来微信支付v3官方叫APIv3最难的不是接口本身而是它换了整套安全体系。v2时代一个API密钥走天下签名用MD5回调内容用API密钥做AES解密逻辑简单但密钥一旦泄露伪造订单和偷看交易数据都能做到。v3把这件事拆成三把钥匙商户私钥只管「发出去的请求签名」平台证书管「收到的响应验签」APIv3密钥单独管「回调密文解密」。分开了以后每一把钥匙泄露的攻击面都可控得多这也是官方文档一再强调「不要复用密钥」的原因。2.1 APIv3的核心安全模型双证书、双密钥与敏感字段加密微信支付v3的安全模型可以概括为「双证书、双密钥」。双证书指的是商户API证书和微信支付平台证书商户API证书用来证明「发起请求的是你」平台证书用来证明「响应和回调真的来自微信支付」。双密钥指的是商户私钥和APIv3密钥商户私钥用于给请求签名、解密敏感信息APIv3密钥用于解密回调里的resource字段。v2和v3的差异用一张表可以看得很清楚对比项v2v3请求签名算法MD5 / HMAC-SHA256SHA256-RSA2048密钥体系一个API密钥商户私钥 APIv3密钥 平台证书回调解密API密钥对称解密AES-256-GCM密钥是APIv3密钥接口路径/pay/unifiedorder 等/v3/...为什么v3要这么绕v2的API密钥本质上是单一对称密钥既能验签又能解密泄露一次就全线崩溃。v3把「签名」和「解密」两个职责拆开即使APIv3密钥泄露攻击者也只能解密回调数据没法伪造一个合法的下单请求反过来商户私钥泄露签名会被伪造但回调密文仍然解不开。除了请求签名和回调验签APIv3还约定了一类特殊字段的加密凡涉及手机号、银行卡号等敏感信息请求体里要上传用平台证书公钥加密后的密文再配合商户私钥解密。这个机制在日常支付下单场景里用得不多但做电商实名认证、代付时会碰到知道有这个约定就行。2.2 商户平台只需要四样东西私钥、证书序列号、APIv3密钥、平台证书真正接入前先把商户平台的配置凑齐。登录微信支付商户平台进入「账户中心-API安全」找到APIv3密钥、商户API证书、平台证书三个入口。配置项一共四样配置项形态来源用途APIv3密钥32字节随机字符串商户平台自行设置AES-256-GCM解密密文商户API证书p12格式需转pem商户平台生成并下载请求签名、敏感信息解密商户证书序列号16位十六进制从pem里读取Authorization头的serial_no微信支付平台证书pem格式商户平台下载或接口获取响应验签、回调验签这里有个容易忽略的点APIv3密钥不是微信发给你的一串密码而是你在商户平台「设置APIv3密钥」时自己填的32位字符。填完之后它不会在平台里再次显示忘了只能重置。重置会立即使旧密钥失效所以要么存进密码管理器要么写进部署环境的.env。商户API证书在平台里下载下来是p12格式文件名通常是apiclient_cert.p12里面同时含证书和私钥。网上有些教程让把p12直接传给PHP的openssl_pkcs12_read但实际项目里更通用、更好维护的做法是用openssl命令转成两个pem文件一个存证书、一个存私钥。转换命令我在第3章给出。2.3 官方签名算法一步步拆解Authorization头的真实构成APIv3的请求签名不是简单地把参数排序后拼一个字符串而是固定格式的消息串加RSA签名。先看最终产物——每次请求都要带一个Authorization头WECHATPAY2-SHA256-RSA2048 mchid1900000001,nonce_strb3f2...,signaturebase64...,timestamp1700000000,serial_no3344AABBCCDDEEFF这个头里六个字段一个都不能少mchid是商户号nonce_str是本次请求的随机串signature是RSA签名结果timestamp是Unix时间戳serial_no是商户API证书序列号。微信支付服务端拿到请求后会先取serial_no找到对应的商户证书公钥再按同样的规则拼出消息串用公钥验证signature。消息串的拼接规则是五段每段用换行符分隔请求方法、URL路径含query参数、时间戳、随机串、请求体。我最早调试时经常漏掉最后一行的换行符导致签名不一致。代码实现如下$method POST; $url /v3/pay/transactions/native; $timestamp time(); $nonce bin2hex(random_bytes(16)); $body {appid:wx1234567890abcdef,mchid:1900000001,description:测试商品,out_trade_no:20250101220001,notify_url:https://example.com/notify,amount:{total:1,currency:CNY}}; $message $method . \n . $url . \n . $timestamp . \n . $nonce . \n . $body . \n; $privateKey file_get_contents(/path/to/apiclient_key.pem); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $authorization sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, 1900000001, $nonce, base64_encode($signature), $timestamp, 3344AABBCCDDEEFF );代码里的$url只取路径部分不带scheme和域名。这里的时间戳和后端校验的时间误差必须控制在几分钟内服务器时间漂移大了会直接返回401。另外要注意网上搜得到一堆php hmacsha256算签名的旧代码那是v2的算法v3的签名类型是SHA256-RSA2048算法和消息串结构完全不同直接套用只会越改越乱。官方文档里还藏着一个换证书的坑如果重新生成过商户API证书serial_no会变Authorization头里必须同步更新。用旧serial_no配合新私钥去签名微信支付也会校验失败。所以证书文件更新后第一件事就是跑一遍读取序列号的命令把配置里的serial_no换掉。3. 写一个能用的PHP签名工具类私钥转pem、Authorization头与验签签名逻辑不要散落在各个业务方法里独立成一个PHP类是最小成本的做法。这一章从证书准备开始到请求签名、应答验签给一套可以直接放进项目使用的类。我把文件路径全部收进构造函数方便在Laravel的config配置里读取ThinkPHP或原生PHP也能直接new。3.1 用openssl命令把p12转成pem并读取证书序列号商户平台下载的apiclient_cert.p12是证书私钥的打包文件PHP的openssl扩展虽然支持直接读取p12但需要额外管理一个证书密码字符串不如转成pem干净。下载后先在本地执行两条命令openssl pkcs12 -in apiclient_cert.p12 -clcerts -nokeys -out apiclient_cert.pem openssl pkcs12 -in apiclient_cert.p12 -nocerts -nodes -out apiclient_key.pem chmod 600 apiclient_key.pem第一条命令导出证书第二条导出私钥。转换过程中会要求输入p12的密码默认是商户号如果下载时设置过其他密码就用自己设置的。导出后用下面这条命令读证书序列号openssl x509 -in apiclient_cert.pem -noout -serial输出形如serial3344AABBCCDDEEFF把serial前缀去掉得到的3344AABBCCDDEEFF就是Authorization头里的serial_no。注意这个序列号是十六进制串不要带冒号微信支付服务端不接受类似33:44:AA:BB这种带分隔符的格式。转换完成后apiclient_cert.pem和apiclient_key.pem两个文件放进服务器的存储目录建议放在项目外部、确保不能被web目录访问。apiclient_key.pem本质上是私钥文件权限至少600不要提交进Git仓库。3.2 PHP签名工具类Authorization头与请求体签名把上面的逻辑整理成一个类。构造函数接收五样配置buildAuthorization方法负责生成签名和Authorization头?php class WxPayV3Client { private string $mchId; private string $serialNo; private string $privateKeyPath; private string $apiV3Key; private string $platformCertPath; public function __construct(array $config) { $this-mchId $config[mch_id]; $this-serialNo $config[serial_no]; $this-privateKeyPath $config[private_key_path]; $this-apiV3Key $config[api_v3_key]; $this-platformCertPath $config[platform_cert_path]; } public function buildAuthorization(string $method, string $url, string $body): string { $timestamp time(); $nonceStr bin2hex(random_bytes(16)); $message $method . \n . $url . \n . $timestamp . \n . $nonceStr . \n . $body . \n; openssl_sign( $message, $signature, file_get_contents($this-privateKeyPath), OPENSSL_ALGO_SHA256 ); return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, $this-mchId, $nonceStr, base64_encode($signature), $timestamp, $this-serialNo ); } }buildAuthorization的参数有讲究$method必须是GET、POST、PATCH、DELETE里的大写形式$url是路径和query不要把完整域名传进来$body是序列化后的请求体字符串如果请求没有body就传空字符串。这个方法每次调用都会读一次私钥文件对支付接口的调用频率来说完全够用不需要做文件缓存。这个类里的apiV3Key和platformCertPath现在还没用到它们在回调解密和验签环节发挥作用。把配置和依赖都收进构造函数的好处是后续在Laravel的ServiceProvider里绑定单例或者在一个普通项目中new一次传给Guzzle中间件都很方便。3.3 应答验签与回调验签平台证书的加载与验证v3要求对微信支付返回的响应也要验签验证「这个响应真的是微信支付发出来的」。响应头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段验签逻辑如下public function verifyWechatpaySignature(array $headers, string $body): bool { $timestamp $headers[Wechatpay-Timestamp] ?? ; $nonce $headers[Wechatpay-Nonce] ?? ; $signature $headers[Wechatpay-Signature] ?? ; $serial $headers[Wechatpay-Serial] ?? ; if ($serial ! $this-platformSerialNo) { return false; } $message $timestamp . \n . $nonce . \n . $body . \n; $publicKey openssl_pkey_get_public(file_get_contents($this-platformCertPath)); return openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256) 1; }这段代码同时适用于「下单接口的响应」和「异步通知的请求」验签消息串永远是时间戳换行随机串换行请求体响应体换行。body参数在响应验签时传响应体字符串在回调验签时传原始请求体字符串。注意PHP里处理HTTP头时原生环境下的$_SERVER会把header名转成大写并加HTTP_前缀比如HTTP_WECHATPAY_TIMESTAMP取值时要做一层兼容转换或者直接用Laravel的request对象去取。我在类里加了一个platformSerialNo的判断目的是防止有人把平台证书路径配错成商户证书导致验签永远失败。有些项目下载平台证书之后不校验序列号等到出问题才发现证书文件不对那时候已经排查了很久了。平台证书建议放在项目配置目录之外和商户私钥一样按敏感文件处理。4. 拉起支付到收到回调Native下单、JSAPI下单与异步通知完整代码这一章把支付全链路串起来。Native支付适合PC网站用户扫码支付JSAPI支付适合公众号H5和小程序需要拿到openid。两条链路的下单接口不同但签名、验签、解密方法完全复用上一章的类。4.1 Native下单从构造请求到拿到code_urlNative支付的下单接口是POST /v3/pay/transactions/native。请求体里核心字段有appid、mchid、description、out_trade_no、notify_url、amount其中amount里total的单位是「分」这是v3和很多开发者本地习惯的「元」最大的冲突点。先看完整实例代码$client new WxPayV3Client($config); $data [ appid $config[app_id], mchid $config[mch_id], description 商品描述, out_trade_no 20250101220001, notify_url https://example.com/notify.php, amount [ total 1, currency CNY ], ]; $url https://api.mch.weixin.qq.com/v3/pay/transactions/native; $body json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $authorization $client-buildAuthorization(POST, /v3/pay/transactions/native, $body); $ch curl_init($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, POST); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, [ Authorization: . $authorization, Content-Type: application/json, Accept: application/json, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $response curl_exec($ch); $status curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);json_encode时加上JSON_UNESCAPED_UNICODE和JSON_UNESCAPED_SLASHES是为了让请求体里的中文和斜杠保持原样避免微信服务端拼消息串时因为转义差异导致签名不一致。签名时用的URL路径和curl实际请求的URL要完全一致都是/v3/pay/transactions/native不能一个带域名一个不带。请求成功返回的HTTP状态码是200响应体里有code_url字段值类似weixin://wxpay/bizpayurl?prxxxx。把这个字符串生成二维码用户扫码后会拉起微信收银台。如果返回非200响应体会带code和message比如NO_AUTH、PARAM_ERROR把message打日志比猜原因快得多。4.2 JSAPI下单与小程序支付参数差异与prepay_idJSAPI下单接口是POST /v3/pay/transactions/jsapi请求体在Native基础上多一个payer字段$data [ appid $config[app_id], mchid $config[mch_id], description 商品描述, out_trade_no 20250101220001, notify_url https://example.com/notify.php, amount [ total 1, currency CNY ], payer [ openid $openid ] ];公众号里通过网页授权拿openid小程序里通过wx.login加code2Session接口拿openid两者最终都是同一个openid。下单成功后返回prepay_id这个prepay_id要拼进前端调起支付时的package参数。拿到prepay_id后需要再生成一份给前端JS调用的签名参数paySign。这个签名串的格式和Authorization头完全不同它是一段以appId开头的四段消息串$params [ appId $config[app_id], timeStamp (string) time(), nonceStr bin2hex(random_bytes(16)), package prepay_id . $prepayId, signType RSA, ]; $message $params[appId] . \n . $params[timeStamp] . \n . $params[nonceStr] . \n . $params[package] . \n; openssl_sign($message, $paySign, file_get_contents($config[private_key_path]), OPENSSL_ALGO_SHA256); $params[paySign] base64_encode($paySign);这里有一个高频错误消息串里的timeStamp必须是字符串和Authorization签名里的整数时间戳不同调起支付参数要求它作为字符串参与拼接和传给前端。另外paySign用的是同一把商户私钥但算法是SHA256withRSAsignType固定写RSA小程序端wx.requestPayment会自己按signType解析。小程序端如果报「用户态签名signature错误」优先检查这里的时间戳类型、nonceStr长度和package前缀这三处错一个都会验签失败。用uniapp打包App时App支付走的是APP支付接口而不是JSAPI参数不完全一样建议按App支付单独写一套下单逻辑。4.3 异步通知回调验签、解密、更新订单状态支付成功后微信支付会向notify_url发起异步通知body是JSONresource字段里是加密后的交易数据。处理顺序有硬性要求先验签、再解密、再更新订单、最后返回SUCCESS给微信支付。颠倒任何一步都可能导致重复通知或订单状态错乱。$headers getallheaders(); $body file_get_contents(php://input); $payload json_decode($body, true); if (!$client-verifyWechatpaySignature($headers, $body)) { http_response_code(401); exit(verify failed); } $resource $payload[resource]; $ciphertext base64_decode($resource[ciphertext]); $tag substr($ciphertext, -16); $ciphertext substr($ciphertext, 0, -16); $decrypted openssl_decrypt( $ciphertext, aes-256-gcm, $config[api_v3_key], OPENSSL_RAW_DATA, $resource[nonce], $tag, $resource[associated_data] ?? );注意微信支付v3的AES-256-GCM密文格式是「密文16字节认证标签tag」直接拿整个ciphertext丢给openssl_decrypt会解密失败。必须先截出最后16字节作为tag剩下的才是真正的密文。解密成功后的JSON里包含out_trade_no、transaction_id、trade_state、amount等关键字段。这里trade_state为SUCCESS才代表支付成功其他状态如REFUND、CLOSED要分开处理。落库的逻辑我建议先查订单如果已经处于已支付状态直接返回SUCCESS不做重复更新否则更新订单状态、记录transaction_id然后返回{code: SUCCESS, message: 成功}返回这个JSON时HTTP状态码用200微信支付收到后才会停止重试通知。如果验签失败或解密失败返回非200或非SUCCESS的JSON微信支付会按它自己的重试策略继续通知通常是间隔递增地重试几次。5. 微信支付v3避坑手册证书序列号、金额单位与回调幂等的5个真实教训这一章是我做支付集成时踩过的坑每条都花了真金白银或小半天时间去查。按现象、原因、解决的顺序写排查时可以对照着看。5.1 证书序列号错一位签名全部失效现象首次下单返回401响应体里提示证书序列号不正确或签名无效。 原因Authorization头里的serial_no填成证书文件名或者从openssl读取结果里没去掉serial前缀或者把平台证书序列号当成了商户证书序列号。这三类错误都会让微信支付找不到对应公钥。 解决用openssl x509 -in apiclient_cert.pem -noout -serial命令读取商户证书序列号去掉前缀后存成配置项。平台证书的序列号是响应头里的Wechatpay-Serial两者用途不同别混。5.2 本地能跑通线上报签名错误URL不一致是最大玄学现象同样的签名工具类本地调试一切正常部署到服务器后所有请求都报「签名错误」。 原因签名串里的URL和实际请求URL不一致。常见于框架把URL做了Rewrite、反向代理加了前缀、或者请求uri里query参数顺序在不同环境下被重排。 解决加一行日志打印签名用的URL和curl实际请求的URL。用Guzzle的话直接取$request-getUri()不要自己拼字符串。注意query参数要按原样拼在URL里包括顺序。5.3 金额单位分和元又双叒搞错现象用户支付1元订单金额记录成1分对账时发现账目不平。 原因v3的amount.total单位是整数分v2也是分。很多从旧系统迁移来的代码里total字段习惯存元直接喂给v3接口。 解决数据库金额字段用整数分存储下单和回调全程用分只在接口返回前端时转成元。下单前加一个校验金额必须是大于0的整数。5.4 回调重复通知幂等没做库存扣两次现象同一笔订单收到三次通知每次进入业务逻辑都执行了库存扣减。 原因微信支付的通知有重试机制收到非SUCCESS或超时会按递增间隔重试本地调试时手动重放请求也会触发。 解决回调入口先按out_trade_no查询订单状态已支付就直接返回SUCCESS。更新订单状态时用乐观锁或状态条件更新确认只有一次能从未支付翻转为已支付。5.5 平台证书过期验签突然全挂现象某天开始所有回调验签失败接口返回401业务方反馈收不到支付结果。 原因平台证书有有效期过期后微信支付会用新证书签名代码里还是旧的pem文件。 解决不要手动下载平台证书写死路径。用GET /v3/certificates接口定期拉取并更新或者用官方PHP SDK的证书管理器自动刷新。最少也要在验签失败时记录Wechatpay-Serial和本地pem的序列号对比几秒钟就能确认是不是证书过期。6. 进阶把签名、验签、日志封装成Guzzle中间件一次配置到处复用集成微信支付v3的项目常见的坏味道是每个Controller里都写一遍curl 签名 验签。业务一多签名代码复制得到处是某天改一次证书路径就要全局搜索。我的习惯是把签名、验签和日志收敛到Guzzle中间件里Controller只管下单和收回调。6.1 用Guzzle中间件统一处理Authorization头用Guzzle时请求体是Stream读了之后不会自动倒带。中间件里先getContents()拿body签名完再rewind()放回去不然下一步请求发出去body是空的。use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use Psr\Http\Message\RequestInterface; $stack HandlerStack::create(); $stack-push(Middleware::mapRequest(function (RequestInterface $request) use ($signer) { $uri $request-getUri(); $url $uri-getPath(); if ($uri-getQuery() ! ) { $url . ? . $uri-getQuery(); } $body $request-getBody()-getContents(); $request-getBody()-rewind(); return $request-withHeader(Authorization, $signer-buildAuthorization($request-getMethod(), $url, $body)); })); $client new Client([ base_uri https://api.mch.weixin.qq.com, handler $stack, timeout 10, ]);url这里拿的是pathquery正好就是签名串要求的URL片段。这个方法同样适用于JSAPI下单、查单、退款等所有v3接口Controller不用再关心Authorization。6.2 请求响应日志排障黑匣子变透明中间件里再加一个tap把每次请求的URL、请求体、响应状态码、响应体、验签结果写进日志文件。我排障时看得最多的不是框架日志而是这一条记录。有一次线上回调验签全失败就是靠日志里对比Wechatpay-Serial和本地平台证书序列号发现证书已经在凌晨过期。$stack-push(Middleware::tap(function ($request, $options) { \Log::info([wxpay] request, [ url (string) $request-getUri(), body $request-getBody()-getContents(), ]); $request-getBody()-rewind(); }));6.3 验证全链路仿真环境优先1分钱垫底能申请到微信支付仿真测试环境的先在沙箱里把下单、回调验签、解密、幂等全跑通再切生产。没有沙箱的找一个低频商品用1分钱真实订单做端到端验证下单后仔细核对回调里的out_trade_no、trade_state和amount再确认本地订单状态已经翻转。我现在的习惯是把签名器、下单、验签解密、中间件四段做成一套固定模板新项目直接复制改配置两小时就能跑通一笔支付。这个方向投入产出比很高希望帮到你。本文还有配套的精品资源点击获取