Skip to content

feat!: 2.0 - stateless exception handling, PHP ^8.3, revived toolchain - #139

Merged
hyperized merged 2 commits into
masterfrom
feat/2.0-stateless-exceptions
Aug 13, 2026
Merged

feat!: 2.0 - stateless exception handling, PHP ^8.3, revived toolchain#139
hyperized merged 2 commits into
masterfrom
feat/2.0-stateless-exceptions

Conversation

@hyperized

Copy link
Copy Markdown
Owner

Revives the php8 branch, untouched since 2024-05-15, and lands it as 2.0.0.

This PR carries two commits: the parked 2024 modernisation, and the 2.0 work on top. Reviewing the second commit alone is enough — the first is already on php8.

Why now

Dependabot has been reporting advisories on this repo while its own security-update jobs failed. It could not resolve master at all, because master still declares "php": ">=7.2" with 2024-era tooling:

- ergebnis/composer-normalize 2.3.2 requires composer-plugin-api ^1.1.0 -> found composer-plugin-api[2.9.0]
- infection/infection 0.15.3 requires php ^7.2.9 -> your php version (7.2; overridden via config.platform)

Patching master in place would have meant rewriting require-dev anyway, which is most of what php8 already did.

Defects fixed

Found by running the branch, not by reading the diff.

1. throwError() was unusable. The typed $exception property had no default:

Error: Typed property Hyperized\Xml\Validator::$exception must not be accessed before initialization

Raised both when nothing had failed and after a failed isXMLStringValid() — that path caught InvalidXml and returned false without recording anything, so string validation could never report a reason.

2. Five implicitly nullable parameters.string $xsdPath = null in three Validator methods and both ValidatorInterface methods, which PHP 8.4+ reports as deprecated on every call. The package declared ^8.1|^8.2|^8.3, so this was invisible to its own CI.

3. Global state leak.libxml_use_internal_errors(true) was never restored, leaving libxml error handling switched on for the rest of the consumer's process.

Design

All three shared one cause: the error lived on the validator object, so a stale error survived the next successful call and two validations on a shared instance overwrote each other.

