Laravel 9升级实战:PHP 8与Symfony Mailer带来的关键变更
2026/9/24 22:34:54 网站建设 项目流程

先说结论:Laravel 9.x 是圈里讨论度很高的一次大版本升级,不只是版本号从 8 跳到 9,而是它第一次把 PHP 版本门槛提到了 8.0,并且把底层邮件库从 SwiftMailer 整个换成了 Symfony Mailer。对老项目来说,这两件事意味着升级不是改一个版本号那么简单。我最近把一个跑了两年多的商城后台从 Laravel 8 升到 9,过程中查了大量资料,也踩了几个典型的坑,这篇就把核心特性和升级实操一起梳理出来,给准备动手升级的朋友做个参考。文章不会写得太教科书,主要是我实际环境里的操作记录和排查经验,适合项目负责人、后端开发以及正准备从旧版本迁移的团队看。

1. 升级前先读懂:9.x 到底改了哪些东西

1.1 PHP 版本门槛与依赖体系变化

Laravel 9 要求 PHP 8.0 以上,官方推荐直接用 8.1。这看起来只是版本号要求,实际影响的是整个运行环境。如果线上还在 PHP 7.4,那问题不光是 Laravel 升级,而是服务器环境得先整体更新一套。PHP 8.x 相比 7.4 的性能提升非常明显,JIT 在 8.0 引入,8.1 又带来了枚举、readonly 属性等语言特性,这些都为后面 Laravel 的新功能打下了基础。

但运维层面要考虑的事情就多了。PHP 版本一升,很多老扩展需要重新编译或换新的兼容版本。我这次升级前就遇到过 xdebug 版本太旧导致 IDE 调试直接失效的情况,imagick 这类扩展到 PHP 8.1 下也踩了编译报错。建议升级前先在测试环境把 php -m 列出来的扩展逐个确认过一遍,特别是 opcache、redis、pdo_mysql、mbstring 这几个高频扩展。

依赖体系方面,Laravel 9 把 Symfony 组件整体提升到了兼容 5.4 和 6.x 的层次,新项目默认跑在 6.x 上。如果你在代码里直接依赖过某些 Symfony 组件,升级后接口变化会比较明显。另外,Flysystem 从 2.x 升到了 3.x,如果项目里自定义过文件系统适配器或者第三方存储扩展,这部分也需要重点排查。我的经验是,不要只盯着 Laravel 本身的 release note,底层依赖的升级说明同样要花时间看,很多隐藏问题都出在自以为没改动的间接依赖上。

1.2 新装项目与老项目默认配置的差异

还有一个很容易被忽略的变更:新装的 Laravel 9 项目,默认缓存驱动从 file 换成了 database。这是老项目升级时最容易产生“为什么别人没事,我这里有 bug”的差异点。

为什么官方要改成 database?我的理解是,数据库驱动在分布式部署和负载均衡多实例场景下更通用,不需要额外装 Redis 扩展,开箱即用。而 file 驱动在多台机器共享会话、缓存时会出问题,除非你把缓存目录挂到共享存储上。官方默认值选择 database,主要是为了降低新手部署时的认知负担。如果你是升级旧项目,不是新装,默认配置不会自动变化,但如果想让配置和新项目保持一致,执行两步操作就行:先运行 php artisan cache:table 生成缓存表迁移文件,然后 migrate,最后把 config/cache.php 里的 default 改成 database。

这个变更的连锁反应是,新项目的迁移文件数量和老项目会有差异。我自己习惯在做版本对比的时候,直接新建一个空的 Laravel 9 项目,把数据库迁移文件目录和 config 目录挨个跟老项目比对一遍,哪些是新增的、哪些被改了,一目了然,这样做比看文档更直接。

1.3 官方废弃和移除的功能清单

Laravel 9 把一批原本在 8.x 里标记为废弃的方法直接移除了,同时新废弃了一批功能。最让我印象深刻的是 route:cache 被标记为废弃,官方建议不再使用。以前很多团队喜欢部署时用 route:cache 提升路由加载速度,但它在闭包路由上一直有问题,而且部署时容易产生缓存不刷新的陷阱。Laravel 9 后期版本对命令本身做了 deprecate 处理,到 Laravel 10 就直接移除了。

