状況
APIを呼び出したり、XMLファイルを読み込んだり、レスポンスを処理しているときに、次のエラーが発生します:
Warning: SimpleXMLElement::__construct(): Entity: line 1: parser error : Start tag expected, '<' not found in /var/www/html/app/XmlParser.php on line 42
コードはおそらく次のような形になっているはずです:
<?php
$response = file_get_contents('https://api.example.com/feed.xml');
$xml = new SimpleXMLElement($response); // <-- ここでエラーが発生
または関数形式の場合:
$xml = simplexml_load_string($response);
根本的な原因はほぼ常に同じです:XMLではないデータをパーサーに渡しているのです。
なぜこのエラーが起きるのか
PHPのXMLパーサーは最初の文字を読み込んで<を期待します。それ以外の文字——スペース、余分なバイト、アルファベット——があるとこの警告が発生します。よくある原因を以下に挙げます:
- APIがHTMLエラーページを返した — XMLの代わりに500レスポンス、ログインリダイレクト、またはCloudflareのチャレンジページ(403)が返ってきた場合
- レスポンスが空 — URLが何も返さなかった、または
file_get_contents()がエラーなく失敗した場合 - BOM文字 — UTF-8 BOM(
\xEF\xBB\xBF)がXML文字列の先頭に付加されており、パーサーが<の前に3つの不可視バイトを検出する場合 - XML宣言の前の空白 — 一部のジェネレーターが
<?xml version="1.0"?>の前に空行を追加する場合 - 変数がエラーメッセージを保持している — 上流で何かが失敗して
$responseに"Error: timeout"のような文字列がセットされている場合 - 誤った場所にあるHTMLエンティティ — 二重デコードまたは不正な形式のエンティティ文字列
まず診断する — 実際のデータを確認する
修正する前に、生の入力を出力して何を扱っているか確認しましょう:
$response = file_get_contents('https://api.example.com/feed.xml');
var_dump(substr($response, 0, 200)); // 最初の200文字
var_dump(strlen($response)); // 空かどうか確認
返ってきた内容を確認してください。string(0) ""はフェッチが失敗したことを意味します。<!DOCTYPE html>はHTMLページが返ってきたことを意味します。空白っぽい出力で長さが3?ほぼ確実にBOMです。
クイックフィックス:パース前にバリデーションする
最も一般的な3つの失敗パターン——空文字列、XML以外のコンテンツ、BOM——をキャッチする安全チェックでパース処理をラップします:
<?php
function safe_parse_xml(string $raw): ?SimpleXMLElement {
// UTF-8 BOMが存在する場合は除去
$raw = ltrim($raw, "\xEF\xBB\xBF");
// 先頭の空白を除去(フィードによっては<?xmlの前に\nがある)
$raw = ltrim($raw);
// 空文字列の場合は処理を中止
if (empty($raw)) {
error_log('XML parse failed: empty input');
return null;
}
// XMLに見えない場合は処理を中止
if ($raw[0] !== '<') {
error_log('XML parse failed: input does not start with < — got: ' . substr($raw, 0, 100));
return null;
}
// 警告を抑制して戻り値を確認
libxml_use_internal_errors(true);
$xml = simplexml_load_string($raw);
if ($xml === false) {
$errors = libxml_get_errors();
foreach ($errors as $error) {
error_log('libxml error: ' . trim($error->message));
}
libxml_clear_errors();
return null;
}
return $xml;
}
// 使用例
$response = file_get_contents('https://api.example.com/feed.xml');
$xml = safe_parse_xml($response);
if ($xml === null) {
// 失敗時の処理 — ユーザーフレンドリーなメッセージを表示、リトライなど
throw new RuntimeException('Could not parse XML response');
}
この処理を機能させる2つのポイント:libxml_use_internal_errors(true)がPHP警告を抑制し、libxml_get_errors()が実際のパースエラーを返します。これらがないと、警告がエラーログを汚染するかブラウザに漏れてしまい、対処できる構造化されたエラー情報も得られません。
BOMが原因の場合の対処法
Windowsツール——メモ帳、Excelエクスポート、古いMicrosoftスタック由来のもの——で保存されたファイルにはUTF-8 BOMが含まれることがよくあります。Windowsクライアントから書き込まれたデータベースカラムを読み込む場合にも同様の問題が発生します。
// BOMを確認
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
$raw = substr($raw, 3);
}
先ほどの関数で使ったltrimのアプローチは、先頭の空白とBOMが混在している場合により柔軟に対応できます。上記の明示的なチェックは、BOMだけを対象にする場合はよりシンプルです。
APIレスポンスがエラー用HTMLを返す場合の対処法
ボディをパースしようとする前に、必ずHTTPステータスコードを確認してください:
$context = stream_context_create([
'http' => [
'ignore_errors' => true, // 4xx/5xx でも失敗しない
]
]);
$response = file_get_contents('https://api.example.com/feed.xml', false, $context);
// $http_response_header は file_get_contents により自動的に設定される
if (isset($http_response_header[0]) && strpos($http_response_header[0], '200') === false) {
throw new RuntimeException('API returned non-200: ' . $http_response_header[0]);
}
$xml = safe_parse_xml($response);
本番コードでは、file_get_contentsよりもcURLを使う方が適切です——ボディを処理する前にcurl_getinfo($ch, CURLINFO_HTTP_CODE)でステータスを確認してください。
恒久的な対処法:堅牢なXMLローダーユーティリティ
次のコードを共有ユーティリティクラスに追加しておけば、バリデーションロジックをコピー&ペーストする必要がなくなります:
<?php
class XmlLoader {
/**
* XML文字列を安全にパースし、失敗した場合はnullを返す。
* BOM、先頭の空白、空の入力、libxmlエラーを処理する。
*/
public static function parse(string $raw, bool $logErrors = true): ?SimpleXMLElement {
$raw = ltrim($raw, "\xEF\xBB\xBF"); // BOMを除去
$raw = ltrim($raw); // 先頭の空白を除去
if ($raw === '' || $raw[0] !== '<') {
if ($logErrors) {
error_log('[XmlLoader] Invalid input: ' . substr($raw, 0, 80));
}
return null;
}
libxml_use_internal_errors(true);
$xml = simplexml_load_string($raw, SimpleXMLElement::class, LIBXML_NOCDATA);
if ($xml === false) {
if ($logErrors) {
foreach (libxml_get_errors() as $err) {
error_log('[XmlLoader] ' . trim($err->message) . ' (line ' . $err->line . ')');
}
}
libxml_clear_errors();
return null;
}
return $xml;
}
/**
* XMLファイルを安全に読み込んでパースする。
*/
public static function parseFile(string $path): ?SimpleXMLElement {
if (!is_readable($path)) {
error_log('[XmlLoader] File not readable: ' . $path);
return null;
}
return self::parse(file_get_contents($path));
}
}
LIBXML_NOCDATAフラグはここで便利なボーナスとして機能します——CDATAセクションを自動的に通常の文字列に変換するため、ノードにアクセスするたびに手動でキャストする必要がなくなります。
修正が効いたか確認する
有効な入力と無効な入力の両方に対して簡単なサニティチェックを実行してください:
// SimpleXMLElement を返すべき
$good = XmlLoader::parse('<?xml version="1.0"?><root><item>test</item></root>');
var_dump($good instanceof SimpleXMLElement); // bool(true)
// null を返すべき(例外をスローしない)
$bad = XmlLoader::parse('');
var_dump($bad); // NULL
$alsoBad = XmlLoader::parse('Error: service unavailable');
var_dump($alsoBad); // NULL
// BOMのケース
$bom = "\xEF\xBB\xBF" . '<root/>';
$result = XmlLoader::parse($bom);
var_dump($result instanceof SimpleXMLElement); // bool(true)
エラーログを確認してください——無効な入力に対しては[XmlLoader]のメッセージが表示され、PHP警告はどこにも出力されていないはずです。
エラーが解消しない場合に確認すること
文字列が明らかに<で始まっているのにまだエラーが出る?不可視文字を探してみてください。一部のAPIはXMLの前にヌルバイトや余分な\rを付加することがあります。生のバイト列をダンプして確認しましょう:
echo bin2hex(substr($response, 0, 10));
// UTF-8 XMLの期待値: 3c3f786d6c ('<?xml')
// BOMプレフィックスの場合: efbbbf3c3f786d6c
3c(<の16進数表現)より前にバイトがあれば、それが原因です。

