Communitygithub.com

php-migration

PHP 버전 업그레이드 시 발생하는 비권장(deprecated) 및 파괴적 변경(breaking change)에 대한 수정 레시피를 버전별로 정리한 가이드입니다. baserCMS와 CakePHP 기반 프로젝트에 최적화되어 있습니다.

php-migration란 무엇인가요?

이 스킬은 PHP 버전 업그레이드(8.2, 8.4, 8.5) 과정에서 만날 수 있는 비권장 기능과 파괴적 변경 사항을 구체적인 수정 방법과 함께 버전별로 정리한 실전 가이드입니다. 특히 baserCMS와 CakePHP 프레임워크 기반 프로젝트에서 직접 확인된 사례를 바탕으로 하여, 추상적인 설명이 아닌 실제 코드 예제와 정규식 기반 탐지 패턴, sed를 통한 일괄 수정 명령어까지 제공합니다. 각 항목은 발생 빈도와 영향도를 고려하여 우선순위가 표시되어 있으며, PHP 8.1 호환성을 유지해야 하는 상황에서 8.5의 비권장 사항을 어떻게 처리할지(✅ 즉시 적용 가능 / ❌ 8.1 지원 중단까지 보류)를 명확히 구분해 줍니다. PHP 8.5의 예약 클래스명 사용으로 인한 치명적 오류(Fatal)와 같이 스위트 전체를 중단시킬 수 있는 문제를 최우선으로 처리하는 등 실무에서의 효율적인 마이그레이션 작업 흐름을 제시합니다. Rector 같은 정적 분석 도구의 활용을 권장하며, 단위 테스트 실행 및 로그 분석을 통한 regression 확인 방법도 함께 포함되어 있습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add baserproject/baser-skills

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

PHP 버전 마이그레이션 가이드 (baserCMS)

PHP 버전 업그레이드 시 마주친 비권장(deprecated) 및 파괴적 변경(breaking change)과 수정 레시피 모음입니다. 버전별로 정리하여 관리합니다. CakePHP 프레임워크 자체에서 발생하는 문제는 별도 스킬 cakephp-migration을 참조하세요.

수록: PHP 8.2 / 8.4 / 8.5. 새 버전 대응 시 새로운 "## PHP X.Y" 절을 추가합니다. 주의: PHP 8.5는 9.0 직전 버전입니다. 9.0에서 비권장 기능은 모두 제거되므로, 8.5에서 발생하는 비권장 경고는 미리 해결해 둡니다.

대원칙

  • "치명적 오류"와 "비권장 경고"를 구분합니다. 디버그 모드에서는 비권장 경고가 화면 하단에 표시되어 "에러"처럼 보일 수 있지만, logs/debug.log에 debug:로 기록되는 것은 경고(동작은 계속됨)입니다. Fatal/Exception이 진짜 에러입니다.
  • regression과 기존 실패를 분리합니다. git diff HEAD -- <file>로 해당 파일이 자신의 변경 대상인지 확인합니다.
  • 부모 클래스의 시그니처를 변경했다면, 자식 클래스와 인터페이스도 함께 맞춥니다.
  • 모든 비권장 사항의 포괄적 탐지에는 Rector 등의 정적 분석 도구가 효과적입니다.

단위 테스트 실행 방법, 문법 검사, 실패 원인 분석은 basercms-unittest 스킬을 참조하세요. php -l (문법 검사)에서는 암시적 nullable 등의 비권장 경고도 함께 출력됩니다.


PHP 8.2

동적 속성 비권장

선언되지 않은 속성에 값을 할당하는 것이 비권장됩니다. Creation of dynamic property X::$prop is deprecated.

  • 대책 A (권장): 클래스에 속성을 선언합니다.
    /** @var ThemeFoldersServiceInterface */
    protected $ThemeFoldersService;
    protected $_templateTypes = [];
    
    ※ 생성자에서 $this->Xxx = $this->getService(...)처럼 할당하는 곳이 전형적입니다.
  • 대책 B: 다수의 자식 클래스에 영향을 미치는 기반 클래스(예: 테스트 기반 BcTestCase는 400개 이상의 서브클래스를 가짐)에는 #[\\AllowDynamicProperties]를 부여합니다. 속성(attribute)은 자식 클래스에 상속되므로 한 곳에서 처리할 수 있습니다.
    #[\AllowDynamicProperties]
    class BcTestCase extends TestCase { ... }
    

