PMK-2061: current PHP versions, and a pipeline that reports the truth (v8.0.0) - #168
PMK-2061: current PHP versions, and a pipeline that reports the truth (v8.0.0)#168ewood-ac wants to merge 11 commits into
Conversation
The composer constraint moves from `~8.1 || …` to `~8.2 || … || ~8.5`, and three PostmarkAttachment factories gain `string` parameter types while two PostmarkOpen getters become nullable. All of those are breaking for consumers and none were recorded, so a release cut from this branch would have shipped as a minor. Adds the CHANGELOG entry marking this as v8.0.0 with each break called out. Also replaces the empty catch in the suppressions teardown with a STDERR warning — a cleanup failure left the list populated and broke the following run somewhere unrelated, with nothing on record pointing back here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions-and-pipeline # Conflicts: # CHANGELOG.md # src/Postmark/PostmarkClient.php
The suite was red on main: 87 tests, 20 errors, 2 failures. Almost none of it
was the SDK. This gets it to 0 errors / 0 failures without papering over
anything -- the account-state cases now skip with a message naming what is
missing, so a configuration problem is distinguishable from a defect.
PHP versions. Collapsed the constraint to ^8.2. The previous
"~8.1 || ~8.2 || ~8.3 || ~8.4" already resolved to >=8.1 <9.0, so 8.5 was
always permitted and the CHANGELOG's "added 8.5 to the supported range" was
misleading; the only real change is dropping 8.1, EOL 2025-12-31. Added php85
to the matrix, moved static-analysis off the dropped 8.1 image, and unchained
the serial `requires` so one slow version no longer hides the other three.
README said 8.1-8.4, wrong in both directions.
Sender signatures. The tests built their address with
str_replace('[TOKEN]', ..., $prototype), which is only unique if the configured
prototype contains the placeholder. testing_keys.json.example documents
"anything+[token]@wildbit.com" but the value in use is a plain address with no
placeholder, so the replace was a no-op and every run tried to create the same
signature -- "This signature already exists", then "already been confirmed" on
the reverify test. The case never matched either: the example is lowercase and
the tests replaced uppercase. uniqueSenderAddress() handles both shapes, and
those tests now pass rather than erroring.
The 10 "'From' address is not a Sender Signature" errors are genuinely account
state, so they now skip naming the address and what to do, instead of looking
like an SDK fault ten times. Same for the statistics token.
Empty-fixture assertions (search returning 0, Opens[0] on an empty list) skip
with the reason rather than failing on an array offset. Stream archiving is
refused for a stream created moments earlier, so archiveOrSkip() retries and
then skips with the API's own message -- it rethrows anything that is not that
specific condition, so a real archive regression still fails.
Two silent data bugs found while in here, both released:
- getDeliveryStatistics() reported Count = 0 for every bounce category in every
version. PostmarkBounceSummary read $values['FirstOpen'] instead of
$values['Count'] -- a copy-paste from PostmarkOpen. Anything calibrated
against the broken zero will start seeing real numbers.
- PostmarkBounce assigned its constructor fallbacks to the wrong properties:
Type (string) got 0 and TypeCode (int) got '', so a response missing either
field threw a TypeError.
PostmarkAttachment::fromFile() threw away file_get_contents()'s false return,
and base64_encode(false) is "", so an unreadable path shipped an empty
attachment with the message. It throws RuntimeException now -- a behaviour
change, hence v8.
Guarded the remaining 10 list models against a missing collection key; only 5
had been done. Removed the returns after markTestSkipped(), which is
@return never and was the sole reason PHPStan was red on the old branch.
PHPStan: [OK] No errors. Suite: 92 tests, 0 errors, 0 failures.
Guzzle 8 support (simPod, #165) belongs in the major rather than a patch. The constraint widening is additive, but Guzzle 8 reclassified transport exceptions, and this SDK re-exports Guzzle's hierarchy via @throws GuzzleException -- with http_errors => false, the transport family is the only one that reaches a caller. So a consumer's catch (ConnectException) around a send silently stops matching a timeout after a composer update, with no code change on their side. A patch release is the wrong signal for that; a major with an upgrade note is the right one. Tightened the floors to ^7.15.2 || ^8.0.1. simPod's ^7.8 || ^8.0 admits Guzzle 8.0.0 and 7.x below 7.15.2, both affected by GHSA-v5mv-p594-2x33 (high, host-check bypass) and GHSA-f7vp-7xgx-4w4r. 8.0.1 postdates that PR. Verified rather than assumed: PHPStan clean and the full suite green against both 8.0.2 and 7.15.2, and CI now runs static analysis under each major so the claim keeps being tested.
This is the half of "a pipeline that reports the truth" that was still missing. The single phpunit testsuite was NAMED "unit" and contained all of tests/ -- 17 of 18 test classes hit the live Postmark API. With no tokens configured, every one of those constructors threw "Argument #1 ($serverToken) must be of type string, null given", so the suite reported 79 ERRORS. That is indistinguishable in CI from 79 real regressions, which is why php81 has been red on main since 2025-10 with nothing but a description-string URL change behind it: a genuine break had nowhere left to show up. PostmarkClientBaseTest now skips with a message instead, in both setUp and setUpBeforeClass -- both are needed, because a few subclasses build a client in their own setUpBeforeClass, which runs first. TestingKeys gains hasAnyCredentials() (BASE_URL excluded deliberately: it has a default and is present even with no tokens, so counting it would report "configured" for an environment that cannot call the API). Two subclasses overrode setUpBeforeClass without chaining to parent, so they read whatever an earlier test class happened to leave in the shared static $testKeys -- order-dependent, and latent even with credentials. They chain now. The testsuites are split for real: `unit` is the credential-free file, `integration` is the live-API set. composer test runs unit (the gate that can actually be green), test:integration runs the rest. displayDetailsOnSkippedTests is on so a skipped run prints why 17 times rather than just "No tests executed!". CI: the per-version jobs run the credential-free suite, so a red php84 means the code is broken on 8.4. Integration is its own unchained job -- chaining php82->83->84->85 is what hid three versions' results behind one failure. Verified both directions: no credentials -> exit 0, 5 passed, 17 classes skipped with the reason stated; WRITE_TEST_SERVER_TOKEN set -> the skip lifts and the integration tests really run (and fail against a fake token, as they should). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ac-bgelfius
left a comment
There was a problem hiding this comment.
Final review — postmark-php #168 (v8.0.0) @ d4a6a698
Recommendation: request-changes — one verified hole in the new credential guard reproduces
the exact fatal this release exists to eliminate, in the test file the PR touches most. Everything
else is documentation placement and CI hardening. The code changes are right.
Base is #167's branch (pmk-2059-nullable-getter-fatals), not main. #167 is reviewed separately;
nothing below is filed against it.
Start here: this is good work, and several parts are better than the PR says
- Both silent data bugs are real and correctly fixed.
PostmarkBounceSummary.php:15read
$values['FirstOpen']intopublic int $Count, and bounce-summary items carry noFirstOpen
field at all — sogetDeliveryStatistics()has reported zero for every bounce category in every
released version. Independently verified by two seats. The CHANGELOG's warning that dashboards
calibrated on that zero will start seeing real numbers is exactly the right call-out. - The undeclared-break sweep came back clean. The architect checked mechanically across both
PRs — every removed/changedpublic/function/classline insrc/— and found no removed
public method, no visibility change, no undeclared signature change. The only breaks are the
threePostmarkAttachmentfactories, all declared. For a major on a published SDK, that is the
single most useful thing a consumer can be told, and it holds up. - Guzzle 7 and 8 are both genuinely supported. Verified by resolving each separately (7.15.3
and 8.0.2), running PHPStan clean on both, and loading all 85 SDK classes on both. The matrix is
buying real signal, not ceremony. - The composer constraint arithmetic is right.
~8.1 || ~8.2 || ~8.3 || ~8.4really did resolve
to>=8.1 <9.0, so 8.5 was always permitted. The author is correct that the earlier "added 8.5"
CHANGELOG line was misleading, and correcting your own prior imprecision in a release note is the
right instinct. archiveOrSkip()genuinely rethrows anything that isn't the cooldown — verified structurally:
it catches onlyPostmarkException, and rethrows immediately when the message doesn't match. The
test philosophy behind it is the right call. Visible skips with reasons beat weakened assertions,
and the diagnosis was exact — the 79 identical credentialTypeErrors were reproduced.- The CI pipeline rework is a correctness fix, not a wall-clock optimisation — and neither the PR
body nor the CHANGELOG says so. Becausephp81is first in therequireschain and has been red
since 2025-10-24,php82/php83/php84have not executed at all for roughly ten months. Not
failed — never started, no commit status posted. Confirmed two ways: the cost seat from CircleCI's
requiressemantics, and the CircleCI seat directly against real commit statuses (ad4b80eposts
onlyphp81; the green commit before it posts all four). Unchaining ends a ten-month coverage
blackout. That is a far stronger justification than "chaining quadrupled wall-clock," and it is
worth putting in the PR body — it is the argument for never chaining a version matrix again.
Blockers
None. The request-changes rests on the should-fix items below — specifically the first one.
Should-fix
1. The credential guard has a hole, in the file this PR touches most — this is what gates the PR.
tests/PostmarkClientBounceTest.php:29, plus PostmarkClientEmailTest.php:26,
PostmarkClientEmailsAsStringOrArrayTest.php:16, PostmarkClientStatisticsTest.php:16,
PostmarkClientTemplatesTest.php:54 — all five override setUp() without calling
parent::setUp(), so the base guard never runs for them.
The bounce class is the sharp case. Its setUp() calls only requireConfirmedSenderSignature(),
which checks WRITE_ACCOUNT_TOKEN and WRITE_TEST_SENDER_EMAIL_ADDRESS. But
testClientCanGetBounce() (:37) and testClientCanGetBounceDump() construct
new PostmarkClient($tk->READ_SELENIUM_TEST_SERVER_TOKEN, …) — and nothing anywhere checks that
token. An environment with write credentials but no Selenium token sails past every guard and
gets back Argument #1 ($serverToken) must be of type string, null given: the exact fatal this PR
exists to eliminate.
One correction that makes the fix bigger than "chain the parent": chaining parent::setUp()
alone does not close it. The base guard is hasAnyCredentials() (tests/TestingKeys.php:56-72),
which returns true if any one of six tokens is set. A partially-configured environment passes it.
The per-key requireKeys() is the part that actually guards. Same gap on WRITE_TEST_SERVER_TOKEN,
which PostmarkClientBounceTest:74, EmailTest:35, EmailsAsStringOrArrayTest:24 and
TemplatesTest:28 all build clients from and no guard checks. PostmarkClientStatisticsTest is the
one that got it right — it guards the exact token it uses.
Fix: chain parent::setUp() in all five, and add requireKeys() for the token each class actually
constructs a client with — READ_SELENIUM_TEST_SERVER_TOKEN on the bounce class,
WRITE_TEST_SERVER_TOKEN on the other three.
2. CHANGELOG.md:57-79 — the Guzzle 8 exception reclassification is filed under ### Added.
This is the largest blast radius in v8.0.0. A catch (ConnectException $e) around a send silently
stops matching a timeout, and a swallowed timeout is worse than a fatal because nothing tells you.
The entry's content is excellent; it is sitting where nobody scanning for BREAKING will read it.
Move it under ### Changed with the same BREAKING marker the other two breaks get. Mis-filing
the compatibility disclosure is not a heading nit when the disclosure is the point of the release.
3. composer.json:13 — the Guzzle floor rises ^7.8 → ^7.15.2, described inside the "Added"
paragraph. The security rationale is sound and stated, but this is a requirement change that can
block composer update for a consumer whose other dependencies pin an older Guzzle 7.x. Put it
under ### Changed next to the PHP floor.
4. .circleci/config.yml:36-37 — integration-tests exits 0 having executed zero tests when
unconfigured (verified). That is the mirror image of the problem being fixed: the old pipeline
screamed when credentials went away, the new one goes quietly green. Skipping is right for a fork
PR — CircleCI withholds project env vars from fork builds by design, and the PR correctly relies on
that. On the trunk build the job should fail if it could not run. This isn't gold-plating: this
very repo just demonstrated a silent-skip failure mode running undetected for ten months. A
test -n "$WRITE_ACCOUNT_TOKEN" before composer test:integration, or --fail-on-skipped, closes it.
5. CHANGELOG.md:86-87 — the PostmarkBounce note is half wrong, and it undersells its own bug.
It says Type got 0 and TypeCode got '', "throwing TypeError on a response missing either
field." Only TypeCode threw. Type silently coerced 0 to the string "0", because no src/
file declares strict_types. Two seats found this independently. Correct it — silent corruption
is the stronger case for the fix, and a reader who checks will find the note doesn't match.
6. The PR body's "0 errors / 0 failures" table needs a caveat. It is the locally-verified full
suite; CircleCI's integration job at this head shows Tests: 87, Errors: 4, Skipped: 31 (build
704, pulled via CircleCI's public v1.1 API — no token needed, the repo is public). The 92-vs-87 test
count reconciles fine (full suite vs --testsuite integration). The 21→4 error reduction is real
and is your fix working. But as written the table reads as if CI agrees, and it visibly doesn't.
See the open question below on the 4th error.
7. src/Postmark/Models/PostmarkAttachment.php:41,44 — @file_get_contents() suppresses the
reason. The new RuntimeException can only say "Unable to read attachment file X" — not missing
vs unreadable vs failed stream wrapper. For an SDK that's the difference between a one-minute fix
and a support ticket. Fold in error_get_last()['message'], or drop the @.
8. Consider a short "Upgrading from v7" section at the top of the v8 entry. Two separate items —
the bounce-count fix and fromFile() now throwing — are both "your program's behaviour changes
though your code didn't," and they currently sit under different headings. A customer whose file-path
generation has a latent bug starts getting hard failures on upgrade; a customer with a bounce-rate
alert starts getting real numbers. Both are correctly documented; neither is prominent. Worth a
callout on the GitHub Release too, which is the artifact more people actually see.
Nits
phpunit.xml.dist:15— after the split, theunitsuite is a single hardcoded<file>. So
php82/php83/php84/php85each run ~5 assertions over 3-4 model classes, and the full suite
runs on 8.2 only. Before, all tests ran on four versions (red). The honesty gain is real and
worth it; the per-version depth loss is also real and isn't mentioned in the PR body or the
CHANGELOG. Two cheap improvements:<directory>tests/Unit</directory>so the next unit test isn't
silently skipped, and a class-load smoke test oversrc/— the php seat wrote one and it loaded
all 85 classes clean on both Guzzle majors, which would make the config comment ("a red one means
the code is broken on that version") actually true.CHANGELOG.md:39— the v8.0.0 block sits below## [v7.0.1]; the file is otherwise newest-first.
Someone opening it to see the latest release can miss the entire breaking block.tests/PostmarkClientMessageStreamsTest.php:47—stripos($lastMessage, 'unable to be archived')
is broader than the cooldown it documents.PostmarkException::getPostmarkApiErrorCode()already
carries the API'sErrorCode; matching on that makes the skip as narrow as the comment claims.
[NEEDS VERIFICATION: whichErrorCodethe cooldown returns — nobody called the API.]tests/PostmarkClientBounceTest.php:26-29— the@depends testClientCanActivateBouncedocblock
rode ontosetUp()in the move, where@dependsmeans nothing, andtestClientCanGetBounce()
lost it. Some new lines carry trailing whitespace.PostmarkBounceList.php:14and four other list models assign?? []to a local first; the other
eight guard inline. Same fix, two shapes, one diff.php-cs-fixernow runs twice per push, for zero added coverage — it lives inside the parameterized
static-analysisjob template, which is invoked as bothstatic-guzzle7andstatic-guzzle8.
PHPStan legitimately needs both (different type surface); the formatter doesn't.- The unchaining is attributed purely to wall-clock. On the credit axis the last ten months ran the
other way — the broken chain was suppressing ~75% of the matrix cost by never starting three
jobs. One sentence completes the story. (Absolute cost either way is cents to a few dollars a
year: one merge tomainin the trailing 12 months.) - The suite now carries 13
markTestSkipped()sites across 6 files. A green run with 13 skips looks
identical to a green run with 0 unless someone opens the log. Not asking for it here — worth a
guardrail before the norm scales.
Open questions
- The 4th integration error is not explained, and it might be yours.
testClientCanRequestNewVerificationForSignaturecreates a brand-new uniquely-suffixed signature
via this PR's ownuniqueSenderAddress()and immediately gets back "This Sender Signature has
already been confirmed." Three of the four errors are a confirmed-environmental stale
READ_SELENIUM_OPEN_TRACKING_TOKEN. This one is explicitly [INFERRED] — the plausible story is
that the domain behindWRITE_TEST_SENDER_SIGNATURE_PROTOTYPEauto-confirms new signatures, which
is account state. But if it's a real interaction between the new dedup logic and an auto-confirming
domain, that's a gap in the fix rather than CI drift, and unlikearchiveOrSkip()this test has no
skip path for it. One class confirmed environmental, one plausible but unconfirmed — please
close the second one rather than filing the whole red under "account state." PostmarkAttachment.php:44throws\RuntimeExceptionrather than something under
Postmark\Models. A consumer wrapping SDK calls incatch (PostmarkException $e)won't catch it.
SPL is defensible for a filesystem failure, but v8 locks the choice in until v9 — worth being
deliberate rather than incidental.- Has anyone checked
^7.15.2against a realistic consumer whose other dependencies pin an older
Guzzle 7.x? The floor bump is either theoretical or a realcomposer updateblocker, and nobody
on the panel established which. [NEEDS VERIFICATION] - Is there any CI-health check on this repo independent of PR activity?
php81broke on 2025-10-24
and nothing surfaced it for ten months because nothing else was merged. #168 resolves it by
deleting the job, which is defensible (8.1 is EOL) but means "what actually broke php81" was routed
around rather than answered. For the record: the CircleCI seat pulled build 678's output — all 21
errors and 3 failures were account-state/credential problems, no PHP-8.1 TypeError or syntax
failure. So the answer is "nothing about 8.1," and that's worth knowing. - Neither PR has a requested reviewer or a recorded review, and there's no
CODEOWNERS. For a
publicly-consumed SDK making a major-version breaking call, is there meant to be a designated
second sign-off, and does that person know these are up? - How does Postmark announce an SDK major beyond
CHANGELOG.md? [NEEDS POSTMARK VERIFICATION —
nothing in the checkout or the system map says.]
Out-of-scope observations
Not filed against this PR — the files are untouched and nothing in the diff makes them more or less
likely to fire. Listed so the sweep reads as closed rather than half-open.
src/Postmark/Models/TemplateValidationResponse.php:197—TextBody::$RenderedContentfalls back
to0where its two siblings in the same file (HtmlBody:92,Subject:252) use'', so an
HTML-only template gets"0"where the truth is"". Unambiguously a typo, and it does produce
a wrong value rather than coercing to a sensible one — worth your attention while you're here. It
gates nothing: "this PR fixes two instances of a defect class and misses a third" is a completeness
argument, not an aggravation argument.TemplateValidationResponse.php:93,198,253— the bigger one in that file. All three body classes
discard the false branch of a ternary, leavingpublic array $ValidationErrorsuninitialized, so
getValidationErrors()throws "must not be accessed before initialization" on the happy path
ofvalidateTemplate(). Verified against a realistic payload. Same shape as #167's bug, three files
over. One-line fix each.PostmarkOpen.php:19,PostmarkServer.php:48,PostmarkServer.php:51— three more fallback/type
mismatches, all benign today because weak mode coerces them, all of which would fail loudly the
momentstrict_typesis added.- No
declare(strict_types=1)anywhere in the 75src/files. That is precisely what would have
caught thePostmarkBouncefallback swap at development time instead of in a v8 audit. A major is
the moment, if it's ever going to happen. phpstan.neonis level 5 andphpstan/phpstanis pinned^1.12→ 1.12.34, which the tool itself
reports as two majors stale. With static analysis now the only credential-free gate, its level is
the entire ceiling on what a fork PR can prove.
Sequencing note
#168's base is #167's branch, so it contains #167's six getter fixes and the getBounces() signature;
the v7.0.1 and v8.0.0 CHANGELOG entries both land on main with #168 if #167 isn't tagged first.
Stated as fact, no ask.
Coverage note
Six seats plus a devil's-advocate pass. None abstained. All six ran full passes with independent
execution — no seat deferred, and every load-bearing claim above was executed or pulled from a real
build, not read.
- php — request-changes. Executed everything in
php:8.1/8.2/composer:2containers with
--network none. The live Postmark API was never called by any seat. - architect — request-changes (mostly on #167's version digit, which is #167's review). Ran the
variance tests and the mechanical undeclared-break sweep in Docker. - circleci — approve-with-should-fix. Pulled real builds 678 and 704 and real commit statuses.
- product — fit: yes-with-concerns, advisory.
- em — bundled-but-defensible, advisory.
- cost — approve. Increase is real (1-2 executing jobs/push → 7) but lands on ~1 merge/year.
- devil — advisory. Downgraded the
TemplateValidationResponse:197item to out-of-scope,
independently verified the credential-guard hole, and ruled thatrequest-changessurvives on that
finding alone. Applied as written.
Two calibration notes. The pr-review-php seat is tuned for Postmark's Craft CMS 4 / Yii2 estate;
none of that applies to a standalone client SDK, so it was reviewed on the transferable axes — type
correctness, null handling, exception paths, composer supply chain, static analysis — with published-
library API compatibility and SemVer as the governing concern. And the usual Postmark tie-breaker
doesn't bite here: this touches no sending path, no message streams, no webhook processing, no
quota logic and no tenant isolation. The nearest thing to a production surface is
getDeliveryStatistics() returning real bounce counts for the first time, which is a customer-visible
deliverability signal and is correctly documented. That absence is exactly why a test-harness
credential guard is the thing gating this review: with no production blast radius competing for
attention, the strongest finding is the one that reproduces the fatal the release was written to kill.
Jira: PMK-2061
Stacked on #167 (PMK-2059 / v7.0.1) — review that first; this branch contains it.
The suite was red on
main: 87 tests, 20 errors, 2 failures. Almost none of it was the SDK. This gets to 92 tests, 0 errors, 0 failures without papering over anything — the account-state cases now skip with a message naming exactly what's missing, so a configuration problem is distinguishable from a defect.PHP versions
Collapsed the constraint to
^8.2. The previous~8.1 || ~8.2 || ~8.3 || ~8.4already resolved to>=8.1 <9.0, so 8.5 was always permitted — the old CHANGELOG line about "adding 8.5 to the supported range" was misleading. The only real change is dropping 8.1 (EOL 2025-12-31).php85to the matrix; movedstatic-analysisoff the now-dropped 8.1 image.requires— one slow version was hiding the other three and quadrupling wall-clock.README.mdsaid "PHP 8.1, 8.2, 8.3, or 8.4" — wrong in both directions.Sender signatures — the root cause
The tests built their address with
str_replace('[TOKEN]', …, $prototype). That's only unique if the configured prototype contains the placeholder.testing_keys.json.exampledocumentsanything+[token]@wildbit.com, but the value in use is a plain address with no placeholder — so the replace was a no-op and every run tried to create the same signature:The case never matched either: the example is lowercase, the tests replaced uppercase.
uniqueSenderAddress()handles both shapes, and those tests now pass rather than erroring.The 10
'From' address is not a Sender Signatureerrors are genuinely account state — nothing in code can confirm a signature. They now skip once, naming the address and the fix, instead of failing ten times in a way that reads like an SDK fault. Same for the statistics token.Making the rest honest rather than green
Opens[0]on an empty list) skip with the reason instead of failing on an array offset.archiveOrSkip()retries, then skips with the API's own message — and rethrows anything that isn't that specific condition, so a real archive regression still fails.I've deliberately kept these as visible skips with reasons rather than weakened assertions. A skip that says why is recoverable;
assertGreaterThanOrEqual(1)is silently permanent.Two silent data bugs, both in released versions
getDeliveryStatistics()has reportedCount = 0for every bounce category, in every version.PostmarkBounceSummaryread$values['FirstOpen']instead of$values['Count']— a copy-paste fromPostmarkOpen. Anything calibrated against the broken zero will start seeing real numbers.PostmarkBounceassigned its constructor fallbacks to the wrong properties —Type(string) got0,TypeCode(int) got''— so a response missing either field threw aTypeError.Breaking changes (hence v8.0.0)
PostmarkAttachment::fromRawData()/::fromBase64EncodedData()/::fromFile()declarestringparams and aPostmarkAttachmentreturn. Subclasses overriding them must add the return type or PHP fatals at class-load.PostmarkAttachment::fromFile()throwsRuntimeExceptionwhen the file can't be read. It previously discardedfile_get_contents()'sfalse, andbase64_encode(false)is""— so an unreadable path shipped an empty attachment with the message.Also
Guarded the remaining 10 list models against a missing collection key (only 5 had been done). Removed the
returns aftermarkTestSkipped()— it's@return never, and they were the sole reason PHPStan was red on the old #164 branch.Supersedes #164
Same ticket family, but #164 predates the v7.0.1 split and its two nullable fixes now ship in #167. This branch contains everything #164 had that's still relevant. Close #164 in favour of this once you're happy.