搞 Laravel 项目这么多年,每次大版本发布,圈子里总会分成两派:一派是“马上尝鲜派”,另一派是“等稳定再升派”。到了 9.x 这次,情况有点不一样——Laravel 9 在 2022 年 2 月 8 日正式发布,官方直接把它定位成了“面向现代 PHP 的版本”,最低要求 PHP 8.0,底层大规模换装 Symfony 6,同时把一年前 PHP 8.1 带来的枚举等新特性真正用进了框架核心。
这篇文章我想从一个实际做升级、做维护的开发者角度,把 Laravel 9.x 这次升级的核心特性、底层的设计意图、从 8.x 迁到 9.x 的实操步骤,还有我在真实项目里踩过、排查过的坑,一次性讲清楚。不管你是在评估“要不要升”,还是已经升级到一半正在跟报错搏斗,这篇应该都能给到一些有用的参考。
我自己的经验是:9.x 不是一个“多了几个新函数”的小版本,它更接近一次“换地基”式的架构升级。如果你只把它当成普通 feature release,升级过程中会吃不少亏;反过来,如果你理解了它背后的取舍,升级完你能拿到的不仅是一个新版本,还有一整套更现代的 PHP 开发体验。
1. 这次升级到底改了啥:Laravel 9.x 不是一次“功能上新”,而是把地基重做了一遍
1.1 为什么说 9.x 是“为现代 PHP 而生”的版本
先聊一个很多人忽略的背景:Laravel 9 把最低 PHP 版本拉到了 8.0,这是一个信号,说明框架开始真正以 PHP 8 为基线来写代码了。PHP 8.0 带来了构造函数属性提升、联合类型、match 表达式、nullsafe 操作符,8.1 又带来了枚举、readonly 属性、first-class callable。Laravel 9 是第一个能直接吃到这些语言红利的长期支持版本。
举个最直观的例子,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() { Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('description')->nullable(); $table->timestamps(); }); } public function down() { Schema::dropIfExists('tasks'); } };注意看,这里没有类名了,直接return new class。这是一个“匿名类迁移”的写法,也是 Laravel 9 的默认迁移风格。它解决的是我过去经常遇到的一个非常现实的痛点:两个开发者同一天各自创建了一个迁移文件,结果两个代码分支合到一起时,自动生成的迁移类名完全一样,于是报Cannot declare class CreateUsersTable because the name is already in use,整个php artisan migrate直接挂掉。匿名类迁移从根上杜绝了这个问题,因为每个迁移类都是独立的匿名类,名字不会再冲突。
所以你看,9.x 的很多“核心特性”表面上不花哨,但每一条都是在解决真实开发里让人头疼的问题。
1.2 升级对普通项目的真实影响面
升级到 9.x 之前,要先明白这次变更会动到哪些地方。根据我拆过的项目,影响面主要集中在几块:
- 运行环境:PHP 必须是 8.0 及以上,推荐 8.1;
- Composer 依赖树:
laravel/framework要升到^9.0,同时一大票第三方包的版本也会跟着动; - 文件存储层:Flysystem 从 2.x 升级到 3.x,属于破坏性变更;
- 队列任务分发:
dispatchNow方法被移除,必须改用dispatchSync; - 迁移机制:默认迁移格式改成匿名类,旧格式还能跑但建议逐步迁移;
- 前端构建:官方生态从 Laravel Mix/Webpack 逐步转向 Vite。
千万别觉得“Laravel 升级就是改一下 composer.json 里的版本号”。这套连带变更,如果项目里有老代码、自定义存储驱动、自定义队列逻辑,工作量会成倍上涨。后面我会详细讲每一步怎么做。
2. 值得重点关注的 6 个核心特性拆解
2.1 匿名迁移:解决了困扰多年的“同名迁移类冲突”
我刚才提到匿名迁移,这里展开说说它的价值。在 Laravel 8 及更早的版本里,每次执行php artisan make:migration create_tasks_table,框架都会生成一个带名字的类:
class CreateTasksTable extends Migration { // ... }类名是根据迁移文件名自动生成并追加时间戳的,理论上不会重复。但我在真实项目里至少遇到过三次类名冲突,全是多分支并行开发时合代码合出来的。有一次是在 20 多个迁移文件堆积的老项目里,两个同事分别在各自分支建了CreateOrdersTable,合并后一执行迁移就直接白屏,查了很久才发现是 PHP 类声明冲突,而不是 SQL 问题。
Laravel 9 把新迁移默认改成匿名类,等于彻底把这个坑填平了。这个设计思路值得夸一下:它没有通过复杂的命名规则去减少冲突概率,而是直接让类“没有名字”,从机制上消灭问题。对我们开发者的启示也很直接:如果项目已经升到 9.x,以后新建迁移直接就用默认的匿名类风格,不用再改回老写法。
2.2 枚举路由绑定:PHP 8.1 枚举进入 Web 层
PHP 8.1 的枚举(Enum)是个好东西,但如果没有框架层面的配合,你在路由里拿到的大多还是字符串,还得自己手动校验、转换。Laravel 9 直接支持了枚举的路由绑定,这在使用上非常舒服。
假设你有一个文章状态枚举:
<?php namespace App\Enums; enum PostStatus: string { case Draft = 'draft'; case Published = 'published'; case Archived = 'archived'; }路由可以这么写:
use App\Enums\PostStatus; Route::get('/posts/{status}', function (PostStatus $status) { return match ($status) { PostStatus::Draft => '草稿', PostStatus::Published => '已发布', PostStatus::Archived => '已归档', }; });访问/posts/draft,Laravel 会自动把draft字符串转换成PostStatus::Draft;访问一个不存在的状态比如/posts/deleted,会直接返回 404,不需要你手写一堆 if/else 做校验。
这个特性真正爽到的地方在控制器里。以前你要写“状态参数是否合法”的校验逻辑,现在类型系统自己就把这层干掉了。如果你的项目正在用 PHP 8.1,升级到 9.x 后这类代码会简洁很多。
2.3 新的 Query Builder 接口:契约先行
Laravel 9 新增了Illuminate\Contracts\Database\Query\Builder接口,并且让核心的查询构建器和 Eloquent Builder 都实现了它。很多业务开发同学看到这种抽象层更新,第一反应是“跟我有什么关系”。
我的理解是:这个接口是给框架生态和包作者的一层“稳定契约”。以前如果你想写一个函数,接受任意“能执行查询的东西”,很难做类型约束,因为项目里直接使用的是具体类Illuminate\Database\Query\Builder。现在可以通过接口来约束,意味着第三方包可以更安全地对查询构建器做扩展、替换和测试。
对普通业务项目来说,你几乎感知不到这个变化;但如果你自己维护 composer 包,或者正在做一套需要兼容多种查询场景的基础组件,这个接口就是 9.x 送给你的礼物。升级后建议检查一下自己的包代码,把函数入参类型从具体类改成接口,扩展性会好很多。
2.4 从 Flysystem 2 到 Flysystem 3:文件存储底层变更的连锁反应
Flysystem 是 Laravel 文件存储的底层库,9.x 把它升到了 3.x。这个升级属于“表面看似小、实际破坏性不小”的类型。
最典型的破坏点是:Flysystem 3 中,适配器(Adapter)层被隐藏了。以前很多老代码会这么干:
$adapter = Storage::disk('s3')->getAdapter();在 9.x 里,这行代码会直接报方法不存在。因为新版的 Filesystem 类不再暴露底层 adapter,官方目的是让上层文件操作接口更统一,防止开发者依赖到具体存储适配器的实现细节。
另一个变化是自定义文件系统驱动的方式变了。以前你可能实现的是某个旧版 AdapterInterface,现在需要实现League\Flysystem\FilesystemAdapter,接口方法更细,返回类型也更明确。如果项目里有对接阿里云 OSS、七牛、MinIO 这种自定义存储驱动的代码,升级时这基本是必踩的坑。一个靠谱的操作顺序是:先把存储相关的单元测试跑一遍,确认哪些接口调用挂了,再根据新版接口逐个改,不要直接上生产环境试。
2.5 Laravel Scout 数据库引擎:小项目也能全文检索
Laravel Scout 是官方全文搜索组件,以前主要配合 Algolia、Meilisearch、Typesense 这类外部搜索引擎使用。但外部服务需要注册账号、引入 SDK、维护索引,对很多中小项目来说有点“杀鸡用牛刀”。
Laravel 9 给 Scout 加了一个数据库驱动,只要在模型里引入 Searchable,再把SCOUT_DRIVER配成database,就可以用:
$posts = Post::search('关键字')->get();平时我会把这种驱动用在两类场景里:一类是后台管理系统的简单搜索,数据量在几万条以内,没必要上外置搜索引擎;另一类是项目早期快速验证搜索功能的交互逻辑,等数据量上来再平滑切换到 Meilisearch。这个 feature 本身不复杂,但扩展了 Scout 的适用范围,对预算有限的小团队特别友好。
2.6 周边工具链的“重磅”升级
除了 Laravel 框架本体,9.x 发布周期里还有几个官方工具值得关注。其中最实用的是laravel/pint,一个基于 PHP-CS-Fixer 的代码风格修复工具,内置 Laravel 官方代码规范,安装后直接:
composer require laravel/pint --dev ./vendor/bin/pint就能把项目代码格式化到 Laravel 官方风格。我接手老项目时,第一步就是跑一遍 pint,再配合 git diff 看代码差异,整顿代码风格效率奇高。
另外 Laravel 9 的生态里,Vite 开始取代 Laravel Mix 成为默认前端构建工具。严格说 Vite 支持是在 9.19 版本才默认铺开的,但整个 9.x 生命周期里,Breeze 和 Jetstream 这类脚手架项目已经全面转向 Vite。如果你在升级框架的同时想顺手把 Mix 换成 Vite,注意静态资源路径、热更新端口、代理配置这几个点都要重新调。
3. 从 8.x 升级到 9.x 的实操记录:步骤与踩坑
3.1 升级前需要确认的环境底线
动手之前,把环境先摸清楚,不然升到一半会因为 PHP 版本不够直接卡死。
我建议按这个顺序检查:
php -v composer --version composer show laravel/framework确认三点:PHP 版本是否在 8.0 及以上,Composer 是否 2.x,当前 laravel/framework 是不是 8.x。如果 PHP 还是 7.4,就别硬升级,先解决运行环境。
数据库这边也看了一眼,Laravel 9 支持的数据库版本比 8 略高一点。MySQL 5.7 还能用,但官方推荐 MySQL 8.0;MariaDB 建议 10.3+;PostgreSQL 建议 12+。如果你的项目还在 MySQL 5.6 这种老古董上跑,升级框架前先规划数据库升级,不然后面 InnoDB 全文索引、JSON 查询这些新特性都用不顺。
3.2 一步步执行依赖升级
环境确认没问题后,开始改依赖。推荐直接在项目根目录执行:
composer require laravel/framework:^9.0 --with-all-dependencies注意一定要加--with-all-dependencies,Composer 会把其它相关依赖(比如laravel/ui、laravel/sanctum、nunomaduro/collision等)一起解析到兼容 9.x 的版本,减少很多手动调整的麻烦。
执行过程中,Composer 会报哪些包需要升级、哪些包当前版本不兼容。这一步通常会牵扯出 PHP 8.0/8.1 的兼容性问题,比如老包用了each()、create_function()之类的 PHP 7 函数。另一个常见问题是一些第三方 Laravel 包还没发布兼容 9.x 的版本,这时候要么等官方更新,要么用 fork 分支临时顶一下,要么换替代包。我的习惯是在升级前先去 Packagist 查一遍核心依赖的兼容状态,避免升到一半发现某个包没有 9.x 版本。
依赖都解析通过后,再把config/app.php、config/auth.php等核心配置文件和 9.x 的默认版本对比一下,看有没有新增的配置项。最稳妥的办法是先把laravel/framework升上去,跑一遍php artisan about看版本号和环境信息,再根据输出微调环境变量。
3.3 代码层面最常踩的 5 个不兼容点
根据我升级过的几个项目,下面这些不兼容点出场率最高,一条条对号入座检查。
第一,dispatchNow被移除。老代码里常见的:
dispatchNow(new SomeJob());在 9.x 里已经不认识了,必须改成:
dispatchSync(new SomeJob());全局dispatchNow函数也一样,去全局搜一下替换掉。我有个老项目里这种调用有 30 多处,当时就是全局搜索dispatchNow,逐行改掉。
第二,getAdapter()不可用。前面说过 Flysystem 3 隐藏了 adapter,老代码里所有Storage::disk(...)->getAdapter()都要重构。通常的做法是改用高层次的存储方法,比如Storage::disk('custom')->put()、Storage::disk('custom')->readStream(),尽量避免直接接触底层 adapter。如果确有必要,可以通过自定义驱动的方式注入自己管理 adapter 实例。
第三,自定义 Flysystem 驱动的接口变了。原来实现League\Flysystem\AdapterInterface的类,需要改成实现League\Flysystem\FilesystemAdapter。这个接口的方法更细,包括fileExists、writeStream、readStream、deleteDirectory、createDirectory、setVisibility、getVisibility等,返回类型也更严格。改完后记得把所有存储相关的测试跑一遍,文件上传、下载、删除、目录遍历这些最容易漏。
第四,PHP 8.x 的弃用提示。Laravel 9 在 PHP 8.0/8.1 下运行没问题,但老代码里经常有动态属性、each()这类写法,PHP 8 开始会产生大量Deprecated警告。这些警告不会直接中断程序,但会污染日志,影响问题排查。升级时顺手把日志里的 deprecation 清理一遍,对后续维护帮助很大。
第五,第三方包 config 文件发布。升级后重新执行:
php artisan vendor:publish --tag=config --force把第三方包的最新配置文件发布到项目里。这一步很容易漏,漏了之后可能出现“配置项不存在”或者包行为异常的问题。
3.4 schema:dump 带来的迁移性能红利
升级到 9.x 后,我强烈建议试一下schema:dump,这是提升部署效率的神器。
php artisan schema:dump这个命令会把当前数据库的结构快照导出到database/schema/目录下。之后执行php artisan migrate时,Laravel 会优先加载 schema 文件,再执行那些还没有跑过的增量迁移,而不是从第一个迁移文件慢慢执行到最后一个。
对老项目来说,这个优化太香了。我之前有个项目积累了 200 多个迁移文件,在 CI 环境里跑migrate要耗 3 分钟以上,做了schema:dump之后压缩到 30 秒以内。注意一点:schema:dump导出的结构是“执行时刻的数据库状态”,所以在生产环境跑之前,最好由维护方先从一份干净的、执行完所有迁移的测试库里生成 dump,避免把开发环境里临时改的表结构带进去。
如果迁移文件里掺了数据填充或者存储过程,schema:dump默认只导出表结构,数据填充还得用 seeder 解决。这块结合项目实际情况取舍,但大多数项目用了之后部署提速非常明显。
4. 升级后常见问题与排查清单
4.1 问题速查表
下面是我在 Laravel 9.x 升级和日常使用中遇到问题的整理,按频率从高到低排列:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
执行migrate报类名冲突 | 老迁移文件仍是具名类,且多人并行创建同名迁移 | 新迁移统一使用匿名类;老迁移建议重构为匿名类 |
storage相关操作报方法不存在 | Flysystem 3 不暴露getAdapter() | 重构为高层存储 API,或按新版接口实现自定义驱动 |
dispatchNow调用报错 | Laravel 9 移除了该方法 | 全局替换为dispatchSync |
| 升级后某第三方包异常 | 包版本不兼容 9.x 或配置未重新发布 | 升级包到兼容版本,重新vendor:publish |
| 页面出现大量 deprecation 日志 | 项目代码使用了 PHP 8 弃用特性 | 根据日志逐项修改,重启 PHP-FPM |
| 前端资源构建失败 | Mix/Webpack 升级后不兼容 Node 高版本 | 迁移到 Vite,使用新的构建脚本 |
| 迁移时提示 schema 文件与迁移文件不一致 | schema:dump生成的快照字段与当前迁移不一致 | 用干净的测试库重新生成 schema dump |
| Redis 缓存或队列报连接错误 | phpredis 扩展版本太老 | 升级 phpredis 扩展到支持 PHP 8 的版本 |
4.2 我的两个排查心得
第一个心得:升完级之后,优先跑测试而不是手动点页面。项目里只要写了一定量的功能测试,升级后立刻跑一遍php artisan test,很多不兼容问题会直接暴露出来,比如 response 结构变化、session 驱动差异、队列同步执行的行为变化。我之前有一次就是升级后没跑测试,结果登录功能是坏的,排查了半天才发现是 session 的加密方式变了。测试套件在这里的价值,是能帮你把隐性破坏快速定位到具体模块。
第二个心得:遇到诡异的报错,先把缓存清一遍。Laravel 9 的缓存系统对 config、route、view 都有缓存,升级后旧缓存经常会把老代码的类映射、路由表残留在里面。我处理过一个案例,升级完一个路由怎么都访问不到,查路由列表又是存在的,最后发现是 route cache 没清。所以升级后立刻跑:
php artisan optimize:clear再继续排错,能省掉很多冤枉时间。
另外再分享一个 Laravel 9 里很实用的小技巧:php artisan route:list --json。升级后路由数量多、检查路由是否正常时,这个命令可以直接输出 JSON 格式的路由表,配合 jq 或者自己写脚本做路由检查非常方便,比原来纯文本输出好解析太多了。这也是 9.x 在开发体验上一个很实用的细节改进。
最后再聊一下我个人的真实体会:Laravel 9.x 这轮升级,最值得的不是某个具体的新函数,而是整个技术栈的“地基现代化”。如果你还在 8.x 甚至更老的版本上,而且 PHP 版本也有条件升到 8.1,我建议认真规划一次升级。升的过程中可能会烦,会遇到 Flysystem、队列、第三方包各种不兼容,但升完之后,你会发现项目在性能、代码可维护性、生态兼容性上都往前迈了一大步。尤其是那些计划长期维护的项目,停在老版本上越久,后面升级的墙就越高。选一个业务相对平稳的窗口期,配好测试,把 9.x 一次升到位,这笔技术债还得很值。