PHP 8.4

1. 암시적 nullable 인자 비권장

型 $x = null는 암시적 nullable로 비권장됩니다. 명시적으로 ?型으로 변경합니다.

// Before
function foo(int $contentId = null, BcAbstractDetector $agent = null)
// After
function foo(?int $contentId = null, ?BcAbstractDetector $agent = null)
  • 탐지 (정규식): 앞에 ?가 없으면서 型 $name = null 형태의 인자와 일치합니다. 예: ([A-Za-z_\\\\][A-Za-z0-9_\\\\]*)\\s+(\\$[A-Za-z0-9_]+)\\s*=\\s*null (직전에 ?가 없는 것).
  • 부모 클래스의 시그니처를 변경할 때는 자식 클래스/인터페이스도 함께 맞춥니다.
  • 경고: Implicitly marking parameter $x as nullable is deprecated, the explicit nullable type must be used instead.

2. fgetcsv()의 $escape 비권장

$escape 인수 생략이 비권장됩니다. 명시적으로 작성합니다.

// Before
$head = fgetcsv($fp, 10240);
// After
$head = fgetcsv($fp, 10240, ',', '"', '\\\\');
  • 자체 구현(fgets+정규식으로 CSV를 파싱하는 함수 등)은 fgetcsv를 사용하지 않으므로 대상이 아닙니다. 실제로 fgetcsv()를 호출하는 곳만 수정합니다.

3. ReflectionProperty::setValue() 단일 인자 호출 비권장

정적이 아닌 속성은 대상 객체를 전달해야 합니다.

// Before
$property->setValue($value);
// After
$property->setValue($object, $value);
  • 경고: Calling ReflectionProperty::setValue() with a single argument is deprecated.
  • 테스트 유틸리티(요청 생성이나 private 속성 조작 헬퍼)에 숨어 있기 쉽습니다.
  • 정적 속성의 경우 첫 번째 인자에 null을 전달합니다: setValue(null, $value). 이 방식은 8.1~8.5 모두에서 유효합니다 (8.1 호환성을 깨지 않음).

4. 내부 함수의 string 인자에 null 전달 비권장 (실제로는 8.1+)

parse_url(null) / version_compare($a, null) / str_replace(..., null) 등, ?string이 아닌 인자에 null을 전달하면 Passing null to parameter #N ($x) of type string is deprecated가 발생합니다. (string) 캐스트로 명시적 변환합니다.

// Before → After
parse_url($url);                       →  parse_url((string)$url);
version_compare($current, $required);  →  version_compare((string)$current, (string)$required);
  • 8.1에서 도입된 비권장이지만, 마이그레이션 작업(특히 8.4/8.5로 업데이트)에서 대량으로 발생하므로 본 문서에도 수록합니다.
  • 실행 시 값이 null일 때만 발생하므로 정적 grep으로는 완전히 찾을 수 없습니다. 전체 테스트 로그에서 Passing null to parameter를 집계하여 발생원을 특정합니다.

PHP 8.5 (2025-11-20 릴리스)

9.0 직전의 최종 마이너 버전입니다. 아래 대부분은 경고이며 동작은 계속되지만, 9.0에서 제거되므로 대응해야 합니다. 애플리케이션/프레임워크에 영향이 큰 순서로 나열합니다. ※"0."만 경고가 아닌 치명적 오류(파괴적 변경)이므로 최우선 처리합니다.

⚠️ PHP 8.1 호환성을 유지하는 동안의 주의사항 (composer.json이 "php": ">=8.1"인 경우). 8.5의 비권장 사항을 "권장하는 대체 코드"로 수정하면, 그 대체 코드가 8.1에 존재하지 않아 8.1에서 Fatal이 발생하여 동작하지 않는 경우가 있습니다. 아래 ❌ 표시는 8.1 지원을 중단할 때까지 보류 (8.5에서 경고는 발생하지만 허용)합니다. 8.5에서 동작시키는 것 자체는 8.1을 깨뜨리지 않습니다 (경고만 발생).

  • ❌ str_increment() (8.3+) → 8.1에서는 $s++ 그대로 유지
  • ❌ http_get_last_response_headers() (8.4+) → 8.1에서는 $http_response_header 그대로 유지
  • ❌ 네임스페이스 PDO (Pdo\Mysql::ATTR_* 등, 8.4+) → 8.1에서는 PDO::MYSQL_ATTR_* 그대로 유지

