PHPOffice/PhpSpreadsheet · error · PhpOffice\PhpSpreadsheet\Calculation\Exception

Unsupported binary comparison operator

Error message

Unsupported binary comparison operator

What it means

XmlScanner::toUtf8() normalizes every XML payload it loads to UTF-8 before libxml parses it. Before converting, it records whether the payload starts with an XML declaration via '/^.{0,4}\s*<?xml/s'; if the declaration was detectable before mb_convert_encoding() but is no longer detectable after it, the conversion has mangled the document start. That only happens when the file's real byte encoding does not match the charset PhpSpreadsheet detected (from the BOM via Csv::guessEncodingBom() or the encoding="..." declaration), i.e. the file is mis-declared or already double-encoded, so the load is aborted with 'Double encoding not permitted'.

Source

Thrown at src/PhpSpreadsheet/Calculation/BinaryComparison.php:89

        }

        $useLowercaseFirstComparison = is_string($operand1)
            && is_string($operand2)
            && Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE;

        return self::evaluateComparison($operand1, $operand2, $operator, $useLowercaseFirstComparison);
    }

    private static function evaluateComparison(mixed $operand1, mixed $operand2, string $operator, bool $useLowercaseFirstComparison): bool
    {
        return match ($operator) {
            '=' => self::equal($operand1, $operand2),
            '>' => self::greaterThan($operand1, $operand2, $useLowercaseFirstComparison),
            '<' => self::lessThan($operand1, $operand2, $useLowercaseFirstComparison),
            '>=' => self::greaterThanOrEqual($operand1, $operand2, $useLowercaseFirstComparison),
            '<=' => self::lessThanOrEqual($operand1, $operand2, $useLowercaseFirstComparison),
            '<>' => self::notEqual($operand1, $operand2),
            default => throw new Exception('Unsupported binary comparison operator'),
        };
    }

    private static function equal(mixed $operand1, mixed $operand2): bool
    {
        if (is_numeric($operand1) && is_numeric($operand2)) {
            $result = (abs($operand1 - $operand2) < self::DELTA);
        } elseif (($operand1 === null && is_numeric($operand2)) || ($operand2 === null && is_numeric($operand1))) {
            $result = $operand1 == $operand2;
        } else {
            $result = self::strcmpAllowNull($operand1, $operand2) == 0;
        }

        return $result;
    }

    private static function greaterThanOrEqual(mixed $operand1, mixed $operand2, bool $useLowercaseFirstComparison): bool
    {

View on GitHub (pinned to 65b080eef4)

Solutions

  1. Re-export or re-save the file as UTF-8 (with a matching encoding="UTF-8" declaration or no declaration at all) and retry the load.
  2. Verify the file's true encoding against its declaration: check the BOM bytes with bin2hex(substr($raw,0,4)) and compare with the XML declaration before loading.
  3. If you produce the XML yourself, drop the XML declaration or make encoding="UTF-8" explicit so findCharSet() returns UTF-8 and no conversion runs.
  4. Pre-normalize untrusted input before load(): strip the mismatched declaration with preg_replace('/^<\?xml[^>]*\?>/', '', $raw), convert with mb_convert_encoding($raw,'UTF-8',$realCharset), write to a temp file, and load that.

Example fix

// before
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load('export.xml');
// throws: Double encoding not permitted (declaration says UTF-16, bytes are UTF-8)

// after - normalize the declaration/encoding mismatch before loading
$raw = file_get_contents('export.xml');
$raw = preg_replace('/^<\?xml[^>]*\?>/', '', $raw) ?? $raw; // drop mismatched declaration
$utf8 = mb_convert_encoding($raw, 'UTF-8', 'UTF-8');
$tmp = tempnam(sys_get_temp_dir(), 'xlsx');
file_put_contents($tmp, $utf8);
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($tmp);
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight: does the declared/BOM charset match reality before loading?
function encodingLooksConsistent(string $path): bool
{
    $raw = file_get_contents($path);
    if ($raw === false || $raw === '') {
        return false;
    }
    $bom = strtoupper(bin2hex(substr($raw, 0, 4)));
    $declared = null;
    if (preg_match('/^\s*<\?xml[^>]*encoding\s*=\s*(["\'])(.+?)\1/s', $raw, $m)) {
        $declared = strtoupper($m[2]);
    }
    $looksAsciiXml = (bool) preg_match('/^.{0,4}\s*<\?xml/s', $raw);
    // ASCII-readable declaration + multi-byte BOM/declaration => will double-convert
    if ($looksAsciiXml && ($bom === 'FFFE0000' || $bom === 'FEFF' || $bom === '0000FFFE' || $declared === 'UTF-16')) {
        return false;
    }
    return true;
}

Try / catch

use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;

try {
    $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
} catch (ReaderException $e) {
    if (str_contains($e->getMessage(), 'Double encoding')) {
        // normalize the file to UTF-8 and retry once, or report to the uploader
        log::warn('Encoding mismatch in upload', ['file' => $path]);
    }
    throw $e; // or return an error result
}

Prevention

When it happens

Trigger: Loading a file through IOFactory::load() or any reader (Xlsx, Xml, Html, ...) where findCharSet() returns something other than UTF-8 (e.g. a UTF-16 BOM, or encoding="UTF-16"/"ISO-8859-1" in the declaration) while the actual bytes are plain UTF-8/ASCII text. mb_convert_encoding() from the wrong source charset garbles the leading '<?xml' token, the post-conversion regex no longer matches, and Reader\Exception is thrown at XmlScanner.php:48.

Common situations: Spreadsheet/XML exports re-saved by editors (Notepad++, IDEs) or piped through iconv that re-encoded the body but left the original encoding declaration; UTF-16 files whose BOM was stripped or re-added by a transfer layer; concatenating XML fragments produced in different encodings; user-uploaded .xls/.xml from third-party tools that write inconsistent declarations.

Related errors


AI-assisted analysis of PHPOffice/PhpSpreadsheet@65b080eef4 (2026-08-17). Data as JSON: /api/errors/0146341d361519dc. Report an issue: GitHub.