其他需要留意的移除项包括:dispatchNow() 改名成 dispatchSync() 后在 9.x 里彻底移除,Route::home() 这类冷门快捷方法也删了。还有控制器路由命名空间前缀不再自动解析,这个影响范围比较大,后面实操章节我会专门展开。升级前可以用 grep 全局搜一遍这些老写法,把代码层面的雷提前排掉,比等到上线报错再处理要省心得多。

2. 头号破坏性变更:邮件系统从 SwiftMailer 全面换成 Symfony Mailer

2.1 Laravel 为什么决定换掉 SwiftMailer

SwiftMailer 这个库从 2021 年后基本停止维护了,而 Symfony 旗下的 Mailer 组件一直在活跃更新,支持更多现代邮件规范,比如 DKIM 签名、自定义 header、多种 transport 组合。Laravel 9 干脆把整个邮件子系统从 SwiftMailer 迁移到了 Symfony Mailer。这是一个彻底的底层替换,对业务代码的影响程度取决于你之前是怎么用邮件的。

如果你只是通过 Mail facade 的 Mail::to()->send() 方式发邮件,或者使用 Mailable 类,那影响其实很小,因为 Laravel 在中间做了一层封装,对外 API 基本没变。但如果你在代码里直接 new 过 Swift_Message、Swift_Attachment,或者自定义过 Swift Transport,那升级后会直接报 class not found 之类的错误,这类代码必须手动改掉。

2.2 升级后邮件相关代码要怎么改

先说最常见的老写法:

// Laravel 8 及之前的常见写法 $message = (new \Swift_Message('测试邮件')) ->setFrom(['noreply@example.com' => '系统通知']) ->setTo(['user@example.com']) ->setBody('邮件正文', 'text/html'); Mail::send($message);

这段代码在 Laravel 9 下必然报错,因为 Swift_Message 类已经不存在了。我当时的改法是直接用 Mailable 类,或者换用 Symfony Mailer 的 Email 对象:

use Symfony\Component\Mime\Email; $email = (new Email()) ->from('noreply@example.com') ->to('user@example.com') ->subject('测试邮件') ->html('<p>邮件正文</p>'); Mail::send($email);

但实际项目中,我建议所有邮件逻辑尽量收敛到 Mailable 类里维护,不要散落在控制器和 Service 层。这样升级时只需要处理 Mailable 内部实现,业务调用方不用动。

另一个容易踩的坑是自定义 transport。如果项目里为了实现某些特殊发送通道而实现了 Swift_Transport 接口,升级后需要重写为 Symfony 的 TransportInterface,这两个接口的方法签名差别很大,不是简单改个类名就行。我当时是直接找了社区里维护好的第三方包来替代,省去了自己维护 transport 的成本,这是个比较稳妥的路线。

2.3 邮件配置和本地测试实践

config/mail.php 里的配置项大部分没变,MAIL_MAILER 仍然支持 smtp、log、sendmail、mailgun、ses、postmark 等驱动,只是底层实现换掉了。实际升级时注意一点:如果你之前用的是 MAIL_MAILER=mail 这种直发方式,在 9.x 下行为有变化,建议测试环境统一用 log 或者 smtp 指向本地测试服务,避免一封测试邮件真的发到真实用户邮箱里。

我本地测试邮件推荐用 Mailpit 或者 Mailtrap,部署在 Docker 里几分钟就能起来。在 CI 环境里用 log 驱动最省事,跑完看日志文件里的渲染结果。这里有一个容易出问题的地方:Symfony Mailer 对 header 的解析比 SwiftMailer 更严格,如果之前往邮件里塞过非标准 header,升级后可能发送阶段直接报错,或者收件方解析异常。回归测试时要重点检查带附件、带抄送密送、使用模板渲染的邮件,这三类是最容易出问题的场景。

3. 值得升级的新特性:不只是底层换血

3.1 匿名迁移类让数据库迁移更清爽

Laravel 9 引入了匿名迁移类,把原来具名的迁移类改成 return new class extends Migration 的形式。新老写法对比如下:

