The situation
You're calling an API, reading an XML file, or processing a response — and you hit this:
Warning: SimpleXMLElement::__construct(): Entity: line 1: parser error : Start tag expected, '<' not found in /var/www/html/app/XmlParser.php on line 42
Your code probably looks something like this:
<?php
$response = file_get_contents('https://api.example.com/feed.xml');
$xml = new SimpleXMLElement($response); // <-- error thrown here
Or the function form:
$xml = simplexml_load_string($response);
The root cause is almost always the same: you're feeding the parser something that isn't XML.
Why this happens
PHP's XML parser reads the very first character and expects <. Anything else — a space, a stray byte, a letter — triggers this warning. Here are the usual suspects:
- The API returned an HTML error page — a 500 response, a login redirect, or a Cloudflare challenge page (403) instead of XML
- The response is empty — the URL returned nothing, or
file_get_contents()failed silently - BOM characters — a UTF-8 BOM (
\xEF\xBB\xBF) is prepended to the XML string, so the parser sees 3 invisible bytes before< - Whitespace before the XML declaration — some generators add a blank line before
<?xml version="1.0"?> - The variable holds an error message — something upstream failed and set
$responseto a string like"Error: timeout" - HTML entities in the wrong place — double-decoded or malformed entity strings
Diagnose first — dump what you actually have
Before fixing anything, print the raw input so you know what you're dealing with:
$response = file_get_contents('https://api.example.com/feed.xml');
var_dump(substr($response, 0, 200)); // first 200 chars
var_dump(strlen($response)); // is it empty?
Look at what comes back. string(0) "" means the fetch failed. <!DOCTYPE html> means you got an HTML page. A length of 3 with blank-looking output? That's almost certainly a BOM.
Quick fix: validate before you parse
Wrap your parsing in a safety check that catches the three most common failure modes — empty string, non-XML content, and BOM:
<?php
function safe_parse_xml(string $raw): ?SimpleXMLElement {
// Strip UTF-8 BOM if present
$raw = ltrim($raw, "\xEF\xBB\xBF");
// Strip leading whitespace (some feeds have \n before <?xml)
$raw = ltrim($raw);
// Bail on empty string
if (empty($raw)) {
error_log('XML parse failed: empty input');
return null;
}
// Bail if it doesn't look like XML at all
if ($raw[0] !== '<') {
error_log('XML parse failed: input does not start with < — got: ' . substr($raw, 0, 100));
return null;
}
// Suppress the warning and check the return value
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;
}
// Usage
$response = file_get_contents('https://api.example.com/feed.xml');
$xml = safe_parse_xml($response);
if ($xml === null) {
// handle the failure — show a user-friendly message, retry, etc.
throw new RuntimeException('Could not parse XML response');
}
Two things make this work: libxml_use_internal_errors(true) silences the PHP warning, and libxml_get_errors() gives you the actual parse error. Without them, the warning either pollutes your error log or leaks to the browser — and you still have nothing structured to act on.
Fix for the BOM case specifically
Files saved by Windows tools — Notepad, Excel exports, anything from an older Microsoft stack — commonly include a UTF-8 BOM. You'll also hit this when reading from a database column populated by a Windows client.
// Check for BOM
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
$raw = substr($raw, 3);
}
The ltrim approach from the earlier function is more forgiving when you have a mix of leading whitespace and a BOM. The explicit check above is cleaner when you're only targeting the BOM.
Fix for API responses that return error HTML
Always check the HTTP status code before you try to parse the body:
$context = stream_context_create([
'http' => [
'ignore_errors' => true, // don't fail on 4xx/5xx
]
]);
$response = file_get_contents('https://api.example.com/feed.xml', false, $context);
// $http_response_header is populated automatically by 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);
For production code, cURL is a better choice than file_get_contents — use curl_getinfo($ch, CURLINFO_HTTP_CODE) to check the status before touching the body.
Permanent fix: a robust XML loader utility
Drop this in a shared utility class and you'll never need to copy-paste the validation logic again:
<?php
class XmlLoader {
/**
* Parse XML string safely, returning null on any failure.
* Handles BOM, leading whitespace, empty input, and libxml errors.
*/
public static function parse(string $raw, bool $logErrors = true): ?SimpleXMLElement {
$raw = ltrim($raw, "\xEF\xBB\xBF"); // strip BOM
$raw = ltrim($raw); // strip leading whitespace
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;
}
/**
* Load and parse an XML file safely.
*/
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));
}
}
The LIBXML_NOCDATA flag is a useful bonus here — it converts CDATA sections to plain strings automatically, so you don't need to cast nodes manually every time you access them.
Verify the fix worked
Run a quick sanity check against both valid and invalid inputs:
// Should return a SimpleXMLElement
$good = XmlLoader::parse('<?xml version="1.0"?><root><item>test</item></root>');
var_dump($good instanceof SimpleXMLElement); // bool(true)
// Should return null (not throw)
$bad = XmlLoader::parse('');
var_dump($bad); // NULL
$alsoBad = XmlLoader::parse('Error: service unavailable');
var_dump($alsoBad); // NULL
// BOM case
$bom = "\xEF\xBB\xBF" . '<root/>';
$result = XmlLoader::parse($bom);
var_dump($result instanceof SimpleXMLElement); // bool(true)
Check your error log — you should see [XmlLoader] messages for the bad inputs, and zero PHP warnings anywhere.
One thing to check if the error persists
Still hitting the error even though your string clearly starts with <? Look for invisible characters. Some APIs prepend a null byte or a stray \r before the XML. Dump the raw bytes to find them:
echo bin2hex(substr($response, 0, 10));
// Expected for UTF-8 XML: 3c3f786d6c ('<?xml')
// BOM prefix looks like: efbbbf3c3f786d6c
Any bytes before 3c (hex for <) point to your culprit.

