Skip to content

PMK-2059: stop fatalling on valid API responses (v7.0.1) - #167

Open
ewood-ac wants to merge 1 commit into
mainfrom
pmk-2059-nullable-getter-fatals
Open

PMK-2059: stop fatalling on valid API responses (v7.0.1)#167
ewood-ac wants to merge 1 commit into
mainfrom
pmk-2059-nullable-getter-fatals

Conversation

@ewood-ac

@ewood-ac ewood-ac commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Jira: PMK-2059

Patch release for the two customer-reported defects on PMK-2059. Both are fatals on responses the API legitimately returns, and neither should require a major upgrade to escape.

Supersedes #166, which GitHub closed automatically when the branch was renamed to match the ticket. Same commit, no content change.

1. Six getters fatal when the API omits a field

Fatal error: Uncaught TypeError: Postmark\Models\PostmarkOpen::getGeo():
Return value must be of type Postmark\Models\PostmarkGeographyInfo, null returned

Each of these declares a non-nullable return while its own constructor explicitly assigns null when the key is absent — so the getter was guaranteed to fatal, not merely at risk of it:

PostmarkOpen::getGeo() reported — broadcast-stream message with no geo data
PostmarkOpen::getClient() same class, unreported
PostmarkOpen::getOS() same class, unreported
PostmarkMessageBase::getMetadata() base of the outbound send payload
PostmarkMessageBase::getMessageStream() same
WebhookConfiguration::getHttpAuth() any webhook without basic auth

The report was for getGeo(); scanning src/Postmark for the pattern found the other five. PostmarkClick already declares its three equivalents loosely, which is why clicks worked and opens didn't — that inconsistency is what marks this as an oversight rather than a design.

2. getBounces() can't filter by MessageID

Postmark\PostmarkClient::getBounces(): Argument #7 ($messageID) must be of type ?int, string given

c7a4371 ("Update to level 5 PHPStan") changed the docblock from @param string $messageID to @param int|null and added ?int to the signature. It shipped in v5.0.1 and every release since. Postmark MessageIDs are GUIDs, so the filter has been unusable for over two years — nobody can be passing a valid value as an int. Restored to ?string.

The reporter's diagnosis was exactly right, including the commit.

Why a patch and not the v8 branch

Widening a return type to nullable is covariant, so a subclass overriding one of these getters with the narrower type stays compatible — no class-load break. And callers weren't getting a wrong value, they were getting a fatal.

The one contravariant case is getBounces(): a subclass that overrides it with ?int $messageID will need to change. That's called out in the CHANGELOG, and no caller can currently be passing a usable value regardless.

Holding these for #164 would mean a customer with a production fatal has to accept a major upgrade — which also drops PHP 8.1 and tightens PostmarkAttachment signatures — to get a one-character fix. #164 keeps the genuine breaking changes and will rebase on this.

Verification

tests/NullableGetterRegressionTest.php covers all seven, needs no credentials, and fails on unfixed main (2 errors, 3 failures) — confirmed both directions.

  • PHPStan: [OK] No errors
  • New tests: 5 passing, 14 assertions

Tooling, included because it's what would have caught these

  • .php-cs-fixer.dist.php built a Finder and never called setFinder(), so the fixer aborted with "You must call one of in() or append() methods". It has never run in this repo. Fixed.
  • Added a credential-free static-analysis CI job. Every existing job is an integration suite against the live API that can't start without tokens, so this is the only check a fork PR can currently exercise. PHPStan gates; php-cs-fixer is advisory only, because 8 files are already non-conforming on main and a check that ships red just trains people to ignore it. Promote it after a formatting pass.

Two production reports, one defect class. Six getters promised a non-nullable
return while their constructor explicitly assigns null when the API omits the
field, so they were guaranteed to throw TypeError on a perfectly valid
response. Reported against PostmarkOpen::getGeo() for a broadcast-stream
message with no geo data; a scan of src/Postmark found five more of the same
shape. PostmarkClick already declared its equivalents loosely, which is why
clicks worked and opens did not -- the inconsistency is the tell that this was
an oversight rather than a design.

Separately, getBounces()'s $messageID filter became ?int in c7a4371 and shipped
from v5.0.1 onward. Postmark MessageIDs are GUIDs, so the filter has been
unusable for two years: passing one throws "Argument #7 ($messageID) must be of
type ?int, string given". Restored to ?string, matching the pre-v5.0.1
documented type.

Shipped as a patch rather than held for the v8 branch. Widening a return type
to nullable is covariant, so a subclass overriding one of these getters with
the narrower type stays compatible -- no class-load break, and callers were
receiving a fatal rather than a value. The one contravariant case is
getBounces(), where a subclass override declaring ?int must change; that is
noted in the CHANGELOG, and no caller can currently be passing a usable value
anyway. Customers hitting a fatal should not have to take a major upgrade
(which also drops PHP 8.1) to get the fix.

NullableGetterRegressionTest covers all seven and needs no credentials.
Verified it fails on unfixed main: 2 errors, 3 failures.

Also fixed .php-cs-fixer.dist.php, which built a Finder and never called
setFinder(), so the fixer aborted with "You must call one of in() or append()
methods" and had never run at all. Added a credential-free static-analysis job
to CI -- PHPStan gates, php-cs-fixer is advisory only because 8 files are
already non-conforming on main and a check that ships red just teaches people
to ignore it.
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