// 老写法:每个迁移文件一个类名 class AddStatusToOrdersTable extends Migration { public function up() { Schema::table('orders', function (Blueprint $table) { $table->string('status')->default('pending'); }); } public function down() { Schema::table('orders', function (Blueprint $table) { $table->dropColumn('status'); }); } }
// Laravel 9 推荐写法:匿名类 return new class extends Migration { public function up() { Schema::table('orders', function (Blueprint $table) { $table->string('status')->default('pending'); }); } public function down() { Schema::table('orders', function (Blueprint $table) { $table->dropColumn('status'); }); } };

匿名迁移最大的价值是避免类名冲突。团队协作时,两个成员如果都建了一个名叫 AddStatusToXXX 的迁移类,合并代码后类名重复会直接报错。匿名类从根本上消除了这个问题,因为 PHP 对匿名类的类名是自动生成的。老项目里已有的迁移不强制改,但新写的迁移建议统一用这个风格。

3.2 枚举在路由和验证里的实战用法

PHP 8.1 原生枚举出现后,Laravel 9 第一时间支持了在路由模型绑定和验证规则里直接使用枚举。这个特性在实际业务中特别香,尤其是状态机比较多的系统。

例如一个订单状态枚举:

enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Cancelled = 'cancelled'; }

路由可以直接绑定枚举:

Route::get('/orders/{status}', function (OrderStatus $status) { return $status->value; });

验证规则可以直接校验枚举值:

$request->validate([ 'status' => ['required', Rule::enum(OrderStatus::class)], ]);

以前校验状态字段只能写 in:pending,paid,cancelled,一旦枚举加一个值就很容易漏改验证规则。现在枚举作为单一事实来源,状态增加时验证规则自动跟进,少了一类低级 bug。如果你项目里涉及审批流、订单流转这种状态很多的场景,这个特性配合枚举是实实在在提升效率的。

3.3 类型化查询构建器对工程化的意义

Laravel 9 给查询构建器和 Eloquent Builder 加了 PHPDoc 泛型标注,配合 PHPStan 这类静态分析工具,可以在编译期发现 where 条件字段拼写错误、模型类型不匹配这类问题。这个特性的价值不太好用一句话说清,但对工程化团队来说是质变级别的提升。

我自己的体会是,以前写 ->where('user_id', $id) 的时候,user_id 是字符串,拼错了也只是运行时报错,测试覆盖不到就是线上事故。现在有类型标注加持,静态分析能在提交代码前就揪出这类低级错误。如果你的项目已经有 PHPStan 或 Psalm 的基础,升级 9.x 之后可以把泛型标注利用起来,成本低收益高。

3.4 前端脚手架和开发体验改善

Laravel 9 的 Breeze 和 Jetstream 都重新设计了,默认 Tailwind 的 UI 做得更精致,Breeze 也支持了带 Livewire 或 Vue 的变体。如果你是从零开始的新项目,直接 Laravel 9 加 Breeze 搭出一套带登录注册的基础骨架,比自己手写认证逻辑快非常多。

Jetstream 这边增加了团队管理、API 令牌、暗色模式等开箱即用的能力,适合对后台权限体系要求不高的项目快速起步。不过我个人建议,如果项目对权限模型有个性化要求,还是不要全部照搬 Jetstream 的 team 体系,它的数据库结构是预设的,后面再改成本很高。这属于经验之谈,刚上手的时候总觉得现成的就是好的,真到了业务复杂阶段才意识到定制成本。

4. 从 8.x 升到 9.x 的实操记录

4.1 升级前准备和 composer 依赖调整

升级前一定要做三件事:代码仓库打好 tag、数据库备份到位、跑一遍现有的自动化测试拿到基线结果。没有测试基线的项目,升级之后出了问题都无法判断是代码改坏了还是本来就坏着。

然后开始改 composer.json。核心是把 laravel/framework 从 ^8.0 改成 ^9.0,同时检查 phpunit/phpunit 版本要能支持 PHP 8.0 以上。如果用了很多第三方包,建议把这些包的版本要求也一并放宽,再统一更新:

composer update -W

