编程 Laravel 12 → 13 升级:几处不报错却会出事的改动

2026-10-29 21:03:38

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 解析时。

升级检查顺序

  1. 先落实 PHP 8.3 与上面的依赖版本,composer update 后完整跑一遍测试。
  2. 搜测试与路由里的 withoutMiddleware([VerifyCsrfToken::class]),全部换成 PreventRequestForgery;再回归反向代理与跨子域的请求。
  3. 决定 config/session.php 的 serialization:保在线会话用 php,能接受全员重登再改 json。
  4. 检查 cache 配置的 serializable_classes,把存对象的缓存改成白名单或数组负载,并处理存量 key。
  5. 确认 CACHE_PREFIX、REDIS_PREFIX、SESSION_COOKIE 是否需要固定为旧值。
  6. 搜 upsert 的 uniqueBy 传参,以及带 join + orderBy/limit 的 delete 语句。
  7. 搜全局 array_first() / array_last() 调用,替换为 Illuminate\Support\Arr。
  8. 最后按上面低影响项逐条回归:Container::call、模型 boot、队列事件属性、分页视图名。
复制全文 生成海报 PHP Laravel 升级

推荐文章

程序员茄子在线接单