技術資料

CodeIgniter 3のPHP更新に備える:8.1・8.3で主要機能の回帰テストを作る

作成日:2026.10.29

CodeIgniter 3のシステムをPHP 8.1から8.3へ更新する際の確認方法を解説します。ログイン・セッション・DB更新を7ケースの回帰テストで比較し、HTTP応答・保存結果・警告ログから不具合を切り分ける手順を紹介します。

CodeIgniter 3のシステムをPHP 8.1から8.3へ更新するとき、警告を消すだけで作業を終えてよいのか、気になるところです。

今回は、ログイン・セッション維持・ログアウトと、入力検証を含む商品情報のDB更新を題材に、更新前後で同じ動作を確認する回帰テストを作ります。

PHP 8.1から8.3への変更を確認する

8.1から8.3へ更新する場合、PHP 8.2への移行ガイドと、PHP 8.3への移行ガイドの両方を確認します。実行環境を8.2に切り替える手順が必須という意味ではなく、途中のバージョンで入った変更も影響するためです。

例えば、動的プロパティの作成が非推奨になったのは8.2です。8.3では引数なしのget_class()なども非推奨になっています。フレームワーク本体だけでなく、独自のライブラリやヘルパーが使っている処理も確認します。変更の一覧は公式資料を参照し、実際に利用している機能から優先して調べるとよいかと思います。

なお、2026年10月29日時点でPHP 8.1は公式サポートが終了しています。今回は更新前の比較用として隔離した環境で使います。移行先のPHP 8.3のセキュリティサポート期限は2027年12月31日なので、公開時や実際の移行時には最新の修正版と残りの期間も確認してください。

比較する環境と、変えないものを決める

今回確認した環境は次のとおりです。CodeIgniter 3全体のPHP 8.3対応を保証するものではなく、この組み合わせでサンプルの動作を比べた結果です。

項目更新前更新後
PHP8.1.348.3.36
CodeIgniter3.1.13。両環境で同一のsystemディレクトリを使用し、本体の修正は行わない
OS・HTTP実行環境Docker上のDebian bookworm系Linux / x86_64、PHP組み込みWebサーバー
DBSQLite 3.40.1 / PDO_SQLITE。各環境に同じ初期データを用意
セッションCodeIgniterのfilesドライバー
テストの実行Python 3.12系の標準ライブラリによるHTTPリクエストとSQLiteの再取得

PHP以外の条件をできるだけそろえるため、アプリケーションのコード、初期データ、DB、セッション保存方式を固定しています。ただし、PHPイメージに含まれるライブラリまで完全に同一とは限りません。差が出た場合は、PHP本体だけでなく拡張や設定の違いも調べます。

実システムでは、CI3本体に当てたパッチ、Composerの依存関係、DBドライバー、文字コード、セッション保存先と権限なども記録しておきます。PHPと同時にライブラリやDBまでまとめて更新すると、失敗した原因を追いにくくなるので、変更を分けられるものは分けておきたいですね。

CLIだけでなく、Web側のPHPも確認する

コマンドラインのPHPと、ApacheやFPMから動くPHPが異なる環境もあります。次のコマンドだけでWeb側まで確認したことにせず、検証環境のWebリクエスト内でもバージョンと設定を記録します。

php -v
php --ini
php -m

例えば、検証用コントローラーなどで次の情報をログへ出します。設定を確認するためのコードなので、一般公開する診断画面は作らず、確認後は外します。

