PHP面接の技術質問:型・例外・入力検証を小さなAPIで確認する
Quick Overview
PHP面接の技術質問を、補充設定のプレビューAPIで練習します。数値0・null・省略の区別、strict_typesの境界、JSON解析と入力検証、ThrowableとHTTP応答を実行結果で説明。42項目のPHP検証と12件のHTTP検証、反例の表で判断を確かめます。
PHP面接の技術質問では、「strict_typesを使えば安全です」と答えた後が大切です。JSONの数値0、文字列"0"、falseを同じ値へ変換してしまうと、型宣言を通っても入力の意味は失われます。変換より先に、受け取った型と値を検証する必要があります。
この記事では、在庫の補充設定を変更する前に確認する、小さなプレビューAPIを作ります。省略した項目は維持し、nullは明示的な消去、0は有効な数値として扱うのが課題です。入力と期待結果を並べ、型、例外、HTTP応答、処理の順序を一つのケースで説明します。
根拠の区分: PHPの型宣言、JSON解析、例外の性質は公式マニュアルで確認した事実です。入力契約とエラーへの割り当てはこの記事の独自の設計で、採用企業の指定ではありません。PHP 8.5.11で行った42項目のローカル検証と、組み込みサーバーに対する12件のHTTP検証を観察結果として示します。候補者報告は使用しておらず、出題頻度や合格基準も推定しません。

