> ## 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 分鐘**。不過因為郵件寄送、檔案儲存、自訂 cast、以及框架核心類別的覆寫狀況，工作量可能會增加。
</Info>

### 使用 Laravel Shift 自動化升級

也可以使用 [Laravel Shift](https://laravelshift.com/) 自動化升級。Shift 會協助更新 `composer.json` 與設定檔，作為差異確認的起點很方便。

***

## 依影響程度分類的變更

### 影響程度：高

* 更新依賴套件
* 遷移到 Flysystem 3.x
* 遷移到 Symfony Mailer

### 影響程度：中

* `BelongsToMany` 的 `firstOrNew` / `firstOrCreate` / `updateOrCreate` 方法
* Custom Cast 與 `null` 的行為
* HTTP client 的預設 timeout
* PHP Return Types 的新增
* Postgres 的 `schema` 設定名稱變更
* 廢除 `assertDeleted` 方法
* `lang` 目錄的搬移
* 密碼規則的變更
* `when` / `unless` 方法的變更
* validation 中未驗證陣列 key 的處理

***

## 升級步驟

### 更新依賴套件

**影響程度:高**

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 通知 channel，也請確認個別的升級指南

更新後執行以下指令安裝依賴。

```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，就需要確認。

#### Driver 依賴套件

```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` 屬性中，現在會是 `Symfony\Component\Mime\Email` 而非 `Swift_Message`。

#### 重新檢視 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。檔案操作方法盡可能保持相容，但在例外、回傳值、adapter 註冊等部分有差異。

#### 追加安裝 Driver

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

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

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

#### `Storage` 主要行為變更

* `put` / `write` / `writeStream` 預設會覆寫既有檔案
* 寫入失敗時回傳 `false` 而非例外
* 讀取不存在的檔案時回傳 `null` 而非例外
* 刪除不存在的檔案時 `delete` 回傳 `true`
* cached adapter 已被移除，可移除 `disk` 設定中的 `cache` key

若想像過去一樣在寫入失敗時使用例外，請設定 `throw` 選項。

```php theme={null}
return [
    'disks' => [
        'public' => [
            'driver' => 'local',
            // 只在想於寫入失敗時丟出例外時啟用
            'throw' => true,
        ],
    ],
];
```

若有註冊自訂 filesystem driver，請調整 `Storage::extend()` 的 callback 直接回傳 `Illuminate\Filesystem\FilesystemAdapter`。

***

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

**影響程度:中**

Laravel 8 中傳給這些方法的第 1 個引數屬性陣列是與中間表比對。Laravel 9 則是與關聯 model 的資料表比對。

```php theme={null}
// 對關聯 model 的資料表搜尋、更新 name
$user->roles()->updateOrCreate([
    'name' => 'Administrator',
]);
```

另外 `firstOrCreate` 開始可接收第 2 引數 `$values`，行為與其他 relation 對齊。

```php theme={null}
// 只在新增時 merge created_by
$user->roles()->firstOrCreate([
    'name' => 'Administrator',
], [
    'created_by' => $user->id,
]);
```

***

### Custom Cast 與 `null`

**影響程度:中**

Laravel 9 中，即使對 cast 對象指派 `null`，也會呼叫 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 的預設 timeout

**影響程度:中**

HTTP client 的預設 timeout 改為 30 秒。以前可能會無限等待。

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

// 只對較長時間的 API 呼叫個別延長 timeout
$response = Http::timeout(120)->get('https://example.com/api/status');
```

***

### PHP Return Types 的新增

**影響程度:中**

Laravel 9 中，為配合 PHP 本體或 Symfony 的需求，各種核心類別已加上回傳型別。若你繼承 Laravel 的核心類別並覆寫 `offsetGet`、`offsetSet`、`jsonSerialize`、`open`、`read` 等方法，也請在自己的實作加上相同的回傳型別。

```php theme={null}
// 若有覆寫核心類別請對齊回傳型別
public function offsetGet($key): mixed
{
    return parent::offsetGet($key);
}
```

***

### Postgres 的 `schema` 設定名稱變更

**影響程度:中**

若在 Postgres 連線設定 search path，請將 `config/database.php` 的 key 名稱從 `schema` 改為 `search_path`。

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

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

***

### 從 `assertDeleted` 到 `assertModelMissing`

**影響程度:中**

用於確認 model 刪除的 `assertDeleted` 請替換為 `assertModelMissing`。

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

// 變更後
$this->assertModelMissing($user);
```

***

### `lang` 目錄的搬移

**影響程度:中**

新的 Laravel 9 應用中，語言檔案的放置位置從 `resources/lang` 改為專案根目錄的 `lang`。若只是直接執行既有應用影響不大，但若要對齊新的 skeleton，或套件公開翻譯檔案時請重新檢視。

```php theme={null}
// 不使用固定路徑，改用 langPath() 決定公開位置
$this->publishes([
    __DIR__.'/../lang' => app()->langPath('vendor/package-name'),
]);
```

***

### 密碼規則的變更

**影響程度:中**

驗證目前登入使用者的密碼是否相符的 `password` rule 已重新命名為 `current_password`。

```php theme={null}
// 變更前
'password' => ['required', 'password'],

// 變更後
'password' => ['required', 'current_password'],
```

***

### `when` / `unless` 方法的變更

**影響程度:中**

Laravel 8 中將 closure 傳給 `when` 或 `unless` 時，該 closure 本身會被評估為 truthy，可能導致意外的條件分支執行。Laravel 9 中會執行 closure，並將其回傳值作為條件使用。

```php theme={null}
$collection->when(function ($collection) {
    // 這裡回傳的值會被評估為條件式
    return false;
}, function ($collection) {
    // 因為回傳 false，此處理不會執行
    $collection->merge([1, 2, 3]);
});
```

***

### 未驗證陣列 key 的處理

**影響程度:中**

Laravel 9 中，`validated()` 回傳的陣列總是會排除未驗證的陣列 key。若要維持 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。先檢查郵件寄送、儲存、自訂 cast、測試 helper，可以減少升級後的問題。

| 變更點                                  | 影響程度 | 對應                                      |
| ------------------------------------ | ---- | --------------------------------------- |
| PHP 8.0.2 / `laravel/framework:^9.0` | 高    | 更新 `composer.json` 與執行環境                |
| 遷移到 Symfony Mailer                   | 高    | 確認 SwiftMailer 系 API 與 mail 設定          |
| Flysystem 3.x                        | 高    | 確認 `Storage` 的回傳值、例外、driver 依賴          |
| `BelongsToMany` 的 upsert 系方法         | 中    | 確認搜尋對象已變成關聯 model 的資料表                  |
| Custom Cast 與 `null`                 | 中    | 修正 `set()` 收到 `null` 也不會壞               |
| HTTP client 30 秒 timeout             | 中    | 對必要的請求明確使用 `timeout()`                  |
| 廢除 `assertDeleted`                   | 中    | 替換為 `assertModelMissing()`              |
| `lang` 目錄搬移                          | 中    | 將固定路徑參照改為 `app()->langPath()`           |
| `password` rule 變更                   | 中    | 更新為 `current_password`                  |
| 排除未驗證陣列 key                          | 中    | 僅在必要時使用 `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 repository 差異（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-TW/blog/upgrade-9-to-10.md)
- [從 Laravel 10 升級到 11](/zh-TW/blog/upgrade-10-to-11.md)
- [從 Laravel 11 升級到 12 指南](/zh-TW/blog/upgrade-11-to-12.md)
- [從 Laravel 12 升級到 13 指南](/zh-TW/blog/upgrade-12-to-13.md)
