Skip to content

Fix PHP 8.x deprecations, drop PHP 7.1-7.3 support (3.5.0) - #13

Merged
mSprunskas merged 24 commits into
paysera:masterfrom
Okspen:php8-deprecations-fix
Jul 31, 2026
Merged

Fix PHP 8.x deprecations, drop PHP 7.1-7.3 support (3.5.0)#13
mSprunskas merged 24 commits into
paysera:masterfrom
Okspen:php8-deprecations-fix

Conversation

@Okspen

@Okspen Okspen commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Resolves the PHP 8.1 deprecations emitted by this library, drops end-of-life PHP versions, and cleans up class references. Released as 3.5.0 — no API change.

The reported symptom was this notice on every request touching a serialized Result:

Deprecated: Return type of Paysera\Component\Serializer\Entity\Result::getIterator()
should either be compatible with IteratorAggregate::getIterator(): Traversable,
or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice

PHP 8.1 added tentative return types to built-in interfaces, so an implementation without a compatible return type deprecates now and becomes a hard signature error in PHP 9.

Why 3.5.0 rather than 4.0.0

This branch originally added the native : \Traversable return type and shipped as 4.0.0. Following review (@borilyordanov, @mSprunskas), it now uses #[\ReturnTypeWillChange] instead and ships as a minor.

The reasoning: the attribute fixes the actual reported symptom — the deprecation on every request — with no signature change, so consumers get it through an ordinary composer update rather than having to edit constraints and read migration notes. The native return type is banked for 4.0.0, where it gets batched with the other type additions instead of being a major release on its own. The @return \Traversable docblock stays, so static analysis is unaffected.

Changes

Deprecation fixes

  • Result::getIterator() is marked #[ReturnTypeWillChange] (imported, not backslash-prefixed, matching the rest of the branch). On PHP 7.4 the attribute is parsed as a comment, so this is safe across the whole supported range.
  • CamelCaseToSnakeCaseConverter::convert() no longer passes null to preg_replace(). This one only fires at runtime and was found by running the suite, not by static inspection — CamelCaseToSnakeCaseConverterTest explicitly asserts convert(null) === '', so null is a supported input and that behaviour is preserved.
  • DateNormalizer::mapToEntity() rejects null up front instead of passing it to DateTime::createFromFormat(). Reachable via ArrayNormalizer, which forwards raw elements straight through, so a JSON null inside a date array lands here. Still raises InvalidDataException, now reporting Date must be provided rather than a message blaming the configured format.

Default initialisation

  • Result::$items now defaults to [], fixing a pre-existing crash: new Result() threw on iteration (TypeError on PHP 8, InvalidArgumentException on 7.4) and getItems() returned null against its documented @return mixed[].
  • Both this and $totalCount = 0 live on the property declaration, not in the constructor. An earlier revision moved them into the constructor per the style guide's default-property-values rule; review showed that only covers instances built through the constructor, leaving ReflectionClass::newInstanceWithoutConstructor() — and the ORM hydration, reflection deserializers and disableOriginalConstructor() mocks built on it — still broken. That is the likeliest path in a serializer library, so the defaults belong on the declaration.
  • FollowUpFilter no longer redeclares $offset without an initialiser. The shadowing declaration gave it a null default where Filter declares 0.
  • DistributedNormalizer's three field maps are on their declarations too, so the rule is uniform across the branch: Result, Filter, BaseDenormalizer and DistributedNormalizer all keep defaults where every instantiation path sees them. Net diff against master for those three lines is only array()[].
  • Each of these is pinned by a regression test that was verified to fail against the constructor-assignment version, so the invariant can't be reintroduced silently.

Supported versions

  • Minimum PHP raised to 7.4; 7.1/7.2/7.3 are years past end of life.
  • phpunit/phpunit dev requirement narrowed to ^9.3 — the version that introduced the <coverage> element used by phpunit.xml.dist. The --prefer-lowest CI job would otherwise pin 9.0.0 and silently ignore the coverage config.
  • CI matrix trimmed to match, including removal of the now-unreachable PHP 7.1 branch in the Composer audit step.

Cleanup

  • Leading-backslash class references replaced with use statements — global classes (ArrayIterator, ArrayObject, DateTime, DateTimeZone, Exception, SPL exceptions) and fully qualified Paysera\... references in docblocks.
  • Long array syntax (array(...)) replaced with short syntax.

BC considerations

No API break. Dropping PHP 7.1–7.3 means Composer simply won't resolve 3.5.0 for consumers on those versions; they stay on 3.4.x.

Test plan

  • composer test — 78/78 pass on PHP 8.1
  • Full suite under error_reporting=E_ALL — zero deprecations. PHPUnit 9 defaults convertDeprecationsToExceptions to true, so a deprecation fails the run rather than passing silently.
  • Reproducer confirms the fix, not just silence — iterating a Result printed the Deprecated: line before the change and is clean after; getIterator() reflects as having no native return type and carrying the ReturnTypeWillChange attribute
  • Constructor-bypass behaviour verified directly: newInstanceWithoutConstructor() yields getItems() === [], iterates clean, getTotalCount() === 0
  • Regression tests for every default the branch touches — Result, Filter/FollowUpFilter and DistributedNormalizer — each verified RED against the constructor-assignment version before landing the declaration default
  • FilterTest pins the Filter subclass contract: default offset and a descendant declaring its own constructor without calling parent::__construct(). ResultTest covers calculateTotalCount() against such a subclass.
  • PHP 7.4 (Docker): all 58 files under src/ and tests/ lint, and a reflection-built Result returns []/0 and iterates clean — confirms #[ReturnTypeWillChange] is parsed as a comment and harmless on the floor version, and that importing it costs nothing there
  • Deprecation suppression verified as non-vacuous: with the attribute removed the run re-emits the Deprecated: notice, so the imported (unqualified) form is genuinely resolving
  • ResultTest and FilterTest each pass run in isolation — proves the extracted fixture resolves through PSR-4 rather than through suite load order
  • CI matrix (7.4 → 8.4 × Symfony 3–6) green

