
前几天有个朋友拿着需求来找我他想做一个化妆品美妆类的微信小程序商城后台管商品、会员、订单小程序端能正常加购、下单、调起微信支付。他在网上搜了一圈发现 ThinkPHP 和 Laravel 这两个框架被反复提起于是跑来问我这两个框架到底能不能做哪个更合适我当时的回答很简单都能做。微信小程序本质上只认 HTTP 接口后端只要能把请求处理好、返回 JSON不看你是哪个框架写的。ThinkPHP 和 Laravel 在 PHP 生态里几乎是中小型商城项目的地基所以不存在“不支持微信小程序”的问题。真正值得掰开说的是同一个美妆商城业务落到这两个框架里之后开发节奏、踩坑点、维护体验差别有多大。这篇文章就把我从数据库建模到微信支付这一条完整链路分别在 ThinkPHP 和 Laravel 下的落地方案、坑点和选型思路都写出来。给正在纠结用什么框架做微信小程序商城的同学做个参考。1. 微信小程序美妆商城的定位为什么需要框架后端而不是纯前端1.1 美妆商城的标准功能清单一个能跑起来的化妆品美妆商城最小闭环是用户浏览商品 - 加入购物车 - 下单 - 微信支付 - 订单状态更新 - 售后。这中间每一环都需要服务端支撑不是小程序端存个本地数组就能解决的。我列一下实际项目里最常涉及的模块商品体系SPU 与 SKU 管理、多规格色号、容量、价格、库存、上下架、分类、品牌、图文详情。用户体系微信登录绑定 openid、手机号获取、收货地址维护、会员等级或者积分。交易体系购物车、下单、订单状态流转、微信支付回调、退款售后。营销体系优惠券、满减、限时活动美妆品牌经常做秒杀和新人礼。后台管理哪怕只是一个最简单的 Web 后台用来录入商品、审核订单、处理退款。你会发现这些功能一旦认真做起来业务逻辑会越来越多。用纯前端加云开发的方案做个小范围试用没问题但商品数据要有人管理、库存要被多端共享、订单要跟微信支付账目对得上这些都需要一个稳定可扩展的服务端。框架在这里的价值是路由、ORM、中间件、队列、日志、测试这些能力都是现成的你不用从头写一套 HTTP 服务也不用自己封装 SQL 防注入把精力放到业务上就好。1.2 ThinkPHP 与 Laravel 的框架画像对比平时遇到很多朋友会把这两个框架对立起来其实它们的定位不同。一句话概括ThinkPHP 更像一个可靠的工具箱Laravel 更像一套完整的工程体系。对比项ThinkPHPLaravel学习曲线平缓中文文档和案例多适合快速上手较陡概念多但成体系熟悉后很顺手社区生态国内开发者多中文资源丰富全球化生态大Composer 包极多ORM 能力自带 ORM够用复杂关联稍弱Eloquent 很强关联模型写起来很舒服中间件机制TP6 支持中间件但用法相对精简中间件是核心概念路由、限流、鉴权都靠它模板引擎think-templateBlade部署习惯老式虚拟主机也能跑门槛低更倾向云服务器 / Docker对 PHP 版本有要求长期维护中小型项目没问题但工程化约束弱规范强适合多人协作和中长期迭代从这里能看出来两个框架并没有绝对的优劣。ThinkPHP 的优势是“快”尤其一个人从零到一交付一个商城时写代码的手感非常直接Laravel 的优势是“稳”团队协作时路由、模型、队列、测试都有统一约定代码结构不容易散。1.3 我按三个标准做选型如果有人让我帮忙拍板我会先问三个问题。第一团队里谁写代码如果团队本来就用 ThinkPHP那没有必要为了“Laravel 更时髦”强行切换。框架只是地基真正重要的是把业务流程理清楚。换个不熟悉的框架等于先付一遍学习成本。第二这个项目要活多久如果只是给品牌方做一个试水版本快速上线验证市场我倾向 ThinkPHP一天能跑通接口一周能交付核心链路。如果要做长期运营的基础电商系统后面会不断加营销玩法、对接多端、上数据报表那 Laravel 更好它的分层和中间件机制能兜得住越来越复杂的业务。第三部署环境是什么有些美妆客户的老服务器还在用虚拟主机PHP 版本停留在 5.6 或者 7.0那 Laravel 的安装条件可能就不满足ThinkPHP 反而能顺利跑起来。反之如果你能在云服务器上用 Docker 跑 PHP 8.x那 Laravel 的体验会明显更好。最后我的结论通常很直接一个人单干且工期紧用 ThinkPHP团队维护、功能会不断膨胀用 Laravel。但后端的业务逻辑其实在两个框架里是一样的。2. 数据库建模化妆品SKU、多规格与库存的低成本方案2.1 化妆品商品模型和普通商品的区别化妆品这个品类和普通商品比有一个非常大的特点一个商品页面下挂着大量规格组合。拿口红来说一支口红的链接下可能有 12 个色号颜色之外还有容量规格一款护肤套装可能包含水、乳、霜三个单品的组合每个组合的库存和价格还不一样。如果按“一个商品一条记录”的思路建表你会发现商品爆炸了同样的标题、同样的详情只因为色号不同就被复制出十几条记录。后台管理要翻很久才能找到某支口红的全部色号前端商品列表也容易乱。所以做美妆商城的第一步就是把“商品”SPU和“具体可购买的规格”SKU分开。SPU 管跟商品相关但不影响购买决策的信息标题、品牌、分类、详情、主图SKU 管跟价格和库存相关的信息色号、容量、价格、库存、货号、规格图片。2.2 SPU 加 SKU 的表结构设计之前一个项目里我做的最小表结构是这样的CREATE TABLE spu ( id int unsigned NOT NULL AUTO_INCREMENT, title varchar(255) NOT NULL COMMENT 商品标题, subtitle varchar(255) DEFAULT COMMENT 卖点副标题, brand_id int unsigned DEFAULT 0 COMMENT 品牌ID, category_id int unsigned DEFAULT 0 COMMENT 分类ID, cover varchar(512) DEFAULT COMMENT 主图, detail mediumtext COMMENT 图文详情存富文本或图片URL, min_price int DEFAULT 0 COMMENT 最低售价(分)冗余字段, sales int DEFAULT 0 COMMENT 销量, status tinyint NOT NULL DEFAULT 1 COMMENT 1在售 0下架, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE sku ( id int unsigned NOT NULL AUTO_INCREMENT, spu_id int unsigned NOT NULL, sku_name varchar(255) NOT NULL COMMENT SKU名称例如 口红-枫叶红-3.4g, spec json DEFAULT NULL COMMENT 规格键值例如 {color:枫叶红,capacity:3.4g}, price int NOT NULL COMMENT 售价(分), market_price int DEFAULT 0 COMMENT 市场价(分), stock int NOT NULL DEFAULT 0, sku_code varchar(64) DEFAULT COMMENT 货号/条码, image varchar(512) DEFAULT COMMENT 规格专属图片, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_spu (spu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格我刻意用“分”存储不用 FLOAT。电商项目里浮点运算很容易出现 0.1 0.2 不等于 0.3 的尴尬一旦涉及优惠分摊和退款误差会被放大。用整数存分显示时除以 100 就行计算和退款都对得上账。另外还有一个规格选择器的问题。小程序端需要用一种结构告诉用户这个商品有哪些颜色每个颜色有哪些容量。这个信息不能放在 SKU 表里散着存否则前端要把 SKU 全量拉下来再聚合很费流量。所以单独维护一张规格组表CREATE TABLE sku_specs ( id int unsigned NOT NULL AUTO_INCREMENT, spu_id int unsigned NOT NULL, spec_name varchar(50) NOT NULL COMMENT 规格名如颜色、容量, spec_values json DEFAULT NULL COMMENT 如 [枫叶红,豆沙色] 或 [30ml,50ml], PRIMARY KEY (id), KEY idx_spu (spu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;前端进入商品详情页时拿 SKU 列表和规格组两份数据就能拼出规格选择器同时高亮哪些组合有货。这个方案不用写复杂的拼接 SQL数据量在中小型商城规模下也完全够用。2.3 下单扣库存的事务写法库存是商城最怕出事的地方。一个卖口红的 SKU 库存就剩 5 支如果同时来了 8 个人下单绝不能超卖成 8 单。最直接的做法是使用原子更新而不是“先查出来判断再改回去”。ThinkPHP 里我一般这样写use think\facade\Db; Db::startTrans(); try { $affected Db::name(sku) -where(id, $skuId) -where(stock, , $quantity) -dec(stock, $quantity) -update(); if (!$affected) { throw new \Exception(库存不足); } // 写入订单主表和订单明细 Db::commit(); } catch (\Exception $e) { Db::rollback(); return json([code 400, msg $e-getMessage()]); }关键在where(stock, , $quantity)这个条件。先把条件写在 UPDATE 里再让数据库自己去判断库存够不够最后通过影响行数判断是否成功。这样即使并发请求同时到达数据库的行锁也会保证只有一个请求能真的扣到库存其他的因为条件不满足影响行数为 0就判定库存不足。Laravel 里思路完全一样只是 Eloquent 的写法规整一些DB::transaction(function () use ($skuId, $quantity) { $updated Sku::query() -where(id, $skuId) -where(stock, , $quantity) -decrement(stock, $quantity); if ($updated 0) { throw new \RuntimeException(库存不足); } // 创建订单 }, 3);这里DB::transaction()的第二个参数3是重试次数。不过要注意重试只解决死锁导致的失败如果库存条件不满足抛出的异常不会自动重试成功所以业务判断还是要靠影响行数兜底。3. 小程序登录与手机号授权两套框架的最简实现3.1 无 Cookie 环境下的登录态方案微信小程序和网页一个很大的区别是不能用 Cookie。后端不管怎么写 session小程序端默认都不会自动带 cookie 过来。所以最常见的方案是自研 Token 登录前端调用wx.login()拿到临时 code。后端拿 code 去微信接口换 openid。后端根据 openid 查找或创建用户。后端生成一个随机 Token 保存下来返回给前端。前端把 Token 存在本地之后每个请求在 header 里带Authorization: Bearer token或者自定义的X-Token。我习惯用随机 Token 而不是 JWT。原因很简单随机 Token 存在数据库里随时可以让某个用户下线排查问题的时候也能直接查表看到这个用户最近登录了哪一次。JWT 虽然省了一次查询但服务端无法主动作废一旦 token 泄露比较被动。3.2 code2session 在 ThinkPHP 中的落地代码先看 ThinkPHP 版本的登录接口。核心就几步收 code、换 openid、建用户、发 token。public function login(Request $request) { $code $request-post(code, ); if (!$code) { return json([code 400, msg 缺少code]); } $appid config(wechat.appid); $secret config(wechat.secret); $api https://api.weixin.qq.com/sns/jscode2session?appid{$appid}secret{$secret}js_code{$code}grant_typeauthorization_code; // 生产环境建议换成 curl并设置超时参数这里为了演示用 file_get_contents $response file_get_contents($api); $result json_decode($response, true); if (empty($result[openid])) { return json([code 401, msg 微信登录失败]); } $user Db::name(users)-where(openid, $result[openid])-find(); if (!$user) { $uid Db::name(users)-insertGetId([ openid $result[openid], created_at date(Y-m-d H:i:s), ]); } else { $uid $user[id]; } $token bin2hex(random_bytes(32)); Db::name(user_tokens)-insert([ user_id $uid, token $token, expire_at date(Y-m-d H:i:s, time() 7 * 86400), ]); return json([code 0, data [token $token, uid $uid]]); }有几个细节我提一下。bin2hex(random_bytes(32))生成的 token 是 64 位十六进制字符串比直接用md5(uniqid())可靠得多。微信的 code 一次性且 5 分钟有效所以登录接口必须处理 code 失效的情况前端拿到失败后重新wx.login()再试一次就行。3.3 Laravel 版实现与 easywechat 的选择Laravel 版本的思路一模一样但代码的组织方式会不一样。我用 Laravel 的Http门面发送请求use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; public function login(Request $request) { $validated $request-validate([ code required|string, ]); $response Http::get(https://api.weixin.qq.com/sns/jscode2session, [ appid config(wechat.appid), secret config(wechat.secret), js_code $validated[code], grant_type authorization_code, ]); $data $response-json(); if (!isset($data[openid])) { return response()-json([code 401, msg 登录失败], 401); } $user User::firstOrCreate( [openid $data[openid]], [nickname , avatar ] ); $token Str::random(64); $user-tokens()-create([ token hash(sha256, $token), expire_at now()-addDays(7), ]); return response()-json([ code 0, token $token, uid $user-id, ]); }实际做项目的时候我会重点考虑是否引入 easywechat 包。这个包把小程序登录、微信支付、素材管理、消息推送都封装好了签名和 API 版本变动都帮你处理了。如果你不想维护底层细节用 easywechat 可以省下很多精力。需要留意的是easywechat 的版本和微信接口版本要对应不要装了个老版本然后去调新接口。3.4 手机号快速获取的新接口与注意事项现在的微信小程序获取手机号主流做法是button组件的open-typegetPhoneNumber。用户点击授权后前端会拿到一个code后端拿这个 code 再去微信接口换手机号。后端逻辑大致是// 1. 获取 access_token $tokenApi https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid{$appid}secret{$secret}; $tokenResponse json_decode(file_get_contents($tokenApi), true); $accessToken $tokenResponse[access_token]; // 2. 调用获取手机号接口 $phoneApi https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token{$accessToken}; $response Http::post($phoneApi, [code $phoneCode]); $phone $response-json()[phone_info][phoneNumber] ?? ;这块有几个现实约束要提前知道个人主体的小程序不能用这个接口必须是已认证的非个人主体小程序。code有效期很短而且只能用一次。手机号属于敏感信息存储前建议做加密或者脱敏至少不要明文打到日志里。前端要用button包裹触发起授权不能用普通的view自己调接口否则会被微信拦截。很多美妆商城把手机号作为会员营销的触达入口这个信息很重要但它也意味着合规责任。团队里最好有一个人专门确认这边数据存储和隐私政策的处理不要等上线被审核打回来再补。4. 商城核心接口商品列表、购物车与订单状态机4.1 商品列表和详情接口的字段裁剪小程序对流量包很敏感所以接口返回的 JSON 一定要做字段裁剪。商品列表接口只返回SPU 的 id、标题、封面图、最低价、销量。不要把所有 SKU 都塞进去也不要返回图文详情。最低价在列表阶段有两种取法。一种是实时查 SKU 表聚合SELECT spu_id, MIN(price) AS min_price FROM sku WHERE status 1 GROUP BY spu_id简单直接但数据量大了之后GROUP BY在无索引的情况下会拖慢查询。另一种是我更推荐的方案在 SPU 表里冗余一个min_price字段在 SKU 价格变动时同步更新或者用定时任务兜底。列表接口直接读这个字段快很多。详情接口返回 SPU 信息 SKU 列表 规格组。图文详情如果是一大段 HTML我建议单独做成一个接口按需加载不要跟基础信息一起返回。4.2 购物车的持久化设计与合并策略购物车表设计成下面这样就够用CREATE TABLE cart ( id int unsigned NOT NULL AUTO_INCREMENT, user_id int unsigned NOT NULL, sku_id int unsigned NOT NULL, quantity int NOT NULL DEFAULT 1, checked tinyint NOT NULL DEFAULT 1 COMMENT 是否选中结算, created_at datetime DEFAULT NULL, updated_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_sku (user_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;购物车适合持久化到后端因为用户换手机、换设备之后购物车数据还是全的。加购接口的逻辑很简单有记录就累加数量没有就插入一条。但要注意单个 SKU 的数量上限比如一次最多买 99 件防止有人恶意刷单把你的库存接口打爆。如果用户未登录也能加购那登录之后要做一次“本地购物车合并到服务端”。实现方式不复杂前端把本地购物车数组传给/cart/merge接口服务端对每个 sku_id 做 upsert数量取两者较大值或者相加然后清掉本地购物车。这个流程容易忽略的点是合并时一定要校验 SKU 是否已经下架以及当前数量是否超过库存上限不然会把失效商品带进结算流程。4.3 订单状态的多维设计很多入门项目爱用一个status字段表达所有状态比如 0 待支付、1 已支付、2 已发货。这样写短期能跑但只要碰到退款你就会很痛苦。举个例子订单已经支付用户申请退款然后管理员已经退款这个状态下订单的“主状态”你到底填 2 还是 5我的做法是把状态拆成几个维度order_status待支付、已支付、已发货、已完成、已取消、售后中。pay_status未支付、已支付、已退款。ship_status未发货、已发货、已签收。refund_status无退款、退款中、已退款。这四个维度组合在一起能准确表达很多复杂场景。比如“已支付 但退款中”order_status 可以保持已支付pay_status 是已支付refund_status 是退款中退款完成后 pay_status 改成已退款order_status 可以归到已取消或者售后关闭。订单号生成也值得认真对待。不要用简单的time()并发情况下会重复。我常用的格式是日期时间 用户ID 随机数比如$orderNo date(YmdHis) . $uid . str_pad(random_int(0, 9999), 4, 0, STR_PAD_LEFT);再加上订单表对order_no建唯一索引基本能挡住重复。5. 微信支付、异步回调与退款最折腾的环节5.1 统一下单接口接入微信支付是商城项目里最折腾的环节没有之一。新项目我强烈建议直接上微信支付 V3不要再去用老的 V2 接口。V3 使用商户证书做签名安全性更好文档结构也更清晰。核心步骤是后端拿到用户在小程序端传来的 openid 和订单金额调用统一下单接口POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi请求体大致是{ appid: 小程序appid, mchid: 商户号, description: 美妆商城-订单20250612101012, out_trade_no: 20250612101012_1001_1234, notify_url: https://api.example.com/wechat/pay/notify, amount: { total: 9900, currency: CNY }, payer: { openid: 用户的openid } }返回结果里有prepay_id后端再把prepay_id拼成小程序端需要的签名参数返回给前端前端调用wx.requestPayment拉起床支付。这里涉及 RSA-SHA256 签名和构造支付参数第一次做的时候通常会被证书路径、私钥格式、nonce 生成这些细节卡一阵子。建议用 easywechat或者照着官方 SDK 做尽量不要手搓签名因为坑太多一个字符串拼接错位就会导致验签失败。5.2 异步回调的验签与幂等处理微信支付成功后微信服务器会异步 POST 一个支付结果到你填的notify_url。这个回调是整个支付流程里最容易出问题的地方因为你无法保证请求一定会按顺序到达也无法保证只到达一次。回调处理有几个硬性要求必须验签确认数据真的是微信发来的。必须幂等同一个订单被回调多次后端的处理结果要一致。处理成功后必须返回{code:SUCCESS,message:成功}HTTP 200否则微信会反复重试。幂等最简单的做法是进入回调后先根据out_trade_no查订单如果订单状态已经是“已支付”直接返回成功不再重复处理。如果你在回调里做了“给用户加积分”或者“通知仓库发货”这类副作用操作就要保证这些操作也被幂等保护。最稳的方式是把“查询订单状态 更新订单 加积分”放到一个数据库事务里执行并发请求会被锁挡住。这里要特别提一下 ThinkPHP 和 Laravel 的一个差异Laravel 对 Web 路由默认开启了 CSRF 中间件如果你把支付回调地址放在web.php里必须把回调地址加入VerifyCsrfToken的排除列表否则微信服务器发来的 POST 请求会被 419 拒绝。我自己的习惯是支付回调单独放一个路由并且保证它只做验签和业务更新不嵌套其他复杂逻辑。5.3 退款的实现与常见坑退款走/v3/refund/domestic/refunds接口需要用商户私钥签名。退款参数和支付参数长得很像但是多了out_refund_no和退款金额{ out_trade_no: 原始订单号, out_refund_no: 退款单号, amount: { refund: 9900, total: 9900, currency: CNY } }退款常见的坑有三个。一是金额单位不对微信支付所有金额都是分如果你传了元退款金额会差 100 倍。二是优惠订单的退款拆分如果订单用了优惠券或者满减退款时要算清楚“用户实际支付了多少”不能直接把订单总金额退回去。三是证书混乱V3 里退款接口用的是商户证书但解密回调结果用的是微信支付平台证书/公钥两个证书别搞混。退款到账通常不会立刻到原路退回一般需要 1 到 3 个工作日所以前端的状态展示要写成“退款处理中”不要误导用户。6. 真机联调中的踩坑记录ThinkPHP 与 Laravel 各自的脾气6.1 ThinkPHP版本差异与老环境部署ThinkPHP 的坑主要集中在版本和运行环境上。TP5 和 TP6 虽然名字接近但路由定义、ORM 方法、容器调用差异都不小。网上搜到的教程经常是“TP5 写法”但你可能装的是 TP6照着抄就翻车。所以做项目前一定要先确认框架版本并且看官方对应版本的文档。部署环节虚拟主机用户比较多。域名必须指向public目录否则会暴露框架结构伪静态规则要根据服务器配置Apache 用.htaccessNginx 要写 rewrite。很多老空间默认不开启pathinfo支持导致路由全部 404这个排查起来还挺烦人。性能方面TP6 的缓存默认是file驱动。开发阶段倒没什么但上线后如果没配 Redis商品列表每次请求读文件缓存也不是不行只是并发一高压力就上来了。建议商城项目至少给商品列表、首页聚合数据配上 Redis 缓存。6.2 LaravelCSRF、中间件和 ORM 性能Laravel 的坑主要出现在工程化带来的复杂度上。第一个是 CSRF 中间件刚才支付回调那里提过新手经常在真机联调时遇到“微信回调 419”一脸懵。解决方案是把回调路由加到except数组或者放到api路由组。第二个是 Eloquent 的性能问题。商城订单列表如果直接循环查关联模型会出现典型的 N1 查询拉 100 个订单然后又发了 100 条查询去读每个订单的用户信息和商品信息。用with([items.sku, user])预加载就能解决。这个不是 Laravel 本身的毛病是对 ORM 理解不到位。第三个是部署环境。Laravel 依赖多composer install首次安装很慢而且对环境要求高PHP 版本、扩展、storage 目录写权限、php artisan optimize缓存每一项漏掉都可能出问题。不过一旦把这些坑填平日常开发是真的舒服。6.3 上线前一定要做的优化与安全检查商城上线之前我会固定做一遍下面这些检查两个框架都一样商品缓存首页、分类页、详情页的高频读接口要接缓存。缓存 key 要包含分类 ID 和分页参数避免串数据。接口限流登录、下单、支付回调这些关键接口要限流。Laravel 直接用throttle中间件ThinkPHP 可以写一个简单的计数中间件。至少把登录接口的频率限制住防止被脚本刷爆。越权检查读取购物车、订单详情接口必须判断当前 Token 对应的用户 ID 和资源所属用户 ID 是否一致。很多线上漏洞就是这么来的按 ID 直接查订单给别人看订单了。敏感信息隔离微信的 appid、secret、商户密钥、证书文件必须放.env或者配置文件里禁止提交到 Git 仓库。日志留存微信回调的原始报文、验签结果、处理后的订单号一定要打日志。线上排查问题时没有日志等于瞎子摸象。我个人在两个框架里都部署过商城项目说实话真正决定项目成败的并不是框架选哪个而是商品模型、订单状态机、库存扣减和支付回调这几块有没有设计清楚。ThinkPHP 能三天跑通接口Laravel 能帮你把中长期复杂度管好它们都支持微信小程序也都能做出化妆品美妆商城。如果你正站在选型路口我的建议是别纠结太久先用手里最熟悉的框架把核心链路做出来跑通支付后面再谈优化和重构。等你踩过了支付回调、库存超卖、N1 查询这些坎回头看框架不过是你手里的工具真正值钱的是你对业务的理解。