Laravel 12 → 13 升级:几处不报错却会出事的改动
官方升级指南估计耗时 10 分钟,最低 PHP 8.3,并明确说这次刻意减少了破坏性变更。但下面这些改动在 composer update 之后不会立刻报错,而是等到跑测试、读老缓存、用户刷新页面时才显形。
升级指南:https://laravel.com/framework/docs/13.x/upgrade
依赖与工具链
"laravel/framework": "^13.0",
"laravel/boost": "^2.0",
"laravel/tinker": "^3.0",
"phpunit/phpunit": "^12.0",
"pestphp/pest": "^4.0"
PHP 最低 8.3。用 installer 的需要 composer global update laravel/installer,或用 Herd 更新。Laravel Boost(官方 MCP server)可在 Claude Code / Cursor / OpenCode / Gemini / VS Code 里跑 /upgrade-laravel-v13,前提是 boost ^2.0。
CSRF 中间件改名,排除列表会静默失效
VerifyCsrfToken 改名为 PreventRequestForgery,并新增基于 Sec-Fetch-Site 头的请求来源校验。旧类名 VerifyCsrfToken、ValidateCsrfToken 保留为 deprecated 别名,所以直接引用既不会编译报错,也不会被静态检查拦住:
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
// Laravel <= 12.x
->withoutMiddleware([VerifyCsrfToken::class]);
// Laravel >= 13.x
->withoutMiddleware([PreventRequestForgery::class]);
问题就出在这里:13 里实际注册的是 PreventRequestForgery,测试中对旧类名做 withoutMiddleware 不报错,但排除不生效,请求仍然走 CSRF 校验并返回 419。测试要么红,要么因为断言没覆盖到而被放过。中间件配置 API 另提供 preventRequestForgery(...)。
Sec-Fetch-Site 校验还有两处要实测回归:反向代理如果没有透传 Sec-Fetch-* 头,来源判断拿不到预期值;子域之间的请求是 same-site 而非 same-origin,跨子域调用要确认落在允许的分支里。非浏览器客户端不带这个头,同样属于需要验证的路径。
cache 的 serializable_classes 默认 false
默认 cache 配置新增 serializable_classes,值为 false,用于收紧反序列化,防止 APP_KEY 泄漏后被拿去做 PHP 反序列化 gadget chain。代价是:以前往缓存里塞 PHP 对象、靠自动反序列化读回来的代码,升级后读不出数据。必须显式列出允许的类:
'serializable_classes' => [
App\Data\CachedDashboardStats::class,
App\Support\CachedPricingSnapshot::class,
],
存量缓存条目同样受影响,要么按白名单回填,要么清掉重写。长期看,直接换成数组等非对象负载更省事。
config/session.php 的 serialization 从 php 改 json
默认骨架把 serialization 设成 json,目的同样是掐断反序列化链。但已有应用同步配置时,把 php 改成 json 会让所有在线 session 立刻失效。取舍很直接:
- 想无损保留在线会话,就让该值保持
php; - session 里不存 PHP 对象、也能接受用户重新登录,再改成
json。
cache / session 默认前缀换成连字符
// Laravel <= 12.x
Str::slug((string) env('APP_NAME', 'laravel'), '_').'_cache_';
// '_database_'、'_session' 同理
// Laravel >= 13.x
Str::slug((string) env('APP_NAME', 'laravel')).'-cache-';
// '-database-'、'-session' 同理
key 形态变了,老缓存相当于全部 miss,session cookie 名也跟着变。要沿用旧命名,显式配置 CACHE_PREFIX、REDIS_PREFIX、SESSION_COOKIE。
upsert 的 uniqueBy 传空会抛异常
Laravel 现在校验 uniqueBy 非空,为空直接抛 InvalidArgumentException,不再生成非法 SQL。注意 MySQL/MariaDB 驱动本身忽略 uniqueBy 的值、始终用主键与唯一索引判断记录是否存在,但这项校验照样生效——以前靠传空数组"让驱动自己判断"的写法,升级后会当场失败。
DELETE ... JOIN 带 ORDER BY / LIMIT
13 的 MySQL grammar 会完整编译 DELETE ... JOIN,包括 ORDER BY 与 LIMIT。12 及以前这些子句在 joined delete 上被静默丢弃,实际执行的是一条无边界删除。现在子句进入 SQL,MySQL 和 11.8.1 之前的 MariaDB 不支持该语法,会抛 QueryException。
换句话说,报错掩盖的是原来更危险的删除行为。升级时要搜出所有带 join 又带 orderBy/limit 的 delete,改成子查询选主键再删。
symfony/polyfill-php85 与 array_first()
13 引入 symfony/polyfill-php85。在低于 8.5 的 PHP 上,polyfill 会定义 array_first()、array_last() 等全局函数(前提是 bootstrap 早期没有提前定义它们)。这和 laravel/helpers 或自定义全局 helper 存在同名冲突:历史 array_first() 接受回调、返回第一个匹配元素;polyfill 版本只返回数组第一个元素。同一个函数名,两种语义,不会报错,只会算错。
统一改用 Illuminate\Support\Arr 里的方法。
其余低影响项
Container::call现在尊重可空类参数的默认值:$container->call(function (?Carbon $date = null) { return $date; });在 12.x 返回 Carbon 实例,13.x 返回null。- Eloquent:模型 boot 期间再次实例化同一模型会抛
LogicException,例如在boot()或 trait 的boot*方法里写(new static())->getTable();使用自定义 pivot 类时,多态中间表名改为复数;集合序列化/还原(如队列 job)会恢复 eager load 关系。 - Queue:
JobAttempted的$exceptionOccurred布尔属性改为$exception(异常对象或null);QueueBusy的$connection改名$connectionName;Queue 契约新增pendingSize/delayedSize/reservedSize/creationTimeOfOldestPendingJob。 - Routing:显式指定
domain的路由优先于非 domain 路由匹配。 - Notifications:默认密码重置邮件主题从
Reset Password Notification改为Reset your password;队列通知现在尊重#[DeleteWhenMissingModels]。 - Views:分页视图内部名改为
pagination::bootstrap-3/pagination::simple-bootstrap-3(原default/simple-default)。 - Support:manager
extend注册的驱动闭包绑定到 manager 实例,原来依赖$this是 service provider 的,改用use (...)捕获;测试之间会重置自定义 Str 工厂;Js::from默认使用JSON_UNESCAPED_UNICODE。 - Scheduling:
ApplicationBuilder::withScheduling()注册的调度延迟到Schedule解析时。
升级检查顺序
- 先落实 PHP 8.3 与上面的依赖版本,
composer update后完整跑一遍测试。 - 搜测试与路由里的
withoutMiddleware([VerifyCsrfToken::class]),全部换成PreventRequestForgery;再回归反向代理与跨子域的请求。 - 决定
config/session.php的serialization:保在线会话用php,能接受全员重登再改json。 - 检查 cache 配置的
serializable_classes,把存对象的缓存改成白名单或数组负载,并处理存量 key。 - 确认
CACHE_PREFIX、REDIS_PREFIX、SESSION_COOKIE是否需要固定为旧值。 - 搜
upsert的uniqueBy传参,以及带 join +orderBy/limit的 delete 语句。 - 搜全局
array_first()/array_last()调用,替换为Illuminate\Support\Arr。 - 最后按上面低影响项逐条回归:
Container::call、模型 boot、队列事件属性、分页视图名。