毕业设计做完之后,我一直想把这次“ThinkPHP + Laravel 双框架共存”的经历整理出来。起因很简单:学校社团要做一个大学生生活服务平台,早期为了快速上线用了ThinkPHP,后边模块越拆越多,新接口又迁移到了Laravel,于是项目就变成了标题里那个“基于ThinkPHP-Laravel”的混合体。这个过程里踩了不少坑,也积累了一些双框架协作的实际经验,今天完整写一遍,给准备做类似校园项目、课设或者团队协作开发的同学做个参考。
先交代一下平台本身:这个大学生生活服务平台主要包括二手集市、失物招领、活动广场和个人中心四大块。其中活动广场涉及社团活动申请、场地预约审批,这是一条完整的审批流;二手集市涉及商品发布、订单流转、超时关闭这一类偏业务的状态处理。平台面向全校学生,高峰期大概有几千个真实用户。项目代码分两个部分:ThinkPHP负责管理后台的登录、商品审核、审批管理、统计报表;Laravel负责前台API、订单异步任务和审批状态机。下面我会按照项目推进的顺序,从功能规划、数据库设计、到具体模块实现、再到部署和踩坑逐一展开。
1. 这个平台到底要做成什么样:功能边界与双框架分工的起点
1.1 功能模块规划:一个学生平台最该做哪些事
大学生生活服务平台听起来很大,但真正落在第一版里,能稳定跑起来、有人愿意用的功能就那么几个。我们当时的取舍是:二手交易必须有,这是学生群体的刚需,毕业季出书、出小电器、代抢课资料,都能在上面消化;失物招领要有,虽然功能简单,但它是平台的“口碑功能”,一旦有人通过平台找回了校园卡,就会自发帮平台宣传;活动广场要有,但只做活动展示和报名,不在第一版做社团自主发帖;审批流单独拉出来做,因为任何涉及场地、经费、校级活动的申请都需要留痕。
砍掉的东西也很明确:不做校园论坛,一个学生团队根本扛不住内容审核压力;不做在线支付,涉及商户资质和资金监管,学生项目碰不了。线上只做到“约定线下交易”,页面展示价格为参考,订单状态推进到“待核销”就交给线下。这一版边界定清楚之后,开发量大概在三个人一个月完成,是一个现实可行的规模。
1.2 为什么是“ThinkPHP+Laravel”而不是二选一
很多同学看到“基于ThinkPHP-Laravel”会以为这是一个项目用了两个框架,是不是在故意炫技。实际上我们不是刻意混用,而是被业务演进逼出来的。
第一版上线时团队里几个主力开发都只熟ThinkPHP,中文文档齐全、上手快、模板引擎用起来直接,两周不到就把后台管理端和一套简单的移动端接口写完了。等到第二个月用户量上来了,问题开始暴露:订单超时未付款需要自动关闭,活动报名后要发通知,审批通过的申请要异步生成凭证——这些都是典型的异步任务场景。ThinkPHP本身也能做队列,但Laravel的队列、事件、任务调度的生态更成熟,用起来更顺手。再加上API资源层用Laravel写接口返回结构更规范,于是我们做了一个决定:
- 管理后台(ThinkPHP):商品管理、用户管理、审批列表、统计面板。
- 前台API(Laravel):App端所有数据接口,包括登录、商品列表、订单操作、活动报名。
- 两个项目共用一个MySQL数据库,通过一套统一的token认证机制互通登录态。
这么分工之后,两边的边界非常清晰。ThinkPHP管后台管理的“人机交互”,Laravel管前台业务的“接口流转”。类目相同、模块不重叠,后期维护没有想象中那么混乱。 如果你现在正处于“选ThinkPHP还是选Laravel”的决策阶段,我的建议是别纠结谁是更好的框架,先看你是要快速上线还是要长期演进的工程基础。我们这种“先TP后转Laravel”的路子,其实就是很多国内项目从快速原型走向工程化的一个缩影。
2. 数据库是双框架共同的地基:表结构设计与时间字段的坑
2.1 核心表结构:从二手交易到审批申请一次说清
两个框架共用一个数据库,表结构就成了一切功能的地基。设计这层的时候,重点考虑了“双方都能顺畅读写、字段命名不打架”。下面直接给出几张核心表的建表语句,用的都是最通用的写法,没有绑定某个框架的限定。
用户表:
CREATE TABLE `users` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `student_no` varchar(20) NOT NULL COMMENT '学号', `real_name` varchar(50) NOT NULL COMMENT '姓名', `phone` varchar(11) DEFAULT '' COMMENT '手机号', `password` varchar(255) NOT NULL, `avatar` varchar(255) DEFAULT '', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';二手商品表:
CREATE TABLE `goods` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL COMMENT '发布者id', `title` varchar(100) NOT NULL, `price` decimal(10,2) NOT NULL COMMENT '现价', `original_price` decimal(10,2) DEFAULT '0.00' COMMENT '原价', `description` text, `category_id` tinyint NOT NULL DEFAULT '0' COMMENT '分类', `images` json DEFAULT NULL COMMENT '图片url列表', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0在售 1已售出 2下架', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_status_category` (`status`,`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表';订单表:
CREATE TABLE `orders` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `goods_id` int unsigned NOT NULL, `buyer_id` int unsigned NOT NULL COMMENT '买家id', `seller_id` int unsigned NOT NULL COMMENT '卖家id', `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待付款 1已付款待核销 2已完成 3已取消 4退款中', `payment_time` datetime DEFAULT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';审批记录表:
CREATE TABLE `approval_records` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `biz_type` varchar(30) NOT NULL COMMENT 'activity-活动 venue-场地 user-认证', `biz_id` int unsigned NOT NULL COMMENT '对应业务表id', `applicant_id` int unsigned NOT NULL COMMENT '申请人id', `approver_id` int unsigned DEFAULT NULL COMMENT '审批人id', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待审批 1通过 2驳回', `comment` varchar(255) DEFAULT '' COMMENT '审批意见', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_biz` (`biz_type`,`biz_id`), KEY `idx_applicant` (`applicant_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录表';审批记录表是审批流的通用载体。biz_type决定这条记录对应的是哪个业务模块,biz_id记录对应业务主键。这样活动申请、场地预约、账号认证都可以复用同一张表,不需要每类审批各建一套结构。
2.2 时间字段与数据类型的框架间兼容处理
字段设计阶段,我们特意把两个框架的时间字段约定统一了。ThinkPHP默认的自动写入时间戳字段是create_time和update_time,Laravel的迁移默认是created_at和updated_at。同一个数据库里,如果ThinkPHP建的表用create_time,Laravel再读这张表的时候,Eloquent模型默认是找不到created_at的,需要另外在模型里声明。为了省掉这些额外的映射代码,我们直接统一采用Laravel风格的created_at和updated_at,在ThinkPHP端自行配置了时间戳字段映射。
ThinkPHP6模型里这样处理:
namespace app\model; use think\Model; class Goods extends Model { // 自动写入时间戳 protected $autoWriteTimestamp = 'datetime'; // 将字段名映射到 Laravel 风格 protected $createTime = 'created_at'; protected $updateTime = 'updated_at'; }这是双框架共库的第一步磨合。类似的坑还不少,比如ThinkPHP默认主键字段名必须配id,而Laravel允许通过$primaryKey自定义,我们所有表统一用id,从根源上避开了这类差异。还比如布尔值字段,避免用0和1之外的表达,因为两个框架做模型转数组时,对小整数类型有各自的类型推断逻辑,统一成TINYINT以后,JSON输出就不会出现“true/false变成1/0字符串”这种两边不一致的结果。双框架共库的一条核心原则就是:能在字段命名上提前达成一致,就不要在代码里做兼容层,省下的每一分钟改映射的时间,都是后续联调的效率。
3. 从订单超时说起:ThinkPHP管理端与Laravel接口层的落地实现
3.1 ThinkPHP负责的部分:后台管理端怎么搭
后台管理端用的是ThinkPHP6的经典分层:控制器、模型、模板。登录这块没做太复杂的权限系统,直接依赖管理员表和会话。管理员表单独建,不跟学生用户混在一起。
登录控制器的核心逻辑大致是这样:
public function login() { if (request()->isPost()) { $account = input('post.account'); $password = input('post.password'); $admin = Admin::where('account', $account)->find(); // password_hash 加密过的密码 if (!$admin || !password_verify($password, $admin->password)) { return json(['code' => 0, 'msg' => '账号或密码错误']); } if ($admin->status != 1) { return json(['code' => 0, 'msg' => '账号已被禁用']); } session('admin_id', $admin->id); return json(['code' => 1, 'msg' => '登录成功', 'url' => url('index/index')]); } return view(); }后台的商品管理,典型搜索列表,按关键词、状态、分类三个维度筛选:
public function index() { $keyword = input('get.keyword', ''); $status = input('get.status', -1); $categoryId = input('get.category_id', 0); $query = Goods::with(['user']); if ($keyword !== '') { $query->whereLike('title', "%{$keyword}%"); } if ($status >= 0) { $query->where('status', $status); } if ($categoryId > 0) { $query->where('category_id', $categoryId); } $list = $query->order('id desc')->paginate(20); return view('list', ['list' => $list, 'request' => request()->get()]); }审批列表同理,模板循环输出,管理员点击通过或驳回,后台直接改审批记录表的状态。因为ThinkPHP的管理后台只处理“当前状态显示”和“人工按钮操作”,没有太多异步逻辑,所以代码量可以维持在很小的规模。当时管理后台大概只花了一周多一点就写完了。
3.2 Laravel负责的部分:API层与队列任务
App端接口全在Laravel这一侧。为什么订单处理这块一定要往Laravel挪?因为订单有一个非常典型的异步需求:买家拍下商品,30分钟未支付,系统要自动关闭订单并释放商品。在ThinkPHP里写一个常驻循环或者依赖crontab每五分钟扫描一次,实现上有点笨重。Laravel的队列加上延迟分发,很适合承担这类任务。
创建订单的时候,直接在业务逻辑里分派延迟任务:
use App\Jobs\CloseOrder; use Illuminate\Support\Facades\DB; public function store(Request $request) { $goodsId = $request->input('goods_id'); $buyerId = auth('api')->id(); $goods = Goods::where('id', $goodsId) ->where('status', 0) ->lockForUpdate() ->first(); if (!$goods) { return $this->error('商品不存在或已下架'); } DB::transaction(function () use ($goods, $buyerId) { // 生成订单状态为待付款 $order = Order::create([ 'order_no' => date('YmdHis') . rand(1000, 9999), 'goods_id' => $goods->id, 'buyer_id' => $buyerId, 'seller_id' => $goods->user_id, 'amount' => $goods->price, 'status' => Order::STATUS_PENDING_PAYMENT, ]); // 延迟30分钟后自动关闭订单 CloseOrder::dispatch($order->id)->delay(now()->addMinutes(30)); }); return $this->success(['order_no' => $order->order_no]); }CloseOrder任务实现:
class CloseOrder implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $order; public function __construct(Order $order) { $this->order = $order; } public function handle() { // 仍然需要判断当前状态,防止用户已在超时前完成支付 if ($this->order->status === Order::STATUS_PENDING_PAYMENT) { $this->order->update(['status' => Order::STATUS_CANCELED]); // 恢复商品为在售 Goods::where('id', $this->order->goods_id)->update(['status' => 0]); } } }接口层统一返回结构,我在基础控制器里封装了success和error两个方法,所有API响应都走这两个方法,保证前端拿到格式一致的JSON:
protected function success($data = [], string $msg = 'ok') { return response()->json([ 'code' => 0, 'msg' => $msg, 'data' => $data, ]); } protected function error(string $msg = 'error', int $code = 1) { return response()->json([ 'code' => $code, 'msg' => $msg, 'data' => new \stdClass(), ]); }3.3 审批流的实现:一个通用状态机解决三类审批
Laravel这一侧除了订单接口,真正有含金量的是审批流设计。活动申请、场地预约、企业账号认证,三类业务都涉及“提交申请 → 管理员审批 → 结果反馈”的过程。如果每类都写一套if else,代码会非常臃肿。我们的做法是抽一个状态机常量类,定义三种状态:
class ApprovalStatus { const PENDING = 0; // 待审批 const APPROVED = 1; // 已通过 const REJECTED = 2; // 已驳回 }提交审批的公共方法是,业务方只需要告诉审批服务三件事:业务类型、业务主键、申请人ID。
class ApprovalService { public function submit(string $bizType, int $bizId, int $applicantId) { return ApprovalRecord::create([ 'biz_type' => $bizType, 'biz_id' => $bizId, 'applicant_id' => $applicantId, 'status' => ApprovalStatus::PENDING, ]); } public function approve(int $recordId, int $approverId, string $comment = '') { $updated = ApprovalRecord::where('id', $recordId) ->where('status', ApprovalStatus::PENDING) ->update([ 'status' => ApprovalStatus::APPROVED, 'approver_id' => $approverId, 'comment' => $comment, 'updated_at' => now(), ]); if ($updated === 0) { throw new \Exception('该申请已被处理,无法重复审批'); } // 触发业务侧变更 event(new ApprovalApproved($recordId)); } }业务侧通过事件监听响应审批结果。比如活动申请通过之后,要自动把活动状态改成“报名中”,还要给申请人发一条站内信。这个模式简单、通用,后期如果要加“二级审批”或者“抄送”,只需要在记录表上扩展字段即可,业务代码不用大改。审批流的本质是“状态推进 + 业务解耦”,事件监听比在控制器里层层嵌套要干净得多。
4. 两个框架共存的工程细节:路由跳转、Session会话与目录部署
4.1 路由与地址跳转配置:TP和Laravel的写法对比
双框架项目跑在同一个域名下很容易出现路由冲突。我们的做法是通过不同的入口文件和目录区分:域名根目录下/tpadmin指向ThinkPHP后台,/api指向Laravel接口。两个框架各自负责自己路径下的路由解析,互不干扰。但老版本App端曾经用过的一些接口地址,后来调整了命名空间,就涉及路由跳转配置。
ThinkPHP6里面做路由跳转很直接,在route/app.php中配置:
// 旧接口地址永久跳转到新地址 Route::redirect('index/old', 'index/new', 302);Laravel侧同样支持全局路由跳转:
Route::redirect('/api/v1/goods', '/api/v2/goods', 301); // 带参数跳转 Route::redirect('/api/v1/goods/{id}', '/api/v2/goods/{id}');这里有个实际操作经验:给App端接口做跳转,能用301就用301。因为App客户端里的WebView和请求库会把301结果缓存下来,这样老版本的客户端也能自动切到新地址,不需要强制用户升级App。如果是后台页面之间的跳转用302就够了,避免浏览器长期缓存造成开发时页面来回跳的错觉。还有一个细节,TP的入口文件index.php在URL里显式保留的时候,最好在路由里统一加一条“无后缀规范化”规则,防止用户记录下“带index.php”的旧链接,后续访问出现404。配合伪静态规则,把index.php隐藏掉,URL会干净很多。
4.2 Session会话的统一:从学生登录到跨端识别
双框架最头疼的兼容点是Session。ThinkPHP默认使用PHP原生Session,Laravel则默认用文件驱动(file)保存Session,并且两者对session的键名、过期机制处理方式都不一样。如果App端登录后,请求打到ThinkPHP接口时识别不到登录态,或者反过来,就会出现“明明登录了,一到另一个模块就提示未登录”的问题。
我们的方案是放弃在双框架之间共享Session,统一改用Token认证。用户登录时,Laravel接口生成一个随机字符串token,存到api_tokens表,同时返回给App端。App端之后每个请求都在Header里带Authorization: Bearer {token}。ThinkPHP和Laravel各自写一个中间件,从Header里取token,查表得到用户信息。
Laravel侧中间件:
public function handle($request, Closure $next) { $token = $request->bearerToken(); if (!$token) { return response()->json(['code' => 401, 'msg' => '未登录'], 401); } $apiToken = ApiToken::with('user')->where('token', $token) ->where('expired_at', '>', now()) ->first(); if (!$apiToken) { return response()->json(['code' => 401, 'msg' => '登录已过期'], 401); } auth('api')->setUser($apiToken->user); return $next($request); }ThinkPHP侧中间件也是同样逻辑,只是取token的写法不同:
public function handle($request, \Closure $next) { $token = $request->header('Authorization', ''); $apiToken = ApiToken::where('token', $token) ->where('expired_at', '>', date('Y-m-d H:i:s')) ->find(); if (!$apiToken) { return json(['code' => 401, 'msg' => '未登录'], 401); } $request->userId = $apiToken->user_id; return $next($request); }这个方案绕开了Session一致性的所有麻烦,也顺便解决了小程序和App请求里Cookie传递不稳定的问题。你在网上搜“laravel session”会看到很多复杂的共享方案,比如统一用Redis驱动,修改Session cookie名,但那些在跨框架场景下依然会有序列化格式不兼容的问题。相比之下,Token方案最稳,代价只是每次请求多一次表查询,校园平台这个量级完全无压力。
4.3 小皮面板指定运行目录:入口文件收敛是安全底线
项目部署用的是Windows服务器上的小皮面板(phpstudy)。很多同学在小皮面板部署ThinkPHP项目时,习惯把站点根目录直接指到项目根目录,结果访问http://域名/runtime/或者http://域名/application/时,直接把源码结构暴露出来了。我们吃过这个亏之后深刻认识到,无论是ThinkPHP还是Laravel,站点的运行目录都应该指向public目录。
在小皮面板的站点设置里,修改“运行目录”指向public,然后保存。这时候访问http://域名就直接进入框架的入口文件,不会暴露应用目录结构。
还要配好伪静态规则。注意index.php重写规则必须保留,否则Laravel依赖路由的url通通404:
location / { try_files $uri $uri/ /index.php?$query_string; }为什么这个配置特别重要?因为双框架项目里,Laravel的public目录和ThinkPHP的public目录都存在index.php,入口文件如果不收敛,两个框架的router就会出现互相捕获请求的情况。我们当时就是没设置运行目录,Laravel的请求经常跑到ThinkPHP的路由里解析,报一堆“控制器不存在”的错。把所有域名指向各自的public之后,两条URL分支才彻底分开。小皮面板同时还建议给runtime、storage、.env、vendor这些敏感路径加访问禁止规则,防止日志和配置泄露。
5. 实际运行中踩过的坑:刷单、图片上传与并发审批
5.1 改价刷单:一个字段校验引发的教训
平台上线第三天,就有人在二手区用1分钱挂了一台Switch。原因是前端商品表单里带了price和original_price两个字段,后端把“现价”直接用request()->param('price')拿到就入库了,没有校验价格下限。成交价最低可以填0.01元,甚至提交0元买赠链接,页面还能正常展示形成倒卖风险。
后来在商品发布接口里加了三层防护:第一层用Laravel的表单验证做字段规则,price必须大于0;第二层在业务Service里做兜底校验,price大于0且不能低于original_price的1%;第三层在管理后台的ThinkPHP商品列表里加了“低价商品”筛选标签,价格低于5元的商品自动进入人工复核列表。
Validator::make($request->all(), [ 'title' => 'required|max:100', 'price' => 'required|numeric|min:0.01', 'original_price' => 'required|numeric|gt:0', 'category_id' => 'required|integer', ]);这事的本质是“接口信任了不该信任的输入”。校园项目没有支付风控系统,所有数据进入业务逻辑之前都必须过校验。不要觉得学生用户都是善意的,任何真实环境里都要按最坏情况设计输入校验逻辑。
5.2 上传文件路径写死的问题
图片上传当时踩了一个很隐蔽的坑。ThinkPHP管理后台上传商品封面时,返回的是“带域名前缀的绝对路径”,比如https://域名/uploads/202406/abc.jpg。Laravel接口层读取商品列表统一输出相对路径/uploads/202406/abc.jpg,前端在展示时候自动拼上当前域名。问题来了:后台编辑商品时如果回显绝对路径,再次提交时,这个绝对路径又被原样存回数据库。一旦以后迁移域名或者换HTTPS,库里所有图片地址全部失效。
我们后续把所有图片存储约定统一为:数据库只存相对路径/uploads/...,任何一层输出时再按当前环境拼域名。这样域名切换不需要刷数据,本地开发、测试环境、生产环境也只需要一份代码一份数据。ThinkPHP上传代码里,去掉->getFileName()之前的域名拼接逻辑;Laravel侧上传后直接返回$request->file('image')->store('uploads', 'public')。另外还写了一个清理脚本,把库里已经存成绝对路径的旧数据批量替换成相对路径。这个习惯到现在我都保留着:数据库里永远不要存完整的URL,只存路径和参数,拼接是渲染层的事。
5.3 审批流并发:一个admin点了两个通过
活动审批功能上线后的一场真实事故:一个社团提交的场地申请,两个管理员同时打开了审批列表,A管理员先点了通过,B管理员几乎同时点了驳回,最终数据库里状态变成了“通过”,但A和B的浏览器提示“操作成功”的时候,B看到的结果和库里不一致。
问题的根源是“检查再更新”的模式天然有竞态。select查到状态是待审批,然后update把状态改成新值,这两步之间不是原子的。修复方案很简单:用条件更新替代先查再某,把状态放在where里作为更新条件。
$updated = ApprovalRecord::where('id', $recordId) ->where('status', ApprovalStatus::PENDING) ->update([ 'status' => ApprovalStatus::APPROVED, 'approver_id' => $adminId, ]); if ($updated === 0) { return $this->error('该申请已被其他管理员处理'); }在ThinkPHP后台也用同样的SQL逻辑,因为数据库层面的UPDATE ... WHERE status=0本身是原子性的。只要影响行数为0,就说明这条记录已经被处理过了,直接提示,不用再做一次SELECT判断。这个模式对任何状态推进型业务都有效,修改订单状态、取消报名、投票投票,凡是“只能从状态A变到状态B”的场景,全部严格要求先条件后更新。
5.4 排查思路复盘:从日志到最终定位
双框架项目出问题的时候,难点往往不是单个框架内部的bug,而是两个框架各自记录的日志不统一,排查链路很容易断。我们的经验是建立统一的“问题定位三件套”。
第一步,确认现象出现在哪个入口。域名的路径开头是/api就去Laravel的storage/logs/laravel.log找;路径开头是/tpadmin就去ThinkPHP的runtime/log/找。不要在一个框架的日志里找另一个框架的报错,纯浪费生命。
第二步,重现问题之前,先开调试模式。Laravel里.env设APP_DEBUG=true,ThinkPHP里.env设APP_DEBUG为true,把页面的报错堆栈完整截下来,再根据堆栈定位到具体文件和行号。排查完一定记得关掉,调试模式在公网开着等于把源码目录结构拱手送人。
第三步,遇到两边数据不一致的bug,写一个临时调试脚本直接查数据库,跳过两个框架的ORM映射,用原生SQL确认库里的真实状态。有一次商品状态怎么都对不上,最后发现是ThinkPHP模型的字段名映射和Laravel模型的不一致,导致同一张表两边读到的status含义完全反了。原生SQL一查,真相立刻清楚:不是业务代码错了,是模型映射错位。
这套流程下来,绝大多数双框架联动问题的定位时间能压缩到半小时以内。不要把排查当侦探故事去灵光一现,按入口分日志、开调试、看数据,按部就班就能找到根因。
项目跑到现在,稳定用了大半年。回看这次“ThinkPHP+Laravel”的混合实践,最大的体会是:技术选型永远要跟着业务阶段走,而不是反过来。快速上线阶段ThinkPHP帮团队省下了大量熟悉成本,业务复杂化之后Laravel的队列和事件机制又很好地承接了异步和状态流转需求。两个框架各有各的脾气,但只要在数据库字段、登录态方案、目录部署这些底层约定上保持一致,共存的成本完全可控。最后再分享一个小技巧:如果你也打算做类似的双框架或框架迁移项目,建议提前抽一个公共的utils包放在两个项目都能引用的位置,专门放时间格式化、金额计算、图片URL拼接这一类通用函数,避免同一个逻辑在两个框架里分别实现两遍,维护到后期这都是实打实的坑。