这里我特别强调用 -W 参数,因为直接 composer require laravel/framework:^9.0 常常会触发依赖冲突,还不如一次性让整个依赖树重新解析。我第一次升级的时候没加 -W,结果 Laravel 核心包升上去了,十几个第三方包还停留在老版本,运行起来全都是不兼容报错。后来把 composer.lock 删掉重新 composer update -W,问题才彻底解决。

4.2 缓存驱动 database 的处理

如果你决定跟随新项目的默认配置,把缓存驱动从 file 改成 database,步骤很清晰:

php artisan cache:table php artisan migrate

然后修改 config/cache.php 里的 default 值为 database。这里要注意一个取舍:database 驱动对单机小项目完全够用,但在高并发场景下,数据库缓存的读写会占用数据库连接池,性能不如 Redis。如果你的系统访问量比较大,建议直接把缓存驱动切到 Redis,而不是改成 database。我自己的项目就是直接切到了 Redis,因为系统里 Redis 早就已经在用了,没必要再引入一个缓存存储。

另一个容易被忽略的点是 session 的驱动。缓存驱动并不影响 session,session 是单独配置的。如果你之前用的是 file session,多实例部署下还是会有问题,升级过程中顺便把 session 驱动也一起规划进去,比以后单独处理要省事。

4.3 处理控制器命名空间导致的 404

这是 Laravel 9 升级里影响力最大、也最隐蔽的变更。RouteServiceProvider 里默认不再设置受保护的 $namespace 属性,路由文件里以字符串形式写的控制器引用不再自动拼接完整的 App\Http\Controllers 前缀。

老项目里常见的写法:

Route::get('users', 'UserController@index');

升级后,如果 RouteServiceProvider 没有特殊处理,这种路由大概率直接 404。推荐改成完整类引用:

use App\Http\Controllers\UserController; Route::get('users', [UserController::class, 'index']);

注意这里的 [UserController::class, 'index'] 是数组语法的 action 字符串,不是闭包,Laravel 会自动解析。升级检查时,我建议全局搜一下 -> 和 Controller@ 这种字符串路由模式,凡是匹配到的都要改成新语法。

这个改动不仅影响路由文件,还会影响 route('xxx') 生成 URL、以及控制器中间件里使用 controller@method 形式的地方。我之前就遇到一个定时任务里用字符串形式调用控制器方法,升级后直接执行失败,排查半天才发现是路由辅助函数解析出了问题。所以这块务必提前清理干净。

4.4 升级后的回归测试清单

升级完成后,我习惯按下面的顺序跑一遍回归,每个环节都不能省:

  • 跑一遍完整的 php artisan test,确认自动化测试全绿
  • 手动验证登录注册,重点测密码规则变化带来的影响
  • 把所有涉及邮件发送的流程走一遍,包括注册验证、密码重置、订单通知
  • 检查队列任务能否正常消费,推荐在测试环境用 sync 驱动跑通后再切回真实驱动
  • 验证缓存读写是否正常,尤其是登录态和验证码这类高频缓存
  • 跑一下 route:list,确认路由数量和控制器绑定没问题
  • 最后把定时任务调度跑一次,看看有没有隐藏的字符串控制器调用

这个清单看着基础,但每一条我都出过问题。最夸张的一次是升级后邮件发不出去,排查了半小时才发现是自定义 Mailable 里用了 Swift_Attachment 静态方法,报错信息又不够直观,最后靠 grep 代码才定位到。

5. 常见问题与排查技巧实录

5.1 邮件发送报“Swift Mailer not configured”或类找不到

升级后邮件报错最常见的两类:

报错 Class 'Swift_Message' not found,说明代码里还有直接引用 Swift_* 的地方。解决办法是先全局搜索 swift_、Swift_ 这些关键字,把所有直接依赖 Swift 类的地方替换成 Mailable 或 Symfony Mime Email。因为 Laravel 9 底层邮件库已经从 SwiftMailer 换成 Symfony Mailer,Swift 类不再安装。

另一类是“Unknown mailer”或者“Expected configured mailer”,这通常是因为 config/mail.php 里有个自定义 mailer 配置项还依赖旧格式,或者某个第三方包在服务注册时引用了一台已经不存在的 mailer。出现这种问题,优先去检查 config/mail.php 里 mailers 数组的 key 和 MAIL_MAILER 环境变量是否对得上。