위 항목 외(비정규 캐스트, null 오프셋 → '', 자동 해제 함수 제거, 백틱 → shell_exec, case ;, __serialize)는 8.1에서도 안전하게 적용할 수 있습니다.

0. [파괴적 변경·치명적] 예약 클래스명 (Array 등)을 use로 참조하면 Fatal

8.5에서 Array 등이 특별한 클래스명으로 예약되어 use ...\Array; 또는 class Array가 Fatal(경고가 아님)을 발생시킵니다. 테스트 수집(오토로드) 단계에서 실패하면 스위트 전체가 시작 불가능해지므로 최우선으로 처리합니다.

Fatal error: Cannot use BcMail\Test\TestCase\Model\Array as Array because 'Array' is a special class name
  • baserCMS 실적: MailMessagesTableTest.php 서두에 CakePHP2 시대의 잔재 use ...\Array; / use ...\ClassRegistry; (둘 다 실제로 존재하지 않는 클래스에 대한 데드 import)가 남아 있었으며, 8.4까지는 무해했지만 8.5에서 Fatal로 전환되었습니다. 해당 use 행을 삭제하여 해결했습니다 (참조하는 곳은 markTestIncomplete 처리된 미실행 코드).
  • 탐지: grep -rnE "use [A-Za-z0-9_\\\\]+\\(Array|Object|Resource|Enum|Mixed|Never|Void|Null|False|True|Iterable|Numeric)\\s*;" --include="*.php".
  • 주의: 치명적이므로 php -l로 개별 파일을 검사할 수 있습니다. 하지만 모든 테스트 전에 grep으로 미리 제거해 두면, 스위트가 수집 단계에서 실패하는 사고를 방지할 수 있습니다.

1. 비정규 캐스트명 비권장 (영향 큼)

(boolean) (integer) (double) (binary)가 비권장됩니다. 정규명으로 교체합니다.

// Before → After
(boolean) → (bool)
(integer) → (int)
(double)  → (float)
(binary)  → (string)
  • 탐지: grep -rnE "\\((boolean|integer|double|binary)\\)" --include="*.php".
  • 오래된 코드에 남아 있기 쉽습니다. 기계적으로 교체 가능합니다.

2. null을 배열 오프셋으로 사용하는 것 비권장

$arr[null] / array_key_exists(null, $arr) 등에서 null을 키로 사용하는 것이 비권장됩니다 (암시적으로 ""로 변환되었음). 명시적으로 빈 문자열 ""을 사용합니다.

// Before
$arr[null] = $v;  array_key_exists(null, $arr);
// After
$arr[''] = $v;    array_key_exists('', $arr);
  • 변수가 키가 되는 곳($arr[$key]에서 $key가 null이 될 수 있는 경우)에 주의합니다.
  • 정적 grep으로는 탐지가 어렵습니다 (실행 시 값이 null일 때만 발생). 전체 테스트를 8.5에서 실행하고 grep "null as an array offset" phpunit.log로 발생원(on line N of <file>)을 집계하는 것이 확실합니다.
  • 수정 방침: ① 대입/참조의 키를 $key ?? ''(기존 암시적 변환과 동일. set/get 양쪽을 같은 키로 맞출 것), ② isset()/참조 앞에서 $key !== null && 가드, 중 하나를 사용합니다. getData('batch') 등의 null 반환을 ?? ''로 받는 것도 효과적입니다.
  • baserCMS 실적 (총 308건): BcFileUploader (uploadingFiles[$bcUploadId]의 set/get을 ?? ''로 통일), BcUploadBehavior (oldEntity[$alias][$entity->_bc_upload_id]를 ?? ''), MailMessagesTable ($dists[$mailField->group_valid]), SearchIndexes/Contents/PermissionsController (getData('batch') ?? ''), BcTextHelper::arrayValue / BcUtil::decodeContent ($key !== null && isset(...)).

