> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel 8 升级到 9

> Laravel 8.x 到 9.x 的升级步骤与主要变更点解析。

## 前言

Laravel 9 于 2022 年 2 月 8 日发布。本文整理从 Laravel 8.x 升级到 9.x 的步骤,以及影响面较大的变更点。

<Info>
  预计升级时间**约 30 分钟**。如果你自定义了邮件发送、文件存储、Custom Cast,或者覆写了框架的核心类,工作量可能更多。
</Info>

### 用 Laravel Shift 自动升级

也可以用 [Laravel Shift](https://laravelshift.com/) 自动化升级。Shift 会协助更新 `composer.json` 和配置文件,可以作为你审阅 diff 的起点。

***

## 按影响面分级的变更

### 影响度:高

* 依赖更新
* 迁移到 Flysystem 3.x
* 迁移到 Symfony Mailer

### 影响度:中

* `BelongsToMany` 的 `firstOrNew` / `firstOrCreate` / `updateOrCreate`
* Custom Cast 与 `null` 的行为
* HTTP Client 的默认超时
* 新增 PHP Return Type
* Postgres `schema` 配置改名
* 废弃 `assertDeleted` 方法
* `lang` 目录的移动
* 密码规则变更
* `when` / `unless` 方法变更
* 校验中未验证数组键的处理

***

## 升级步骤

### 更新依赖

**影响度:高**

Laravel 9 需要 **PHP 8.0.2 或以上**。首先检查 `composer.json` 的依赖。

```json theme={null}
{
  "require": {
    "php": "^8.0.2",
    "laravel/framework": "^9.0",
    "spatie/laravel-ignition": "^1.0"
  },
  "require-dev": {
    "nunomaduro/collision": "^6.1"
  }
}
```

依应用情况还需以下更新:

* 删除 `facade/ignition`,改为 `spatie/laravel-ignition:^1.0`
* 使用 `pusher/pusher-php-server` 时升级到 `^5.0`
* 确认所使用的第三方包已适配 Laravel 9
* 使用 Vonage 通知通道时请另外查阅其升级指南

之后执行:

```shell theme={null}
composer update
```

***

## PHP 版本要求

**影响度:高**

Laravel 9 必须 PHP 8.0.2 及以上。请在 CI、本地开发和生产环境中都统一 PHP 版本之后再升级。

***

### 迁移到 Symfony Mailer

**影响度:高**

Laravel 9 的一大变化是把 2021 年 12 月已终止维护的 SwiftMailer 换成了 Symfony Mailer。只使用 `Mail::to()->send()` 的应用影响很小,但直接使用 SwiftMailer 低层 API 的地方需要检查。

#### 驱动依赖

```shell theme={null}
# 使用 Mailgun 时才需要添加
composer require symfony/mailgun-mailer symfony/http-client

# 使用 Postmark 时,删掉 SwiftMailer 相关包并替换
composer remove wildbit/swiftmailer-postmark
composer require symfony/postmark-mailer symfony/http-client
```

#### 从 `withSwiftMessage` 到 `withSymfonyMessage`

```php theme={null}
// Laravel 8.x:基于 SwiftMailer
$this->withSwiftMessage(function ($message) {
    $message->getHeaders()->addTextHeader('Custom-Header', 'Header Value');
});

// Laravel 9.x:基于 Symfony Mailer
use Symfony\Component\Mime\Email;

$this->withSymfonyMessage(function (Email $message) {
    $message->getHeaders()->addTextHeader('Custom-Header', 'Header Value');
});
```

`Illuminate\Mail\Mailer` 的 `send`、`html`、`raw`、`plain` 现在返回 `Illuminate\Mail\SentMessage` 而不是 `void`。`MessageSent` 事件的 `message` 属性也从 `Swift_Message` 变成了 `Symfony\Component\Mime\Email`。

#### 重新检查 SMTP 配置

Symfony Mailer 废弃了 SMTP 的 `stream` 选项,支持的配置项被移到顶层。

```php theme={null}
return [
    'mailers' => [
        'smtp' => [
            // Laravel 8.x 的写法
            'stream' => [
                'ssl' => [
                    'verify_peer' => false,
                ],
            ],

            // Laravel 9.x 的写法
            'verify_peer' => false,
        ],
    ],
];
```

不再需要显式设置 `auth_mode`。相较发送后再回收无效邮件地址,推荐在发送前完成校验。

***

### 迁移到 Flysystem 3.x

**影响度:高**

Laravel 9 把 `Storage` facade 内部实现从 Flysystem 1.x 升到了 3.x。文件操作方法尽量保持了兼容,但异常、返回值、适配器注册周围有差异。

#### 需要额外安装的驱动

```shell theme={null}
# Amazon S3 驱动
composer require -W league/flysystem-aws-s3-v3 "^3.0"

# FTP 驱动
composer require league/flysystem-ftp "^3.0"

# SFTP 驱动
composer require league/flysystem-sftp-v3 "^3.0"
```

#### `Storage` 的主要行为变化

* `put` / `write` / `writeStream` 默认会覆盖已有文件
* 写入失败时返回 `false` 而不是抛异常
* 读取不存在的文件返回 `null` 而不是异常
* 对不存在文件调用 `delete` 会返回 `true`
* cached adapter 已被移除,可以从 `disk` 配置中删除 `cache` 键

如果想像以前那样在写入失败时抛异常,请设置 `throw` 选项。

```php theme={null}
return [
    'disks' => [
        'public' => [
            'driver' => 'local',
            // 仅当想在写入失败时抛异常时才启用
            'throw' => true,
        ],
    ],
];
```

如果注册了自定义 filesystem 驱动,请把 `Storage::extend()` 的回调改为直接返回 `Illuminate\Filesystem\FilesystemAdapter`。

***

### `BelongsToMany` 的 `firstOrNew` / `firstOrCreate` / `updateOrCreate`

**影响度:中**

在 Laravel 8 中,这些方法的第一个参数(属性数组)是和中间表比较的。Laravel 9 改为和关联模型的表比较。

```php theme={null}
// 会用 name 到关联模型的表里查找并更新
$user->roles()->updateOrCreate([
    'name' => 'Administrator',
]);
```

同时,`firstOrCreate` 也支持第二个参数 `$values`,与其他关联的行为对齐。

```php theme={null}
// 只有新建时才合并 created_by
$user->roles()->firstOrCreate([
    'name' => 'Administrator',
], [
    'created_by' => $user->id,
]);
```

***

### Custom Cast 与 `null`

**影响度:中**

Laravel 9 中,即便把 `null` 赋给要 cast 的字段,custom cast 的 `set` 方法也会被调用。没有考虑 `null` 的 cast 升级后可能抛异常。

```php theme={null}
public function set($model, $key, $value, $attributes)
{
    // Laravel 9 中可能会传入 null
    if ($value === null) {
        return [
            'address_line_one' => null,
            'address_line_two' => null,
        ];
    }

    if (! $value instanceof AddressModel) {
        throw new InvalidArgumentException('请传入 AddressModel。');
    }

    return [
        'address_line_one' => $value->lineOne,
        'address_line_two' => $value->lineTwo,
    ];
}
```

***

### HTTP Client 的默认超时

**影响度:中**

HTTP Client 的默认超时变为 30 秒。以前有可能无限等待。

```php theme={null}
use Illuminate\Support\Facades\Http;

// 只对需要更长时间的 API 调用单独延长超时
$response = Http::timeout(120)->get('https://example.com/api/status');
```

***

### 新增 PHP Return Type

**影响度:中**

为了对齐 PHP 本身和 Symfony 的要求,Laravel 9 给各个核心类加上了返回值类型。如果你继承了 Laravel 的核心类并覆写 `offsetGet`、`offsetSet`、`jsonSerialize`、`open`、`read` 等,请给自己的实现也加上同样的返回值类型。

```php theme={null}
// 覆写核心类时,请对齐返回值类型
public function offsetGet($key): mixed
{
    return parent::offsetGet($key);
}
```

***

### Postgres `schema` 配置改名

**影响度:中**

在 Postgres 连接中设置搜索路径时,请把 `config/database.php` 的键从 `schema` 改成 `search_path`。

```php theme={null}
return [
    'pgsql' => [
        // Laravel 8.x
        'schema' => 'public',

        // Laravel 9.x
        'search_path' => 'public',
    ],
];
```

***

### `assertDeleted` 改为 `assertModelMissing`

**影响度:中**

原本用于断言模型删除的 `assertDeleted` 请替换成 `assertModelMissing`。

```php theme={null}
// Before
$this->assertDeleted($user);

// After
$this->assertModelMissing($user);
```

***

### `lang` 目录移动

**影响度:中**

Laravel 9 新项目里,语言文件的位置从 `resources/lang` 改到了项目根目录下的 `lang`。已有应用只是继续运行的话影响不大,但当你要贴近新骨架结构、或包中发布翻译文件时需要检查。

```php theme={null}
// 不用固定路径,用 langPath() 决定发布位置
$this->publishes([
    __DIR__.'/../lang' => app()->langPath('vendor/package-name'),
]);
```

***

### 密码规则变更

**影响度:中**

用于校验和当前登录用户密码一致的 `password` 规则改名为 `current_password`。

```php theme={null}
// Before
'password' => ['required', 'password'],

// After
'password' => ['required', 'current_password'],
```

***

### `when` / `unless` 方法变更

**影响度:中**

在 Laravel 8 中,给 `when` 或 `unless` 传闭包时,闭包本身会被当作 truthy,可能触发非预期分支。Laravel 9 中会执行闭包,并把返回值作为条件。

```php theme={null}
$collection->when(function ($collection) {
    // 这里返回的值会被当作条件
    return false;
}, function ($collection) {
    // 因为返回了 false,这里不会执行
    $collection->merge([1, 2, 3]);
});
```

***

### 未校验数组键的处理

**影响度:中**

Laravel 9 中,`validated()` 返回的数组会始终排除未校验的数组键。想保留 Laravel 8 的兼容行为时,请显式调用 `includeUnvalidatedArrayKeys()`。

```php theme={null}
use Illuminate\Support\Facades\Validator;

public function boot()
{
    // 只在想回到 Laravel 8 行为时启用
    Validator::includeUnvalidatedArrayKeys();
}
```

***

## 总结

Laravel 8 到 9 的升级重点是:升到 PHP 8.0.2、迁移到 Symfony Mailer、适配 Flysystem 3.x。先检查邮件发送、存储、Custom Cast、测试辅助器,能减少升级后的问题。

| 变更点                                  | 影响度 | 应对                                      |
| ------------------------------------ | --- | --------------------------------------- |
| PHP 8.0.2 / `laravel/framework:^9.0` | 高   | 更新 `composer.json` 与运行环境                |
| 迁移到 Symfony Mailer                   | 高   | 检查 SwiftMailer 相关 API 与 mail 配置         |
| Flysystem 3.x                        | 高   | 检查 `Storage` 的返回值、异常与驱动依赖               |
| `BelongsToMany` 的 upsert 系方法         | 中   | 注意查询对象已变为关联模型的表                         |
| Custom Cast 与 `null`                 | 中   | 修复 `set()` 收到 `null` 时会崩的地方             |
| HTTP Client 30 秒超时                   | 中   | 只对必要请求显式设置 `timeout()`                  |
| 废弃 `assertDeleted`                   | 中   | 替换为 `assertModelMissing()`              |
| `lang` 目录移动                          | 中   | 固定路径引用改用 `app()->langPath()`            |
| `password` 规则变更                      | 中   | 改成 `current_password`                   |
| 排除未校验数组键                             | 中   | 只在需要时使用 `includeUnvalidatedArrayKeys()` |

***

## 参考资料

* [官方升级指南(英文)](https://laravel.com/docs/9.x/upgrade)
* [laravel/docs 9.x `upgrade.md`](https://github.com/laravel/docs/blob/9.x/upgrade.md)
* [laravel/laravel 仓库差异(8.x → 9.x)](https://github.com/laravel/laravel/compare/8.x...9.x)
* [Laravel Shift](https://laravelshift.com) —— 自动化升级的社区服务
* [Symfony Mailer 文档](https://symfony.com/doc/6.0/mailer.html)
* [Flysystem 3 文档](https://flysystem.thephpleague.com/v3/docs/)


## Related topics

- [Laravel 9 升级到 10](/zh/blog/upgrade-9-to-10.md)
- [Laravel 10 升级到 11](/zh/blog/upgrade-10-to-11.md)
- [Laravel 11 升级到 12 指南](/zh/blog/upgrade-11-to-12.md)
- [Laravel 12 升级到 13 指南](/zh/blog/upgrade-12-to-13.md)