error_log(json_encode([
    'php' => PHP_VERSION,
    'sapi' => PHP_SAPI,
    'extensions' => get_loaded_extensions(),
    'error_reporting' => error_reporting(),
    'display_errors' => ini_get('display_errors'),
    'log_errors' => ini_get('log_errors'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
警告は画面表示とログ出力を分ける

今回の検証では、CIの起動処理を読み込む前に次の設定を行いました。画面へ警告を混ぜずに、ログで確認するためです。

error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');

CI3のindex.phpは環境ごとにエラー設定を切り替えるため、ファイルの冒頭に書くだけでは後の設定で上書きされることがあります。実システムでは、環境分岐後に有効な設定と、PHP側のエラーログ・CI側のログの両方を確認してください。今回はCI側のlog_thresholdを1にし、書き込み可能な専用ログ保存先を用意しました。

display_errorsを無効にすることと、E_DEPRECATEDを記録対象から外すことは別です。移行時の確認では、警告を記録したうえで内容を分類します。

最初の回帰テストは2機能・7ケースに絞る

回帰テストは、変更前に動いていた機能が変更後も期待どおりに動くかを確認するテストです。最初から全画面を対象にすると準備だけで大変なので、まずは状態を引き継ぐ処理と、データを書き換える処理を選んでみます。

今回は、ダミーの利用者と商品1件を持つ商品管理画面を用意しました。商品名の編集にはログインが必要で、商品名の必須入力をチェックします。

ID操作期待する結果
C1未ログインで編集画面を開く303でログイン画面へ移動する
C2誤ったパスワードでログインする401とエラー表示。続けて編集画面を開いても入れない
C3正しい情報でログインし、別リクエストで編集画面を開く303で編集画面へ案内され、同じCookieを使ったGETが200になる
C4ログアウトしてから編集画面を開くログイン画面へ戻り、その後も編集画面へ入れない
C5日本語の商品名へ更新する更新後の画面と、DBから再取得した商品名が一致する
C6商品名を空欄で送信する422と入力エラー表示。DBの値は更新されない
C7未ログインで更新URLへ直接POSTするログイン画面へ戻され、DBの値も更新されない

このステータスコードは今回のサンプルで決めた仕様です。既存システムの認証失敗が200でフォームを再表示する仕様なら、それに合わせた期待結果を作ります。ただし、更新前に出た結果を無条件に正解とするのではなく、業務上期待する動作かを先に確認しておきます。

ファイルアップロードも重要ですが、保存先の権限、サイズ制限、MIME判定など確認項目が増えるため、今回は含めていません。実際の主要機能に合わせて追加する項目になります。

HTTP応答とDBの両方を確認するテストを作る

関数の戻り値を確認する単体テストも役立ちますが、ログイン後の状態維持にはCookieを伴う複数のリクエストが必要です。今回はテスト側でCookieを保持し、フォームからCSRFトークンを取得してPOSTします。リダイレクトは自動追跡せず、ステータスと移動先を確認した後、次のGETを送る形にしています。

サンプルのURLと入力項目は次のとおりです。既存システムへ適用するときは、URL、フォーム項目、画面の目印、DBの読み取り方法をそのシステムに合わせます。認証処理を新しく作るためのコードではなく、すでに動く機能の確認方法として使ってください。

パス役割
/index.php/regression/loginGETでフォーム表示、POSTで認証。項目はusername・password
/index.php/regression/edit認証後の編集画面。見出しにid="edit"、商品名のinputにname="name"を付ける
/index.php/regression/savePOSTで商品ID=1のnameを更新。成功時は編集画面へ303
/index.php/regression/logoutPOSTでセッションを破棄し、ログイン画面へ303

サンプルではCodeIgniterのSession・Form Validation・Query Builderを使用しています。CSRFを有効にし、Viewではform_open()でフォームを出力します。ログイン成功時にはセッションIDを再生成し、商品名の表示にはHTMLエスケープを行っています。以下のテスト用アカウントは隔離したサンプル専用のダミーです。

テストの初期状態をそろえる

両環境の専用DBに、商品ID=1、商品名「更新前の商品」を用意します。利用者名はdemo、パスワードはテストコード内のダミー値とし、DBにはpassword_hash()で生成したハッシュを保存します。認証にはpassword_verify()を使います。

更新後の環境へ、すでに変更済みのDBをそのまま渡すと「更新処理が何もしなくてもテストが通る」ということが起こり得ます。環境ごとの比較前に同じ初期データを用意し、Cookieも新しくします。下のコードはC1〜C7を順番に実行し、C6・C7ではC5で保存した値が変わらないことを確認します。再実行時も初期データを戻してください。

Pythonの標準ライブラリで7ケースを実行する

次のコードをregression_test.pyとして保存します。外部パッケージは使いません。DBの確認部分は、実行場所から読み取れる検証用SQLiteファイルを対象にしています。Webサーバーと別のコンテナで実行する場合は、そのファイルを読み取り専用で共有するなどの準備が必要です。

import argparse
import http.cookiejar
import json
import sqlite3
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
from pathlib import Path


class Fields(HTMLParser):
    def __init__(self, body):
        super().__init__()
        self.values = {}
        self.feed(body)

    def handle_starttag(self, tag, attributes):
        attrs = dict(attributes)
        if tag == 'input' and 'name' in attrs:
            self.values[attrs['name']] = attrs.get('value', '')


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


class Browser:
    def __init__(self, base):
        self.base = base
        self.opener = urllib.request.build_opener(
            urllib.request.ProxyHandler({}),
            urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
            NoRedirect(),
        )

    def request(self, action, fields=None):
        data = None if fields is None else urllib.parse.urlencode(fields).encode('utf-8')
        req = urllib.request.Request(self.base + action, data=data)
        try:
            response = self.opener.open(req, timeout=5)
        except urllib.error.HTTPError as error:
            response = error
        with response:
            return response.code, response.headers, response.read().decode('utf-8')

    def post(self, action, form_action, **values):
        status, _, body = self.request(form_action)
        check(status == 200, 'form must be available')
        token = Fields(body).values.get('csrf_test_name')
        check(bool(token), 'CSRF token must be present')
        return self.request(action, {'csrf_test_name': token, **values})


def check(condition, message):
    if not condition:
        raise AssertionError(message)


def redirect_to(response, action):
    status, headers, _ = response
    check(status == 303, 'expected 303')
    check(urllib.parse.urlsplit(headers.get('Location', '')).path
          == '/index.php/regression/' + action, 'unexpected redirect target')


def login(browser):
    redirect_to(browser.post('login', 'login', username='demo',
                             password='dummy-password-for-test'), 'edit')


def stored_name(path):
    with sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True) as db:
        row = db.execute('SELECT name FROM products WHERE id = 1').fetchone()
        check(row is not None, 'product must exist')
        return row[0]


def verify(base, database):
    passed = []
    browser = Browser(base)
    redirect_to(browser.request('edit'), 'login')
    passed.append('C1')

    status, _, body = browser.post('login', 'login', username='demo', password='wrong')
    check(status == 401 and '認証できませんでした' in body, 'invalid login')
    redirect_to(browser.request('edit'), 'login')
    passed.append('C2')

    login(browser)
    status, _, body = browser.request('edit')
    check(status == 200 and 'id="edit"' in body, 'session must persist')
    passed.append('C3')

    redirect_to(browser.post('logout', 'edit'), 'login')
    redirect_to(browser.request('edit'), 'login')
    passed.append('C4')

    login(browser)
    new_name = '更新後の商品'
    check(stored_name(database) != new_name, 'fixture must differ from update')
    redirect_to(browser.post('save', 'edit', name=new_name), 'edit')
    status, _, body = browser.request('edit')
    check(status == 200 and Fields(body).values.get('name') == new_name, 'redisplay')
    check(stored_name(database) == new_name, 'database update')
    passed.append('C5')

    before = stored_name(database)
    status, _, body = browser.post('save', 'edit', name='')
    check(status == 422 and '商品名を入力してください' in body, 'validation rejection')
    check(stored_name(database) == before, 'invalid input must not change database')
    passed.append('C6')

    visitor = Browser(base)
    # Use a valid CSRF token, so an unrelated CSRF rejection cannot pass C7.
    redirect_to(visitor.post('save', 'login', name='未認証の変更'), 'login')
    check(stored_name(database) == before, 'anonymous update must not change database')
    passed.append('C7')
    return passed


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--base', required=True)
    parser.add_argument('--database', required=True)
    args = parser.parse_args()
    parsed = urllib.parse.urlsplit(args.base)
    if parsed.scheme not in ('http', 'https') or not parsed.netloc or not args.base.endswith('/'):
        parser.error('--base must be an HTTP(S) URL ending in /')
    passed = verify(args.base, args.database)
    print(json.dumps({'passed': passed}, ensure_ascii=False))


if __name__ == '__main__':
    try:
        main()
    except Exception as error:
        print('FAIL ' + str(error), file=sys.stderr)
        sys.exit(1)

Browserを新しく作るとCookieも新しくなり、同じインスタンスを使うとログイン状態を引き継ぎます。CSRFトークンは固定せず、送信前にフォームから取得しています。C7も有効なトークンを付けているので、CSRFエラーを認証による拒否と取り違えずに確認できます。

また、C5ではHTMLの表示だけでなく、SELECT name FROM products WHERE id = 1でDBを再取得しています。成功メッセージだけを見ていると、実際には保存されていない不具合を見落とすためです。MySQLを使うシステムではstored_name()をそのDBの読み取り処理へ置き換え、同じDB・ドライバー・接続設定で確認してください。SQLiteで通った結果をMySQLでの確認の代わりにはしません。

更新前と更新後に同じコマンドを実行する

PowerShellでは、例えば次のように実行します。URLはサンプルと同じ画面を用意した検証先、DBパスはその検証先が使用しているSQLiteファイルに置き換えてください。テストは商品名を書き換えるので、必ず専用のダミーデータを使います。

python ./regression_test.py --base http://127.0.0.1:8181/index.php/regression/ --database D:/ci3-verify/php81/app.sqlite
if ($LASTEXITCODE -ne 0) { throw 'PHP 8.1側のテストが失敗しました。' }

python ./regression_test.py --base http://127.0.0.1:8183/index.php/regression/ --database D:/ci3-verify/php83/app.sqlite
if ($LASTEXITCODE -ne 0) { throw 'PHP 8.3側のテストが失敗しました。' }

成功すると、両方で次の結果になります。失敗した場合はFAILと確認内容を出し、終了コード1で止まります。HTTP応答の本文やCookieをそのままログへ出さないようにしています。

{"passed": ["C1", "C2", "C3", "C4", "C5", "C6", "C7"]}

今回のDocker検証ではホストへポートを公開せず、内部ネットワークから2環境へアクセスしました。上のコマンドは、読者が手元に用意した検証先を指定する場合の例です。組み込みWebサーバーは検証用で、本番公開用には使いません。

7ケースは通ったが、PHP 8.3では警告が残った

CodeIgniter 3.1.13の本体を変更しない状態で実行した結果です。

確認対象PHP 8.1.34PHP 8.3.36
C1〜C4:認証とセッション4ケース成功4ケース成功
C5〜C7:更新と保存拒否3ケース成功3ケース成功
採取したPHP・CIのエラーログ今回の操作では警告の記録なし動的プロパティの非推奨警告あり

PHP 8.3側のCIログには、例えば次の警告が出ています。8192はE_DEPRECATEDに当たります。

Severity: 8192 --> Creation of dynamic property CI_URI::$config is deprecated
Severity: 8192 --> Creation of dynamic property CI_Router::$uri is deprecated
Severity: 8192 --> Creation of dynamic property Regression::$session is deprecated

機能の結果は同じでも、保守上の問題がなくなったわけではありませんね。動的プロパティへの対応は、PHP 8.2の非推奨機能を確認し、アプリケーション側の修正か、フレームワーク側の対応かを切り分けます。今回の記事では、警告を消すための本体パッチは適用していません。

この結果から言えるのは、今回のサンプルと7ケースでは機能の違いを検出しなかった、というところまでです。CodeIgniter 3の全機能、別のライブラリ、MySQL、Apache/FPM上の動作、HTTPSのCookie設定、画面の見た目まで確認した結果ではありません。

失敗したら、機能・警告・環境差を分けて追う

失敗した項目は、ID・入力・期待結果・実際の結果・関連するログを組にして残します。ログは件数だけで比較せず、メッセージと発生箇所を見ます。同じ警告でもリクエスト数によって件数が変わるためです。

結果次に確認すること
更新前の8.1でも失敗する初期データ、URL、期待結果、既存の不具合を確認する。更新による不具合と決めつけない
8.3だけ機能テストが失敗する同じコード・データで再現するかを確認し、ログと8.2・8.3の移行資料を照合する
機能は通るが8.3だけ警告が増える警告の発生箇所と対応方針を記録する。テスト成功だけで対応完了としない
ログイン直後は成功するが次のGETで戻されるCookieの送受信、保存先の権限、ドメイン・パス・Secure属性などを確認する
画面では成功するがDBが変わらない接続先、更新対象、SQLの失敗、トランザクションの確定を確認する

修正したら、そのケースだけでなく7ケースをもう一度実行します。認証周りの修正で更新機能が使えなくなる、といった影響も確認したいためです。実システムでは、ここへファイルアップロードやメール送信などの主要機能を足していきます。

また、切り替え前にはDB・ファイルのバックアップ、切り戻し手順、切り替え中の更新データの扱いを別途決めておきます。今回の7ケースが通ることは、移行判断の材料の一つです。確認した環境と未確認の機能を残しておくと、次に何を調べるかも分かりやすくなるかと思います。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。

CodeIgniter 4のViewにVue.jsを組み込む

2026.08.20

CodeIgniter 4のView全体はサーバー側で描画したまま、一部分だけVue.jsを組み込む方法を解説します。Viteの開発サーバーからJavaScriptを読み込む方法、本番ビルドしたアセットをmanifestから取得してApacheで配信する方法、ControllerからViewへ渡したデータをVue.jsで検索する方法を確認します。

CodeIgniter JavaScript PHP Vue.js

CodeIgniter 4 クエリビルダ入門 ― 実践的なデータ操作とSQL組み立てのテクニック

2025.05.30

CodeIgniter 4 のクエリビルダの基本から実践的な利用方法までを、具体的なコード例とともに解説します。単純な条件指定から複雑なAND/ORや部分一致検索、IN句など、さまざまな使い方を段階的に学ぶことができます。これにより、SQL文を直接記述せずに柔軟なデータ操作が可能になる手法をご紹介します。

CodeIgniter PHP

CodeIgniter 4 フォーム入力バリデーション入門 ― 基本操作とカスタマイズ手法

2025.04.06

CodeIgniter 4でのフォーム入力値取得とバリデーション処理を、シンプルな実装例を通して解説します。基本的なルール設定から、エラーメッセージの出力、フォームラベルの日本語化やローカライズ方法まで、実践的なテクニックをコンパクトにまとめています。

CodeIgniter PHP

CodeIgniter 4 モデルの基本 ― CRUD操作とデータ操作ロジックの解説

2025.04.04

本記事では、CodeIgniter 4 のモデルの基本概念から、実際のCRUD(Create, Read, Update, Delete)操作の実装方法までを詳しく解説します。モデルを用いたデータベース操作の簡略化や、ビジネスロジックの分離手法、さらにコントローラとの連携方法など、実践的なコード例を交えながら、効率的なアプリケーション開発に必要な知識を提供します。

CodeIgniter PHP

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP