2026-11-03時点の公開資料と、架空CMSのローカル検証です。PHPサイトの一覧に更新日を足す。小さな改修でも、「更新日が出た」だけで完了にすると、非公開記事まで表示する変更を見逃すかもしれません。
部分改修の完了条件には、変える機能・変えない振る舞い・戻した後に残すデータを含めます。 コードの確認、利用者の確認、本番反映、復旧確認は別々に記録します。DB構造や保存形式を変えず、旧コードが新しいデータを読める場合は、まずコードだけを戻す案を検討できます。その互換性が不明なら、反映前に追加検証が必要です。
この記事で実行したのは、PythonとSQLiteによる公開可能な架空例です。AI支援で作成・実行しています。PHPの実行環境は手元になく、PHP/PDO、実際のCMS、本番デプロイ、利用者による受入れは未検証です。
更新日を足しても、公開条件は変えない
例では既存のpagesテーブルに、ID、タイトル、公開状態、更新日があります。DBの構造は変更せず、公開一覧に更新日を追加する想定です。
改修前に、次の契約を短く書きます。
| 確認対象 | 期待する結果 | 分けておく理由 |
|---|---|---|
| 今回変える部分 | 公開記事に更新日が表示される | 追加機能の合否 |
| 変えない部分 | 非公開記事は一覧に出ない | 誤表示・情報漏えいを防ぐ回帰確認 |
| 出力の扱い | タイトルのHTMLが文字として出る | 表示追加でエスケープを落とさない |
| 戻した後 | 反映後に追加した正当な記事を失わない | コード復旧とデータ復元を混同しない |
「今の出力と同じなら合格」では、既存の不具合まで固定します。非公開を出さない等の守るべき仕様を先に定め、不明なら確認待ちにします。今回は期待値が決まっている架空例です。
変更の小ささより、戻せる条件で比べる
| 選択肢 | 選びやすい条件 | 残る負担・見送る条件 |
|---|---|---|
| 現状維持・手動で確認 | 更新日がなくても業務を続けられ、頻度が低い | 確認漏れと作業量を記録する。情報漏えいが既にある場合の放置策にはしない |
| 既製CMSの標準設定を使う | 標準の表示設定で要件を満たす | 表示だけでなく公開条件・権限への影響を確認する |
| 既存一覧の限定改修 | 必要な列があり、DB構造を変えずに済む | 既存条件を保った回帰確認と、旧コードでの読取確認が必要 |
| DB変更を伴う刷新 | 既存構造では要件を満たせない | 新旧互換・データ移行・復旧が別の検証対象になる |
この例では限定改修を選びます。表示に必要な値が既にあり、DB変更まで広げる理由がないためです。実際の選定ではCMSの設定、テーマ、拡張機能を先に調べます。今回、それらの製品比較は行っていません。
追加機能の確認は通っても、改修は不合格になる
v1を旧版、v2-badを公開条件を落とした不良候補、v2-fixedを条件を残した候補とします。更新日の確認だけなら、どちらのv2も通ります。非公開を含む入力を用意すると差が出ます。
検証はPython 3.11.8、SQLite 3.51.0で実行しました。次をregression_rollback.pyとして保存し、python3 regression_rollback.pyで再現できます。DBは全てメモリ上の架空データです。外部送信や実サイトへの接続は行いません。
import html
import sqlite3
import sys
def listing(db, version):
# v2-bad deliberately drops the old publication filter.
where = '' if version == 'v2-bad' else ' WHERE published=1'
rows = db.execute(
'SELECT id,title,updated_at FROM pages' + where + ' ORDER BY id'
).fetchall()
return [(key, html.escape(title), date if version != 'v1' else '')
for key, title, date in rows]
def checks(db, version):
rows = listing(db, version)
return {
'updated_date': rows[0][2] == '2026-11-03',
'private_hidden': [row[0] for row in rows] == [1],
'html_escaped': rows[0][1] == '<b>public</b>',
}
if __name__ == '__main__':
print(f'Python {sys.version.split()[0]} / SQLite {sqlite3.sqlite_version}')
db = sqlite3.connect(':memory:')
snapshot = sqlite3.connect(':memory:')
restored = sqlite3.connect(':memory:')
try:
db.execute('''CREATE TABLE pages (
id INTEGER PRIMARY KEY, title TEXT NOT NULL,
published INTEGER NOT NULL, updated_at TEXT NOT NULL)''')
db.executemany('INSERT INTO pages VALUES (?,?,?,?)', [
(1, '<b>public</b>', 1, '2026-11-03'),
(2, 'private', 0, '2026-11-03'),
])
db.commit()
db.backup(snapshot) # Before the simulated release.
assert [row[0] for row in listing(db, 'v1')] == [1]
for version in ('v2-bad', 'v2-fixed'):
result = checks(db, version)
print(version, result)
assert result == {
'updated_date': True,
'private_hidden': version == 'v2-fixed',
'html_escaped': True,
}
# Legitimate write after the simulated release, committed already.
db.execute('INSERT INTO pages VALUES (?,?,?,?)',
(3, 'new-public', 1, '2026-11-03'))
db.commit()
code_only = [row[0] for row in listing(db, 'v1')]
snapshot.backup(restored) # Disposable comparison, never a real DB.
old_snapshot = [row[0] for row in listing(restored, 'v1')]
assert code_only == [1, 3]
assert old_snapshot == [1]
assert db.execute('SELECT COUNT(*) FROM pages').fetchone()[0] == 3
print('old code + current DB:', code_only)
print('old code + old snapshot:', old_snapshot)
print('PASS: regression detected; fixed candidate checked; rollback compared')
finally:
restored.close()
snapshot.close()
db.close()
結果は次のとおりでした。不良候補で回帰確認が失敗することも、この検証の期待結果です。
v2-bad updated_date=True private_hidden=False html_escaped=True
v2-fixed updated_date=True private_hidden=True html_escaped=True
old code + current DB: [1, 3]
old code + old snapshot: [1]
前半は、更新日が表示されても公開条件の合格とは限らないことを示します。後半は、反映後の更新を模したID=3をcommitしてから、旧コードで読み直した結果です。コードだけ旧版へ戻すとID=3は残り、改修前スナップショットを別DBへ復元した比較では消えました。
図の破線は、①の後に本番DBを巻き戻す手順ではありません。別の比較用DBへ改修前の複製を復元しています。「バックアップがある」と「反映後の更新を保ったまま戻せる」は、別の確認です。
なお、このfixtureはSQL条件と戻した後のデータを確認するためのモデルです。PHPで同じ出力になる証拠や、ブラウザ上のXSS対策全体の証明にはなりません。PHPへ移すなら、HTML本文の出力箇所はhtmlspecialcharsの公式資料に照らし、文字コードと出力先に合わせて確認します。JavaScriptやURLへの出力を、このテスト一つで安全とは扱いません。
完了を四つの欄に分ける
次は実運用へ持ち込むための設計案です。今回、実際に確認したのはローカルの候補と、単純化した戻し方だけです。
| 段階 | 完了の証拠 | 今回の状態 |
|---|---|---|
| コード確認 | 対象版、入力、期待値、実行結果、レビュー記録 | 架空fixtureを実行。独立レビューとPHP実装は未実施 |
| 利用者確認 | 利用者の主要操作と合否、残る例外 | 未実施 |
| 本番反映 | 許可された対象版、反映時刻、稼働版、反映後の確認 | 未実施 |
| 復旧確認 | 戻し先、発動条件、担当者、残す更新、復旧後の照合 | メモリDBの比較のみ。本番手順・所要時間は未検証 |
Google SREのRelease Engineeringでは、再現可能なビルドや、ソースからデプロイまでの意図したリリース工程が重視されています。ここでの四段階の表は、それを小規模な改修へ当てはめた本記事の整理です。表自体が公式標準という意味ではありません。
実際のPHPサイトでは、コードに加えて設定、依存ライブラリ、PHP版、DB構造、アップロードファイル、キャッシュ等が影響します。戻し先のコードだけ保存しても、その組合せを再現できるとは限りません。
DB構造を変える場合は、さらに条件が変わります。PDOの公式資料には、MySQL等でDDLが暗黙にcommitを起こす注意があります。rollBack()を呼べばデプロイ全体が戻る、と考えるのは危険です。今回のコードはDDLを使った切戻しを検証していません。
反映前に止める条件を決める
保守担当は対象版と検証結果を残し、運用担当は誰が停止・復旧を実行できるか確認します。利用者側には主要操作の期待結果を確認してもらいます。一人が兼務する場合も、この確認を一つの「納品済み」にまとめません。
例えば、非公開記事が出る回帰失敗は反映を止める条件です。反映後に見つかった場合は、アクセス遮断等の封じ込めも検討し、記録した対象版とデータ状態から戻し方を選びます。コードを戻しても、既に閲覧された情報や送信済み通知は取り消せません。
旧コードが新しいデータを読めないなら、今回のコードだけの切戻しは選べません。書込みを止める範囲、更新差分の保全、補償処理、修正版を前へ進める方法を別に検討します。DB全体を復元するなら、失われる更新と許容範囲を先に合意する必要があります。
小さな改修でも、回帰ケース作成、受入れ、復旧練習には工数が掛かります。今回は時間・費用の削減を測っていません。既存テストやCMS標準機能を再利用しつつ、表示変更に不要な全面刷新は追加しない、という範囲の判断です。
次に実環境相当の検証用コピーで確かめたいのは、公開一覧と詳細の権限、0件時、文字コード、更新日時のタイムゾーン、キャッシュ反映、書込み中の切戻しです。DB構造が同じでも、値の意味や保存形式が変わる場合は新旧互換を試します。これらが未確認なら、今回のfixture合格を本番反映の許可へ置き換えず、調査・追加検証で止めます。
参考資料
2026-11-03確認。仕様の説明、ローカル実測、運用設計案は区別しています。
- PHP: htmlspecialchars:HTML文字参照への変換。PHPでの実行は未実施。
- PHP: Transactions and auto-commit:PDOとDB側のトランザクション制約。
- Python 3.11: sqlite3:fixtureのDB接続・backup API。
- Google SRE: Release Engineering:再現可能なリリース工程。
この記事を書いた人✏️@YushiYamamoto
個人事業ITPRODX.comの山本勇志です。PHPサイトの改修・保守、Web・CMS開発の担当経験を基に、既存システムの切り分けや設計判断を発信しています。
古いホームページやPHPサイトの調査・限定改修は、対応できる範囲を確認のうえご相談を承ります。