Follow-up

  • 4.0.0 should add the native : \Traversable return type on Result::getIterator() alongside the other type additions.
  • Consumers such as app-accounting-events should move to ^3.5 and confirm their own PHP floor is ≥ 7.4.

Andrii Krasnoholovets added 4 commits July 29, 2026 10:06
These versions are long past end of life. Raises the floor to PHP 7.4,
narrows the phpunit dev requirement to ^9.0 and trims the CI matrix
accordingly, including the now-dead PHP 7.1 branch in the audit step.
Replaces leading-backslash references with use statements: global classes
(ArrayIterator, ArrayObject, DateTime, DateTimeZone, Exception and the
SPL exceptions) and fully qualified Paysera references in docblocks.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Andrii Krasnoholovets added 3 commits July 29, 2026 10:13
Applied via PHP CS Fixer's array_syntax rule. No behaviour change --
225 insertions and 225 deletions, all line modifications.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. SemVer violation — a documented BC break shipped as a minor release — High
  
  CHANGELOG.md:7,17-19

  ## 3.5.0
  ### Changed
  - **BC break:** any subclass of `Result` that overrides `getIterator()` must now declare a
    compatible return type (`\Traversable` or a subtype such as `\Iterator`).

  The file header (CHANGELOG.md:5) states "this project adheres to Semantic Versioning", yet a self-labelled BC break plus a narrowed platform constraint (composer.json:19, ^7.1 || ^8.0 → ^7.4 || ^8.0) are released as 3.5.0.

  The break is real, not theoretical: src/Entity/Result.php:234 went from public function getIterator() to public function getIterator(): Traversable. Any downstream subclass overriding it without a return type becomes a fatal compile error on upgrade, and
  Composer will happily resolve ^3.4 → 3.5.0.

  Repo precedent confirms the intended convention: 3.0.0 was the release used for "Dropped Symfony 2 support" (CHANGELOG.md:47-48) — a strictly smaller break than this one.

  Fix: release as 4.0.0.

  ---
  2. Result::getIterator() throws when items was never set — Medium

  src/Entity/Result.php:45,236

  protected $items;                       // no default — null
  public function getIterator(): Traversable
  {
      return new ArrayIterator($this->items);
  }

  Verified by execution on PHP 7.4:

  iterate on fresh Result: InvalidArgumentException: Passed variable is not an array or object

  new Result() → foreach blows up. On PHP 8.x it surfaces as a TypeError instead, so the exception type differs across the newly widened 7.4–8.4 CI matrix. getItems() is likewise documented @return array (src/Entity/ResultInterface.php:22) but returns null.

  This is pre-existing, but the diff rewrites this exact line and the release is explicitly framed as a PHP 8.x-compatibility pass — so it belongs in scope.

  Fix:  $this->items = []; in constructor (or new ArrayIterator($this->items ?? [])).

  ---
  3. Null-deprecation sweep applied inconsistently — DateNormalizer::mapToEntity() — Medium
  
  This is the "Method A handles it, Method B doesn't" pattern.

  src/Converter/CamelCaseToSnakeCaseConverter.php:16 was hardened:

  preg_replace('/[A-Z]/u', '_$0', $path ?? '')

  But src/Normalizer/DateNormalizer.php:33-37 has the identical PHP 8.1 deprecation and was left untouched:

  $date = DateTime::createFromFormat($this->format, $data, $this->remoteTimezone);

  DateTime::createFromFormat()'s $datetime parameter is a non-nullable string; passing null is deprecated on 8.1+.

  It is reachable. DenormalizerInterface::mapToEntity($data) documents @param mixed $data, and ArrayNormalizer::mapToEntity() (src/Normalizer/ArrayNormalizer.php:38-40) forwards each raw element straight through:

  foreach ($data as $innerElement) {
      $result[] = $this->innerMapper->mapToEntity($innerElement);
  }

  A JSON null inside a date array lands directly in DateNormalizer::mapToEntity(null). The asymmetry is visible within the same class: mapFromEntity() explicitly guards if ($entity === null) { return null; } (line 58), mapToEntity() does not.

  Because phpunit.xml.dist does not set convertDeprecationsToExceptions and PHPUnit 9 defaults it to true, this surfaces as a test error on 8.1+ rather than the intended InvalidDataException.

  Fix: guard $data in mapToEntity() consistently with the converter.

  ---
  4. phpunit/phpunit: ^9.0 lower bound is below what phpunit.xml.dist requires — Low

  composer.json:15 narrowed to "phpunit/phpunit": "^9.0", but phpunit.xml.dist:3 targets the 9.3 schema and uses the <coverage> element, which did not exist before PHPUnit 9.3 (9.0–9.2 use <filter><whitelist>).

  This matters because .github/workflows/ci.yml:35 actively runs a --prefer-lowest job:

  include:
    - { php: '7.4', symfony: '3.*', dependency: 'lowest' }

  which pins PHPUnit to 9.0.0. Result: schema-validation warning and the coverage configuration silently ignored. Not fatal, and strictly better than the previous ^7.0 || ^8.0 || ^9.0, but the bound should match the config.

  Fix: "phpunit/phpunit": "^9.3".

  ---
  5. Nits

  - src/Normalizer/DistributedNormalizer.php:50-53 — docblock @param column alignment is off by one. Confirmed via cat -A: $factory/$fieldsParser/$fieldsFilter pad to column 48, $normalizer sits at 49 (DenormalizerInterface|NormalizerInterface is one char
  wider than the padding allows).
  - src/Factory/ContextAwareNormalizerFactory.php — file touched but three pre-existing issues left: trailing whitespace on the final line (confirmed } $ via cat -A), a stray blank line after the class opening brace (line 13), and non-alphabetical use
  ordering (DistributedNormalizer on line 8 precedes ContextAwareNormalizerInterface on line 9).
  - tests/Filter/FieldsFilterTest.php:220-236 — the commented-out 'Takes curly braces' block still uses long array() syntax, the only remainder after the sweep.
  - CHANGELOG.md:7-25 — section order is Fixed / Removed / Changed; Keep a Changelog (which line 4 cites) specifies Added / Changed / Deprecated / Removed / Fixed / Security. Also, the getIterator() return type is described twice (once under Fixed, once
  under Changed), and the phpunit constraint narrowing is not mentioned at all.

