Skip to content

fix(parser): stop swallowing validation errors - #14

Merged
danielebarbaro merged 2 commits into
mainfrom
fix/xml-parsing-and-error-handling
Sep 8, 2026
Merged

fix(parser): stop swallowing validation errors#14
danielebarbaro merged 2 commits into
mainfrom
fix/xml-parsing-and-error-handling

Conversation

@danielebarbaro

Copy link
Copy Markdown
Contributor

Four related defects in KmlParser::loadFromString() and KmlValidator, all in the same few lines, so they are fixed together.

1. Validation errors lose their type

loadFromString() wrapped its entire body in catch (\Exception) and rethrew everything through KmlParserException::failedToParse():

try {
    $this->validator->validate($content);
    // ...
} catch (\Exception $e) {
    libxml_clear_errors();
    throw KmlParserException::failedToParse($e->getMessage());
}

A KmlException raised by the validator ("Invalid longitude value: 181", "Missing required element: Document") arrived at the caller as KmlParserException: Failed to parse KML content: .... The original message survived inside the string, but the type did not, so there is no way to distinguish "this file is not XML" from "this file is XML but the coordinates are out of range" other than substring matching on the message.

Validation exceptions now propagate untouched.

2. invalidXml() was unreachable

$errors = libxml_get_errors();
if ($errors) {
    throw KmlParserException::invalidXml($errorMessage);
}

new SimpleXMLElement($content) throws on malformed input, so execution never reached the libxml_get_errors() check. And if it somehow had, the catch (\Exception) two lines below would have caught the invalidXml exception and re-wrapped it as failedToParse. Dead in two independent ways.

invalidXml() is now what malformed XML actually throws, which is what its name always claimed.

3. Everything was parsed twice

KmlValidator::validate() built a SimpleXMLElement, threw it away, and then loadFromString() built a second one from the same string. Double the CPU and double the peak memory on every single load.

KmlValidator gained validateDocument(SimpleXMLElement $xml), which validates an already parsed document. KmlParser parses once and passes the result. validate(string $content) keeps its existing signature and delegates, so direct users of the validator are unaffected.

4. Global libxml state was never restored

Both classes called libxml_use_internal_errors(true) and never put it back. That is process-global: after one KML parse, every other library in the application using libxml (DOM, XMLReader, an XML HTTP client) silently stopped emitting warnings for the rest of the request.

The previous value is now captured and restored in a finally, on the success and the failure path.

Breaking change

Malformed XML previously threw:

KmlParserException: Failed to parse KML content: String could not be parsed as XML

and now throws:

KmlParserException: XML parsing error: String could not be parsed as XML

Still KmlParserException, different message prefix. Anything matching on the old string needs updating. tests/ExceptionsTest.php is updated accordingly.

Validation failures previously threw KmlParserException and now throw the underlying KmlException. Since KmlParserException extends KmlException, any catch (KmlException) keeps working; a catch (KmlParserException) around a validation failure does not. That is the point of the fix, but it belongs in the changelog.

KmlParserException::failedToParse() is no longer thrown by anything. Marked @deprecated rather than deleted, so callers referencing it keep working for one cycle.

Tests

tests/ParsingErrorsTest.php, all five failing on main:

  • a validation failure arrives as KmlException, not KmlParserException, with its own unwrapped message
  • malformed XML arrives as KmlParserException prefixed XML parsing error:
  • the libxml error mode is restored after a successful load
  • the libxml error mode is restored after a failed load
  • validateDocument() validates a pre-parsed document

Pint and PHPStan clean.

loadFromString() wrapped its whole body in `catch (\Exception)` and
rethrew everything as KmlParserException::failedToParse(). A validation
failure raised by KmlValidator therefore reached the caller as a generic
parse error, so "Invalid longitude value: 181" and "malformed XML" were
indistinguishable by type. Validation exceptions now propagate untouched
and malformed XML surfaces as invalidXml(), which until now was
unreachable because the catch block below it re-wrapped it immediately.

The content was also parsed twice, once by the validator and once by the
parser, doubling the work and the peak memory of every load. The
validator gained validateDocument(), which takes an already parsed
document, and the parser builds the SimpleXMLElement once and hands it
over. validate() keeps its string signature and delegates.

Both classes flipped libxml_use_internal_errors(true) on and never
restored it, changing libxml error handling for the rest of the
application. The previous value is now saved and restored in a finally,
on the success and failure paths alike.

failedToParse() is no longer thrown and is marked deprecated rather than
removed, so callers referencing it keep working for one cycle.
KmlValidator hardcoded the OGC 2.2 namespace and rejected everything
else, so any document exported before the OGC took the format over
(earth.google.com/kml/2.0, 2.1 and 2.2, still common in the wild) failed
validation with "Invalid or missing KML namespace" and could not be
parsed at all.

The accepted namespaces are now a list, configurable through the new
supported_namespaces key, and XPath is registered against the namespace
the document actually declares rather than the one we assumed. That last
part is what makes a 2.1 document parse end to end instead of validating
and then returning no placemarks.

The validator also ignored the kml-parser.namespace config entirely,
even though KmlParser read it. Setting that key made every document
invalid, since validation still demanded 2.2. KmlParser now passes the
configured namespaces to the validator, so the key finally does what it
says. KmlValidator keeps working standalone, defaulting to the known
namespaces without touching the container.
@danielebarbaro
danielebarbaro force-pushed the fix/xml-parsing-and-error-handling branch from 1d06a29 to 2a454b7 Compare September 8, 2026 13:14
@danielebarbaro
danielebarbaro merged commit 46a1893 into main Sep 8, 2026
19 checks passed
@danielebarbaro
danielebarbaro deleted the fix/xml-parsing-and-error-handling branch September 8, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant