
简介多商户电商系统PHP源码是一套基于Laravel 5.7开发的全功能电商解决方案面向需要搭建B2B2C多商户平台的技术人员与中小企业支持超级响应式布局适配桌面、平板与手机端内置AJAX购物篮、产品比较、心愿单、推荐商品、折扣与今日特惠等模块。资源共28个文件压缩包约971.3MB其中18个zip与7个rar涵盖Active eCommerce CMS主体及退款、POS、联盟营销、OTP、卖家订阅等扩展插件另有php补丁文件、操作录屏mp4和jpg界面预览图便于对照安装与二次开发。目前已有1167人学习下载。包内包含5.5.4、5.5.7等多个版本与修复补丁附官方原版及破解版对比说明可帮助开发者快速完成系统部署、插件选型与功能定制适合具备一定PHP/Laravel基础并希望快速搭建多商户电商平台的读者。1. 多商户 PHP 商城落地先想清楚这三件事做多商户电商系统最容易翻车的不是写代码是没想清楚商户怎么隔离、钱怎么分账、插件怎么扩展就急着开工。这套基于 Laravel 的 PHP 多商户电商源码把商城主体、全套扩展插件和双端 APP 端凑齐了从商品、订单到支付结算都有完整实现适合手里有商城项目但不想从零造轮子的团队。拿到手先别急着往服务器上传代码我把它拆开讲一遍数据模型怎么设计、分账怎么算、插件怎么挂、APP 接口有哪些坑。这篇笔记按我实际排过坑的顺序写照着复现能省不少调试时间。2. 商户与商品隔离数据表拆法与 Laravel 全局作用域多商户系统第一个绕不开的问题是一个「商户」在数据库里到底怎么表示商品、订单这些业务数据怎么保证不串店。隔离粒度选错了后面每一个联表查询都是背债。2.1 单库多租户还是分库隔离常见做法是两种独立数据库和单库多租户。独立库隔离最干净但迁移、备份、跨店报表都麻烦PHP 商城项目里除非客户预算特别高否则很少这么干。大多数现成源码走的是单库多租户——也就是一张业务表上带 merchant_id 字段查询时强制带商家条件。选单库多租户有三个底气第一数据量在百万级订单内单库完全扛得住第二代码里可以用 Laravel 的全局作用域把商家条件自动拼上减少人为漏写第三平台后台要做全店维度的统计单库比多库好写得多。提示如果你的目标是万台级高并发 SASS单库方案确实到后期要拆但拆的路径也是从单库多租户开始的不影响现在先用起来。2.2 建表脚本merchant_id 怎么撒到每张业务表我拆过的这套源码里表设计有两条线商户侧和交易侧。交易侧的表全部冗余 merchant_id包括订单表。理由很现实查询订单列表时如果要通过商品反查商户再过滤SQL 会丑得没法维护。CREATE TABLE merchants ( id bigint unsigned NOT NULL AUTO_INCREMENT, merchant_no varchar(32) NOT NULL COMMENT 商户编号如 M202501001, name varchar(120) NOT NULL COMMENT 商户名称, rate decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT 平台抽成比例0.05 表示 5%, status tinyint NOT NULL DEFAULT 1 COMMENT 1 启用0 停用, created_at timestamp NULL DEFAULT NULL, updated_at timestamp NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_merchant_no (merchant_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT商户表; CREATE TABLE products ( id bigint unsigned NOT NULL AUTO_INCREMENT, merchant_id bigint unsigned NOT NULL, title varchar(200) NOT NULL COMMENT 商品标题, price decimal(10,2) NOT NULL COMMENT 销售价, stock int NOT NULL DEFAULT 0 COMMENT 库存, status tinyint NOT NULL DEFAULT 1 COMMENT 1 上架0 下架, PRIMARY KEY (id), KEY idx_merchant_id (merchant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT商品表; CREATE TABLE orders ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务唯一, merchant_id bigint unsigned NOT NULL COMMENT 冗余商户ID方便商户端查单, buyer_id bigint unsigned NOT NULL COMMENT 买家ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总额, commission_amount decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 平台抽成金额, status varchar(20) NOT NULL DEFAULT pending COMMENT 订单状态, paid_at timestamp NULL DEFAULT NULL COMMENT 支付时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_merchant_status (merchant_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT订单表;这里三个要点说明一下。merchant_no 是给财务看的业务编号不要用自增 id 直接展示rate 字段是分账用的抽成比例decimal 类型别用 float否则对账时总差几分钱orders 表冗余 merchant_id虽然违反一点范式但商户后台几乎每个查询都按商户过滤冗余后一条索引就能覆盖值得。2.3 全局作用域查询不会串店的 Laravel 写法表建好了最担心的是团队里有人写查询时忘加 merchant_id 条件。Laravel 的全局作用域就是为这个场景设计的注册一次所有查询自动带上。?php namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; class MerchantScope implements Scope { public function apply(Builder $builder, Model $model): void { $merchantId auth(merchant)-id(); if ($merchantId) { $builder-where($model-getTable() . .merchant_id, $merchantId); } } }然后在模型里注册?php namespace App\Models; use App\Models\Scopes\MerchantScope; class Product extends Model { protected $fillable [merchant_id, title, price, stock, status]; protected static function booted(): void { static::addGlobalScope(new MerchantScope()); } }apply 方法里有个细节auth(merchant)-id()。多商户系统的后台不能跟 C 端用户混用一个 guard否则商户登录后会拿到用户的 id。常见做法是单独配一个 merchant guard模型指向 Merchant。// config/auth.php 中新增 guard guards [ merchant [ driver session, provider merchants, ], ], providers [ merchants [ driver eloquent, model App\Models\Merchant::class, ], ],注意 apply 里的判断如果 auth(merchant) 没拿到登录态比如命令行脚本或队列任务里执行查询就直接不拼条件。这里踩过一个坑在队列里任务没有商户上下文如果条件拼上 00 反而查不到数据所以必须先判断再拼 where。2.4 商户后台路由与中间件全局作用域管的是模型查询路由层的隔离靠中间件。商户后台的控制器要不要全部再校验一次商户权限我的建议是路由层面控制住控制器里专心写业务。Route::prefix(merchant)-middleware([web, auth:merchant])-group(function () { Route::get(/products, [ProductController::class, index]); Route::post(/products, [ProductController::class, store]); Route::put(/products/{product}, [ProductController::class, update]); });这么写的价值在于所有商户后台的路由都经过 auth:merchant非登录态直接弹回登录页。配合全局作用域即使控制器里忘记过滤数据也不会跨店。文件上传、图片访问这类资源也建议加中间件图片走 storage 软链后用 signed url 或者防盗链配置避免商户商品图被未授权爬走。3. 订单支付与分账把平台抽成和 TN 结算算对订单模块是整套系统里最容易扯皮的地方。状态乱、回调重复、分账算错每个都能让你在客户现场待到半夜。这一章按订单生命周期往下拆状态机、支付回调、结算分账。3.1 订单状态机从下单到售后的合法流转很多源码的订单状态就是一堆 if/else改着改着就乱了。这套源码里用的是显式状态机把所有合法流转写在一个数组里非法流转直接抛异常。class Order extends Model { public const STATUS_PENDING pending; // 待支付 public const STATUS_PAID paid; // 已支付 public const STATUS_SHIPPED shipped; // 已发货 public const STATUS_COMPLETED completed; // 已完成 public const STATUS_REFUNDING refunding; // 退款中 public const STATUS_CLOSED closed; // 已关闭 private array $transitions [ pending [paid, closed], paid [shipped, refunding], shipped [completed, refunding], refunding [closed, completed], ]; public function canTransitionTo(string $newStatus): bool { return in_array($newStatus, $this-transitions[$this-status] ?? [], true); } public function transitionTo(string $newStatus, string $note ): bool { if (!$this-canTransitionTo($newStatus)) { throw new \RuntimeException( sprintf(订单 %s 不能从 %s 流转到 %s, $this-order_no, $this-status, $newStatus) ); } $this-status $newStatus; $this-save(); // 记录状态流转日志 OrderStatusLog::create([ order_id $this-id, from $this-getOriginal(status), to $newStatus, note $note, ]); return true; } }这套写法的价值第一非法流转在开发期就会报错而不是上线后出现「已关闭订单被发货」的脏数据第二每个状态变更都有日志对账审计时能说清每一步第三控制器里不需要写复杂的条件判断一行$order-transitionTo(paid)就完事。transitions 数组是状态机的核心配置后续要加「已取消」或「待核销」只要在这里补一条合法路径其余代码不用动。3.2 支付回调幂等队列重试怎么防重复入账支付回调不是「一定会被调一次」而是「可能被调很多次」。微信、支付宝的通知有重试机制官方文档明确说会多次推送最长持续三天。所以回调处理的第一个动作必须是幂等处理否则一张订单会被入账两次。常见做法是支付流水表加唯一约束回调进来先用订单号和第三方交易号查流水已存在就直接返回成功不再走业务逻辑。$paymentLog PaymentLog::firstOrCreate( [order_no $orderNo, transaction_id $transactionId], [payload json_encode($payload), status notified] ); if (!$paymentLog-wasRecentlyCreated) { // 重复通知直接告诉支付渠道已收到 return response()-json([code 0, msg duplicate notification]); }加上数据库层的唯一索引双保险Schema::create(payment_logs, function (Blueprint $table) { $table-id(); $table-string(order_no); $table-string(transaction_id)-comment(第三方交易号); $table-text(payload); $table-string(status)-default(notified); $table-timestamps(); $table-unique([order_no, transaction_id], uk_order_txn); });处理完回调后下单相关的后续动作全部丢到队列里执行不要在回调线程里同步做完。原因很简单回调接口对响应时间有要求微信支付宝都有超时重试的阈值你在回调里发短信、算佣金、更新库存任何一个外部接口慢一点回调就超时了。dispatch(new ProcessPaidOrder($order-id))-onQueue(orders)-afterResponse();3.3 分账与提现抽成比例、冻结与结算单分账是平台型电商的命门。平台收多少、商户拿多少、什么时候结算这些参数最好做成配置而不是写死在代码里。商家表里的 rate 字段就是干这个的比如 0.03 代表平台抽 3%。class SettlementService { public function settleByDate(string $date): void { $orders Order::whereDate(paid_at, $date) -where(status, paid) -where(settled, false) -get(); $grouped $orders-groupBy(merchant_id); foreach ($grouped as $merchantId $items) { $totalAmount $items-sum(total_amount); $merchant Merchant::find($merchantId); $commissionAmount round($totalAmount * $merchant-rate, 2); $settleAmount $totalAmount - $commissionAmount; Settlement::create([ merchant_id $merchantId, settle_date $date, total_amount $totalAmount, commission_amount $commissionAmount, settle_amount $settleAmount, status pending, ]); // 标记订单已结算防止重复入账 Order::whereIn(id, $items-pluck(id))-update([settled true]); } } }几个参数说明round(…, 2) 是因为金额计算用 decimal 后PHP 浮点运算还可能产生 0.0000001 的尾差必须四舍五入到分settled 字段是结算状态标记和订单状态机相互独立避免一单多结按天跑的任务用 settle_date 做分区条件某天算错了可以单独重算那一天。我的习惯是 T1 结算放在每天早上低峰期执行用 Laravel 的定时任务跑// app/Console/Kernel.php 中注册 $schedule-call(fn () app(SettlementService::class)-settleByDate(now()-subDay()-toDateString())) -dailyAt(03:30) -onOneServer();4. 插件扩展机制composer 包 事件钩子怎么挂多商户系统最值钱的是可扩展性。客户今天要分销、明天要拼团如果每个功能都直接改主工程升级维护就是噩梦。这套源码的扩展思路很 Laravel小事用事件大事用包。4.1 Laravel 生态的插件选型package 还是事件系统Laravel 没有官方插件中心但生态里最接近「插件」的是 composer 包加服务提供者。把营销功能拆成一个独立包主工程只留扩展点这就是最朴素也最好维护的插件方案。选型时把握一个原则只跟业务状态有关、不需要改表结构的用事件就够了。比如「订单支付后发短信」「支付后送优惠券」一个 Event 加若干 Listener 就搞定。而需要新增页面、新增数据库表的比如分销系统就拆成 composer 包。4.2 一个营销插件的完整落地注册、配置与钩子我拆的这个 marketing 插件是标准 composer 包结构包含优惠券、满减、限时折扣三类营销能力。它的骨架是这样{ name: shop/marketing-plugin, description: 营销插件优惠券、满减、限时折扣, type: library, require: { php: ^8.1, laravel/framework: ^10.0|^11.0 }, autoload: { psr-4: { Shop\\Plugins\\Marketing\\: src/ } }, extra: { laravel: { providers: [ Shop\\Plugins\\Marketing\\MarketingServiceProvider ] } } }extra.laravel.providers 字段是给 Laravel 的包自动发现机制用的。包安装后不需要手动在 config/app.php 里注册框架扫描 vendor/composer/installed.json 时自动加载这就是 artisan package:discover 干的事。服务提供者是插件的入口namespace Shop\Plugins\Marketing; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Event; use App\Events\OrderPaid; class MarketingServiceProvider extends ServiceProvider { public function register(): void { $this-mergeConfigFrom(__DIR__ . /config/marketing.php, marketing); } public function boot(): void { $this-loadMigrationsFrom(__DIR__ . /migrations); $this-loadRoutesFrom(__DIR__ . /routes.php); $this-publishes([ __DIR__ . /config/marketing.php config_path(marketing.php), ], marketing-config); // 订单支付后发放优惠券 Event::listen(OrderPaid::class, function (OrderPaid $event) { app(CouponService::class)-giveCoupon( $event-order-buyer_id, config(marketing.coupon_id) ); }); // 价格计算管道里插入限时折扣 app()-extend(price.pipeline, function ($pipeline) { return $pipeline-pipe(new LimitedDiscountPipe()); }); } }register 方法负责合并配置boot 方法负责加载资源事件。loadMigrationsFrom 和 loadRoutesFrom 让插件自带表和路由主工程只需要发布配置生产环境按需调整营销参数。页面开发时配置文件的发布命令也一起给了php artisan vendor:publish --tagmarketing-config。4.3 给插件预留扩展点价格、库存、登录方式插件机制能不能玩转取决于主工程预留了多少扩展点。这套源码里三个典型的钩子值得抄价格管道、库存变更事件、登录跳转钩子。价格管道是把「商品原价到最终成交价」的过程拆成一段流水线每个插件往管道里塞一段处理逻辑。比如会员价插件算完限时折扣接着算满减再减顺序由管道管理public function resolvePrice(Product $product): float { $price $product-price; foreach (config(plugins.price_pipes, []) as $pipeClass) { $pipe app($pipeClass); $price $pipe-handle($price, $product); } return $price; }库存变更同样走事件下单扣减、退款回补插件如果要做「低库存提醒」「库存同步到第三方」监听 InventoryChanged 事件就行。这比在控制器里到处塞通知代码干净得多。提示给第三方插件留扩展点时别把扩展点做成「万能钩子」否则调试时根本分不清价格被哪个插件改过。我的经验是价格、库存、登录这三处必须做其余能不做就不做。5. 上线避坑实录鉴权、跨域与重复入账的五个现场这套系统接线阶段最容易让人通宵的五个坑都在这。每一条都是从现象到原因再到解法的完整记录确认过照着改就行。5.1 坑一全局作用域 auth() 返回 null商户后台全空现象商户登录后列表页一直空日志里没有 SQL 错误但 Laravel debug 显示查询条件里没有 merchant_id。原因admin 用户和商户混用了同一个 guard。登录时用的是 auth(admin)但全局作用域里取的是 auth(merchant)两个 session 不同名取不到就是 null条件被跳过查询查全表但前端从 session 里拿的商户 ID 过滤后自然为空。解决商户登录时要走 Auth::guard(merchant)-login($merchant) 。排查时先确认登录用的 guard 和作用域里的 guard 完全一致。5.2 坑二支付回调重复通知一单入账两次现象对账时发现同一订单被商家发起两笔退款后台订单金额对不上。原因回调处理没有做幂等微信第一次通知超时后重试业务侧又执行了一遍加余额、改状态。解决先加支付流水唯一约束再在回调里 firstOrCreate 判断。并发场景下可以在订单上加锁Cache::lock避免两个队列任务同时处理同一订单。代码示例见 3.2 节这套源码里回调入口就用了同样的套路。5.3 坑三CORS 预检失败APP 内嵌页一直 401现象APP 里内嵌 H5 页面登录接口正常但带 Authorization 头的接口全部 401Postman 却正常。原因跨域请求带自定义头时浏览器会先发 OPTIONS 预检。Laravel 的 CORS 配置如果没有放行 Authorization 头预检直接被拒正式请求连后端的边都摸不到。解决在 config/cors.php 里把 allowed_headers 放开并确认 api/* 路径已经在 paths 里paths [api/*], allowed_methods [*], allowed_origins [*], allowed_headers [Authorization, Content-Type, X-Requested-With], exposed_headers [], max_age 0, supports_credentials false,这里的场景是内嵌 H5 才需要 CORS原生 APP 的 HTTP 客户端不存在跨域问题。5.4 坑四uniapp 分包没配好首包超限现象uni-app 打包后主包超过微信小程序的 2MB 限制审核不通过。原因所有页面都塞在主包扩展插件里的活动页、分销页也一起打进主包了。解决按业务拆分包把插件相关的页面拆到 subPackages{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 首页 } } ], subPackages: [ { root: pages/shop, pages: [ { path: list, style: {} }, { path: detail, style: {} } ] } ], preloadRule: { pages/index/index: { network: all, packages: [pages/shop] } } }preloadRule 会在进入首页后预下载分包用户点进店铺页时不用等加载。5.5 坑五Artisan 队列起多了结算任务重复跑现象线上跑了三个 queue:work 进程T1 结算发现金额翻倍。原因同一个订单的 paid 事件被多个 worker 同时拉到各自执行了结算流程settled 标记是「先查后更新」导致竞争。解决结算那段代码必须放在 DB::transaction 里并且对订单行加锁再更新。Laravel 的原子更新是最后防线$updated Order::where(id, $orderId) -where(settled, false) -update([settled true]); if ($updated 1) { // 只有抢到更新权的 worker 才继续做结算 }这个where(settled, false)是整段代码的灵魂它把幂等性从应用层下沉到了数据库层。6. 上线前的压测与日志追踪把队列失败率压到 0 的三个习惯这一章给你三个上线前必做的动作。第一队列任务一定要设置重试次数和超时时间我一般这样起php artisan queue:work --tries3 --timeout90 --max-time3600tries3 是任务失败重试三次timeout90 是单任务最长执行 90 秒max-time3600 是 worker 跑满一小时自动退出。这个参数组合是防内存泄漏的后悔药配合 supervisor 守护进程挂了会自动拉起[program:shop-worker] commandphp /var/www/shop/artisan queue:work --sleep3 --tries3 --max-time3600 autostarttrue autorestarttrue numprocs2 process_name%(program_name)s_%(process_num)02d第二把 Laravel Telescope 装到测试环境。它有队列面板、请求面板、异常面板支付回调失败、SQL 慢查询一眼就能看到生产环境建议关掉避免日志写入拖慢业务。第三日志分流。订单和结算的日志别混在 laravel.log 里按日期拆频道崩溃时能按频道快速定位// config/logging.php channels [ settlement [ driver daily, path storage_path(logs/settlement.log), days 30, ], ],调试队列时先把失败任务捞出来看报错比盲猜管用得多php artisan queue:failed php artisan queue:retry all如果你把这套源码下载到本地别一上来就摊开全部文件。先把 routes/merchant.php、app/Services/SettlementService.php、plugins/marketing 这三个位置定位到其余代码基本都是围着它们转的。把这一章说的三个动作在本地过一遍比看十遍文档都值。从拆这套源码的经验看真正让我省心的不是代码多优雅而是这些兜底手段。从那以后我每次上线前都强制自己走一遍「队列参数 → 测试环境 Telescope → 日志分流」的流程把最容易黑盒化的队列和回调操作全部暴露在日志里。希望这些能帮到你至少让多商户上线的第一个晚上不用蹲在服务器前刷日志。本文还有配套的精品资源点击获取