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_argvINI가 비권장됩니다 ($_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)와 재실행으로 반드시 확인합니다.
조사 진행 방법 (체크리스트)
php -l(문법 검사) 또는logs/debug.log에서 비권장 경고가 발생하는 파일·행을 특정합니다.- 해당 버전 절의 어떤 패턴인지 분류하여 수정합니다. 기계적 교체가 가능한 것(비정규 캐스트 등)은 grep으로 일괄 탐지합니다.
php -l로 재확인합니다 (경고가 사라졌는지).- 해당 테스트를
--filter로 단독 확인 → 마무리로 전체 테스트 재실행.
관련: 프레임워크 원인 (이벤트 반환값·Association 중복·ResultSet·FormProtection 등)은 cakephp-migration 스킬로 이동하세요.