☰
PHP外卖点餐源码实战:从零跑通到二次开发避坑指南
2026/10/27 8:24:55 网站建设 项目流程

简介:这是一套面向餐饮商家与PHP开发者的外卖点餐系统源码,适合想搭建在线订餐平台或学习Web全栈开发的初中级开发者参考。系统覆盖用户注册登录、菜单浏览、选菜下单、在线支付与订单追踪等完整流程,前端采用响应式布局适配手机与桌面,后端基于PHP处理身份验证、订单计价与状态更新,并集成支付宝、微信等第三方支付接口,同时包含防SQL注入、XSS攻击等安全防护设计。压缩包共1166个文件,以jpg、gif图片资源和php脚本为主,辅以js、html、css等前端文件及db数据库文件,整体约5.13MB,另附源码说明、安装指南、流程图与版本更新记录,便于理解系统结构与部署。已有1816人学习下载,读者可据此快速搭建外卖订餐平台,或通过研读前后端代码掌握订单业务逻辑与支付回调机制。

1. 拿到一份外卖点餐源码,先别急着改模板

上周有个做本地餐饮 SaaS 的朋友丢给我一份 PHP 外卖点餐源码,说客户要三天内上线一个点餐小程序后台,问我能不能直接跑。我打开压缩包看了一眼,目录结构规整,前后台分离,模板文件独立,数据库脚本齐全,这种项目最怕的不是代码烂,而是你上来就动模板,改完发现订单流程断了。外卖点餐源码这类项目,核心价值不在界面多花哨,而在于「菜品管理 → 购物车 → 下单 → 订单状态流转」这条链路是否闭环。这份源码定位很明确:给中小餐饮商家做堂食扫码点餐或自提外卖的后台系统,适合想快速搭一个点餐 Demo 的开发者,也适合拿来做二次开发底座。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。

2. 项目定位与技术栈:这套 PHP 点餐系统到底由什么组成

2.1 功能边界:它解决的是「点餐闭环」而不是「外卖平台」

先把这个项目的功能范围说清楚,避免你跑起来之后发现缺东西。从源码目录和路由文件看,它覆盖的是单店或多门店的点餐场景,核心模块包括:菜品分类与菜品管理、购物车、订单创建与状态流转、桌台/取餐号管理、后台数据统计。它不是一个美团饿了么那种多商家入驻平台,没有骑手调度、没有分账结算、没有复杂的营销裂变体系。这一点很关键,很多开发者拿到「外卖点餐源码」就默认它带配送调度,结果翻遍代码找不到骑手模块,然后开始怀疑源码残缺。

我一般会先看路由配置来确认功能边界。这份源码的前台路由大致长这样:

// route/app.php 前台路由片段(示意,按实际文件调整) Route::get('index', 'Index/index'); // 首页/菜品列表 Route::get('menu', 'Index/menu'); // 菜单页 Route::post('cart/add', 'Cart/add'); // 加入购物车 Route::post('order/create', 'Order/create'); // 提交订单 Route::get('order/detail', 'Order/detail'); // 订单详情 Route::get('order/status', 'Order/status'); // 订单状态轮询

这段路由说明了几件事:购物车是服务端维护的(有cart/add接口),订单创建和状态查询是分开的(order/create与order/status),说明前端下单后需要轮询或刷新拿状态。参数上,order/create通常接收table_id(桌号)、cart_ids(购物车项)、remark(备注)、pay_type(支付方式)。如果你要做自提模式,把table_id换成pickup_no即可,逻辑不用大改。

提示:先跑通「加购物车 → 下单 → 查状态」这条最小链路,再去动界面,否则改完模板你连订单有没有生成都判断不了。

2.2 技术栈拆解:PHP 框架 + 模板引擎 + MySQL 的三层结构

这份源码用的是典型的 PHP MVC 结构,框架层大概率是 ThinkPHP 系(国内点餐类源码最常见的选择),模板引擎独立,数据库 MySQL。目录结构一般是这样:

├── application/ # 应用目录 │ ├── admin/ # 后台模块(菜品、订单、统计) │ ├── index/ # 前台模块(点餐、购物车) │ └── common/ # 公共函数、模型基类 ├── public/ # 入口文件与静态资源 │ ├── index.php # 入口 │ └── static/ # css/js/images ├── config/ # 数据库与路由配置 ├── route/ # 路由定义 ├── runtime/ # 缓存与日志(需可写) └── sql/ # 数据库脚本