Comment on lines +37 to +47
protected $fieldAccessors = [];

/**
* @var array of boolean
*/
protected $fieldDefault = array();
protected $fieldDefault = [];

/**
* @var DenormalizerInterface[]|NormalizerInterface[]
*/
protected $fieldNormalizers = array();
protected $fieldNormalizers = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default property values should be set in constructor https://github.com/paysera/php-style-guide#default-property-values

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated for all similar places.

- Release as 4.0.0 instead of 3.5.0: the getIterator() signature change
  and the dropped PHP versions are both BC breaks, matching the 3.0.0
  precedent for dropping Symfony 2.
- Default Result::$items to an empty array so a Result with no items
  iterates instead of raising TypeError/InvalidArgumentException, and
  getItems() honours its documented @return array.
- Guard DateNormalizer::mapToEntity() against null, consistently with
  CamelCaseToSnakeCaseConverter. Null input still raises
  InvalidDataException.
- Raise the phpunit dev requirement to ^9.3, the version that introduced
  the <coverage> element used by phpunit.xml.dist.
- Add regression tests for both runtime fixes; the date test asserts no
  deprecation is raised, since deprecations are not exceptions here.
- Restructure the changelog per Keep a Changelog section order, drop the
  duplicated getIterator() entry and record the phpunit change.
- Fix docblock alignment, use ordering, stray blank line and trailing
  whitespace; convert the last commented-out array() block.
@Okspen Okspen changed the title Fix PHP 8.1 deprecations, drop PHP 7.1-7.3 support Fix PHP 8.x deprecations, drop PHP 7.1-7.3 support (4.0.0) Jul 29, 2026
Follows the Paysera style guide: default values belong in the
constructor, not the property declaration.

Filter and BaseDenormalizer had no constructor, so one was added. Their
subclasses that declare their own constructor (FollowUpFilter,
DateNormalizer, FilterNormalizer) now call parent::__construct(), so the
base defaults are still applied.
Comment thread src/Entity/Result.php Outdated
* @return \Traversable
*/
public function getIterator()
public function getIterator(): Traversable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to introduce a new MAJOR version just for this return type change? Since the behaviour is unchanged, could we use #[\ReturnTypeWillChange] here for now and include the actual type additions in a future major release together with other API cleanups?

A major release for a single small signature change feels a bit excessive and makes upgrades harder for users.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that an issue? On older PHP versions new typehint will not be a problem. The related code changes in the project that uses the library should not be an issue either.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is only about making this the reason for a MAJOR release on its own.

For consumers of the package, a normal update within the same major version is transparent (composer update is enough). A new major version, however, requires them to manually change constraints, review the migration notes, and explicitly opt in.

Since this is a small, signature-only change with no behavioural impact, it feels like a premature major bump if this is the only reason for it. It might make more sense to include this together with other intentional API cleanups in a future major release.