The API now follows the shape the ecosystem settled on (league/flysystem's FilesystemException, PSR-18's ClientExceptionInterface, Respect/Validation's validate()/assert() split) — a predicate plus a throwing variant:

isXMLFileValid() / isXMLStringValid()bool, never throw
validateXMLFile() / validateXMLString()void, throw with detail

The predicates are implemented by catching from the throwing methods, so the two forms cannot drift apart. throwError() is removed.

Every exception implements the XmlValidatorException marker interface and extends RuntimeException, so one catch block replaces a union that silently stops covering everything as exceptions are added. InvalidXml keeps the LibXMLError objects, so line 2 column 23: xmlParseEntityRef: no name survives where before you got one imploded string. A missing XSD now reports as FileDoesNotExist instead of surfacing as a libxml parse error about the document.

Closes the gap #138 was aiming at, from a different direction — worth a reply to @duncan412 either way, since that report identified a real bug.

Toolchain

  • PHP floor ^8.3, replacing ^8.1|^8.2|^8.3 which excluded the 8.4/8.5 the matrix should test
  • phan and churn-php dropped. Phan duplicates phpstan at level max and needs ext-ast, present in neither the local nor the CI environment, so it had been running through the slow polyfill. Churn reports a metric rather than gating anything. Dependencies fall 67 → 42. Easy to reverse if you disagree.
  • phpstan analyses against the whole 8.3–8.5 range, so a version-specific slip is caught here rather than in a consumer's runtime
  • phpcs.xml.dist and phpstan.neon.dist replace inline CLI flags; both cover src, tests and example.php

Tests

Rewritten and split by concern. The old suite wrapped assertions in if (is_string($contents)), so an unreadable fixture made the test pass having asserted nothing; fixtures now load through a helper that fails loudly.

46 tests, 91 assertions
Classes: 100.00% (3/3) Methods: 100.00% (16/16) Lines: 100.00% (45/45)

PHPUnit runs with failOnRisky, failOnWarning, failOnDeprecation, failOnNotice and requireCoverageMetadata.

CI and automation

  • actions/checkout@v1@v7
  • Dropped composer self-update and --no-suggest, which has not existed since Composer 2
  • Removed the psalm matrix entry: no matching script and no vimeo/psalm dependency, so it could only ever fail
  • Matrix is now PHP 8.3/8.4/8.5 × prefer-lowest/prefer-stable
  • New Composer audit job guarding the committed lock, deliberately outside the matrix because --prefer-lowest resolves to the oldest permitted versions and will always carry advisories
  • .mergify.yml gated Dependabot merges on continuous-integration/travis-ci/pr, retired years ago — part of why this repo drifted. Now gates on the test and audit checks.
  • dependabot.yml gained the github-actions ecosystem that would have kept checkout current

Not carried over: the Scrutinizer coverage job. ocular.phar now sits behind a Cloudflare challenge and returns a 403 HTML page rather than the phar, so the job could not work. Its badge is removed with it; the quality badge stays. Say the word if you want coverage reporting back and I'll wire Codecov.

Verification

phpcs PASS phpmd PASS
phpstan PASS phpunit OK (46 tests, 91 assertions), 100% coverage
composer audit --locked No security vulnerability advisories found.
make (clean slate) PASS

example.php runs deprecation-free on real PHP 8.4.24 and 8.5.9 — the version that exhibited defect 2. Also checked untrusted input: external entities do not resolve and an entity-expansion bomb is rejected in 1ms, so there was nothing to fix there.

Breaking changes

throwError() removed, exceptions extend RuntimeException instead of Exception, PHP 8.3 minimum. Migration table is in README.md.

Tag as 2.0.0 after merge.

Revives the php8 branch, untouched since May 2024, and lands it as 2.0.
Three defects were confirmed by running the branch, not just read out of
the diff:
throwError() could not be used. The typed $exception property had no
default, so calling it raised "Typed property must not be accessed
before initialization" when nothing had failed. It also stayed
uninitialised after a failed isXMLStringValid(), because that path
caught InvalidXml and returned false without recording anything.
Five parameters were implicitly nullable (string $xsdPath = null), which
PHP 8.4 and newer report as deprecated on every call.
libxml_use_internal_errors(true) was never restored, leaving libxml
error handling switched on for the rest of the consumer's process.
All three shared a cause: the error lived on the validator object. A
stale error survived the next successful call, and two validations on a
shared instance overwrote each other. The API now follows what the
ecosystem settled on, a predicate plus a throwing variant:
isXMLFileValid() / isXMLStringValid() bool, never throw
validateXMLFile() / validateXMLString() void, throw with detail
The predicates are implemented by catching from the throwing methods, so
the two forms cannot drift apart. throwError() is gone.
Every exception now implements the XmlValidatorException marker
interface and extends RuntimeException, so one catch block replaces a
union that would silently stop covering everything as exceptions get
added. InvalidXml carries the LibXMLError objects, so line and column
survive instead of being flattened into one imploded string. A missing
XSD reports as FileDoesNotExist rather than surfacing as a libxml parse
error about the document.
PHP floor moves to ^8.3, replacing ^8.1|^8.2|^8.3, which excluded the
8.4 and 8.5 the CI matrix should be testing. phan and churn-php are
dropped: phan duplicates phpstan at level max and needs ext-ast, which
was in neither the local nor the CI environment, and churn reports a
metric rather than gating anything. Dependency count falls from 67 to
42. phpstan now analyses against the whole 8.3 to 8.5 range so a
version-specific slip is caught here rather than in a consumer's
runtime.
Tests were rewritten and split by concern. The old suite wrapped
assertions in "if (is_string($contents))", so an unreadable fixture made
the test pass having asserted nothing; fixtures now load through a
helper that fails loudly. Coverage is 100% of lines, methods and
classes, with PHPUnit set to fail on risky, warning, deprecation and
notice.
CI had rotted. actions/checkout was still v1, composer self-update ran
on every job, --no-suggest has not existed since Composer 2, and the
matrix listed a psalm job with no matching script or dependency, so it
could only ever fail. Mergify gated Dependabot merges on a Travis check
retired years ago, which is part of why this repo drifted. A Composer
audit job now guards the committed lock, and dependabot.yml gained the
github-actions ecosystem that would have kept checkout current.
The Scrutinizer coverage job is not carried over: ocular.phar now sits
behind a Cloudflare challenge and returns a 403 HTML page rather than
the phar, so the job could not work. Its badge is removed with it.
BREAKING CHANGE: throwError() is removed, exceptions extend
RuntimeException instead of Exception, and PHP 8.3 is the minimum. See
the migration table in README.md.
@mergify

mergifyBot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@hyperized
hyperized merged commit 9259e72 into masterAug 13, 2026
12 checks passed
Sign up for freeto 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

@hyperized