这个结构的好处是前后台模块隔离清晰,admin和index各自有控制器和视图,公共逻辑放common。坏处是新手容易把后台控制器当前台用,改错文件。判断方法很简单:看 URL 前缀,/admin/xxx走后台,/index/xxx或根路径走前台。

数据库层面,核心表通常包括goods(菜品)、goods_category(分类)、cart(购物车)、orders(订单主表)、order_goods(订单明细)、table(桌台)。订单主表和明细表分离是标准做法,因为一个订单可能包含多个菜品,明细表存order_id、goods_id、num、price。这个设计决定了你后面做统计报表时,要 join 两张表才能算出单品销量。

2.3 运行环境要求:PHP 版本与扩展别踩坑

跑这份源码之前,环境这块有几个硬性要求。PHP 版本建议 7.2 以上(ThinkPHP 5.x 系常见要求),MySQL 5.6 以上,Web 服务器 Nginx 或 Apache 都行。必须开启的扩展包括pdo_mysql、mbstring、gd(图片处理)、fileinfo。runtime目录和public/static下的上传目录必须可写,否则会出现「页面空白」或「图片上传失败」这类玄学问题。

我一般用宝塔面板或 phpstudy 快速搭环境,省去手动配扩展的时间。如果你用命令行,检查扩展的命令是:

php -m | grep -E 'pdo_mysql|mbstring|gd|fileinfo'

四个都出现才算齐。缺gd的话,菜品图片缩略图生成会失败;缺fileinfo的话,上传文件类型校验会报错。这两个是最容易被忽略的。

3. 从零跑起来:数据库导入、配置修改与最小链路验证

3.1 数据库导入:先看 sql 目录再动手

源码包里的sql/目录一般有一个.sql文件,导入之前先打开看一眼字符集和表前缀。字符集建议utf8mb4,否则菜品名里带 emoji 或特殊符号会乱码。表前缀通常是tp_或空,这个要和配置文件里的prefix保持一致,不一致的话所有查询都会报表不存在。

导入步骤:

# 1. 创建数据库(字符集必须 utf8mb4) mysql -u root -p -e "CREATE DATABASE food_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入 sql 文件 mysql -u root -p food_order < sql/food_order.sql # 3. 确认表数量和前缀 mysql -u root -p food_order -e "SHOW TABLES;"

第三步的输出要和你配置文件里的表前缀对得上。如果 sql 文件里建表语句是CREATE TABLE tp_goods,而配置里prefix写的是空,那就要么改配置,要么批量替换 sql 里的前缀。我一般直接改配置,因为改 sql 容易漏。

3.2 配置文件修改:数据库、域名、调试开关三处必改

配置文件通常在config/database.php和config/app.php。数据库这块填 hostname、database、username、password、hostport、prefix 六个值。域名配置在app.php里的app_host或类似字段,影响静态资源路径和回调地址。调试开关app_debug建议开发阶段设为true,上线前改false,否则错误信息会暴露路径。

// config/database.php 关键字段(按实际键名调整) return [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'food_order', 'username' => 'root', 'password' => '你的密码', 'hostport' => '3306', 'prefix' => 'tp_', // 必须与 sql 建表前缀一致 'charset' => 'utf8mb4', ];

改完配置后,访问http://你的域名/看前台是否出菜品列表,访问/admin看后台登录页是否正常。两个都出来,说明配置这关过了。如果前台白屏,先看runtime/log下的日志,八成是数据库连不上或表前缀不对。

3.3 最小链路验证:加购物车到订单生成

配置通了之后,别急着看界面美不美,先验证核心链路。手动走一遍:前台选一个菜品 → 加入购物车 → 提交订单 → 后台订单列表能看到这条记录。这一步能过,说明购物车表、订单表、订单明细表的写入逻辑都是通的。

如果卡在某一步,按这个顺序排查:加购物车失败看cart表有没有写入、下单失败看orders表有没有主记录、明细缺失看order_goods表。常见原因是购物车session或cookie没带过去,导致服务端拿不到购物车项。检查方法是在Cart/add控制器里临时打印session内容,确认用户标识存在。

注意:订单号生成逻辑如果用了时间戳 + 随机数,并发下单时可能重复。测试阶段单用户看不出问题,上线后多桌同时下单就会翻车。后面第 5 章会讲怎么改。

4. 核心模块拆解:购物车、订单与状态流转的代码实现