3. 객체를 자동 해제하는 리소스 해제 함수 비권장

대상이 리소스가 아닌 객체가 되었으며, GC에 의해 자동 해제되므로 명시적인 close/free 호출이 비권장됩니다. 호출을 삭제하면 됩니다.

  • curl_close() / curl_share_close() (CurlHandle / CurlShareHandle)
  • finfo_close() (finfo)
  • imagedestroy() (GdImage) ← 이미지 처리 유틸리티에서 자주 사용됨
  • xml_parser_free() (XMLParser)
  • 탐지: grep -rnE "\(curl_close|curl_share_close|finfo_close|imagedestroy|xml_parser_free)\\s*\\(" --include="*.php".
  • baserCMS 실적: Imageresizer.php · BcFileUploader.php의 imagedestroy() 4곳. 단순 삭제도 괜찮지만, 루프 내 메모리 즉시 해제 의도를 유지하기 위해 unset($img)로 교체 (GdImage는 객체이므로 참조를 끊으면 GC가 해제. >=8.1에서 안전).

3-2. ReflectionProperty/Method::setAccessible() 비권장 (발생 건수 가장 많음)

setAccessible()는 PHP 8.1 이후 no-op (private/protected도 getValue/setValue/invoke로 직접 접근 가능). 8.5에서 비권장화됩니다. 호출 행을 삭제하면 됩니다 (>=8.1에서 안전).

// Before
$property = new ReflectionProperty($obj, 'foo');
$property->setAccessible(true);   // ← 이 행 삭제
$property->setValue($obj, $v);
  • baserCMS 실적: src/테스트 합쳐 33곳 (BcUtil/BcTestCase/BaserCorePlugin/PreviewController/BcDatabaseService 등). 핫 패스(테스트 기반 등)에 있으므로 런타임 발생 건수가 비교할 수 없을 정도로 많습니다 (전체 테스트에서 약 6,300건 ≒ 8.5 비권장의 대부분). 이것을 먼저 해결하면 로그가 급감합니다.
  • 일괄 삭제 (단독 문장에 한정): sed -i -E "/^[[:space:]]*\\$[A-Za-z0-9_]+->setAccessible\(true\);[[:space:]]*$/d" <file>.
  • 탐지 시 주의: grep 패턴을 ->setAccessible처럼 -로 시작하면 grep이 옵션으로 오인하여 0건이 됩니다. grep -rn "setAccessible"처럼 -로 시작하지 않게 합니다 (또는 grep -e 사용).

4. 숫자가 아닌 문자열의 증가(increment) 비권장

$str++ (숫자가 아닌 문자열)이 비권장됩니다. str_increment()을 사용합니다. ※ str_increment()는 8.3+입니다. 8.1 유지 중에는 $str++ 그대로 보류합니다.

// Before
$code++;            // $code가 'AZ' 등인 경우
// After
$code = str_increment($code);

5. __sleep() / __wakeup()의 소프트 비권장

__serialize() / __unserialize()를 사용합니다 (PHP7 호환이 필요하지 않다면 마이그레이션).

6. $http_response_header 비권장

슈퍼글로벌 $http_response_header가 비권장됩니다. http_get_last_response_headers()를 사용합니다. ※ http_get_last_response_headers()는 8.4+입니다. 8.1 유지 중에는 $http_response_header 그대로 보류합니다.

7. 백틱 연산자 비권장

`command` (shell_exec()의 에일리어스)가 비권장됩니다. shell_exec()를 직접 사용합니다.

8. case 문을 ;로 종료하는 것 비권장

case 1;는 비권장됩니다. case 1:로 변경합니다.

  • 탐지: grep -rnE "case\\s+.+;\\s*$" --include="*.php" (오탐이 많으므로 육안 확인).

8-2. array_key_exists(null, ...) 비권장 (serialize의 비정규형으로 유발)