Having said that, the current approach is also a valid one and it should not cause issues for new or existing consumers of the package. This is not a blocker from my side. I just wanted to raise another aspect: whether a change of this size deserves its own major version, given that it requires clients to explicitly update their version constraints to receive the new release.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with #[\ReturnTypeWillChange] and release this as 3.5.0. It fixes the actual reported symptom — the deprecation on every request — with no API change, and we bank the return type for a 4.0.0 that batches it with the other type additions. @Okspen two things for that to work: keep the @return \Traversable docblock, and please revert the default-property-value moves into constructors. My earlier comment was about our style guide for leaf entities; applied to Filter, which has no constructor and is designed for subclassing, it changes getOffset() from 0 to null for any subclass that doesn't call parent::__construct(), which silently breaks Result::calculateTotalCount(). That's fine to revisit in the 4.0.0, but it can't go in a minor.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. High — Filter::__construct() replaces the $offset = 0 property default, silently producing null offsets and wrong pagination totals
  
  Location: src/Entity/Filter.php:23 and src/Entity/Filter.php:41-44

  Problem: Filter previously had no constructor and declared protected $offset = 0;. This change deletes the default and introduces __construct() to assign it. In PHP, property defaults apply to every instance regardless of how it is created; constructor
  assignments do not.

  Two ordinary instantiation paths therefore now yield $offset === null where they previously yielded 0:

  1. A subclass with its own constructor that does not call parent::__construct() — previously perfectly legal, since there was no parent constructor to call. Filter is explicitly designed for subclassing (its own docblock says "Should be one of ORDER_BY_
  constants in descendant classes"*), so downstream subclasses are the expected usage, not an edge case.
  2. ReflectionClass::newInstanceWithoutConstructor() — the mechanism behind Doctrine hydration, PHPUnit createMock()/disableOriginalConstructor(), and reflection-based serializers. Notably relevant in a serializer library.

  Verified on PHP 7.4.3 against the actual source:

  class PaymentFilter extends Filter {              // own ctor, no parent:: call
      public function __construct($status = null) { $this->status = $status; }
  }
  $f = new PaymentFilter('done');
  $f->getOffset()                       => NULL     (was 0)

  $r = new Result($f);  $r->setItems([1,2]);
  $r->calculateTotalCount(2)            => NULL     (was 2)
  $r->getTotalCount()                   => 0        (was 2)

  (new ReflectionClass(Filter::class))->newInstanceWithoutConstructor()->getOffset()
                                        => NULL     (was 0)

  Baseline confirmed against git show HEAD:src/Entity/Filter.php:23 (protected $offset = 0;) and with a minimal repro (class B extends A with a non-parent-calling constructor still inherits A's property default int(0)).

  Impact: Result::calculateTotalCount() (src/Entity/Result.php:216-228) guards on getOffset() !== null. With a null offset it silently skips the calculation and returns null, leaving totalCount at 0. No exception, no warning — API responses report a total of
  0 for non-empty result sets. FilterNormalizer::mapFromEntity() (src/Normalizer/FilterNormalizer.php:47-49) likewise drops the offset key from serialized output. Both are silent wrong-output paths.

  Fix: Revert to the property default and delete the constructor:

  protected $offset = 0;

  This is behaviour-identical to the pre-change code for every instantiation path. If the constructor is kept for another reason, the default must also stay on the declaration.

  ---
  2. Medium — Result::$totalCount moved into the constructor; getTotalCount() now violates its @return int and the advertised $items fix does not cover constructor-less instantiation
  
  Location: src/Entity/Result.php:15, src/Entity/Result.php:45, src/Entity/Result.php:48-53

  Problem: Same mechanism as #1. protected $totalCount = 0; became protected $totalCount; with $this->totalCount = 0; in the constructor. The new $items initialisation was also placed in the constructor rather than on the declaration at line 45, which is
  still protected $items;.

  Verified:
  
  $r = (new ReflectionClass(Result::class))->newInstanceWithoutConstructor();
  $r->getTotalCount()          => NULL   (was 0 — regression)
  iterator_to_array($r)        => InvalidArgumentException: Passed variable is not an array or object

  Impact: Two distinct problems.

  - getTotalCount() is documented @return int (line 70-72) but now returns null for constructor-less instances — a regression introduced by this change.
  - CHANGELOG line 79-81 advertises "Iterating a Result whose items were never set no longer fails — $items now defaults to an empty array". $items does not default to an empty array; it is assigned in the constructor. The documented failure still reproduces
  exactly as described for any Result built without its constructor. The new tests/Entity/ResultTest.php only exercises new Result(), so it cannot catch this.

  The lower severity relative to #1 reflects that Result already had a constructor before this change, so subclasses were already obliged to call it.

  Fix: Put both defaults where they belong and drop the constructor assignments:

  protected $totalCount = 0;
  protected $items = [];
  
  The constructor then only needs $this->filter = $filter;, and the CHANGELOG wording becomes accurate.

 ---
  3. Medium — CHANGELOG's 4.0.0 BC-break list omits the newly added constructors

  Location: CHANGELOG.md:7-22

  Problem: The 4.0.0 entry marks exactly two BC breaks (line 9: getIterator() return type; line 22: PHP 7.1–7.3 drop). It does not mention that Filter (src/Entity/Filter.php:41) and BaseDenormalizer (src/Normalizer/BaseDenormalizer.php:11) — two classes that
  previously had no constructor at all — now have one, and that every downstream subclass with its own constructor must add a parent::__construct() call.

  The Filter case is described in the changelog only as "No behaviour change" refactoring (lines 63-68 cover the use-statement and short-array cleanups), which is the opposite of what finding #1 demonstrates.

  Impact: Integrators reading the upgrade notes get no signal about the one change most likely to break their code silently. Since 4.0.0 is a major release the break is permissible — but it has to be documented.

  Fix: Preferred — apply the fixes in #1 and #2, at which point there is nothing to document. If the constructors are kept deliberately, add explicit **BC break:** entries naming Filter and BaseDenormalizer and instructing subclasses to call
  parent::__construct().

  ---
  4. Low — BaseDenormalizer::__construct() adds a subclass obligation with no behavioural benefit

  Location: src/Normalizer/BaseDenormalizer.php:9-14

  Problem: protected $availableKeysCheckIgnored = false; became an uninitialised property plus a constructor assignment, and DateNormalizer (src/Normalizer/DateNormalizer.php:25) and FilterNormalizer (src/Normalizer/FilterNormalizer.php:16) were both amended
  to call parent::__construct().

  Verified as behaviour-neutral: the only read site is the guard at line 38, if (!$this->availableKeysCheckIgnored), and !null === !false evaluates to true — confirmed on PHP 7.4. ViolationNormalizer, the third in-repo subclass, declares no constructor and
  correctly inherits the new one, so nothing in this repository is broken.

  Impact: No runtime effect today. But it converts a previously constructor-less abstract extension point into one where every downstream subclass must remember parent::__construct(), and it makes the property's initialised state depend on instantiation path
  — for zero gain, since the guard already treats null and false identically.

  Fix: Restore protected $availableKeysCheckIgnored = false; and remove the constructor along with the two now-unnecessary parent::__construct() calls.

  ---
  5. Low — DistributedNormalizer array defaults moved into the constructor

  Location: src/Normalizer/DistributedNormalizer.php:37, :42, :47, :65-67

  Problem: $fieldAccessors, $fieldDefault and $fieldNormalizers lost their = array() defaults (confirmed present at git show HEAD:src/Normalizer/DistributedNormalizer.php:36,41,46) in favour of constructor assignments.

  Verified: newInstanceWithoutConstructor() leaves $fieldAccessors as NULL where it was previously [].

  Impact: Narrower than #1/#2 — this class's constructor already takes four required arguments, so real code cannot skip it. The affected path is test doubles created with disableOriginalConstructor(), where foreach ($this->fieldAccessors as ...) at line 148
  changes from a clean no-op to a foreach() argument must be of type array|object warning.

  Fix: Restore the three = [] property defaults; the constructor assignments at lines 65-67 then become redundant and can be dropped.

  ---
  6. Low — no test covers the default-initialisation behaviour this change rewrote

  Location: tests/Entity/ResultTest.php (new file)

  Problem: The new test file exercises new Result() only. Given that the diff's central mechanism is where defaults are initialised, there is no test asserting (new Filter())->getOffset() === 0, no test for a Filter subclass, and no test for
  calculateTotalCount() — the method whose output silently changes in finding #1.

  Impact: Finding #1 would have been caught by CI with a three-line test. There is currently nothing guarding against reintroducing it.

  Fix: Add a FilterTest asserting the default offset and calculateTotalCount() behaviour, plus a fixture subclass declaring its own constructor to pin down the inheritance contract.

  ---
  7. Note — DateNormalizer::mapToEntity(null) reports "Provided date format is invalid"

  Location: src/Normalizer/DateNormalizer.php:37 and :41

  Problem: $data ?? '' routes a null input into DateTime::createFromFormat(), which returns false, which raises InvalidDataException('Provided date format is invalid'). The message blames the configured format when the actual cause is a null input.

  Verified: DateTime::createFromFormat('Y-m-d H:i:s', '') returns bool(false) on PHP 7.4 — so the changelog's claim (line 77-78) that null still raises InvalidDataException is accurate, and the new test at tests/Normalizer/DateNormalizerTest.php:64-92
  correctly confirms no deprecation is emitted. This is a diagnostics nit, not a behavioural defect.

  Fix (optional): An explicit guard gives a more accurate message and is clearer than the ?? workaround:

  if ($data === null) {
      throw new InvalidDataException('Date must be provided');
  }

  ---
  8. Note — FollowUpFilter redeclares $offset and its new parent::__construct() call is immediately overwritten

  Location: src/Entity/FollowUpFilter.php:10 and :19-21

  Problem: FollowUpFilter declares protected $offset;, shadowing the parent declaration, and its constructor now calls parent::__construct() (which sets $this->offset = 0) before immediately overwriting it with $this->offset = $offset; on the next line.

  Impact: None — the added call is correct defensive practice and the net state is right. The redundant property redeclaration at line 10 predates this change. Noted only because it is the kind of dead assignment that obscures the initialisation contract
  this diff is otherwise rearranging.

  Fix: Optionally drop the redundant protected $offset; at line 10; keep the parent::__construct() call.

Andrii Krasnoholovets and others added 8 commits July 31, 2026 13:07
Per review, fix the reported symptom (the tentative return type
deprecation on every request) without changing the public API, so this
ships as a minor rather than a major.

- Mark Result::getIterator() with #[\ReturnTypeWillChange] and restore
  the untyped signature plus the @return Traversable docblock. On PHP
  7.4 the attribute is parsed as a comment, so it is a no-op there.
  The native \Traversable return type is banked for 4.0.0, where it
  will land together with the other type additions.
- Revert the default-property-value moves into constructors. The style
  guide rule targets leaf entities; Filter has no constructor and is
  designed for subclassing, so adding one made getOffset() return null
  instead of 0 for any subclass that does not call
  parent::__construct(), which silently broke
  Result::calculateTotalCount(). Worth revisiting in 4.0.0, but it
  cannot go into a minor.
- Retitle the changelog entry 4.0.0 -> 3.5.0 and drop the BC break
  labels. Raising the PHP requirement to 7.4 stays under Removed:
  Composer keeps projects on older PHP at 3.4.x rather than breaking
  them.

Result::$items keeps its empty array default on the property
declaration, so the fix for iterating an item-less Result is retained.
Follow-up to the review on PR paysera#13. Findings 1, 2, 4 and 5 were already
resolved by reverting the constructor moves; this adds the missing test
coverage that would have caught them, plus two scope adjustments.

- New FilterTest pins the contract the revert restored: offset defaults
  to 0 for a plain Filter, for a subclass that declares its own
  constructor without calling parent::__construct(), and for
  newInstanceWithoutConstructor(). Also covers calculateTotalCount()
  through such a subclass, the path that silently returned null.
  Verified by reintroducing the regression: all four fail without it.

- ResultTest gains a totalCount default check for constructor-less
  instantiation, and a guard asserting getIterator() either declares a
  native return type or carries #[\ReturnTypeWillChange]. The tentative
  return type notice is emitted when the class is declared, not when the
  method is called, so an error handler inside a test cannot observe it;
  the assertion is on the declaration and stays valid in 4.0.0.

- Drop the Result::$items empty array default. This reverts to the
  master behaviour: getItems() returns null and iterating an item-less
  Result raises a TypeError on PHP 8. The changelog entry advertising
  the fix and the tests covering it are removed with it.

- Keep the default values in DistributedNormalizer's constructor per the
  style guide. Its constructor takes four required arguments, so no real
  code path can bypass it; only test doubles built with
  disableOriginalConstructor() see null instead of [].
Restores the empty array initialisation dropped in the previous commit,
in the constructor rather than on the property declaration.

Every Result built through its constructor iterates and returns an array
from getItems() again. Instances created without the constructor keep a
null $items, so the changelog entry states that limit rather than
claiming a declaration default, which the review flagged as inaccurate.

Restores the two ResultTest cases covering the constructor path.
mapToEntity(null) routed null through DateTime::createFromFormat() via a
$data ?? '' workaround, which returned false and raised
InvalidDataException('Provided date format is invalid') — blaming the
configured format for what is a missing input.

Guard on null explicitly instead. The exception type is unchanged, so
callers catching InvalidDataException are unaffected; only the message
differs. The existing regression test now pins that message alongside
its assertion that no deprecation is emitted.
Restores = [] on $fieldAccessors, $fieldDefault and $fieldNormalizers
and drops the now-redundant constructor assignments, matching how the
rest of the library initialises defaults after the earlier revert.

Property defaults apply on every instantiation path, so test doubles
built with disableOriginalConstructor() get [] instead of null and the
foreach over $fieldAccessors stays a clean no-op rather than warning.

Against master this file now differs only by the use-statement and short
array syntax cleanups.
FollowUpFilter redeclared protected $offset;, shadowing the parent
declaration and its = 0 default. Same name, same visibility, so the
child declaration bought nothing except making FollowUpFilter the one
filter that starts at null instead of 0 when built without its
constructor.

Its constructor requires an offset, so no normal instantiation changes.
Covered by two tests: the inherited default on a constructor-less
instance, and the constructor argument still winning.

The getOffset() override stays — unlike the parent it returns the raw
offset rather than null when an after/before cursor is set, which is a
behavioural difference, not duplication.
Reapplies the style guide form for this class. Nothing extends
DistributedNormalizer, it is not abstract, and its constructor takes
four required arguments, so no real code path reaches it uninitialised —
it is a leaf, unlike Filter and BaseDenormalizer.

Test doubles built with disableOriginalConstructor() see null rather
than [] for the three arrays, which is the narrow case raised as a low
severity note in review. No such double exists in this repository.
A constructor assignment does not cover instances built without the
constructor — ReflectionClass::newInstanceWithoutConstructor(), and the
ORM hydration, reflection deserializers and disableOriginalConstructor()
mocks that build on it. Those still threw on iteration, which is the
symptom this branch set out to fix and the likeliest path in a
serializer library.

Moves the default to the declaration alongside $totalCount, adds the
matching constructor-bypass test, and corrects the changelog entry,
which credited the constructor and stated the bypass path was
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Okspen Okspen changed the title Fix PHP 8.x deprecations, drop PHP 7.1-7.3 support (4.0.0) Fix PHP 8.x deprecations, drop PHP 7.1-7.3 support (3.5.0) Jul 31, 2026

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Medium — DistributedNormalizer moves array defaults off the property declarations, silently dropping distributed fields on constructor-bypassed instances
  
  Issue Type: Backwards Compatibility — Logic Bypass / Consistency Across Branches
  Location: src/Normalizer/DistributedNormalizer.php:37, :42, :47 (declarations) and :65-67 (constructor)

  The diff removes the = array() initialisers from three property declarations and re-adds them as constructor assignments:

  protected $fieldAccessors;      // was: = array()
  protected $fieldDefault;        // was: = array()
  protected $fieldNormalizers;    // was: = array()

  public function __construct(...) {
      ...
      $this->fieldAccessors = [];
      $this->fieldDefault = [];
      $this->fieldNormalizers = [];
  }

  Problem: This is the exact inverse of the invariant the rest of this changeset establishes and pins with tests. CHANGELOG.md:34-38 states the Result::$items default "lives on the property declaration rather than in the constructor, so it also applies to 
  instances built without one — ReflectionClass::newInstanceWithoutConstructor(), and the ORM hydration and mocking that build on it", and tests/Entity/ResultTest.php:49-53 documents the same rule ("a constructor assignment would not cover this path").
  DistributedNormalizer now violates it.

  Impact: For any instance built without the constructor — PHPUnit's disableOriginalConstructor()/createMock(), reflection hydration, or a downstream subclass with its own constructor — the three properties are null instead of []:

  - mapFromEntity() (:152) → foreach ($this->fieldAccessors ...) over null. Verified on PHP 7.4: this is a warning, not a fatal, so the loop is silently skipped and every distributed field is dropped from the output with no exception.
  - mapToEntity() (:112) → isset($this->fieldNormalizers[$key]) is false for every key, so all distributed fields are silently ignored during denormalisation.

  Verified with a standalone reproduction of the before/after declarations: Before yields array(), After yields NULL, and foreach over NULL emits Warning: Invalid argument supplied for foreach() and continues. No in-repo caller triggers this
  (ContextAwareNormalizerFactory:39 is the only construction site and it calls the constructor), but this is a public library class.

  Fix: Restore the defaults on the declarations (protected $fieldAccessors = []; etc.) and drop the three constructor assignments. Keeping both is also acceptable; keeping only the constructor is not.

  ---
  2. Medium — CHANGELOG claims "No behaviour change" while the diff contains two undocumented behavioural changes
  
  Issue Type: Documentation / Release-notes accuracy
  Location: CHANGELOG.md:7-38

  The 3.5.0 entry documents four fixes and describes the remaining work as "No behaviour change". Two behavioural changes in this same diff are missing:

  1. src/Entity/FollowUpFilter.php:7-11 — removal of the shadowing protected $offset; declaration. Filter:23 declares protected $offset = 0;; the child re-declared it with no initialiser, so the child's default was null. Removing the redeclaration changes
  the default from null to 0 for any FollowUpFilter built without its constructor. This is a fix, and the changeset adds a test for it (tests/Entity/FilterTest.php:36-41, testFollowUpFilterInheritsOffsetDefault) — but it is not in the changelog, even though
  the structurally identical Result::$items fix is documented at length.
  2. src/Normalizer/DistributedNormalizer.php property-init move (finding #1) — not mentioned at all.

  Impact: Consumers reading the release notes to assess upgrade risk get an incomplete picture; the DistributedNormalizer regression in particular is invisible.

  Fix: Add the FollowUpFilter offset default to the ### Fixed section; revert the DistributedNormalizer change per #1 (no changelog entry then needed).

  ---
  3. Low — DateNormalizer::mapToEntity(null) changes the exception message consumers may be matching on
  
  Issue Type: Backwards Compatibility — observable behaviour change
  Location: src/Normalizer/DateNormalizer.php:34-36

  if ($data === null) {
      throw new InvalidDataException('Date must be provided');
  }

  Previously null fell through to DateTime::createFromFormat(), which returned false, producing InvalidDataException('Provided date format is invalid') at :44. The exception class is unchanged, so catch blocks are safe, but any consumer that inspects
  getMessage() — API error-mapping layers in particular, which is the primary use of this library — will see a different string for the same input.

  This is documented in CHANGELOG.md:26-29, so it is a deliberate choice rather than an oversight; flagged so the message change is weighed against consumers before tagging, since the entry is filed under ### Fixed rather than ### Changed.

  Fix: Either keep the original message text for the null branch, or move this bullet under ### Changed so the BC surface is visible at the right heading.

   ---
  4. Low — Result::calculateTotalCount() docblock declares @return null but the method returns int|null, and the new test now pins the int

  Issue Type: Incorrect docblock
  Location: src/Entity/Result.php:209

   * @param $resultCount
   * @return null                       // <- line 209
   * @throws BadMethodCallException      // <- touched by this diff
   */
  public function calculateTotalCount($resultCount)
  {
      ...
      $this->totalCount = $resultCount + $this->getFilter()->getOffset();
      return $this->totalCount;          // <- int

  The diff edits the @throws line directly beneath this one, and the changeset adds tests/Entity/FilterTest.php:52 — $this->assertSame(2, $result->calculateTotalCount(2)) — which asserts the int return the docblock denies. Static analysis and IDEs will now
  report a false positive on any consumer that uses the return value.

  Fix: @return int|null.

  ---
  5. Low — a second class is declared inside tests/Entity/FilterTest.php, breaking PSR-4 autoloading for it

  Issue Type: PSR-4 / PSR-1 violation
  Location: tests/Entity/FilterTest.php:62

  class OwnConstructorFilter extends Filter is declared in FilterTest.php. composer.json:9-13 maps Paysera\Component\Serializer\Tests\ → tests/ via PSR-4, so Paysera\Component\Serializer\Tests\Entity\OwnConstructorFilter is not resolvable by the autoloader —
  it only exists as a side effect of PHPUnit having loaded FilterTest.php. Any other test file referencing it (including ResultTest.php, which covers overlapping ground) will fail with "class not found" depending on suite ordering.

  Related: FilterTest::testCalculateTotalCountForSubclassNotCallingParentConstructor (:48-54) asserts on Result, not Filter, and belongs in tests/Entity/ResultTest.php.

  Fix: Move OwnConstructorFilter to tests/Entity/OwnConstructorFilter.php (or a tests/Fixtures/ directory), and relocate the Result assertion to ResultTest.

  ---
  6. Note — #[\ReturnTypeWillChange] keeps the leading backslash this changeset exists to remove

  Issue Type: Consistency with the changeset's own stated goal
  Location: src/Entity/Result.php:235

  CHANGELOG.md:11-14 states the changeset "Replaced leading-backslash class references with use statements throughout the library". Verified by grep: after this diff, src/Entity/Result.php:235 is the only remaining leading-backslash class reference in src/.
  The companion test imports it the other way (tests/Entity/ResultTest.php:9, use ReturnTypeWillChange;).

  Functionally correct either way — php -l passes on PHP 7.4, where the attribute is parsed as a comment.

  Fix: use ReturnTypeWillChange; + #[ReturnTypeWillChange], matching the test file. (Safe on PHP 7.4: an unused use for a non-existent global class triggers no autoload.)

  ---
  7. Note — stray blank line after the class brace left in FieldsParser while the same formatting was cleaned elsewhere

  Issue Type: Inconsistent cleanup
  Location: src/Filter/FieldsParser.php:8-9

  class FieldsParser
  {
                      // <- line 9, blank
      /**

  The same diff removes exactly this pattern from src/Factory/ContextAwareNormalizerFactory.php:13 and normalises the missing-EOF-newline/trailing-space issues in ContextAwareNormalizerFactory, ResponseMapperFactory, FieldsConfig and BaseDenormalizer.
  FieldsParser — a file this diff otherwise touches — was skipped.

  Fix: Remove line 9.

Andrii Krasnoholovets and others added 6 commits July 31, 2026 14:15
The null guard alters an observable string: InvalidDataException now
reports "Date must be provided" where it previously reported "Provided
date format is invalid". The exception class is unchanged, so catch
blocks are safe, but consumers matching on getMessage() — API
error-mapping layers in particular — see a different value for the same
input. Filing it under Fixed hid that BC surface under the wrong heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same formatting was cleaned from ContextAwareNormalizerFactory
earlier in this branch but skipped in three other files the branch also
touches. EncoderFactoryInterface and FieldAccessorInterface have it too
and are left alone — this branch does not otherwise touch them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch replaced leading-backslash class references with use
statements throughout the library; the attribute added later reintroduced
the only remaining one in src/. The companion test already imports it
this way.

Safe on PHP 7.4: the attribute is parsed as a comment there, and an
unused use for a non-existent global class triggers no autoload.
Verified that the imported form still suppresses the tentative return
type deprecation — the engine resolves the attribute name through the
file's use statements, and a run with the attribute removed re-emits the
notice, confirming the check is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The method returns $this->totalCount (int) on the success path and null
otherwise, but the docblock declared @return null, so any consumer using
the return value drew a false positive from static analysis. FilterTest
now asserts the int return directly. Also types the previously bare
@PARAM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OwnConstructorFilter was declared as a second class inside FilterTest.php,
so it existed only as a side effect of PHPUnit loading that file rather
than being resolvable through the PSR-4 map. Any other test referencing it
would fail depending on suite ordering — which ResultTest now does.

Also relocates testCalculateTotalCountForSubclassNotCallingParentConstructor
to ResultTest, since it asserts on Result rather than Filter.

Verified by running each test file in isolation: ResultTest passes on its
own, which it could not have done under the previous arrangement. The
fixture is not collected as a test — PHPUnit's default Test.php suffix
does not match it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 3.5.0 entry described the remaining work as "No behaviour change"
while two behavioural changes went unlisted:

- FollowUpFilter's shadowing $offset redeclaration gave it a null default
  where Filter declares 0. Removing it is a fix, and the branch already
  tests for it, but it was undocumented while the structurally identical
  Result::$items fix was described at length.
- The DistributedNormalizer property-init move was not mentioned at all.
  It stays as-is rather than being reverted, so the entry states plainly
  what it costs: doubles built with disableOriginalConstructor() see null
  instead of [].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the = [] initialisers on $fieldAccessors, $fieldDefault and
$fieldNormalizers and drops the matching constructor assignments, making
the class consistent with Result, Filter and BaseDenormalizer rather than
the exact inverse of the invariant the rest of the branch establishes.

Both readers degrade silently when the properties are null, so nothing
surfaces the problem at the point of failure: mapFromEntity() iterates
$fieldAccessors, which warns and skips, dropping every distributed field
from the output, and mapToEntity() probes isset($fieldNormalizers[$key]),
which is false for every key. No in-repo caller can reach this — the
constructor takes four required arguments and the sole construction site
calls it — but the class is public API.

Adds a regression test over all three properties, verified RED against
the constructor-assignment version. The net diff against master for these
declarations is now only array() -> [], part of the short-array sweep,
so the changelog entry describing a behaviour change is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Medium — DistributedNormalizer array defaults moved to the constructor, regressing constructor-bypassing subclasses and mocks

  Location: src/Normalizer/DistributedNormalizer.php:37, :42, :47, :65-67

  Problem: $fieldAccessors, $fieldDefault and $fieldNormalizers lost their = [] declaration defaults; they are now only assigned inside __construct(). Any instance built without the constructor holds null in all three.

  Impact: DistributedNormalizer is not final and is a library class, so downstream code can subclass it. A subclass that declares its own __construct() without calling parent::__construct() — or a partial double via disableOriginalConstructor() /
  ReflectionClass::newInstanceWithoutConstructor() — now hits foreach ($this->fieldAccessors as ...) at :152 with null. Verified on the PHP 7.4.3 in this environment:

  PHP Warning:  Invalid argument supplied for foreach()

  Previously this was a silent no-op. In any suite using this repo's own phpunit.xml.dist setting convertWarningsToExceptions="true", that warning becomes a test error rather than a skipped loop. The CHANGELOG discloses the test-double case but not the
  subclass case.

  Fix: Restore = [] on the three property declarations and drop lines 65-67 — see #2, the two changes need to agree.

  ---
  2. Medium — Result::$items default placed on the declaration, contradicting the style-guide rule this same release applies to DistributedNormalizer
  
  Location: src/Entity/Result.php:46; test pinning it at tests/Entity/ResultTest.php:114

  Problem: The Paysera PHP style guide states:

  ▎ "If we need to define some default value for class property, we do this in constructor, not in property declaration."

  Result::$items = [] is added directly on the declaration, while DistributedNormalizer (#1) is moved the opposite direction in the same commit, with the CHANGELOG citing that rule as the reason. One release therefore ships both conventions for the identical
  concern.

  Impact: The two changes have mutually exclusive rationales. The CHANGELOG argues for Result: "The default lives on the property declaration rather than in the constructor, so it also applies to instances built without one — 
  ReflectionClass::newInstanceWithoutConstructor(), and the ORM hydration and mocking that build on it." That is precisely the argument against the DistributedNormalizer change. tests/Entity/ResultTest.php:114 then hard-pins the non-compliant form with the
  comment "a constructor assignment would not cover this path", so a later style-guide sweep that "corrects" Result::$items into __construct() will fail that test and silently reintroduce the null-items bug for reflection-hydrated instances.

  Fix: Pick one convention and apply it to both. Given Result has a real functional requirement the constructor cannot serve (reflection hydration), the safe resolution is declaration defaults in both classes, with the style-guide deviation stated explicitly
  in the CHANGELOG rather than claimed as compliance.

  ---
  3. Low — DateNormalizer null input now reports a different message than empty-string input
  
  Location: src/Normalizer/DateNormalizer.php:34-36

  Problem: mapToEntity(null) throws InvalidDataException('Date must be provided'); mapToEntity('') still falls through to DateTime::createFromFormat(), returns false, and throws InvalidDataException('Provided date format is invalid') at :44.

  Impact: Two representations of the same "no date supplied" condition now produce two different messages. For an API error-mapping layer that switches on getMessage() — the consumer class the CHANGELOG itself flags — a request omitting the field and a
  request sending "" map to different error strings. The exception type is unchanged, so catch blocks are unaffected.

  Fix: Either extend the guard to if ($data === null || $data === ''), or keep the original message text for the null branch.

  ---
  4. Low — Non-strict in_array() left on a line this changeset edited, next to a strict one in the same method
  
  Location: src/Normalizer/FilterNormalizer.php:127

  Problem: The changeset rewrote this line's array literal (array('ASC', 'DESC') → ['ASC', 'DESC']) but left the missing strict flag:

  if (!in_array($orderDirection, ['ASC', 'DESC'])) {

  Line 117 in the same method already complies: in_array($orderBy, $this->orderByFields, true). It is the only non-strict in_array() remaining in src/ — FieldsConfig.php:40 is also strict.

  Impact: No functional defect: $orderDirection is the result of strtoupper() at :126, so it is always a string and loose comparison against two string literals behaves identically. This is a style-guide violation (PHP005, rated Critical in the guide) on a
  line the commit already touched.

  Fix: in_array($orderDirection, ['ASC', 'DESC'], true).
  Fix: Restore = [] on the three property declarations and drop lines 65-67 — see #2, the two changes need to agree.

@mSprunskas
mSprunskas merged commit abc8f4a into paysera:master Jul 31, 2026
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.

3 participants