
简介一套面向PHP开发者的在线聊天室快速部署源码适合需要为网站或项目增加多人实时沟通能力的技术人员。系统支持多用户同时在线聊天注册时记录IP并提供封禁功能后台可进行用户与消息管理兼顾基础安全管控。源码包共19个文件以14个PHP业务脚本为主涵盖安装向导、登录注册、聊天界面、API与后台管理模块另辅以JavaScript交互脚本、默认头像图片、说明文档及两个快捷网址文件压缩包仅118KB结构轻量、便于二次开发。目前已有298人学习下载。通过安装向导配置数据库后即可搭建完整聊天环境调试者可快速理解从用户注册到消息互通的实现流程也能直接基于现有代码扩展IP黑名单、在线状态更新等管理功能。1. 一套能上线的 PHP 在线聊天系统从 WebSocket 到离线消息接手客服系统的朋友应该都有同感用 PHP 写在线聊天系统最尴尬的不是聊天逻辑而是怎么让服务器主动把消息推到浏览器。HTTP 协议天生是“请求-响应”服务器不能主动开口传统 PHP-FPM 跑完一次请求就释放所有资源长连接想都别想。这套 2025 PHP 在线聊天系统源码用 Workerman 常驻进程方案解决这个问题PHP 8 环境下直接跑 WebSocket 服务前端连上就能收发消息离线消息登录后会自动补拉。适合两类人一是有 PHP 基础、想快速搭一套客服或社群聊天系统的开发者二是想搞懂长连接服务怎么实现、怎么部署的学习型读者从启动到排错都有现成路径可参考。2. 实时聊天的技术底子轮询、WebSocket 与 Workerman 的取舍2.1 轮询与 WebSocket实时聊天的两条路线聊天系统的实时性本质上取决于消息从服务端到客户端的通路。传统 HTTP 里服务器没法主动给浏览器发数据浏览器只能一遍遍问。这就是轮询方案的由来。假设页面每两秒请求一次 getNewMessage 接口用户少时问题不大用户量一上来带宽和数据库查询压力就翻倍而且无论有没有新消息请求都照发实时性还卡在两秒左右。长轮询稍微聪明一点请求挂着等到有新消息再返回。但挂着的请求会占用连接资源浏览器和代理对并发连接数都有限制在线用户一多同样撑不住。WebSocket 的思路完全不一样。它先通过一次 HTTP 握手协商升级协议服务端返回 101 状态码之后这条 TCP 连接就一直开着双方随时可以互相发数据。聊天、弹幕、协作编辑这类场景本身就是为 WebSocket 这种双工通信设计的。这套源码的选择是服务端用 Workerman 跑 WebSocket 服务前端直接使用浏览器内置的 WebSocket 对象没有引入 Socket.IO 这类重量级依赖也不依赖第三方推送服务部署边界很清楚。这里要特别说一下选择 WebSocket 而不是坚持轮询的理由实时性只是表面原因更深一层是连接成本。轮询每一秒都在建立 HTTP 连接再销毁每次都要带上 Cookie、Token、User-Agent 这些头部WebSocket 建立一次连接后后续消息只传业务数据头部开销几乎没有。几百人同时在线的场景下这个差距直接反映在服务器带宽和 PHP 进程占用上。2.2 为什么选 Workerman常驻进程与事件驱动先澄清一个误区PHP 不是不能做长连接关键看你用什么模式跑。PHP-FPM 是“处理完请求就退出”的每次请求结束后所有资源都被释放长连接服务需要进程常驻内存并监听端口这正是 Workerman 做的事情。它用 CLI 模式启动一个常驻 PHP 进程内部通过事件循环监听端口所有连接对象一直存活在内存里。对比 SwooleSwoole 性能确实更强但它是 C 扩展安装时得匹配 PHP 版本有些编译环境还过不去Workerman 是纯 PHP 实现的事件驱动框架只要 PHP 环境正常composer install 之后就能跑。对大多数中小型在线聊天系统来说Workerman 完全够用调试时直接看 PHP 日志就行不用碰 C 层。这套源码在 PHP 8 下运行更稳内存占用比 PHP 7 下还要小一些这也是 2025 年做新项目我一般直接建议上 PHP 8 的原因。Workerman 的运行模型对新手很友好进程启动后不退出每来一个连接触发 onConnect 回调每来一条消息触发 onMessage 回调开发者只需要把这些回调函数填好不用关心底层 epoll 怎么调度。与传统 PHP-FPM 最大的区别是连接对象一直存活你可以直接把用户信息挂在 $connection-uid 属性上下次消息来了直接读省去了每次请求都要查库的步骤。2.3 源码目录解读与启动流程这套源码的目录结构基本是这种布局chat-server/ ├── app/ │ ├── ChatHandler.php # WebSocket 连接与消息处理 │ ├── Auth.php # 登录鉴权逻辑 │ └── OfflineMessage.php # 离线消息存取 ├── config/ │ └── database.php # MySQL 连接配置 ├── public/ │ └── index.html # 前端聊天页面 ├── start.php # Workerman 入口文件 └── vendor/ # Composer 依赖入口文件 start.php 里的核心逻辑是声明一个 WebSocket 协议的 Worker指定监听地址和端口?php require_once __DIR__ . /vendor/autoload.php; use Workerman\Worker; use Workerman\Timer; $worker new Worker(websocket://0.0.0.0:8080); $worker-count 4; // 启动 4 个进程 $handler new ChatHandler($worker); $worker-onConnect [$handler, onConnect]; $worker-onMessage [$handler, onMessage]; $worker-onClose [$handler, onClose]; $worker-onWorkerStart function () { Timer::add(5, function () { // 每 5 秒扫描一次心跳见 3.3 节 }); }; Worker::runAll();代码里的参数要解释一下websocket://0.0.0.0:8080 表示监听所有网卡的 8080 端口0.0.0.0 可以保证服务器内网、外网都能连上用 127.0.0.1 就只能本机访问生产环境靠 Nginx 反代时也建议监听 0.0.0.0。count 表示 Worker 进程数一般按 CPU 核数设置4 个进程代表可以同时处理 4 倍于单进程的连接和消息。启动流程就两条命令先装依赖再启动cd chat-server composer install php start.php startcomposer install 会拉取 Workerman 框架到 vendor 目录php start.php start 是前台运行模式日志直接打印在终端开发调试用这种线上要加 -d 参数表示守护进程模式php start.php start -d。想停服务就执行 php start.php stopWorkerman 会自动管理进程的启停不用手动 kill。3. 核心功能实现登录鉴权、消息收发与心跳保活3.1 登录鉴权把 uid 绑定到连接聊天的前提是给每个连接一个“身份证”。前端先通过 HTTP 接口登录拿到 token再把这个 token 作为 WebSocket 连接的第一条消息发到服务端服务端校验通过后才允许收发消息。看 ChatHandler 里 login 分支的代码public function onMessage($connection, $data) { $data json_decode($data, true); switch ($data[type] ?? ) { case login: $uid (int)$data[uid]; $token $data[token] ?? ; if (!Auth::check($uid, $token)) { $connection-send(json_encode([type error, msg 鉴权失败])); $connection-close(); return; } $connection-uid $uid; $this-bindUid($connection, $uid); $connection-send(json_encode([type login_ok, uid $uid])); break; // 其他分支见 3.2、3.3 } }这个片段的逻辑是强制先登录再干活。$connection-uid 是把用户 ID 挂在连接对象上以后这条连接再发来任何消息直接用这个属性就能知道是谁发的。bindUid 方法会把 uid 和连接对象放进一个全局数组这就是连接池别人发消息时靠它找到目标连接。参数方面token 由 HTTP 接口签发这里只做校验如果鉴权失败直接 close 连接防止匿名用户挂着占用资源。这里有一个实际项目中容易踩的细节token 校验不要放在 WebSocket 连接建立时因为 URL 参数会出现在日志里。放在第一条 login 消息里传比 url 参数方式安全得多。另外同一个用户从两台设备同时登录时后面登录的会顶掉前面的连接这是常见设计也是下一段要处理的问题。3.2 消息收发单聊推送与离线兜底聊天最核心的分支是 chat处理逻辑是“先判断对方在不在线在线直接推不在线写离线表”case chat: $fromUid $connection-uid ?? 0; $toUid (int)$data[to_uid]; $content htmlspecialchars(trim($data[content]), ENT_QUOTES, UTF-8); if (!$fromUid || !$toUid || $content ) { $connection-send(json_encode([type error, msg 参数不合法])); return; } $toConn $this-getConnectionByUid($toUid); if ($toConn) { $toConn-send(json_encode([ type chat, from_uid $fromUid, content $content, time time() ])); } else { OfflineMessage::push($toUid, $fromUid, $content); } $connection-send(json_encode([type chat_ack, msg_id $msgId])); break;逻辑说明很关键内容做 htmlspecialchars 过滤是因为聊天内容会回显到其他用户的浏览器不过滤的话别人发一段 script 就能在对方页面上执行脚本这是聊天系统最常见的漏洞。接收方在线就直接推送不在线则把消息存入数据库离线表等对方上线时补拉。最后给发送方回一个 chat_ack 确认表示服务端已经收到了这个回执对前端判断“消息是否发送失败”很重要。参数方面to_uid 是接收方用户 ID0 可以预留为群聊标识当 to_uid 为 0 时遍历连接池向所有在线连接广播代码逻辑一样只是目标从单个连接变成多个。msg_id 是消息在数据库里的自增主键返回给前端用于消息去重。这个设计保证即使前端重发也能识别出重复消息。3.3 心跳保活清理僵尸连接WebSocket 虽然保持长连接但中间代理设备会掐掉空闲连接。常见做法是客户端每 30 秒发一次 ping服务端回 pong服务端再用定时器扫描超过 60 秒没心跳的连接并关闭。服务端代码case ping: $connection-lastPing time(); $connection-send(json_encode([type pong])); break; // onWorkerStart 里注册的定时器 Timer::add(5, function () { foreach ($worker-connections as $conn) { if ($conn-lastPing time() - $conn-lastPing 60) { $conn-close(); } } });心跳的意义不只是保活更重要的是把死连接从连接池里清出去。客户端直接断网时TCP 层面不一定立刻感知连接会以僵尸状态留在连接池里消息发过去不报错但永远送不到。60 秒没心跳的就按掉线处理客户端自己重连重连后重新走 login 流程。参数上客户端 ping 间隔设为 30 秒服务端超时设为 60 秒刚好留出一次网络抖动的余量。前端配合的写法也很简单const ws new WebSocket(ws:// location.host :8080/); ws.onopen () ws.send(JSON.stringify({ type: login, uid: 1001, token: localStorage.getItem(token) })); setInterval(() { if (ws.readyState 1) { ws.send(JSON.stringify({ type: ping })); } }, 30000);前端在 30 秒间隔里检查 readyState 为 1OPEN才发送避免连接没建好就盲目发 ping。如果心跳丢失客户端要主动重连。这里给一个实际经验重连前先删除旧 WebSocket 对象再创建一个新的否则旧连接的 onmessage 回调可能和新连接交叉触发导致消息重复。4. 数据层设计消息表结构、离线补偿与已读回执4.1 核心表结构用户表与消息表的设计要点聊天系统最少需要两张表users 存用户messages 存消息。先看建表语句CREATE TABLE users ( id int unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password_hash varchar(255) NOT NULL COMMENT 密码哈希, nickname varchar(50) NOT NULL DEFAULT COMMENT 显示昵称, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE messages ( id bigint unsigned NOT NULL AUTO_INCREMENT, from_uid int unsigned NOT NULL COMMENT 发送方, to_uid int unsigned NOT NULL COMMENT 接收方, 0 表示群聊, content text NOT NULL COMMENT 消息内容, msg_type tinyint NOT NULL DEFAULT 1 COMMENT 1文本 2图片 3文件, is_read tinyint NOT NULL DEFAULT 0 COMMENT 0未读 1已读, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_to_read (to_uid, is_read, id), KEY idx_from_to (from_uid, to_uid, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表设计的核心在 messages 表。to_uid 为 0 时代表群聊广播如果要支持多个群可以再加一张 room 表和 room_member 关联表但一对一的场景这两张表就够了。msg_type 字段预留图片、文件类型实际使用时 content 存文本内容图片消息存 URL 地址就行。charset 用 utf8mb4 是因为要存 Emojiutf8 字符集存不了四个字节的 Emoji 编码。索引 idx_to_read 是给“查未读”这条 SQL 服务的查询条件是 to_uid 和 is_read排序按 id联合索引刚好覆盖没有这个索引消息多了之后离线拉取就是全表扫描。刚做聊天系统的时候我看到不少项目在这里翻车——消息量过万后接口响应直接飙到秒级。4.2 离线消息补偿拉取与置已读要在一个事务里离线消息最大的坑是拉取了没置已读会导致每次上线都重复拉取同一条消息。正确的做法是把“查未读”和“批量置已读”放在一个事务里$db-beginTransaction(); $rows $db-query( SELECT id, from_uid, content, msg_type, created_at FROM messages WHERE to_uid ? AND is_read 0 ORDER BY id ASC LIMIT 200, [$uid] ); $ids array_column($rows, id); if ($ids) { $db-query( UPDATE messages SET is_read 1 WHERE id IN ( . implode(,, $ids) . ) ); } $db-commit();逻辑说明事务保证两个操作要么都成功要么都失败不会出现“拉到了但没标记已读”或“标记了但没拉到”的中间状态。$ids 来自查询结果都是整数直接拼进 IN 子句不会引入注入风险但从代码整洁角度看框架支持参数绑定就尽量用绑定。LIMIT 200 是兜底防止离线几个月消息上万条把接口打爆。参数说明ORDER BY id ASC 保证历史消息顺序不乱LIMIT 200 一次最多拉 200 条多于 200 条让前端循环拉取或者进入会话后再按时间分页加载。这里标识一下个人习惯我一般会在前端拉完离线消息后再调用一次接口更新会话列表里的最后一条消息摘要这样页面打开时各聊天列表的排序是准的否则会出现列表顺序还是上线前旧数据的错觉。5. 部署与避坑Nginx 反代、进程守护与五个经典排查5.1 生产部署Nginx 反代 WebSocket 与 systemd 守护生产环境一般不能让客户端直连 8080 端口需要 Nginx 反代核心是把 HTTP 升级头传给后端map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 80; server_name chat.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } }map 块的作用是把请求的 Upgrade 头映射给 Connection 头这在 WebSocket 反向代理里是必须的否则 Nginx 默认用 close 会导致握手失败。proxy_read_timeout 设 3600 秒是防止 Nginx 在客户端长时间不聊天的场景下自动掐掉空闲连接——默认值只有 60 秒聊天系统不改这个配置必被断开。客户端连接地址写成 ws://chat.example.com/Nginx 自动把升级协议转发给 Workerman 监听的 8080。启用 HTTPS 的话前端连接要用 wssNginx 的 443 server 配置里带上同样的 proxy_set_header 即可其他逻辑不变。服务进程要随系统自启用 systemd 配置守护[Unit] DescriptionPHP Chat Server Afternetwork.target [Service] ExecStart/usr/bin/php /www/wwwroot/chat/start.php start -d Restartalways RestartSec3 Userwww [Install] WantedBymulti-user.targetRestartalways 保证进程崩溃后 3 秒自动拉起这是聊天服务运维里必须的一步。把文件放到 /etc/systemd/system/chat.service执行 systemctl enable chat 和 systemctl start chat 就能管理服务。提示生产环境记得在云安全组放行 WebSocket 端口如果用了 Nginx 反代公网只放 80/443 即可8080 端口不要暴露到公网。5.2 部署与使用中的常见问题排查1. 现象前端 WebSocket 连接一直报 400 Bad Request握手失败。原因Nginx 反代没有传 Upgrade 和 Connection 头Workerman 收到的是普通 HTTP 请求拒绝了协议升级。 解决在 location 块加上 proxy_set_header Upgrade $http_upgrade 和 proxy_set_header Connection $connection_upgrade改完重载 Nginx。初次部署这套源码时这个坑几乎必遇属于没改默认配置导致的。2. 现象连接能建立但大约 60 秒后自动断开前端日志出现 close 事件。原因Nginx 默认 proxy_read_timeout 是 60 秒前端心跳间隔超过这个值空闲连接被代理切断。 解决把 proxy_read_timeout 调到 3600 秒以上同时确认前端心跳间隔在 30 秒左右。两个参数配合才能保证连接稳定存活。3. 现象Linux 上执行 php start.php start 报错 “pcntl_fork has been disabled”。原因PHP 编译时或者 php.ini 的 disable_functions 里禁用了 pcntl 系列函数Workerman 多进程依赖 pcntl_fork 创建子进程。 解决检查 php.ini 中 disable_functions 配置去掉 pcntl_fork或者在 php -m 命令输出里确认 pcntl 扩展是否加载。用 php 网站调试工具排查时先执行 php -m 看一眼扩展列表比反复猜快得多。4. 现象消息偶尔丢失服务端日志能看到消息进来但对方没收到。原因count 设为 4 时同一个 uid 的两次连接可能被分到不同进程而连接池在每个进程内各自维护跨进程查不到对方连接还有一种情况是同 uid 旧连接没关闭消息被路由到旧连接导致送不到。 解决登录时先检查连接池里有没有同 uid 的旧连接有就先 close 掉保证一个 uid 同时只有一条有效连接。多进程场景可以用 Redis 共享用户路由映射连接对象无法跨进程但 uid 到进程的映射可以共享。5. 现象服务器重启后聊天系统访问不了服务端也没有任何报错。原因Workerman 是命令行进程没有配置开机自启服务器重启后进程就没了。 解决按 5.1 节配置 systemd 服务并设置 Restartalways命令写成 php start.php start -d 守护进程模式。这是部署阶段最容易忽略的一步踩过一次之后就会长记性。6. 扩展思路Redis 广播、多进程路由与场景定制6.1 多进程下的消息路由用 Redis Pub/Sub 做广播当 worker count 大于 1 时客户端连接分散在多个进程里每个进程各自维护一份连接池。A 进程里的用户给 B 进程里的用户发消息直接查本地连接池会落空。最简单的解决方式是引入 Redis Pub/Sub每个 Worker 进程订阅同一个频道收到消息事件后查询自己进程内的连接池有目标就推送$worker-onWorkerStart function () use ($worker) { $redis new Redis(); $redis-pconnect(127.0.0.1, 6379); $redis-subscribe([chat_channel], function ($redis, $channel, $msg) { $data json_decode($msg, true); $conn $this-getConnectionByUid($data[to_uid]); if ($conn) { $conn-send($msg); } }); };这里要强调 subscribe 回调里不能执行阻塞函数否则整个 Worker 进程的事件循环会被卡住这是用 Workerman 配合 Redis 最常见的坑。pconnect 用长连接模式避免每条消息反复握手。这套方案实现简单代价是每条消息在每个进程里都要过一遍连接量到万级之后再考虑用 Redis 做一致哈希路由但中小项目完全够用。6.2 场景定制客服转接、已读回执与消息类型扩展私人聊天只是起点。把这套源码改造成客服系统核心加一条 enter_room 消息用户进入会话后服务端把连接分配到客服队列客服空闲则直接接入否则进入排队。已读回执可以在 messages 表加一个 read_at 字段前端发送已读确认时更新不依赖 is_read 一个字段扛所有状态。msg_type 字段已经预留了 2图片和 3文件前端发送时把 content 替换成 URL服务端逻辑不用动。说一个我做客服项目的教训之前把 is_read 更新放在每次 onMessage 里处理用户挂断连接后消息状态一直是未读客服追问用户“怎么不回我”其实用户早看到了。从那以后我每次做在线聊天离线补偿和已读回执都强制按“事务拉取 批量置位”这个模式来。聊天系统翻车多半不是长连接的问题而是消息状态整理得不够干净。这套源码把这些基础都搭好了你拿到手直接改业务层希望能帮到你。本文还有配套的精品资源点击获取