まず入力契約を決める:0、null、省略を混ぜない
対象はPOST /api/policy-preview/SKU-Aです。現在の設定はreorder_point=10、note="review"。応答のproposedには変更案を入れ、persisted=falseを返します。データベースへの書き込みは行いません。 更新APIなら必要になる排他制御や再送時の扱いを、この例で解決したことにはしないでください。
変更できる項目は二つです。reorder_pointはJSONからPHPの整数として得られる0〜500、noteはnullまたは0〜40文字の印字可能ASCII文字列とします。少なくとも一項目を指定し、未知のキーは拒否します。日本語を受け付けないのは型と境界条件を明確にする演習上の制限で、日本向けサービスの推奨仕様ではありません。
| リクエスト本文 | 期待する結果 | 説明するべき違い |
|---|---|---|
{"reorder_point":0} | 200、補充点0、noteはreview | 0は未入力ではない |
{"reorder_point":"0"} | 422 | 数値に見えても文字列 |
{"reorder_point":false} | 422 | 真偽値を0へ変換しない |
{"reorder_point":0.0} | 422 | この契約では整数の表現が必要 |
{"note":null} | 200、noteをnullにする変更案 | 明示的な消去 |
{"note":""} | 200、noteを空文字にする変更案 | 空文字とnullは別の値 |
{"reorder_point":2} | 200、noteはreview | 省略した項目は維持 |
{} / [] / null | 422 | 変更項目なし/オブジェクトでない |
空白だけのnoteも今回の契約ではそのまま保持します。trim()を暗黙に追加すれば仕様が変わります。面接では「一般にこうするべき」から入るより、このAPIが何を受け入れるかを先に確認すると、コードで何を守るのかを説明できます。
strict_typesは外部入力の検証を代わりにしない
PHP公式の型宣言の説明では、スカラー型への厳密なチェックには呼び出し元ファイルの設定が関係します。関数を定義したファイルへdeclare(strict_types=1);を書くだけで、すべての呼び出しを厳密にできるわけではありません。ここでは呼び出しも同じ厳密なファイルで行います。
<?php
declare(strict_types=1);
function acceptPoint(int $point): int { return $point; }
var_export([(int) 'no', (int) false, (int) '0', empty(0)]);
// array (0 => 0, 1 => 0, 2 => 0, 3 => true)
try {
acceptPoint('0');
} catch (TypeError $e) {
echo "\nTypeError\n";
}
この出力はPHP 8.5.11で確認しました。キャスト後の0だけ見ても、元が有効な整数、誤った文字列、真偽値のどれだったかは分かりません。empty(0)もtrueなので、有効な補充点を未入力として落としてしまいます。
strict_typesがある場合でも、まず(int)$inputへ変換してから渡せば、関数が受け取るのは既に整数です。型宣言はその前に失われた情報を復元しません。したがって、キャストを検証の代わりにはできません。またintを受け取れることと、0〜500の範囲内であることは別の条件です。境界で表現と範囲を検証する必要があります。
比較演算子もバージョンを添えて説明します。公式の比較演算子の表にある非数値文字列との比較はPHP 8で変更されました。今回のPHP 8.5.11では0 == 'no'はfalse、0 == '0'はtrue、0 === '0'はfalseでした。PHP 7での違いは文書上の説明であり、今回旧ランタイムを実行した結果ではありません。
JSONの解析と項目の検証を別々にする
JSONとして読めることは、入力契約を満たすことではありません。json_decodeの公式説明に従い、JSON_THROW_ON_ERRORを使うと解析失敗をJsonExceptionで扱えます。本文のnullは有効なJSONなので、解析成功後に「オブジェクトではない」と判定します。
以下が実際の検証関数です。深さの上限32も演習の選択です。JSONが壊れている場合と、解析できるが使えない入力を別の例外へ分けています。
<?php
declare(strict_types=1);
final class BadJson extends RuntimeException {}
final class InvalidInput extends RuntimeException {}
function parseChanges(string $raw): stdClass {
try {
$body = json_decode($raw, false, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BadJson('bad_json', 0, $e);
}
if (!$body instanceof stdClass) {
throw new InvalidInput('object_required');
}
$keys = array_keys(get_object_vars($body));
if ($keys === [] || array_diff($keys, ['reorder_point', 'note']) !== []) {
throw new InvalidInput('invalid_fields');
}
if (property_exists($body, 'reorder_point')) {
$value = $body->reorder_point;
if (!is_int($value) || $value < 0 || $value > 500) {
throw new InvalidInput('invalid_reorder_point');
}
}
if (property_exists($body, 'note')) {
$note = $body->note;
if ($note !== null &&
(!is_string($note) || preg_match('/\A[\x20-\x7E]{0,40}\z/D', $note) !== 1)) {
throw new InvalidInput('invalid_note');
}
}
return $body;
}
is_int()を通す前にキャストしない点を説明してください。0と500は通り、-1と501は落ちます。文字数の下限は0なので空文字を受け入れ、改行、全角文字、41文字は拒否します。ここでは印字可能ASCIIだけを許すため、この正規表現で文字数とバイト数の混同を避けています。Unicodeへ広げるなら、文字数の定義と正規化を仕様から決め直します。
未知のキーを拒否するのも設計判断です。クライアントがreorderPointと誤記した場合に、成功したように見えるのを防げます。一方、追加項目を古いサーバーが受け流す互換性は失われます。どちらを選んだか、その影響まで言えると、単なるif文の説明からAPI契約の議論になります。
issetではnullと省略を区別できない
公式マニュアルでは、issetは存在しても値がnullならfalseを返します。一方、property_existsは、そのプロパティの値がnullでも存在を判定できます。今回のオブジェクトはJSONから得たstdClassなので、この違いをそのまま利用できます。
$body->note ?? $oldNoteでは、明示的なnullも古い値へ置き換わってしまいます。「nullなら消す」という契約を守るには、存在の確認と値の利用を分けます。配列で処理するならarray_key_exists()を検討しますが、オブジェクトの例へそのまま混ぜないでください。
final class PolicyMissing extends RuntimeException {}
function preview(string $raw, string $sku, callable $load): array {
$changes = parseChanges($raw);
$policy = $load($sku);
if ($policy === null) {
throw new PolicyMissing('policy_not_found');
}
if (property_exists($changes, 'reorder_point')) {
$policy['reorder_point'] = $changes->reorder_point;
}
if (property_exists($changes, 'note')) {
$policy['note'] = $changes->note;
}
return ['sku' => $sku, 'proposed' => $policy, 'persisted' => false];
}
この断片は直前の検証関数と組み合わせて使います。読み込んだ配列をローカルに変更して返すだけで、保存処理はありません。今回の200は「変更案を作れた」を意味し、「在庫設定を保存した」を意味しません。
順序にも理由があります。先に入力を検証するため、不正な本文ではloadを呼びません。存在しないSKUへの壊れたJSONは400、有効な変更本文で存在しないSKUなら404です。これは今回の観察可能な契約です。認証や認可が必要な実サービスでは、それらの位置や情報露出も含めて順序を検討する必要があり、この例だけで安全性を保証できません。
例外は原因ごとにHTTP応答へ変換する
PHP公式のThrowableはExceptionとErrorの両系統に共通するインターフェースです。TypeErrorはExceptionだけのcatchでは捕捉できません。ただし、すべてをThrowableで捕まえて422にすればよいわけでもありません。実装の型違反を利用者の入力ミスとして隠してしまいます。
この演習では、具体的な例外を先に処理し、最後のThrowableで予期しない失敗を500へ変換します。422はPHPが強制する番号ではなく、HTTPの422の意味を踏まえて選んだものです。既存APIの400契約を、面接中に無断で変更する必要はありません。
| 起きたこと | 今回の応答 | 利用者へ出す情報 |
|---|---|---|
| JSON解析失敗 | 400 | bad_json |
| 型、範囲、項目の違反 | 422 | 管理した検証エラーコード |
| 有効な入力だがSKUなし | 404 | policy_not_found |
| 対応パスでPOST以外 | 405、Allow: POST | method_not_allowed |
| Content-TypeがJSON以外 | 415 | json_required |
| 予期しない例外や型エラー | 500 | internal_error |
検証例外のメッセージには、自分で定義した固定コードだけを入れています。最後のcatchで$e->getMessage()を返さないのは、ファイルパスや内部構造の露出を避けるためです。ローカル検証では秘密を模した文字列を含むRuntimeExceptionを注入しても、応答はinternal_errorだけでした。
このcatchはエラーの原因を修正するものではありません。内部ログ、追跡ID、監視と通知は別に必要です。今回のコードには本番向けのログ処理を実装していないので、「ログまで含めて運用可能」とは答えません。レスポンスの外側にあるJSONのエンコード失敗も、別途境界を決める課題です。

テストでは値だけでなく呼ばれなかった処理を見る
正常系の200だけでは、検証が読込の後へ移動した不具合を見逃します。loadを呼ぶたびにカウンターを増やすテスト用関数を渡し、不正入力では回数が増えないことを確認しました。422の本文が正しくても、先に読込していれば今回の契約には違反するためです。
42項目のPHP検証では、境界値、noteの消去と維持、未知のキー、トップレベルの型、解析失敗、存在しないSKU、例外の変換、キャストと比較の結果を確認しました。18種類の不正な本文については422に加え、読込が行われないことも調べています。42項目はこのローカル検証の単位であり、網羅性の証明ではありません。
HTTP側ではPHP 8.5.11の組み込みサーバーをループバックだけに立て、12件の実リクエストを送りました。200、400、404、405、415、422のステータスとJSON本文、JSONのContent-Type、405のAllowを確認しました。500の注入検証は関数境界で実施し、HTTP経由では行っていません。
| 変更を加えて考える練習 | 失われる性質 | 確認に使う入力 |
|---|---|---|
is_int()より先にキャスト | 元の型の区別 | "0"、false、"no" |
存在判定をisset()に変更 | 明示的なnullの消去 | {"note":null} |
未入力判定をempty()に変更 | 0の受理 | {"reorder_point":0} |
| 読込を検証より先へ移動 | 不正入力で読込しない約束 | 壊れたJSON+呼出回数 |
| 最後のcatchで詳細を返す | 内部情報を出さない境界 | 秘密を模した例外 |
この表はレビュー練習用の変更案です。すべての改変版を実際に実行したという意味ではありません。確認済みの元コードの結果と、変更した場合の予測を分けて説明してください。
重複したJSONキーの拒否、本文サイズの制限、認証・認可、データベース障害、同時更新、レート制限は未検証です。組み込みサーバーで通った結果を、そのまま本番サーバーやフレームワーク全体の保証へ広げないことも、面接で伝えたい判断です。
面接では契約、実行結果、限界の順に答える
最初の説明は短くまとめられます。「補充点は整数0〜500です。noteは省略なら維持、nullなら消去、空文字はそのままです。入力の検証を読込より前に行い、プレビューだけを返します」。ここまで言えば、どの入力を追えば実装を確かめられるかが相手にも伝わります。
次に{"reorder_point":0,"note":null}を選び、解析、整数判定、存在判定、読込、変更案の順に追います。比較用に文字列"0"へ変え、422で読込へ進まないことを示してください。「厳密にしました」という抽象的な説明より、相手と同じ入力を見ながら、結果が変わる理由を確認できます。
追問で「実際に保存して」と言われたら、新しい要件として扱います。プレビュー後に設定が変わった場合の競合、保存のトランザクション、再送の扱い、成功応答のタイミングが必要になります。今回のpersisted=falseをtrueへ書き換えるだけでは、その保証は増えません。
以下のPracHub問題は、PHP固有の出題実績を示すものではありません。入力契約、境界条件、HTTPレビュー、テストの説明を別の題材で練習するために選んでいます。
| PracHubの問題 | このケースから持ち込む観点 |
|---|---|
| Design input validation and error handling | 型と範囲、エラーの責任範囲を先に決める |
| Code Review of a Multi-File HTTP API: Wrong Method, Payload and Validation Bugs | メソッド、本文、検証の層を分けて追う |
| Identify and prevent code-breaking inputs | null、空、境界値を具体的に選ぶ |
| Implement a simple service with tests | 依存を差し替えて処理順序まで確かめる |
| Review getEvents endpoint for readability, performance, scalability, security | 入力の正しさと読込・運用上のリスクを分ける |
0を受理し、nullを消去として保持できる理由を説明したら、入力検証とエラー処理の問題で別のAPI契約を作ってみてください。数値0、文字列"0"、不正入力では呼ばれない読込を一つずつ示すと、コードの意図が伝わります。
Comments (0)