4.1 购物车模块:服务端存储与数量合并逻辑

购物车这块,这份源码用的是服务端存储,不是纯前端 localStorage。好处是换设备购物车不丢,坏处是每次加减都要请求接口。核心逻辑在Cart控制器的add方法,大致流程是:接收goods_id和num→ 查菜品是否存在且在售 → 查购物车是否已有该菜品 → 有则数量累加,无则插入。

// application/index/controller/Cart.php 加入购物车(示意) public function add() { $goodsId = input('post.goods_id/d'); // 强制转整型,防注入 $num = input('post.num/d', 1); $userId = session('user_id'); // 未登录可用 session_id 代替 $goods = Db::name('goods')->where('id', $goodsId)->find(); if (!$goods || $goods['status'] != 1) { return json(['code' => 0, 'msg' => '菜品已下架']); } $where = ['user_id' => $userId, 'goods_id' => $goodsId]; $cart = Db::name('cart')->where($where)->find(); if ($cart) { Db::name('cart')->where('id', $cart['id']) ->setInc('num', $num); // 数量累加 } else { Db::name('cart')->insert([ 'user_id' => $userId, 'goods_id' => $goodsId, 'num' => $num, 'price' => $goods['price'], // 快照价格,防菜品调价影响已加购项 'add_time' => time(), ]); } return json(['code' => 1, 'msg' => '已加入购物车']); }

这段代码有两个细节值得说。第一,price存的是加入时的快照价,不是下单时再查菜品表,这样商家中途调价不会影响用户已加购的商品,避免价格纠纷。第二,input('post.goods_id/d')里的/d是框架的强制类型转换,防止字符串注入。参数上,num默认 1,前端传负数的话这里没做校验,是个小隐患,二次开发时建议加if ($num < 1) $num = 1;。

4.2 订单创建:事务与库存扣减的配合

订单创建是整个项目最容易出问题的地方。这份源码的Order/create方法大致做了四件事:校验购物车非空 → 计算总价 → 开启事务写订单主表和明细表 → 清空购物车。库存扣减如果做了,也在事务里。

// application/index/controller/Order.php 创建订单(示意) public function create() { $userId = session('user_id'); $cartList = Db::name('cart')->where('user_id', $userId)->select(); if (empty($cartList)) { return json(['code' => 0, 'msg' => '购物车为空']); } Db::startTrans(); // 开启事务 try { $total = 0; foreach ($cartList as $item) { $total += $item['price'] * $item['num']; } $orderNo = date('YmdHis') . mt_rand(1000, 9999); $orderId = Db::name('orders')->insertGetId([ 'order_no' => $orderNo, 'user_id' => $userId, 'table_id' => input('post.table_id/d', 0), 'total' => $total, 'status' => 0, // 0 待支付/待接单 'remark' => input('post.remark/s', ''), 'create_time'=> time(), ]); foreach ($cartList as $item) { Db::name('order_goods')->insert([ 'order_id' => $orderId, 'goods_id' => $item['goods_id'], 'num' => $item['num'], 'price' => $item['price'], ]); } Db::name('cart')->where('user_id', $userId)->delete(); Db::commit(); return json(['code' => 1, 'data' => ['order_id' => $orderId, 'order_no' => $orderNo]]); } catch (\Exception $e) { Db::rollback(); return json(['code' => 0, 'msg' => '下单失败']); } }

事务包住了订单主表、明细表、购物车清空三步,任何一步失败都回滚,这是合格做法。status初始值 0 代表待处理,后台接单后改成 1,完成改 2,取消改 -1,这个状态机要和后台按钮对应上。orderNo用时间戳加四位随机数,前面提过并发可能重复,改进方案是用date('YmdHis') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT)或者直接上 Redis 自增序列。

4.3 订单状态流转:后台接单与前台轮询

订单状态流转是前后台联动的核心。后台订单列表有「接单」「完成」「取消」按钮,对应控制器里改status字段。前台订单详情页通过order/status接口轮询拿最新状态,展示给用户。

// application/admin/controller/Order.php 修改订单状态(示意) public function changeStatus() { $orderId = input('post.order_id/d'); $status = input('post.status/d'); $allow = [1, 2, -1]; // 只允许接单/完成/取消 if (!in_array($status, $allow)) { return json(['code' => 0, 'msg' => '非法状态']); } $order = Db::name('orders')->where('id', $orderId)->find(); if (!$order) { return json(['code' => 0, 'msg' => '订单不存在']); } Db::name('orders')->where('id', $orderId)->update([ 'status' => $status, 'update_time' => time(), ]); return json(['code' => 1, 'msg' => '操作成功']); }

