Forge默认部署必然停机,因其deploy脚本顺序覆盖current目录:git pull直接修改文件,composer install时可能被PHP-FPM中断,migrate在新代码加载后执行导致新旧逻辑混用,且未隔离.env和storage/,无releases/版本目录与原子切链机制。

Forge 本身不支持零停机部署,Envoyer 虽设计为此目的但已停止维护;真正在生产环境稳定落地的零停机,必须手动实现符号链接切换 + 迁移前置 + 共享路径隔离三要素,缺一不可。
为什么 Forge 默认部署必然有停机
Forge 的 deploy 脚本本质是顺序覆盖当前目录:git pull 直接改 current 下的文件,composer install 写入 vendor/ 时可能被 PHP-FPM 中断,php artisan migrate 在新代码加载后执行,旧请求仍调用老逻辑却连新表结构 —— 这些都会触发 Class not found、SQLSTATE[42S22] 或 500。
- 它不创建带时间戳的
releases/目录,无法保留旧版本用于回滚 -
current是软链,但 Forge 默认不设这个链,而是直接把代码拉到项目根目录(即/home/forge/example.com),等于没用软链机制 -
.env和storage/每次都随git pull被覆盖,配置丢失、上传文件消失
Envoyer 的“零停机”开关只是个幻觉
Envoyer 界面里勾选 Zero Downtime Deployment 不代表自动生效。它只在你配对了共享路径、把迁移放在 Deploy 阶段(而非 After)、且迁移本身向前兼容的前提下才起作用。
- 若没手动把
.env和storage/映射到shared/目录,新 release 启动时就读不到配置,直接报APP_KEY is not set - 若迁移写在
After阶段,current已切过去,但旧 worker 还在跑,新迁移删了字段,旧代码 INSERT 就崩 - Envoyer 不校验迁移兼容性 —— 它不会阻止你提交一个
dropColumn('email')的迁移,而线上还有用户正注册
真正能跑通的零停机,得自己搭骨架
核心就三步:建 releases/ 目录、复制共享文件、原子切链。Forge 可以帮你配好服务器和 supervisord,但部署逻辑必须重写。
- 每次部署生成新目录:
releases/$(date +%Y%m%d%H%M%S),用git clone --depth=1拉代码 - 从
shared/.env复制配置,再cp -r shared/storage ./storage,然后php artisan storage:link - 在新目录内运行
php artisan migrate --force,确认无破坏性变更后再切链:ln -nfs releases/20260514170500 current - 最后 reload php-fpm(不是 restart):
sudo systemctl reload php8.2-fpm,并重启队列:sudo supervisorctl restart myapp-queue
容易被忽略的致命点
很多人切完链就以为完事了,结果第二天发现队列任务还是旧逻辑、缓存读的是上个 release 的 config、Nginx 返回 404 —— 因为这些不是靠切链就能更新的。
-
supervisord的command必须写成/home/forge/example.com/current/artisan queue:work,不能写死绝对路径 -
php artisan config:cache和view:cache必须在新 release 目录里执行,否则缓存文件里还存着旧路径 -
storage:link要在新 release 目录里运行,不然 public/storage 指向的是旧 release 的 storage - Nginx 的
root必须指向/home/forge/example.com/current/public,不能是/home/forge/example.com/public


