5.2 cache 表不存在

这个问题在新装项目上不太会出现,老项目升级后如果改过缓存驱动就很容易踩到。报错信息一般是:

SQLSTATE[42S02]: Base table or view not found: 1146 Table 'xxx.cache' doesn't exist

解决办法就是执行 php artisan cache:table 加 php artisan migrate,把 cache 表建出来。如果项目部署在容器环境,数据库迁移是自动执行的,那就要确保迁移任务里包含这个新生成的迁移文件,不要把它漏在本地。

5.3 控制器命名空间导致的 404 和 route:list 异常

升级后遇到路由 404,第一反应不要查服务器配置,先跑 php artisan route:list 看路由是否正常加载。如果某些路由没有出现在列表里,大概率是字符串控制器解析失败。排查思路是从 RouteServiceProvider 的 $namespace 属性开始,看看是否还在用老代码的写法。

这里有一个细节:如果项目里大量使用了字符串控制器路由,一次性全部改成完整类引用工作量不小。为了平滑过渡,可以在 RouteServiceProvider 的 boot 方法里手动做一次前缀处理,但我不建议长期依赖这种兼容写法,因为它只是延续了旧行为,后续如果别人不知道这个逻辑,会非常困惑。尽早改成显式引用才是正路。

5.4 密码验证规则导致测试数据失败

Laravel 9 的默认密码验证规则比 8.x 严格了不少,长度至少 8 位,还要求包含字母、数字等组合。如果项目测试初始化数据时用的弱密码,比如 password 这种,升级后登录可能直接 fail。

解决办法有两个方向:一是把所有测试初始数据的密码改成符合新规则的;二是在 AppServiceProvider 或专门的验证规则配置文件里自定义默认的 Password 规则,比如只要求长度,不要求必须包含特殊字符。具体怎么选,要看业务对密码强度的需求。我个人的做法是测试环境统一用 Password 生成器生成随机密码,既避免规则问题,也更接近真实用户行为。

5.5 升级后常见问题速查表

报错现象可能原因解决办法
Class "Swift_Message" not found代码里直接依赖 SwiftMailer 类全局搜索并替换为 Mailable 或 Symfony Mime Email
Unknown mailer / mailer not configuredconfig/mail.php 配置不一致检查 mailers 数组 key 与 MAIL_MAILER 环境变量
Table 'xxx.cache' doesn't exist缓存驱动为 database 但表未创建执行 cache:table 和 migrate
路由全部 404RouteServiceProvider $namespace 移除改用完整类引用或数组语法
The password field must contain...默认密码规则变严调整测试数据或自定义 Password 规则
队列任务不执行sync 与真实队列驱动行为差异检查 QUEUE_CONNECTION 配置,重新启动 worker

这张表是我这次升级过程中实际遇到的问题汇总,不是从文档里抄的。如果你也遇到了类似报错,按表格里的思路去排查,基本能在十分钟内定位到根因。

结尾留一个实际经验

升级 Laravel 9 的过程,最让我意外的不是底层邮件库的更换,而是很多看似不起眼的配置变更叠加在一起,产生的连锁问题远比单个变更复杂。比如缓存驱动变 database 影响性能、控制器命名空间移除导致 404、密码规则变严导致测试数据失效,这些问题单独看都不难解决,但一旦你在升级当天集中遇到,很容易被搞到心态崩溃。

我现在的习惯是,每次大版本升级都先在小项目或者分支上完整走一遍流程,把升级时间拉长到两三天,第一天只处理依赖和配置,第二天处理业务代码兼容,第三天跑回归和压测。很多人觉得升级就应该一鼓作气全部改完,实际上分阶段推进,反而能更早暴露问题,也方便用 git bisect 定位到具体是哪一次改动引入的新 bug。

最后再分享一个小技巧:官方升级指南页面其实提供了一份非常完整的变更清单,但纯文本阅读容易漏细节。我是把指南里的 breaking changes 部分逐条摘出来,做成一个 Markdown checklist,每处理一条就勾一条,最后对照着检查,基本不会漏掉隐藏的坑。这个办法我后来在同事项目里也推过,效果不错,推荐你也试试。

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

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

立即咨询