这里$allow白名单是关键,防止前端传个status=99把订单改成未知状态。实际项目里还应该加状态前置校验,比如已完成的订单不能再取消,这份源码没做,二次开发时建议补上:if ($order['status'] == 2 && $status == -1) return json(['code'=>0,'msg'=>'已完成订单不可取消']);。前台轮询接口order/status只返回status和status_text两个字段,减少传输量,轮询间隔建议 5 到 10 秒,太频繁会给服务器压力。

5. 避坑与排查:跑这份源码时最容易翻车的五个地方

5.1 页面白屏但日志为空

现象:访问前台或后台直接白屏,runtime/log里没有错误记录。原因通常是 PHP 致命错误被框架的异常处理吞了,或者runtime目录不可写导致日志写不进去。解决:先把app_debug设为true,再给runtime目录 755 权限,属主改成 Web 服务器用户(Nginx 一般是www)。如果还白屏,在入口文件public/index.php顶部临时加ini_set('display_errors', 1); error_reporting(E_ALL);看原始报错。

5.2 数据库连上了但报表不存在

现象:配置检查没问题,但一访问就报Table 'xxx.tp_goods' doesn't exist。原因:sql 文件里的表前缀和配置里的prefix不一致,或者导入时选错了数据库。解决:SHOW TABLES;看实际表名,把配置里的prefix改成实际前缀,或者用RENAME TABLE批量改表名。我一般直接改配置,一条命令的事。

5.3 图片上传成功但前台不显示

现象:后台上传菜品图片提示成功,前台菜品列表图片裂开。原因:上传目录路径和访问路径不一致,常见于项目放在子目录下但配置里写的是根路径。解决:检查config/app.php里的域名配置和public/static/uploads的实际路径,确保上传后的 URL 能直接访问。用浏览器开发者工具看图片请求返回 404 还是 403,404 是路径错,403 是权限问题。

5.4 下单成功但后台订单列表为空

现象:前台提示下单成功,后台订单列表查不到。原因:订单表status默认值和后台列表查询条件不匹配,比如后台只查status >= 1的订单,而新订单是 0。解决:看后台Order/index方法的查询条件,把status为 0 的待接单订单也纳入列表,或者把新订单初始状态改成 1。这个坑很隐蔽,因为前台和后台各自逻辑都对,就是条件对不上。

5.5 并发下单订单号重复

现象:压测或高峰期出现两条订单号相同的记录,主键不同但order_no一样。原因:订单号生成用了秒级时间戳加四位随机数,同一秒内随机数碰撞概率不低。解决:给order_no加唯一索引,生成时循环重试直到不重复,或者改用uniqid()加毫秒时间戳。加唯一索引是兜底,重试是治本,两个一起上最稳。

6. 二次开发改造:从「能跑」到「能交付」的几个关键动作

拿到这份源码,跑通只是起点,真正交付前我一般会做三件事:补状态校验、加订单号唯一约束、把配置抽成环境变量。第一件,订单状态流转加前置判断,已完成不可取消、已取消不可接单,这段逻辑放在changeStatus里,十行代码的事,但能挡掉大量脏数据。第二件,给orders.order_no加唯一索引,同时把生成逻辑改成重试模式:

// 订单号生成:唯一索引 + 重试兜底 do { $orderNo = date('YmdHis') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT); $exists = Db::name('orders')->where('order_no', $orderNo)->count(); } while ($exists);

第三件,把数据库密码、域名这些从配置文件里挪到.env,部署时不同环境改环境变量就行,不用动代码。这份源码如果没带.env支持,手动加一个读取逻辑也不复杂。

改造完怎么验证?我的习惯是写一个最小回归脚本,用 curl 模拟「加购物车 → 下单 → 后台接单 → 前台查状态」四步,每步断言返回码。脚本不用复杂,能覆盖主链路就行,每次改完跑一遍,比手动点强。另外,菜品价格快照这个设计要保留,二次开发时别图省事改成实时查价,否则商家调价后历史订单金额会变,对账时就是血泪经验。

从那以后我每次接这类点餐源码,都强制先跑一遍最小链路再动界面,配置和数据库前缀对不上就绝不往下走。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询