array_key_exists()의 첫 번째 인자 null이 비권장됩니다 (빈 문자열 사용). 프레임워크 내부 (CakePHP JsonView::_dataToSerialize)에서 발생하는 경우, 원인은 자체 코드의 serialize 옵션의 비정규형입니다. setOption('serialize', ['foo', 'message' => $message])처럼 변수명 목록에 'key' => 값을 혼합하면, $message가 null일 때 serialize 배열에 'message' => null이 들어가 array_key_exists(null, ...)가 실행됩니다.

  • 수정: serialize는 뷰 변수명의 나열로 만듭니다. ['foo', 'message' => $message] → ['foo', 'message'] (값은 $this->set()으로 이미 설정됨). baserCMS 실적: MailMessagesController의 index/view 액션.
  • 발생원이 프레임워크 내부일 때의 특정법: vendor를 임시 계측 (if ($key === null) file_put_contents(..., var_export($serialize,true).getTraceAsString())) → 해당 플러그인의 테스트 실행 → 스택 트레이스의 최상단에 있는 자체 컨트롤러 특정 → vendor 복원.

8-3. ArrayObject에 객체를 backing array로 전달하는 것 비권장

new ArrayObject($obj)로 객체 (json_decode()의 stdClass 등)를 전달하는 것이 비권장됩니다. 배열로 캐스트합니다.

  • 수정: new ArrayObject(json_decode($body)->devices) → new ArrayObject((array)json_decode($body)->devices). baserCMS 실적: SitesControllerTest.

9. 기타 (해당 시에만)

  • PDO 드라이버 고유의 상수/메서드가 네임스페이스 형태로 이전 (PDO::MYSQL_ATTR_* → Pdo\Mysql::ATTR_*, PDO::sqliteCreateFunction() → Pdo\Sqlite::createFunction() 등). 대부분은 프레임워크 내부이지만, 자체적으로 DB 드라이버 상수를 사용하고 있다면 대응해야 합니다. PDO의 "uri:" DSN 스키마도 비권장됩니다. ※ 네임스페이스 PDO 클래스는 8.4+입니다. 8.1 유지 중에는 PDO::MYSQL_ATTR_* 그대로 보류합니다.
  • openssl_pkey_derive()의 $key_length, finfo_buffer()의 $context 인자가 비권장됩니다 (무시됨).
  • 비CLI SAPI에서의 register_argc_argv INI가 비권장됩니다 ($_GET / $_SERVER['QUERY_STRING'] 사용).
  • static 클로저에 대한 bind 등, 일부 클로저 바인딩이 비권장됩니다.
  • clone null의 에러 메시지 변경 (테스트 영향): 8.4까지는 __clone method called on non-object, 8.5는 clone(): Argument #1 ($object) must be of type object, null given. null을 clone하는 예외 케이스를 문자열 일치로 검증하는 테스트는 기대값 업데이트가 필요합니다 (baserCMS 실적: UploaderCategoriesControllerTest의 Admin/Api 각 test_copy).

⚠️ 전체 테스트 실행 시 연쇄 에러는 먼저 재실행으로 원인을 분리하세요: 본 코드베이스는 full-run에서 드물게 "어떤 선행 테스트가 전역 상태(플러그인 설정 Configure 등)를 오염 → 이후 거의 모든 테스트가 Application bootstrap에서 PluginCollection::create(): $config ... null given 등으로 연쇄 에러"라는 일시적인 오염이 발생할 수 있습니다. 플러그인 단독 스위트 (예 plugins/baser-core/tests/TestCase)가 통과하고, 재실행 시 깨끗하다면 코드 원인이 아닌 flaky입니다. 격리(isolation)와 재실행으로 반드시 확인합니다.


조사 진행 방법 (체크리스트)

  1. php -l (문법 검사) 또는 logs/debug.log에서 비권장 경고가 발생하는 파일·행을 특정합니다.
  2. 해당 버전 절의 어떤 패턴인지 분류하여 수정합니다. 기계적 교체가 가능한 것(비정규 캐스트 등)은 grep으로 일괄 탐지합니다.
  3. php -l로 재확인합니다 (경고가 사라졌는지).
  4. 해당 테스트를 --filter로 단독 확인 → 마무리로 전체 테스트 재실행.

관련: 프레임워크 원인 (이벤트 반환값·Association 중복·ResultSet·FormProtection 등)은 cakephp-migration 스킬로 이동하세요.


출처

관련 스킬