Skip to content

chore: raise PHPStan to level 9 - #27

Merged
danielebarbaro merged 1 commit into
mainfrom
chore/phpstan-level-9
Sep 9, 2026
Merged

chore: raise PHPStan to level 9#27
danielebarbaro merged 1 commit into
mainfrom
chore/phpstan-level-9

Conversation

@danielebarbaro

Copy link
Copy Markdown
Contributor

Why

Level 5 was letting the entire public surface through untyped:

public function getPlacemarks(): array
public function getStyles(): array
public function toGeoJson(): array

A bare array tells an IDE nothing and tells the analyser nothing. There is no way to catch a caller reading $placemark['coordinates'] on a MultiGeometry, which is exactly the mistake the README now warns about in prose.

Level 9 also found three real defects, which is the actual argument for the bump.

Defects found

1. An unreadable file reported the wrong problem

return $this->loadFromString(file_get_contents($path));

file_get_contents() returns false when the file exists but cannot be read: a permissions problem, a file being written, a broken symlink. false was passed straight into loadFromString(), where it became '', and the user got XML parsing error: String could not be parsed as XML.

That points at the content. The content was fine. The file could not be opened.

Now throws KmlParserException::failedToRead(): Unable to read KML file: <path>. loadFromFile() already had a file_exists() check for the missing case, so the two are now distinguishable.

2. xpath() and preg_split() failures were iterated

$placemarksXml = $this->xml->xpath('//kml:Placemark');
foreach ($placemarksXml as $placemarkXml) {

SimpleXMLElement::xpath() returns array|false|null, and preg_split() returns array|false. Both were fed straight to foreach. Five call sites across the parser, the validator and the coordinate trait. All now fall back to an empty array.

3. A non-numeric archive limit turned the limit off

$maxEntries = (int) $this->packageConfig('kml-parser.max_archive_entries', self::DEFAULT_MAX_ENTRIES);

(int) 'plenty' is 0, and 0 is the documented way to disable the limit. So a typo in the config silently removed the protection added in #24, which is the worst possible failure direction for that particular setting. A value that is not a number now falls back to the documented default.

Typing

ParsesCoordinates declares Position and PolygonBoundaries as @phpstan-type, imported by KmlParser, so the precise shapes are written once.

The getters are typed as precisely as they honestly can be: list<Placemark>, array<string, Style>, array<string, array{id: string, pairs: array<string, string>}>, array{type: string, features: list<array<string, mixed>>}. A placemark really is a heterogeneous array<string, mixed>, and pretending otherwise would be a fiction the analyser then enforces on everyone. Typed DTOs are the real answer there and remain a separate, breaking piece of work.

The GeoJSON conversion is narrowed with is_array() and is_numeric() rather than by widening the signatures. As a side effect, a hand-built or partially malformed geometry array now returns null or 0.0 for the bad part instead of raising a TypeError, so one broken coordinate cannot take a whole document's conversion down.

Fixed at the source

  • phpstan-baseline.neon is still 0 bytes
  • no @phpstan-ignore comments
  • no inline @var overrides (the two @var in KmlValidator are pre-existing property declarations, not suppressions)
  • no casts added to quiet the analyser, and no parameter or return type widened to make an error go away

Two @ suppressions, deliberate

@file_get_contents() and, from #24, @mkdir(). In both cases the return value is what is acted on, and the exception thrown carries the path while PHP's warning does not. Without the @, an application converting warnings to exceptions gets file_get_contents(): Failed to open stream: Permission denied instead of Unable to read KML file: <path>. Both carry a comment saying so, and both are covered by a test that asserts the package's message, not PHP's.

Tests

  • an unreadable file throws Unable to read KML file (skipped on Windows, where chmod does not deny reads the same way)
  • a missing file still throws KML file not found, so the two cases stay distinct
  • a non-numeric max_archive_entries falls back to the default instead of disabling the limit

Suite 88 to 91. phpstan analyse reports [OK] No errors at level 9, Pint clean.

Level 5 left the whole public surface untyped: every getter returned a
bare array, so nothing described the shape of a placemark or a style to
an IDE, and no analysis could catch a caller reading a key that is never
set. It also hid three real defects.

loadFromFile() passed the result of file_get_contents() straight on. An
unreadable file, a permissions problem rather than a missing one, made
that false, which became the empty string and surfaced as a parse error
about the content instead of about the file. It now throws
KmlParserException::failedToRead() with the path.

Every xpath() call was iterated without checking for the false it
returns on a malformed expression, and preg_split() the same. Both now
fall back to an empty array.

The KMZ limits were read from config with a blind (int) cast, so a
non-numeric value silently became 0, which turns the limit off. That is
the opposite of what someone setting a limit wants, so a value that is
not a number now falls back to the documented default.

Fixed at the source: the baseline is still empty, and there are no
phpstan-ignore comments, no inline @var overrides and no casts added to
quiet the analyser.
@danielebarbaro
danielebarbaro merged commit 3fd097a into main Sep 9, 2026
27 checks passed
@danielebarbaro
danielebarbaro deleted the chore/phpstan-level-9 branch September 9, 2026 09:04
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