PHPOffice/PhpSpreadsheet · critical · PhpOffice\PhpSpreadsheet\Calculation\Exception
Locale file not found
Error message
Locale file not found
What it means
XmlScanner::scan() pattern-matches every payload for a forbidden marker - '<!DOCTYPE' for spreadsheet readers, '<!ENTITY' for the Html reader (see getInstance(), XmlScanner.php:22-27) - allowing NUL bytes between each character so '\0'-obfuscation cannot slip through. The first check at line 93-95 runs on the raw bytes before any charset conversion; a match means the document contains a DOCTYPE/ENTITY construct and the load is aborted with 'Detected use of ENTITY in XML...' to prevent XXE (external entity injection) and XEE (entity-expansion / billion-laughs) attacks, since libxml's own protections are not considered sufficient.
Source
Thrown at src/PhpSpreadsheet/Calculation/CalculationLocale.php:111
}
/**
* Get the currently defined locale code.
*/
public function getLocale(): string
{
return self::$localeLanguage;
}
protected function getLocaleFile(string $localeDir, string $locale, string $language, string $file): string
{
$localeFileName = $localeDir . str_replace('_', DIRECTORY_SEPARATOR, $locale)
. DIRECTORY_SEPARATOR . $file;
if (!file_exists($localeFileName)) {
// If there isn't a locale specific file, look for a language specific file
$localeFileName = $localeDir . $language . DIRECTORY_SEPARATOR . $file;
if (!file_exists($localeFileName)) {
throw new Exception('Locale file not found');
}
}
return $localeFileName;
}
/** @return array<int, array<int, string>> */
public function getFalseTrueArray(): array
{
if (!empty(self::$falseTrueArray)) {
return self::$falseTrueArray;
}
if (count(self::$validLocaleLanguages) == 1) {
self::loadLocales();
}
$falseTrueArray = [['FALSE'], ['TRUE']];
foreach (self::$validLocaleLanguages as $language) {
if (str_starts_with($language, 'en')) {View on GitHub (pinned to 65b080eef4)
Solutions
- If the file is trusted, remove the <!DOCTYPE ...> block (and any internal subset with ENTITY definitions) before loading - most spreadsheet XML does not need a DTD.
- If the file is untrusted, do not bypass the check: treat the exception as an attack indicator, log and reject the upload.
- Sanitize programmatically only for trusted sources: preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) into a temp file, then load that.
- Keep PhpSpreadsheet updated - the scanner default pattern and libxml flags are periodically tightened as XXE bypasses are found.
Example fix
// before
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load('report2003.xml');
// throws: Detected use of ENTITY in XML ... (file has a <!DOCTYPE declaration)
// after - strip DTD constructs from a TRUSTED file before loading
$xml = file_get_contents('report2003.xml');
$xml = preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) ?? $xml;
$tmp = tempnam(sys_get_temp_dir(), 'xlsx');
file_put_contents($tmp, $xml);
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($tmp); Defensive patterns
Strategy: try-catch
Validate before calling
// Pre-scan raw bytes for the scanner's own pattern before loading
function containsBlockedMarker(string $path, string $marker = '<!DOCTYPE'): bool
{
$raw = (string) file_get_contents($path);
$pattern = '/\0*' . implode('\0*', mb_str_split($marker, 1, 'UTF-8')) . '\0*/';
return (bool) preg_match($pattern, $raw);
}
if (containsBlockedMarker($path)) {
// trusted source: strip DTD into a sanitized temp copy; untrusted: reject here
} Try / catch
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
try {
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
} catch (ReaderException $e) {
if (str_contains($e->getMessage(), 'ENTITY in XML')) {
// Do NOT strip-and-retry for untrusted input: this is the XXE/XEE guard.
// Quarantine the file, alert, and return a validation error to the sender.
}
throw $e;
} Prevention
- Ask upstream systems to omit DTDs/DOCTYPE from spreadsheet XML - spreadsheet formats do not require them.
- Sanitize untrusted XML/HTML with a purifier (strip DOCTYPE and ENTITY definitions) before it reaches PhpSpreadsheet.
- Never work around this guard by patching XmlScanner or downgrading the package; the block exists because libxml defenses alone are insufficient.
- Alert on this exception in import endpoints - frequent hits usually mean someone is probing for XXE.
- Keep the package updated so scanner pattern fixes land as they are released.
When it happens
Trigger: Loading a file whose raw bytes contain the reader's pattern: an Excel 2003 XML / SpreadsheetML or generic .xml file carrying a <!DOCTYPE declaration; an XLSX whose embedded XML was hand-crafted to include <!ENTITY; for the HTML reader, any input containing '<!ENTITY' - typically inline SVG or crafted HTML; also payloads with interleaved NUL bytes ('<\0!\0DOCTYPE') which the \0*-joined regex still catches.
Common situations: Legitimately generated XML from third-party exporters that include a DOCTYPE line (false positive); user uploads specifically probing for XXE against PhpSpreadsheet; SVG or entity-heavy HTML fed to the Html reader; files edited by hand where a DTD reference was inserted.
Related errors
- #VALUE!
- Cloning the calculation engine is not allowed!
- Unsupported binary comparison operator
- Unsupported numeric binary operation
- #NUM!
AI-assisted analysis of PHPOffice/PhpSpreadsheet@65b080eef4 (2026-08-17).
Data as JSON: /api/errors/5d0d15e43f176e9a.
Report an issue: GitHub.