Laravel 9.x 发布已经过去很长时间了,但直到现在我依然经常在后端群里看到讨论“Laravel 9 到底改了什么”“要不要升级”这类问题。很多人把它理解成“8.0 加了点新功能”,其实完全不是一回事——Laravel 9.x 是 Laravel 历史上第一个把 PHP 8.0 作为硬性门槛的版本,也是底层依赖变化最剧烈的一次大版本。这篇文章我会从实际项目的角度,把 Laravel 9.x 的核心特性、升级经验和个人踩坑记录全部梳理一遍,写给正在评估要不要升级老项目的朋友,也写给刚入门想直接学最新版本的新人。
1. 版本底色:PHP 8.0 门槛与底层依赖重构
Laravel 9.x 发布于 2022 年 2 月 8 日,这个消息现在看有点“历史感”了,但它的很多设计思路一直影响到现在。官方当时明确提出,Laravel 9 是长期支持版本,安全修复会持续到 2024 年 2 月。不过比起支持周期,我更看重的是它对底层依赖的大换血——最低 PHP 版本从 7.3 直接跳到 8.0,同时全面拥抱 Symfony 6,邮件库从 SwiftMailer 换成 Symfony Mailer,文件系统抽象层升级到 Flysystem 3.x。这一连串变动意味着,升级到这个版本不是常规的功能追加,而是要把项目底层“地基”重新打一遍。
1.1 最低 PHP 8.0 门槛意味着什么
很多人看到“最低 PHP 8.0”没什么感觉,觉得反正现在服务器都装 PHP 8.1 了,这不是好事吗。但从软件工程角度看,这个决定把 Laravel 从“兼容性优先”转向了“主动拥抱新语言特性”的方向。PHP 8.0 带来的构造器属性提升、命名参数、match 表达式、JIT 等特性,让框架内部的代码风格有了明显变化,你去看 Laravel 9 的源码,会发现很多类不再写一堆protected $xxx然后在构造函数里逐个赋值,而是直接写在构造函数的参数列表里,代码干净了不少。
但这里有个隐蔽的坑:Laravel 9.x 虽然在语法上只要求 PHP 8.0,但很多核心新特性其实依赖 PHP 8.1。最典型的就是枚举转换,这个后面我会详细说。如果你服务器上只有 PHP 8.0,我建议直接升到 8.1,否则你会发现官方文档里的不少代码示例跑到你本地就报语法错误。我个人在部署环境里的做法是:只要项目用 Laravel 9,PHP 版本一律锁定 8.1 及以上,避免中途出现“本地能跑、线上挂”的尴尬。
另外要说的是str_contains、str_starts_with、str_ends_with这些原生字符串函数早在 PHP 8.0 就有了,Laravel 9 提供的Str::contains()底层其实也是包的这层逻辑,所以在 Laravel 9 里你可以放心混用原生函数和辅助函数,性能上差别不大,主要是编码风格统一的问题。
1.2 Symfony 6 与 Flysystem 3 的连带影响
如果问 Laravel 9 升级过程中最让开发者头疼的是什么,我会毫不犹豫说是底层的 Symfony 6 升级。Symfony 是 Laravel 底层最关键的第三方组件,负责 HTTP 内核、路由、控制台、邮件、事件分发等模块。Symfony 5 到 6 本身就有大量破坏性变更,这些变更会传导到 Laravel 的调用方式上。
邮件系统是受影响最明显的一处。旧版 Laravel 用 SwiftMailer 发邮件,代码里经常出现依赖Swift_Message的写法,比如在 Notification 里定义toMail($notifiable)时用$message->attach()追加附件。Laravel 9 全面切换到了 Symfony Mailer,表面上Mail::to()这些高层 API 没变,但如果你在项目里直接依赖了 SwiftMailer 的类,升级时就会到处报错。我自己踩过的一个例子是,之前在通知类里写过类似这样的代码:
public function toMail($notifiable) { return (new MailMessage) ->attach(public_path('documents/report.pdf'), [ 'mime' => 'application/pdf', ]); }这段代码在新版本里其实还是能用的,问题出在更底层的自定义 Transport 或者自定义 Mailable 类。如果代码里出现了use Swift_Attachment或者Swift_Message,那升级时必须改成 Symfony 的Symfony\Component\Mime\Email。邮件渲染、附件处理、错误回执的 API 都有变化,不能只替换 use 语句就完事,得对照官方升级文档逐个检查。
文件系统这边,Laravel 9 默认使用 Flysystem 3.x。大多数项目只是用Storage::disk('s3')->put()这类常规操作,影响不大。但如果你使用了自定义的 Flysystem Adapter,或者依赖了旧版 Flysystem 的FilesystemInterface,那就需要重写适配层了。另外,有些第三方的云存储扩展包如果不支持 Flysystem 3,在 Laravel 9 里也会出现方法签名不匹配的问题。遇到这种情况我的建议是优先换官方维护的适配包,而不是自己去 hack 底层接口。
2. 数据库与 Eloquent 层:匿名迁移、枚举转换与查询构造器重构
这一部分是我觉得 Laravel 9.x 最有价值的地方,也是普通开发者日常真正会用到的东西。数据库迁移、模型属性转换、查询构造器这一类功能,几乎每天都在写,官方在这些细节上的打磨,直接决定了我们的开发效率。
2.1 匿名迁移类:告别“类名冲突”这个历史问题
Laravel 9 默认生成的新迁移文件不再使用带类名的传统写法,而是返回一个匿名类。打开database/migrations随便找一个 9.x 创建的迁移文件,你会看到这种结构:
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('body'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('posts'); } };用匿名类的核心原因很简单:传统迁移类是通过文件名生成类名的,如果在不同目录或不同模块里建了两个类似类型的迁移,类名很容易重复,运行php artisan migrate时会直接报致命错误。这个坑在大型项目里非常常见,尤其是多人协作时,每个人各自创建迁移文件,类名来来回回就那么几个,Git 合并之后冲突就来了。
匿名类看起来只是语法层面的改变,但它实际上从根上消灭了“类名冲突”这个隐患。这里有一点要注意:匿名迁移的运行原理并不是依赖类名,而是依赖文件名的时间戳前缀和迁移记录表migrations里的记录。所以你升级老项目时,不需要把所有旧迁移都改成匿名类,Laravel 9 依然兼容传统带类名的迁移文件。我见过有人为了“统一风格”把项目里几百个迁移全部重写成匿名类,结果把自己折腾到崩溃,还容易在文件改名时弄丢迁移记录。完全没有必要。
2.2 Eloquent 枚举转换:让业务状态从“魔法字符串”变成强类型
Laravel 9 在 Eloquent 模型里正式支持了 PHP 8.1 枚举的原生转换。这句话听起来很学术,落到实处就是你可以把原来存在数据库里的字符串状态字段,直接对应到一个 PHP 枚举类上,然后在模型里声明转换关系,之后读出来的字段自动变成枚举对象,写入时也自动完成值转换。
用法很简单,先定义一个 backed enum:
<?php namespace App\Enums; enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Shipped = 'shipped'; case Completed = 'completed'; case Cancelled = 'cancelled'; }然后在模型里声明转换:
<?php namespace App\Models; use App\Enums\OrderStatus; use Illuminate\Database\Eloquent\Model; class Order extends Model { protected $casts = [ 'status' => OrderStatus::class, ]; }这样你在业务代码里就能写出$order->status === OrderStatus::Paid这种强类型的判断,彻底告别if ($order->status == 'paid')这样难以追查的魔法字符串。数据库迁移时候字段建议直接存字符串,底层 Laravel 会自动调用enum实例的value做序列化和反序列化。需要注意,如果枚举定义的是纯枚举(非 backed enum),就不能直接做模型转换,Laravel 会要求你转型的对象必须实现Illuminate\Contracts\Database\Eloquent\Castable接口,或者使用带值的 backed enum。
我有一次线上事故就出在这里。当时一个订单状态字段原本只写了pending、paid、cancelled三个值,我用枚举转换后加了一个新状态,却忘记同步处理旧数据里脏数据的问题,某些历史订单里的状态是unknown,结果在读取$order->status时直接抛异常。所以强烈建议:使用枚举转换之前,一定要先在数据库里把历史数据的枚举值清洗一遍,否则一个脏数据就能让整条链路报错。
2.3 查询构造器接口与 Builder 层重构
Laravel 9 对查询构造器做了“契约化”改造,引入了Illuminate\Contracts\Database\Query\Builder接口,Eloquent 的 Builder 也实现了对应的Eloquent\Builder接口。对普通项目来说,这个改动不像匿名迁移那么直观,但对开发扩展包或者需要替换底层查询逻辑的团队来说,它可以带来更明确的边界。
说白了,以前的 Builder 是一个偏“具体类”的架构,你不太容易给它换一套完全不同的实现。Laravel 9 定义了接口,意味着你可以根据自己的数据库场景实现一套兼容的 Builder。比如某些团队用的是 ClickHouse、Doris 这类非关系型数据库,又想沿用 Eloquent 的调用风格,有了接口之后,替换起来相对干净。
另外 Laravel 9 在upsert方法上做了一些补强。之前upsert在批量插入时如果遇到唯一键冲突,可能会因为数据量过大导致 SQL 超长,新版本对批量 upsert 的数据容量也做了一定优化。我实际项目里就靠这个功能实现了“统计数据每日增量同步”,核心逻辑:
UserStat::upsert( $dailyStats, ['user_id', 'stat_date'], ['view_count', 'order_count', 'updated_at'] );要注意upsert的第二个参数是唯一标识字段,数据库表上必须建有对应唯一索引,否则这个 SQL 的行为会不符合预期,甚至产生重复数据。
3. HTTP 客户端并发池:请求效率的一次显著提升
Laravel 9 在 HTTP 客户端组件里加入了Http::pool()这个方法,解决了一个实际开发中非常常见的问题:当你的接口需要同时调用多个第三方服务,比如请求用户中心拿用户资料、请求订单中心拿订单列表、请求营销系统拿优惠信息,如果写串行代码,总耗时等于三个接口的耗时之和。有了并发池,这三个请求可以同时发出,总耗时几乎只取决于最慢的那个接口。
3.1 Http::pool 的用法与并发效果
代码写起来非常直观:
use Illuminate\Support\Facades\Http; $responses = Http::pool(fn ($pool) => [ $pool->as('user')->get('https://api.example.com/user'), $pool->as('order')->get('https://api.example.com/order'), $pool->as('coupon')->get('https://api.example.com/coupon'), ]); $user = $responses['user']->json(); $order = $responses['order']->json(); $coupon = $responses['coupon']->json();as()方法给每个并发请求起了别名,这样返回结果可以用别名来索引。如果没有给请求命名,返回的是一个按顺序排列的数组,用起来没那么直观。并发池内部是基于 Guzzle 的并发能力实现的,Laravel 官方没有强制限制最大并发数,但你在实际项目中最好自己控制一次发起的连接数量,比如最多 5 个。我见过有同事一次 for 循环里创建了 50 个并发请求,结果被对方服务商的反扒机制直接封锁了 IP。
并发池返回的每个响应对象都是Illuminate\Http\Client\Response的实例,所以你可以正常调用$response->ok()、$response->status()、$response->json()这些方法。但是要注意,如果某个请求发生了连接异常,比如超时或者 DNS 解析失败,这个槽位的响应对象是可以正常返回的,只是它的successful()会返回 false,同时failed()返回 true。所以代码里要做失败分支判断:
if ($responses['user']->failed()) { // 回退逻辑或者抛异常 }3.2 延迟和超时设置:并发不是万能的
并发池能调参的地方不多,但timeout一定要提前设置好。默认 Laravel HTTP 客户端的超时时间是 30 秒,如果并发池里有一个外部接口本来要超时,整个操作会一直挂着 30 秒才继续往下走,这在接口响应要求高的场景里是无法接受的。我通常会在创建请求时统一指定超时:
$responses = Http::pool(fn ($pool) => [ $pool->as('user')->timeout(5)->get('https://api.example.com/user'), $pool->as('order')->timeout(5)->get('https://api.example.com/order'), ]);另外,不要以为有了并发池就可以把项目里所有串行 HTTP 请求无脑替换成并发。并发请求对服务端的压力是成倍增加的,如果目标是同一个数据库或者同一个第三方 API,并发数控制不好反而会把对方打崩。稳妥起见,我会把并发池用在“多个不同服务”的聚合场景,而不是“一个大列表里的每个元素都需要发请求”那种场景。后者应该优先考虑合并接口,或者改用队列异步处理。
4. 开箱即用的开发体验:Sail、Breeze 与异常页面细节
Laravel 9 发布时还有一句宣传语叫“适合各种规模的项目”,这句话不是空话。框架在开发体验层面做了很多琐碎但重要的调整,让你从零开始跑一个项目变得更快、更省心。
4.1 Sail 成为默认开发环境:统一团队环境的神器
Laravel 9 官方文档把 Laravel Sail 设置为推荐的本地开发环境。它是一个基于 Docker Compose 的轻量级封装,不用你手动写 Dockerfile,只要执行几个命令就能拉起 MySQL、Redis、Mailpit、Selenium 等服务。
我在团队协作里吃过环境不一致的亏,比如有人本地是 MySQL 5.7,有人是 MySQL 8.0,最终导致线上才出现索引优化和行为差异。用了 Sail 之后,大家共用一套镜像和配置,这类“环境导致的问题”直接消失了。而且 Sail 的新手引导做得不错,你执行:
curl -s https://laravel.build/example-app | bash cd example-app ./vendor/bin/sail up就能跑起来一个完整的 Laravel 9 项目,连 PHP 都不用提前安装在宿主机上。这个过程对小白来说,比自己装 PHP、配 Nginx、装扩展要友好得多。当然,Docker 本身对 Windows 和 macOS 都有额外要求,老一点的电脑跑起来会比较吃力,但那是 Docker 的问题,不是 Laravel 的问题。
4.2 Breeze 提供了更完整的脚手架选项
Laravel Breeze 作为轻量级认证脚手架,在 9.x 里更新得也挺利索。它支持 Blade、Livewire、React、Vue 等不同前端方案,还加入了暗黑模式主题。如果你只需要登录、注册、密码重置这些基础认证功能,又不想引入重量级的 Jetstream,Breeze 是性价比最高的选择。
Breeze 还支持通过--api参数生成纯 API 模式的脚手架,只提供 Laravel Sanctum 的令牌认证,不生成任何 Blade 页面。我自己做前后端分离项目时,经常用这个命令快速起底数据库、用户表、认证接口和路由,然后在此基础上改业务,效率比从头写高很多。
4.3 异常页面与懒加载防护的细节变化
Laravel 9 的异常页面还是基于 Ignition,但交互上更精致了,支持暗黑模式,也能在页面直接查看请求和异常上下文。另一个值得注意的点是,开发模式下 Laravel 9 默认会开启 Eloquent 模型的懒加载防护。
什么叫懒加载防护?简单说,当你通过关系访问未加载的模型时,比如$order->user,如果$order模型没有预先with('user'),Laravel 会先发一条查询去数据库拿数据,这就是懒加载。在用户量小的时候没问题,但一旦在循环里使用懒加载,很容易产生 N+1 查询,数据库压力成倍增加。Laravel 9 在本地开发环境会主动抛一个警告,提醒你“这个模型没有加载 user 关系”,你可以说它“啰嗦”,但大多数情况下它是在帮我们提前发现性能隐患。
线上生产环境如果要启用这个保护,需要在 AppServiceProvider 里手动设置:
Model::preventLazyLoading(! app()->isProduction());这个设置千万别在生产环境一直开着,它的作用是“提醒”,不是“挡车”,如果你线上项目里有历史遗留的懒加载代码,开启保护会让业务直接异常。
5. 从 8.x 升级到 9.x:破坏性变更与避坑清单
官方虽然提供了升级指南,但真正操作起来问题远比文档里写的多。我陪团队做过好几个从 Laravel 8 升级到 9 的项目,总结出了一些高频问题,这里挑几个重点讲。
5.1 升级前务必备份和规划
先说句劝退的话:如果你只是想用新特性,不在乎社区支持周期,Laravel 8 继续跑着也没什么大问题。但如果你决定升,就不要跳着升,也不要只改composer.json里的版本号就完事。我的标准流程是:先在 Git 上拉一个升级分支,把线上数据库做一次完整备份,然后本地把项目整体跑起来,跑一遍核心功能测试,最后才动代码。
在浏览器里访问旧项目前,我习惯先执行:
composer update --dry-run看看到底会更新哪些依赖包,有没有版本冲突。这一步能提前暴露 PHP 扩展缺失、第三方包不兼容等基础问题。
5.2 破坏性变更速查:不是功能更新,是搬家式重构
Laravel 8.x 到 9.x 的破坏性变更,绝不是简单改几个方法名,而是整个依赖生态的迁移。升级过程中最值得先确认的变更,我整理成了下面这个速查表:
| 变更点 | 旧版本情况 | Laravel 9.x 情况 | 升级注意项 |
|---|---|---|---|
| PHP 最低版本 | 7.3 | 8.0(建议 8.1) | 先升级服务器 PHP 环境 |
| Symfony 组件 | 5.x | 6.x | 检查自定义扩展包兼容性 |
| 邮件库 | SwiftMailer | Symfony Mailer | 替换Swift_Message相关代码 |
| Flysystem | 2.x | 3.x | 检查自定义文件适配器 |
| 迁移类 | 传统类名 | 默认匿名类 | 旧迁移文件可以保留 |
| PHPUnit | 9.3 等 | 9.5+ | 更新测试依赖 |
| 维护模式 | php artisan down | php artisan down支持新增机制 | 无明显影响,但命令输出变了 |
| Eloquent 访问器 | getXxxAttribute | 支持新AttributeAPI | 新方法更灵活,旧写法仍兼容 |
表格列得越多,越能看出这是一次“底层搬家”。特别是如果你依赖了“非官方维护”的包,比如某个小众的支付扩展、某个旧版队列驱动,升级到 Laravel 9 之前一定要先去 Packagist 上看一下它有没有声明支持 Laravel 9。很多包停更在 Laravel 8 时代,升完依赖直接红一片。
5.3 我实际升级时踩过的坑
第一个坑是 PHP-FPM 的进程缓存。我们当时在composer update后,线上环境一直报某个类找不到,排查了很久发现 PHP 的 opcache 缓存了旧类映射,重启 PHP-FPM 后问题才消失。所以升级之后如果遇到莫名其妙的“类不存在”错误,第一件事先去重启 PHP-FPM 或者清理 opcache,而不是急着翻代码。
第二个坑是邮件队列任务的序列化方式。旧消息队列里可能积压了大量 Notification 任务,升级到新版本后立即消费,SwiftMailer 相关的类已经不存在了,除非你提前清理队列,否则一堆失败任务会拼命报错。我当时的做法是:升级前先php artisan queue:pause暂停队列,把旧任务清空或标记为失败,升级完成后再恢复队列。不是所有项目都有机会“停机升级”,但这种对队列的主动控制可以避免线上告警连环轰炸。
第三个坑是自定义 Artisan 命令里对$this->output的使用。Symfony 6 对输出接口做了调整,有些旧写法会触发弃用警告,不过不会直接报错。我在升级后发现大量弃用日志刷屏,排查后确认是命令里用了$this->output->writeln("<info>...</info>")这类标签写法,改成$this->info()之后日志干净多了。
写在最后:我对 Laravel 9.x 的判断
我自己写了几个月的 Laravel 9 之后,最大的感受是:这个版本很“硬核”,它不是靠一两个华丽的功能吸引眼球,而是靠 PHP 8、Symfony 6、Flysystem 3 这些底层基础设施彻底换代,为后续 Laravel 版本的稳定性打了底。如果你正在做技术选型,Laravel 9.x 依然是一个靠谱的长期方案,社区生态已经完全跟上来了,第三方包的兼容性也趋于稳定。最后分享一个我自己的习惯:升级框架后不要着急把代码里所有旧写法全部改成新写法,先让项目在目标版本上跑稳定,再逐步替换,这样出问题的时候你能知道是升级造成的,还是重构造成的,查起 Bug 来会省很多力气。