> ## 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분**입니다. 다만 메일 송신 · 파일 스토리지 · 독자 캐스트 · 프레임워크의 코어 클래스의 오버라이드 상황에 따라 작업량은 늘어날 수 있습니다.
</Info>

### Laravel Shift를 사용한 자동 업그레이드

[Laravel Shift](https://laravelshift.com/)를 사용하여 업그레이드를 자동화할 수도 있습니다. Shift는 `composer.json`이나 설정 파일의 갱신을 보조해 주므로, 차분 확인의 출발점으로서 편리합니다.

***

## 영향도별 변경점

### 영향도: 높음

* 의존관계 갱신
* Flysystem 3.x로의 마이그레이션
* Symfony Mailer로의 마이그레이션

### 영향도: 중간

* `BelongsToMany`의 `firstOrNew` / `firstOrCreate` / `updateOrCreate` 메서드
* Custom Casts와 `null`의 동작
* HTTP 클라이언트의 기본 타임아웃
* PHP Return Types 추가
* 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`은 `void`가 아니라 `Illuminate\Mail\SentMessage`를 반환하도록 되었습니다. 또한 `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` 파사드의 내부 구현을 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에서는 이 메서드들에 전달한 제1인자의 속성 배열이 중간 테이블과 비교되고 있었습니다. Laravel 9에서는 관련 모델의 테이블과 비교됩니다.

```php theme={null}
// 관련 모델의 테이블에 대해 name을 검색 · 갱신
$user->roles()->updateOrCreate([
    'name' => 'Administrator',
]);
```

또한 `firstOrCreate`는 제2인자의 `$values`를 받을 수 있게 되어, 다른 릴레이션과 동작이 맞춰졌습니다.

```php theme={null}
// 신규 생성 시에만 created_by를 병합
$user->roles()->firstOrCreate([
    'name' => 'Administrator',
], [
    'created_by' => $user->id,
]);
```

***

### Custom Casts와 `null`

**영향도: 중간**

Laravel 9에서는 캐스트 대상에 `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 클라이언트의 기본 타임아웃

**영향도: 중간**

HTTP 클라이언트의 기본 타임아웃은 30초가 되었습니다. 이전에는 무제한으로 계속 대기하는 경우가 있었습니다.

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

// 오래 걸리는 API 호출만 개별로 타임아웃을 연장
$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 접속에서 검색 경로를 설정하고 있는 경우, `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}
// 변경 전
$this->assertDeleted($user);

// 변경 후
$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}
// 변경 전
'password' => ['required', 'password'],

// 변경 후
'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로의 대응이 중심입니다. 메일 송신 · 스토리지 · 독자 캐스트 · 테스트 헬퍼를 먼저 점검하면, 업그레이드 후의 불편을 줄일 수 있습니다.

| 변경점                                  | 영향도 | 대응                                        |
| ------------------------------------ | --- | ----------------------------------------- |
| PHP 8.0.2 / `laravel/framework:^9.0` | 높음  | `composer.json`과 실행 환경 갱신                 |
| Symfony Mailer로의 마이그레이션              | 높음  | SwiftMailer 계 API와 mail 설정 확인             |
| Flysystem 3.x                        | 높음  | `Storage`의 반환값 · 예외 · 드라이버 의존관계 확인        |
| `BelongsToMany`의 upsert 계 메서드        | 중간  | 검색 대상이 관련 모델의 테이블이 된 점 확인                 |
| Custom Casts와 `null`                 | 중간  | `set()`이 `null`을 받아도 부서지지 않도록 수정          |
| HTTP 클라이언트의 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으로의 업그레이드](/ko/blog/upgrade-9-to-10.md)
- [Laravel 10에서 11로의 업그레이드](/ko/blog/upgrade-10-to-11.md)
- [Laravel 11에서 12로의 업그레이드 가이드](/ko/blog/upgrade-11-to-12.md)
- [Laravel 12에서 13으로의 업그레이드 가이드](/ko/blog/upgrade-12-to-13.md)
