From 4ccce066f3577ac01d67da833e9e4ffebe94a12b Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:05:14 +0300 Subject: [PATCH 01/24] PHP 8 deprecations fix --- src/Converter/CamelCaseToSnakeCaseConverter.php | 2 +- src/Entity/Result.php | 17 ++++++++++------- src/Entity/ResultInterface.php | 4 +++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Converter/CamelCaseToSnakeCaseConverter.php b/src/Converter/CamelCaseToSnakeCaseConverter.php index 0cf1422..78338df 100644 --- a/src/Converter/CamelCaseToSnakeCaseConverter.php +++ b/src/Converter/CamelCaseToSnakeCaseConverter.php @@ -13,7 +13,7 @@ public function convert($path) preg_replace( '/[A-Z]/u', '_$0', - $path + $path ?? '' ) ), '_' diff --git a/src/Entity/Result.php b/src/Entity/Result.php index ea7f3e7..2ecd019 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -2,7 +2,12 @@ namespace Paysera\Component\Serializer\Entity; -class Result implements \IteratorAggregate, ResultInterface +use ArrayIterator; +use BadMethodCallException; +use IteratorAggregate; +use Traversable; + +class Result implements IteratorAggregate, ResultInterface { /** * @var int @@ -203,12 +208,12 @@ public function addItem($item) * * @param $resultCount * @return null - * @throws \BadMethodCallException + * @throws BadMethodCallException */ public function calculateTotalCount($resultCount) { if (!$this->getFilter()) { - throw new \BadMethodCallException('filter must be set before calling this method'); + throw new BadMethodCallException('filter must be set before calling this method'); } if ( @@ -225,11 +230,9 @@ public function calculateTotalCount($resultCount) /** * Retrieve an external iterator - * - * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { - return new \ArrayIterator($this->items); + return new ArrayIterator($this->items); } } diff --git a/src/Entity/ResultInterface.php b/src/Entity/ResultInterface.php index 3afbc99..1293c38 100644 --- a/src/Entity/ResultInterface.php +++ b/src/Entity/ResultInterface.php @@ -2,7 +2,9 @@ namespace Paysera\Component\Serializer\Entity; -interface ResultInterface extends \Traversable +use Traversable; + +interface ResultInterface extends Traversable { /** * Gets totalCount From 48454741d614bb910cbaf5b3b9b39e579981d86e Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:07:03 +0300 Subject: [PATCH 02/24] Drop PHP 7.1, 7.2 and 7.3 support 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. --- .github/workflows/ci.yml | 16 ++-------------- composer.json | 4 ++-- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b24d8..1dd35a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,6 @@ jobs: matrix: php: - - '7.1' - - '7.2' - - '7.3' - '7.4' - '8.0' - '8.1' @@ -35,17 +32,13 @@ jobs: dependency: - 'highest' include: - - { php: '7.1', symfony: '3.*', dependency: 'lowest' } + - { php: '7.4', symfony: '3.*', dependency: 'lowest' } exclude: - { php: '8.0', symfony: '3.*' } - { php: '8.1', symfony: '3.*' } - { php: '8.2', symfony: '3.*' } - { php: '8.3', symfony: '3.*' } - { php: '8.4', symfony: '3.*' } - - { php: '7.1', symfony: '5.*' } - - { php: '7.1', symfony: '6.*' } - - { php: '7.2', symfony: '6.*' } - - { php: '7.3', symfony: '6.*' } - { php: '7.4', symfony: '6.*' } - { php: '8.0', symfony: '6.*' } @@ -60,12 +53,7 @@ jobs: tools: flex - name: Ignore specific Composer audit advisory - run: | - if [[ "${{ matrix.php }}" == "7.1" ]]; then - echo "COMPOSER_AUDIT_BLOCK_INSECURE=0" >> $GITHUB_ENV - else - composer config --global audit.ignore "PKSA-w2tw-kmfg-rt9s" - fi + run: composer config --global audit.ignore "PKSA-w2tw-kmfg-rt9s" - name: Install dependencies uses: ramsey/composer-install@v2 diff --git a/composer.json b/composer.json index e54890a..0f29400 100644 --- a/composer.json +++ b/composer.json @@ -12,10 +12,10 @@ } }, "require-dev": { - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "require": { - "php": "^7.1 || ^8.0", + "php": "^7.4 || ^8.0", "symfony/property-access": "^3.0 || ^4.0 || ^5.0 || ^6.0", "symfony/validator": "^3.0 || ^4.0 || ^5.0 || ^6.0", "willdurand/jsonp-callback-validator": "^1.0" From 1f8622a790f42d2eef345f43acd6f20ddfe86174 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:07:16 +0300 Subject: [PATCH 03/24] Import globally namespaced and fully qualified classes 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. --- src/Exception/EncodingException.php | 4 +++- src/Factory/ContextAwareNormalizerFactory.php | 4 ++-- src/Factory/ResponseMapperFactory.php | 5 +++-- src/Filter/FieldsFilter.php | 4 +++- src/Filter/FieldsParser.php | 6 ++++-- src/Normalizer/ArrayNormalizer.php | 3 ++- src/Normalizer/AssociativeArrayNormalizer.php | 4 +++- src/Normalizer/BaseDenormalizer.php | 2 +- src/Normalizer/DateNormalizer.php | 12 ++++++----- src/Normalizer/DenormalizerInterface.php | 4 +++- src/Normalizer/DistributedNormalizer.php | 15 +++++++------- src/Normalizer/FilterNormalizer.php | 2 +- src/Normalizer/PlainNormalizer.php | 4 +++- src/Normalizer/ResultNormalizer.php | 3 ++- src/Normalizer/TransformerDenormalizer.php | 3 ++- tests/Filter/FieldsFilterTest.php | 7 ++++--- tests/Normalizer/DateNormalizerTest.php | 20 ++++++++++--------- 17 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/Exception/EncodingException.php b/src/Exception/EncodingException.php index e0f0d92..e8a5cf1 100644 --- a/src/Exception/EncodingException.php +++ b/src/Exception/EncodingException.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Exception; -class EncodingException extends \Exception +use Exception; + +class EncodingException extends Exception { } diff --git a/src/Factory/ContextAwareNormalizerFactory.php b/src/Factory/ContextAwareNormalizerFactory.php index 67bb4f1..1c969bf 100644 --- a/src/Factory/ContextAwareNormalizerFactory.php +++ b/src/Factory/ContextAwareNormalizerFactory.php @@ -13,12 +13,12 @@ class ContextAwareNormalizerFactory { /** - * @var \Paysera\Component\Serializer\Filter\FieldsFilter + * @var FieldsFilter */ protected $fieldsFilter; /** - * @var \Paysera\Component\Serializer\Filter\FieldsParser + * @var FieldsParser */ protected $fieldsParser; diff --git a/src/Factory/ResponseMapperFactory.php b/src/Factory/ResponseMapperFactory.php index d9e863f..eae3d82 100644 --- a/src/Factory/ResponseMapperFactory.php +++ b/src/Factory/ResponseMapperFactory.php @@ -3,6 +3,7 @@ namespace Paysera\Component\Serializer\Factory; use Paysera\Component\Serializer\Normalizer\NormalizerInterface; +use RuntimeException; class ResponseMapperFactory implements ResponseMapperFactoryInterface { @@ -40,14 +41,14 @@ public function addMapper($key, NormalizerInterface $mapper) /** * @param array $options * - * @throws \RuntimeException + * @throws RuntimeException * @return NormalizerInterface */ public function createResponseMapper(array $options) { $key = isset($options[self::MAPPER_OPTION]) ? $options[self::MAPPER_OPTION] : null; if ($key !== null && !isset($this->mappers[$key])) { - throw new \RuntimeException('Wrong mapper key specified: ' . $key); + throw new RuntimeException('Wrong mapper key specified: ' . $key); } if ($key === null) { foreach ($options as $optionKey => $value) { diff --git a/src/Filter/FieldsFilter.php b/src/Filter/FieldsFilter.php index 0423384..de3d254 100644 --- a/src/Filter/FieldsFilter.php +++ b/src/Filter/FieldsFilter.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Filter; +use ArrayObject; + class FieldsFilter { /** @@ -33,7 +35,7 @@ public function filter($data, ?array $fields = null, array $scope = []) if ($this->isAssociativeArray($data)) { $result = $this->filterByConfig($data, $fieldsConfig); if (is_array($result) && count($result) === 0) { - $result = new \ArrayObject(); + $result = new ArrayObject(); } return $result; } else { diff --git a/src/Filter/FieldsParser.php b/src/Filter/FieldsParser.php index 885c95e..e68b472 100644 --- a/src/Filter/FieldsParser.php +++ b/src/Filter/FieldsParser.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Filter; +use InvalidArgumentException; + class FieldsParser { @@ -23,7 +25,7 @@ public function parseFields(?array $fields = null, array $scope = []) /** * @param null|array $fields * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException * @return FieldsConfig */ public function parseUnscopedFields(?array $fields = null) @@ -41,7 +43,7 @@ public function parseUnscopedFields(?array $fields = null) foreach (explode(',', $fieldDefinition) as $field) { $list = explode('.', $field, 2); if (isset($list[1]) && $list[1] === '') { - throw new \InvalidArgumentException('Invalid field provided, field cannot end with a dot'); + throw new InvalidArgumentException('Invalid field provided, field cannot end with a dot'); } $name = $list[0]; $extension = isset($list[1]) ? $list[1] : null; diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index 244fa72..985b540 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -3,6 +3,7 @@ namespace Paysera\Component\Serializer\Normalizer; use Paysera\Component\Serializer\Entity\NormalizationContextInterface; +use Paysera\Component\Serializer\Exception\InvalidDataException; class ArrayNormalizer implements DenormalizerInterface, ContextAwareNormalizerInterface { @@ -28,7 +29,7 @@ public function __construct($innerMapper) * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/src/Normalizer/AssociativeArrayNormalizer.php b/src/Normalizer/AssociativeArrayNormalizer.php index 52c6764..f66d365 100644 --- a/src/Normalizer/AssociativeArrayNormalizer.php +++ b/src/Normalizer/AssociativeArrayNormalizer.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Normalizer; +use ArrayObject; + class AssociativeArrayNormalizer implements DenormalizerInterface, NormalizerInterface { /** @@ -28,7 +30,7 @@ public function __construct($innerMapper) */ public function mapFromEntity($entity) { - $result = new \ArrayObject(); + $result = new ArrayObject(); if ($entity !== null) { foreach ($entity as $key => $innerElement) { $result[$key] = $this->innerMapper->mapFromEntity($innerElement); diff --git a/src/Normalizer/BaseDenormalizer.php b/src/Normalizer/BaseDenormalizer.php index bdb67ac..12f6818 100644 --- a/src/Normalizer/BaseDenormalizer.php +++ b/src/Normalizer/BaseDenormalizer.php @@ -63,7 +63,7 @@ protected function checkRequiredKeys($data, $requiredKeys) * * @param $data * @param $keys - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ protected function checkOnlyOneKeyExists($data, $keys) { diff --git a/src/Normalizer/DateNormalizer.php b/src/Normalizer/DateNormalizer.php index cd86fac..f28beb6 100644 --- a/src/Normalizer/DateNormalizer.php +++ b/src/Normalizer/DateNormalizer.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Normalizer; +use DateTime; +use DateTimeZone; use Paysera\Component\Serializer\Exception\InvalidDataException; class DateNormalizer extends BaseDenormalizer implements NormalizerInterface @@ -12,7 +14,7 @@ class DateNormalizer extends BaseDenormalizer implements NormalizerInterface protected $format; /** - * @var \DateTimeZone + * @var DateTimeZone */ protected $remoteTimezone; @@ -25,11 +27,11 @@ public function __construct($format, $remoteTimezone = null) /** * @param string $data * @throws InvalidDataException - * @return \DateTime + * @return DateTime */ public function mapToEntity($data) { - $date = \DateTime::createFromFormat( + $date = DateTime::createFromFormat( $this->format, $data, $this->remoteTimezone @@ -50,7 +52,7 @@ public function mapToEntity($data) } /** - * @param \DateTime $entity + * @param DateTime $entity * @return string */ public function mapFromEntity($entity) @@ -67,6 +69,6 @@ public function mapFromEntity($entity) protected function getLocalTimezone() { - return new \DateTimeZone(date_default_timezone_get()); + return new DateTimeZone(date_default_timezone_get()); } } diff --git a/src/Normalizer/DenormalizerInterface.php b/src/Normalizer/DenormalizerInterface.php index 202a869..23329d8 100644 --- a/src/Normalizer/DenormalizerInterface.php +++ b/src/Normalizer/DenormalizerInterface.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Normalizer; +use Paysera\Component\Serializer\Exception\InvalidDataException; + interface DenormalizerInterface { /** @@ -11,7 +13,7 @@ interface DenormalizerInterface * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data); } diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 3ace9a2..8b4bb83 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -4,6 +4,7 @@ use Paysera\Component\Serializer\Accessor\FieldAccessorInterface; use Paysera\Component\Serializer\Entity\NormalizationContextInterface; +use Paysera\Component\Serializer\Exception\InvalidDataException; use Paysera\Component\Serializer\Factory\ContextAwareNormalizerFactory; use Paysera\Component\Serializer\Filter\FieldsFilter; use Paysera\Component\Serializer\Filter\FieldsParser; @@ -16,12 +17,12 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal protected $factory; /** - * @var \Paysera\Component\Serializer\Filter\FieldsFilter + * @var FieldsFilter */ protected $fieldsFilter; /** - * @var \Paysera\Component\Serializer\Filter\FieldsParser + * @var FieldsParser */ protected $fieldsParser; @@ -46,10 +47,10 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal protected $fieldNormalizers = array(); /** - * @param \Paysera\Component\Serializer\Factory\ContextAwareNormalizerFactory $factory - * @param \Paysera\Component\Serializer\Filter\FieldsParser $fieldsParser - * @param \Paysera\Component\Serializer\Filter\FieldsFilter $fieldsFilter - * @param DenormalizerInterface|NormalizerInterface $normalizer + * @param ContextAwareNormalizerFactory $factory + * @param FieldsParser $fieldsParser + * @param FieldsFilter $fieldsFilter + * @param DenormalizerInterface|NormalizerInterface $normalizer */ public function __construct( ContextAwareNormalizerFactory $factory, @@ -97,7 +98,7 @@ public function addAdditionalField($fieldName, FieldAccessorInterface $fieldAcce * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php index a49abab..6868715 100644 --- a/src/Normalizer/FilterNormalizer.php +++ b/src/Normalizer/FilterNormalizer.php @@ -25,7 +25,7 @@ public function __construct($orderByFields = array(), $defaultLimit = 20, $maxLi * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/src/Normalizer/PlainNormalizer.php b/src/Normalizer/PlainNormalizer.php index 7a4b288..dbefbae 100644 --- a/src/Normalizer/PlainNormalizer.php +++ b/src/Normalizer/PlainNormalizer.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Normalizer; +use Paysera\Component\Serializer\Exception\InvalidDataException; + class PlainNormalizer implements NormalizerInterface, DenormalizerInterface { /** @@ -23,7 +25,7 @@ public function mapFromEntity($entity) * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/src/Normalizer/ResultNormalizer.php b/src/Normalizer/ResultNormalizer.php index 729233b..85f8c5b 100644 --- a/src/Normalizer/ResultNormalizer.php +++ b/src/Normalizer/ResultNormalizer.php @@ -5,6 +5,7 @@ use Paysera\Component\Serializer\Entity\Filter; use Paysera\Component\Serializer\Entity\NormalizationContextInterface; use Paysera\Component\Serializer\Entity\Result; +use Paysera\Component\Serializer\Exception\InvalidDataException; class ResultNormalizer implements ContextAwareNormalizerInterface, DenormalizerInterface { @@ -74,7 +75,7 @@ public function mapFromEntity($entity, ?NormalizationContextInterface $context = * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/src/Normalizer/TransformerDenormalizer.php b/src/Normalizer/TransformerDenormalizer.php index 9e312b1..d0806fe 100644 --- a/src/Normalizer/TransformerDenormalizer.php +++ b/src/Normalizer/TransformerDenormalizer.php @@ -2,6 +2,7 @@ namespace Paysera\Component\Serializer\Normalizer; +use Paysera\Component\Serializer\Exception\InvalidDataException; use Paysera\Component\Serializer\Transformer\TransformerInterface; class TransformerDenormalizer implements DenormalizerInterface @@ -33,7 +34,7 @@ public function __construct(TransformerInterface $transformer, DenormalizerInter * * @return mixed * - * @throws \Paysera\Component\Serializer\Exception\InvalidDataException + * @throws InvalidDataException */ public function mapToEntity($data) { diff --git a/tests/Filter/FieldsFilterTest.php b/tests/Filter/FieldsFilterTest.php index 2526e6a..2630fe4 100644 --- a/tests/Filter/FieldsFilterTest.php +++ b/tests/Filter/FieldsFilterTest.php @@ -2,6 +2,7 @@ namespace Paysera\Component\Serializer\Tests\Filter; +use ArrayObject; use Paysera\Component\Serializer\Filter\FieldsFilter; use Paysera\Component\Serializer\Filter\FieldsParser; use PHPUnit\Framework\TestCase; @@ -173,7 +174,7 @@ public function filterProvider() 5 => array('id' => 125, 'description' => 'abc3'), )), 'fields' => array('payments.id'), - 'result' => array('payments' => new \ArrayObject()), + 'result' => array('payments' => new ArrayObject()), ), 'Takes all fields if wildcard on parent specified' => array( 'data' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), @@ -208,7 +209,7 @@ public function filterProvider() 'Leaves curly braces if all items are filtered' => array( 'data' => array('a1' => '1', 'a2' => '2', 'a3' => '3', 'a4' => '4'), 'fields' => array('b1'), - 'result' => new \ArrayObject(), + 'result' => new ArrayObject(), ), 'Leaves simple array if all items are filtered' => array( 'data' => array('a1' => array('a', 'b', 'c')), @@ -277,7 +278,7 @@ public function filterWithScopeProvider() )), 'fields' => array('scope.payments.id'), 'scope' => array('scope'), - 'result' => array('payments' => new \ArrayObject()), + 'result' => array('payments' => new ArrayObject()), ), 'Takes all fields if wildcard on parent specified' => array( 'data' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), diff --git a/tests/Normalizer/DateNormalizerTest.php b/tests/Normalizer/DateNormalizerTest.php index 51b7abc..3b21ac3 100644 --- a/tests/Normalizer/DateNormalizerTest.php +++ b/tests/Normalizer/DateNormalizerTest.php @@ -2,6 +2,8 @@ namespace Paysera\Component\Serializer\Tests\Normalizer; +use DateTime; +use DateTimeZone; use Paysera\Component\Serializer\Exception\InvalidDataException; use Paysera\Component\Serializer\Normalizer\DateNormalizer; use PHPUnit\Framework\TestCase; @@ -19,28 +21,28 @@ public function testMapToEntity_when_no_correction_by_timezone_needed_then_date_ $service = new DateNormalizer('Y-m-d H:i:s'); $result = $service->mapToEntity('2013-02-01 12:00:00'); - $this->assertEquals(new \DateTime('2013-02-01 12:00:00', new \DateTimeZone('Etc/GMT-2')), $result); + $this->assertEquals(new DateTime('2013-02-01 12:00:00', new DateTimeZone('Etc/GMT-2')), $result); - $result = $service->mapFromEntity(new \DateTime('2013-02-01 12:00:00')); + $result = $service->mapFromEntity(new DateTime('2013-02-01 12:00:00')); $this->assertEquals('2013-02-01 12:00:00', $result); } public function testMapToEntity_when_correction_by_timezone_needed_then_date_modified() { - $service = new DateNormalizer('Y-m-d H:i:s', new \DateTimeZone('Etc/GMT+0')); + $service = new DateNormalizer('Y-m-d H:i:s', new DateTimeZone('Etc/GMT+0')); $result = $service->mapToEntity('2013-02-01 12:00:00'); - $this->assertEquals(new \DateTime('2013-02-01 14:00:00'), $result); + $this->assertEquals(new DateTime('2013-02-01 14:00:00'), $result); - $result = $service->mapFromEntity(new \DateTime('2013-02-01 14:00:00')); + $result = $service->mapFromEntity(new DateTime('2013-02-01 14:00:00')); $this->assertEquals('2013-02-01 12:00:00', $result); } public function testMapToEntity_original_entity_not_modified_when_mapping_from_entity() { - $service = new DateNormalizer('Y-m-d H:i:s', new \DateTimeZone('Etc/GMT+0')); + $service = new DateNormalizer('Y-m-d H:i:s', new DateTimeZone('Etc/GMT+0')); - $datetimeOriginal = new \DateTime('2013-02-01 14:00:00'); + $datetimeOriginal = new DateTime('2013-02-01 14:00:00'); $datetime = clone $datetimeOriginal; $service->mapFromEntity($datetime); @@ -49,7 +51,7 @@ public function testMapToEntity_original_entity_not_modified_when_mapping_from_e public function testMapToEntity_mapping_from_null_entity_returns_null() { - $service = new DateNormalizer('Y-m-d H:i:s', new \DateTimeZone('Etc/GMT+0')); + $service = new DateNormalizer('Y-m-d H:i:s', new DateTimeZone('Etc/GMT+0')); $datetime = null; $result = $service->mapFromEntity($datetime); @@ -59,7 +61,7 @@ public function testMapToEntity_mapping_from_null_entity_returns_null() public function testMapToEntity_invalid_date_throws_exception() { - $service = new DateNormalizer('Y-m-d H:i:s', new \DateTimeZone('Etc/GMT+0')); + $service = new DateNormalizer('Y-m-d H:i:s', new DateTimeZone('Etc/GMT+0')); $datetime = null; $this->expectException(InvalidDataException::class); From 4ad6c192c7a261725bfb002dc8244ac3bd40f247 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:07:28 +0300 Subject: [PATCH 04/24] Add 3.5.0 changelog entry --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f3720..d461b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.5.0 +### Fixed +- Declared native `\Traversable` return type on `Result::getIterator()`, resolving the PHP 8.1 + tentative return type deprecation. +- `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, + resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. + +### Removed +- Dropped support for PHP 7.1, 7.2 and 7.3. Minimum supported version is now PHP 7.4. + +### Changed +- **BC break:** any subclass of `Result` that overrides `getIterator()` must now declare a + compatible return type (`\Traversable` or a subtype such as `\Iterator`). + ## 3.4.0 ### Added - PHP 8.4 support, removed implicitly nullable parameter declarations. From 98c92364489fc53b16b3bcca90f9277f9495b99c Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:13:15 +0300 Subject: [PATCH 05/24] Mention import cleanup in changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d461b79..247b2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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`). +- Replaced leading-backslash class references with `use` statements throughout the library — + global classes (`ArrayIterator`, `ArrayObject`, `DateTime`, `DateTimeZone`, `Exception` and + the SPL exceptions) and fully qualified `Paysera\...` references in docblocks. No behaviour + change. ## 3.4.0 ### Added From 34549b39abf67a54794ef3e8d2250faaeaaa1498 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:16:01 +0300 Subject: [PATCH 06/24] Replace long array syntax with short syntax Applied via PHP CS Fixer's array_syntax rule. No behaviour change -- 225 insertions and 225 deletions, all line modifications. --- src/Encoding/Jsonp.php | 4 +- src/Factory/ResponseMapperFactory.php | 2 +- src/Filter/FieldsConfig.php | 2 +- src/Filter/FieldsFilter.php | 2 +- src/Filter/FieldsParser.php | 6 +- src/Normalizer/ArrayNormalizer.php | 4 +- src/Normalizer/AssociativeArrayNormalizer.php | 2 +- src/Normalizer/DistributedNormalizer.php | 10 +- src/Normalizer/FilterNormalizer.php | 6 +- src/Normalizer/ResultMetadataNormalizer.php | 4 +- src/Normalizer/ResultNormalizer.php | 4 +- src/Normalizer/ViolationNormalizer.php | 2 +- src/Validation/PropertiesAwareValidator.php | 4 +- tests/Filter/FieldsFilterTest.php | 398 +++++++++--------- 14 files changed, 225 insertions(+), 225 deletions(-) diff --git a/src/Encoding/Jsonp.php b/src/Encoding/Jsonp.php index 4b125e0..5f72638 100644 --- a/src/Encoding/Jsonp.php +++ b/src/Encoding/Jsonp.php @@ -57,10 +57,10 @@ public function encode($data) $this->jsonEncoder->decode($this->parameter); } catch (EncodingException $exception) { $this->parameter = null; - $json = $this->jsonEncoder->encode(array( + $json = $this->jsonEncoder->encode([ 'error' => 'invalid_parameters', 'error_description' => 'Passed parameter must be valid JSON string', - )); + ]); } } diff --git a/src/Factory/ResponseMapperFactory.php b/src/Factory/ResponseMapperFactory.php index eae3d82..5b0801f 100644 --- a/src/Factory/ResponseMapperFactory.php +++ b/src/Factory/ResponseMapperFactory.php @@ -23,7 +23,7 @@ class ResponseMapperFactory implements ResponseMapperFactoryInterface public function __construct(NormalizerInterface $defaultMapper) { $this->defaultMapper = $defaultMapper; - $this->mappers = array(); + $this->mappers = []; } /** diff --git a/src/Filter/FieldsConfig.php b/src/Filter/FieldsConfig.php index 7b9dff9..132373c 100644 --- a/src/Filter/FieldsConfig.php +++ b/src/Filter/FieldsConfig.php @@ -55,7 +55,7 @@ public function getFieldExtensions($fieldName) } return $extensions; } else { - return $this->defaultsIncluded ? array('*') : array(); + return $this->defaultsIncluded ? ['*'] : []; } } diff --git a/src/Filter/FieldsFilter.php b/src/Filter/FieldsFilter.php index de3d254..59de91a 100644 --- a/src/Filter/FieldsFilter.php +++ b/src/Filter/FieldsFilter.php @@ -54,7 +54,7 @@ public function filter($data, ?array $fields = null, array $scope = []) */ protected function filterByConfig($data, FieldsConfig $fieldsConfig) { - $result = array(); + $result = []; foreach ($data as $fieldName => $value) { if ($fieldsConfig->isIncluded($fieldName)) { if (is_array($value)) { diff --git a/src/Filter/FieldsParser.php b/src/Filter/FieldsParser.php index e68b472..e886d22 100644 --- a/src/Filter/FieldsParser.php +++ b/src/Filter/FieldsParser.php @@ -35,8 +35,8 @@ public function parseUnscopedFields(?array $fields = null) } $defaultsIncluded = false; - $includedFields = array(); - $fieldExtensions = array(); + $includedFields = []; + $fieldExtensions = []; foreach ($fields as $fieldDefinition) { // todo: take curly braces? see commented test-case for possible usage @@ -65,6 +65,6 @@ public function parseUnscopedFields(?array $fields = null) */ protected function createWithDefaultsIncluded() { - return new FieldsConfig(true, array(), array()); + return new FieldsConfig(true, [], []); } } diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index 985b540..500b0e2 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -33,7 +33,7 @@ public function __construct($innerMapper) */ public function mapToEntity($data) { - $result = array(); + $result = []; if ($data !== null) { foreach ($data as $innerElement) { $result[] = $this->innerMapper->mapToEntity($innerElement); @@ -44,7 +44,7 @@ public function mapToEntity($data) public function mapFromEntity($entity, ?NormalizationContextInterface $context = null) { - $result = array(); + $result = []; if ($entity !== null) { foreach ($entity as $innerElement) { $result[] = $this->innerMapper->mapFromEntity($innerElement, $context); diff --git a/src/Normalizer/AssociativeArrayNormalizer.php b/src/Normalizer/AssociativeArrayNormalizer.php index f66d365..3e25db0 100644 --- a/src/Normalizer/AssociativeArrayNormalizer.php +++ b/src/Normalizer/AssociativeArrayNormalizer.php @@ -48,7 +48,7 @@ public function mapFromEntity($entity) */ public function mapToEntity($data) { - $result = array(); + $result = []; if ($data !== null) { foreach ($data as $key => $innerElement) { $result[$key] = $this->innerMapper->mapToEntity($innerElement); diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 8b4bb83..b463761 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors = array(); + protected $fieldAccessors = []; /** * @var array of boolean */ - protected $fieldDefault = array(); + protected $fieldDefault = []; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers = array(); + protected $fieldNormalizers = []; /** * @param ContextAwareNormalizerFactory $factory @@ -102,7 +102,7 @@ public function addAdditionalField($fieldName, FieldAccessorInterface $fieldAcce */ public function mapToEntity($data) { - $additional = array(); + $additional = []; if (is_array($data)) { foreach ($data as $key => $value) { if ( @@ -142,7 +142,7 @@ public function mapFromEntity($entity, ?NormalizationContextInterface $context = } $fields = $context !== null ? $context->getFields() : null; - $scope = $context !== null ? $context->getScope() : array(); + $scope = $context !== null ? $context->getScope() : []; $data = $this->fieldsFilter->filter($data, $fields, $scope); $fieldsConfig = $this->fieldsParser->parseFields($fields, $scope); diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php index 6868715..87e2b98 100644 --- a/src/Normalizer/FilterNormalizer.php +++ b/src/Normalizer/FilterNormalizer.php @@ -11,7 +11,7 @@ class FilterNormalizer extends BaseDenormalizer implements NormalizerInterface protected $maxLimit; protected $orderByFields; - public function __construct($orderByFields = array(), $defaultLimit = 20, $maxLimit = 200) + public function __construct($orderByFields = [], $defaultLimit = 20, $maxLimit = 200) { $this->defaultLimit = $defaultLimit; $this->maxLimit = $maxLimit; @@ -41,7 +41,7 @@ public function mapToEntity($data) */ public function mapFromEntity($entity) { - $data = array(); + $data = []; if ($entity->getLimit() !== null) { $data['limit'] = $entity->getLimit(); } @@ -124,7 +124,7 @@ protected function mapBaseKeys($data, Filter $filter) throw new InvalidDataException('order_direction is unsupported for this method'); } $orderDirection = strtoupper($orderDirection); - if (!in_array($orderDirection, array('ASC', 'DESC'))) { + if (!in_array($orderDirection, ['ASC', 'DESC'])) { throw new InvalidDataException('Invalid order_direction value'); } $filter->setOrderAsc($orderDirection === 'ASC'); diff --git a/src/Normalizer/ResultMetadataNormalizer.php b/src/Normalizer/ResultMetadataNormalizer.php index 65ff8f9..f9e70e0 100644 --- a/src/Normalizer/ResultMetadataNormalizer.php +++ b/src/Normalizer/ResultMetadataNormalizer.php @@ -18,10 +18,10 @@ public function mapFromEntity($result) { $filter = $result->getFilter(); - $data = array( + $data = [ 'total' => $result->getTotalCount(), 'limit' => $filter ? $filter->getLimit() : null, - ); + ]; if ($result->getAfter() !== null) { $data['cursors']['after'] = $result->getAfter(); diff --git a/src/Normalizer/ResultNormalizer.php b/src/Normalizer/ResultNormalizer.php index 85f8c5b..1f62227 100644 --- a/src/Normalizer/ResultNormalizer.php +++ b/src/Normalizer/ResultNormalizer.php @@ -59,13 +59,13 @@ public function setMetadataNormalizer($metadataNormalizer) */ public function mapFromEntity($entity, ?NormalizationContextInterface $context = null) { - return array( + return [ $this->itemsKey => $this->mapItemsFromEntity( $entity->getItems(), $context !== null ? $context->createScopedContext($this->itemsKey) : null ), '_metadata' => $this->mapMetadataFromEntity($entity), - ); + ]; } /** diff --git a/src/Normalizer/ViolationNormalizer.php b/src/Normalizer/ViolationNormalizer.php index 9c5d917..780b685 100644 --- a/src/Normalizer/ViolationNormalizer.php +++ b/src/Normalizer/ViolationNormalizer.php @@ -37,7 +37,7 @@ public function mapToEntity($data) */ public function mapFromEntity($entity) { - $data = array(); + $data = []; if ($entity->getCode() !== null) { $data['code'] = $entity->getCode(); } diff --git a/src/Validation/PropertiesAwareValidator.php b/src/Validation/PropertiesAwareValidator.php index ba9656c..31a30ef 100644 --- a/src/Validation/PropertiesAwareValidator.php +++ b/src/Validation/PropertiesAwareValidator.php @@ -53,8 +53,8 @@ public function validate($entity, $groups = null) } if ($violationList->count() > 0) { - $properties = array(); - $violations = array(); + $properties = []; + $violations = []; foreach ($violationList as $violation) { $path = $violation->getPropertyPath(); diff --git a/tests/Filter/FieldsFilterTest.php b/tests/Filter/FieldsFilterTest.php index 2630fe4..eaa3f76 100644 --- a/tests/Filter/FieldsFilterTest.php +++ b/tests/Filter/FieldsFilterTest.php @@ -52,170 +52,170 @@ public function testFilterWithScope($data, $fields, $scope, $result) public function filterProvider() { - $simple = array( + $simple = [ 'key1' => 'value1', 'key2' => 'value2', - ); - $complex = array( + ]; + $complex = [ 'key1' => 'value1', 'key2' => 'value2', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ); + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ]; - return array( - 'Matches everything if * provided' => array( + return [ + 'Matches everything if * provided' => [ 'data' => $simple, - 'fields' => array('*'), + 'fields' => ['*'], 'result' => $simple, - ), - 'Filters first level data' => array( + ], + 'Filters first level data' => [ 'data' => $complex, - 'fields' => array('key1', 'key4', 'key3'), - 'result' => array( + 'fields' => ['key1', 'key4', 'key3'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ), - ), - 'Ignores additional fields if * provided' => array( + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ], + ], + 'Ignores additional fields if * provided' => [ 'data' => $complex, - 'fields' => array('key1', '*', 'key2'), + 'fields' => ['key1', '*', 'key2'], 'result' => $complex, - ), - 'Filters second level data' => array( + ], + 'Filters second level data' => [ 'data' => $complex, - 'fields' => array('key1', 'key3', 'key4.key1'), - 'result' => array( + 'fields' => ['key1', 'key3', 'key4.key1'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1'), - ), - ), - 'Takes subtree if item provided' => array( + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1'], + ], + ], + 'Takes subtree if item provided' => [ 'data' => $complex, - 'fields' => array('key1', 'key3', 'key4'), - 'result' => array( + 'fields' => ['key1', 'key3', 'key4'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ), - ), - 'Takes subtree if item with * provided' => array( + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ], + ], + 'Takes subtree if item with * provided' => [ 'data' => $complex, - 'fields' => array('key1', 'key3', 'key4.*', 'key.key2'), - 'result' => array( + 'fields' => ['key1', 'key3', 'key4.*', 'key.key2'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ), - ), - 'Ignores additional fields' => array( + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ], + ], + 'Ignores additional fields' => [ 'data' => $complex, - 'fields' => array('key1', 'key3', 'key4.*', 'key.key2', 'newkey', 'newkey.a', 'newkey.*', 'key4.new'), - 'result' => array( + 'fields' => ['key1', 'key3', 'key4.*', 'key.key2', 'newkey', 'newkey.a', 'newkey.*', 'key4.new'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ), - ), - 'Do not take with key 0' => array( - 'data' => array('payments' => array( - '0' => array('id' => 123, 'description' => 'abc1'), - 'asd' => array('id' => 124, 'description' => 'abc2'), - 'b' => array('id' => 125, 'description' => 'abc3'), - )), - 'fields' => array('payments.asd'), - 'result' => array('payments' => array( - 'asd' => array('id' => 124, 'description' => 'abc2'), - )), - ), - 'Takes numeric keys' => array( - 'data' => array('payments' => array( - '1' => array('id' => 123, 'description' => 'abc1'), - 'asd' => array('id' => 124, 'description' => 'abc2'), - 'b' => array('id' => 125, 'description' => 'abc3'), - )), - 'fields' => array('payments.1'), - 'result' => array('payments' => array( - '1' => array('id' => 123, 'description' => 'abc1'), - )), - ), - 'Filters for array items' => array( - 'data' => array('payments' => array( - array('id' => 123, 'description' => 'abc1'), - array('id' => 124, 'description' => 'abc2'), - array('id' => 125, 'description' => 'abc3'), - )), - 'fields' => array('payments.id'), - 'result' => array('payments' => array( - array('id' => 123), - array('id' => 124), - array('id' => 125), - )), - ), - 'Filters for array items at top level' => array( - 'data' => array( - array('id' => 123, 'description' => 'abc1'), - array('id' => 124, 'description' => 'abc2'), - array('id' => 125, 'description' => 'abc3'), - ), - 'fields' => array('id'), - 'result' => array( - array('id' => 123), - array('id' => 124), - array('id' => 125), - ), - ), - 'Correctly gets associative arrays' => array( - 'data' => array('payments' => array( - array('id' => 123, 'description' => 'abc1'), - array('id' => 124, 'description' => 'abc2'), - 5 => array('id' => 125, 'description' => 'abc3'), - )), - 'fields' => array('payments.id'), - 'result' => array('payments' => new ArrayObject()), - ), - 'Takes all fields if wildcard on parent specified' => array( - 'data' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), - 'fields' => array('*', 'a1.a2.a3.a4'), - 'result' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), - ), - 'Correctly filters deep-nested arrays' => array( - 'data' => array('a1' => array( - 'a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2'), 'a32' => '1'), - )), - 'fields' => array('a1.a2.a3.a4'), - 'result' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1')))), - ), - 'Takes keys from second level arrays' => array( - 'data' => array('scalar' => 'asd', 'array' => array( - array('item1' => 'asd', 'item2' => 'qwe', 'item3' => array('a', 'b')), - array('item1' => 'qwe', 'item2' => 'rty', 'item3' => array('c', 'd')), - array('item1' => 'fgh', 'item2' => 'yui', 'item3' => array('e', 'f')), - )), - 'fields' => array('array.item1', 'array.item3'), - 'result' => array('array' => array( - array('item1' => 'asd', 'item3' => array('a', 'b')), - array('item1' => 'qwe', 'item3' => array('c', 'd')), - array('item1' => 'fgh', 'item3' => array('e', 'f')), - )), - ), - 'Takes several fields from one item' => array( - 'data' => array('a1' => '1', 'a2' => '2', 'a3' => '3', 'a4' => '4'), - 'fields' => array('a1,a4', 'a2'), - 'result' => array('a1' => '1', 'a2' => '2', 'a4' => '4'), - ), - 'Leaves curly braces if all items are filtered' => array( - 'data' => array('a1' => '1', 'a2' => '2', 'a3' => '3', 'a4' => '4'), - 'fields' => array('b1'), + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ], + ], + 'Do not take with key 0' => [ + 'data' => ['payments' => [ + '0' => ['id' => 123, 'description' => 'abc1'], + 'asd' => ['id' => 124, 'description' => 'abc2'], + 'b' => ['id' => 125, 'description' => 'abc3'], + ]], + 'fields' => ['payments.asd'], + 'result' => ['payments' => [ + 'asd' => ['id' => 124, 'description' => 'abc2'], + ]], + ], + 'Takes numeric keys' => [ + 'data' => ['payments' => [ + '1' => ['id' => 123, 'description' => 'abc1'], + 'asd' => ['id' => 124, 'description' => 'abc2'], + 'b' => ['id' => 125, 'description' => 'abc3'], + ]], + 'fields' => ['payments.1'], + 'result' => ['payments' => [ + '1' => ['id' => 123, 'description' => 'abc1'], + ]], + ], + 'Filters for array items' => [ + 'data' => ['payments' => [ + ['id' => 123, 'description' => 'abc1'], + ['id' => 124, 'description' => 'abc2'], + ['id' => 125, 'description' => 'abc3'], + ]], + 'fields' => ['payments.id'], + 'result' => ['payments' => [ + ['id' => 123], + ['id' => 124], + ['id' => 125], + ]], + ], + 'Filters for array items at top level' => [ + 'data' => [ + ['id' => 123, 'description' => 'abc1'], + ['id' => 124, 'description' => 'abc2'], + ['id' => 125, 'description' => 'abc3'], + ], + 'fields' => ['id'], + 'result' => [ + ['id' => 123], + ['id' => 124], + ['id' => 125], + ], + ], + 'Correctly gets associative arrays' => [ + 'data' => ['payments' => [ + ['id' => 123, 'description' => 'abc1'], + ['id' => 124, 'description' => 'abc2'], + 5 => ['id' => 125, 'description' => 'abc3'], + ]], + 'fields' => ['payments.id'], + 'result' => ['payments' => new ArrayObject()], + ], + 'Takes all fields if wildcard on parent specified' => [ + 'data' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2']]]], + 'fields' => ['*', 'a1.a2.a3.a4'], + 'result' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2']]]], + ], + 'Correctly filters deep-nested arrays' => [ + 'data' => ['a1' => [ + 'a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2'], 'a32' => '1'], + ]], + 'fields' => ['a1.a2.a3.a4'], + 'result' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1']]]], + ], + 'Takes keys from second level arrays' => [ + 'data' => ['scalar' => 'asd', 'array' => [ + ['item1' => 'asd', 'item2' => 'qwe', 'item3' => ['a', 'b']], + ['item1' => 'qwe', 'item2' => 'rty', 'item3' => ['c', 'd']], + ['item1' => 'fgh', 'item2' => 'yui', 'item3' => ['e', 'f']], + ]], + 'fields' => ['array.item1', 'array.item3'], + 'result' => ['array' => [ + ['item1' => 'asd', 'item3' => ['a', 'b']], + ['item1' => 'qwe', 'item3' => ['c', 'd']], + ['item1' => 'fgh', 'item3' => ['e', 'f']], + ]], + ], + 'Takes several fields from one item' => [ + 'data' => ['a1' => '1', 'a2' => '2', 'a3' => '3', 'a4' => '4'], + 'fields' => ['a1,a4', 'a2'], + 'result' => ['a1' => '1', 'a2' => '2', 'a4' => '4'], + ], + 'Leaves curly braces if all items are filtered' => [ + 'data' => ['a1' => '1', 'a2' => '2', 'a3' => '3', 'a4' => '4'], + 'fields' => ['b1'], 'result' => new ArrayObject(), - ), - 'Leaves simple array if all items are filtered' => array( - 'data' => array('a1' => array('a', 'b', 'c')), - 'fields' => array('a1.b1'), - 'result' => array('a1' => array('a', 'b', 'c')), - ), + ], + 'Leaves simple array if all items are filtered' => [ + 'data' => ['a1' => ['a', 'b', 'c']], + 'fields' => ['a1.b1'], + 'result' => ['a1' => ['a', 'b', 'c']], + ], // todo: // 'Takes curly braces' => array( // 'data' => array('a1' => '1', 'a2' => '2', 'a3' => array( @@ -237,73 +237,73 @@ public function filterProvider() // ), // ), 'a4' => '4'), // ), - ); + ]; } public function filterWithScopeProvider() { - $simple = array( + $simple = [ 'key1' => 'value1', 'key2' => 'value2', - ); - $complex = array( + ]; + $complex = [ 'key1' => 'value1', 'key2' => 'value2', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1', 'key2' => 'value2'), - ); + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1', 'key2' => 'value2'], + ]; - return array( - 'Matches everything if * provided' => array( + return [ + 'Matches everything if * provided' => [ 'data' => $simple, - 'fields' => array('*'), - 'scope' => array('scope'), + 'fields' => ['*'], + 'scope' => ['scope'], 'result' => $simple, - ), - 'Filters second level data' => array( + ], + 'Filters second level data' => [ 'data' => $complex, - 'fields' => array('key0.key1', 'key0.key3', 'key0.key4.key1'), - 'scope' => array('key0'), - 'result' => array( + 'fields' => ['key0.key1', 'key0.key3', 'key0.key4.key1'], + 'scope' => ['key0'], + 'result' => [ 'key1' => 'value1', - 'key3' => array('value1', 'value2'), - 'key4' => array('key1' => 'value1'), - ), - ), - 'Correctly gets associative arrays' => array( - 'data' => array('payments' => array( - array('id' => 123, 'description' => 'abc1'), - array('id' => 124, 'description' => 'abc2'), - 5 => array('id' => 125, 'description' => 'abc3'), - )), - 'fields' => array('scope.payments.id'), - 'scope' => array('scope'), - 'result' => array('payments' => new ArrayObject()), - ), - 'Takes all fields if wildcard on parent specified' => array( - 'data' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), - 'fields' => array('*', 'scope.a1.a2.a3.a4'), - 'scope' => array('scope'), - 'result' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), - ), - 'Ignores other fields' => array( - 'data' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1', 'a5' => 'value2')))), - 'fields' => array('a0.a1.a2.a3.a4', 'aa.a1.a2.a3.a5'), - 'scope' => array('a0'), - 'result' => array('a1' => array('a2' => array('a3' => array('a4' => 'value1')))), - ), - 'Takes nested scope' => array( - 'data' => array(array('a4' => 'value1', 'a5' => 'value2')), - 'fields' => array('a0.a1.a2.a3.a4', 'aa.a1.a2.a3.a5'), - 'scope' => array('a0', 'a1', 'a2', 'a3'), - 'result' => array(array('a4' => 'value1')), - ), - 'Filters if on another branch' => array( - 'data' => array(array('a4' => 'value1', 'a5' => 'value2')), - 'fields' => array('a1.a2'), - 'scope' => array('a2'), - 'result' => array(array()), - ), - ); + 'key3' => ['value1', 'value2'], + 'key4' => ['key1' => 'value1'], + ], + ], + 'Correctly gets associative arrays' => [ + 'data' => ['payments' => [ + ['id' => 123, 'description' => 'abc1'], + ['id' => 124, 'description' => 'abc2'], + 5 => ['id' => 125, 'description' => 'abc3'], + ]], + 'fields' => ['scope.payments.id'], + 'scope' => ['scope'], + 'result' => ['payments' => new ArrayObject()], + ], + 'Takes all fields if wildcard on parent specified' => [ + 'data' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2']]]], + 'fields' => ['*', 'scope.a1.a2.a3.a4'], + 'scope' => ['scope'], + 'result' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2']]]], + ], + 'Ignores other fields' => [ + 'data' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1', 'a5' => 'value2']]]], + 'fields' => ['a0.a1.a2.a3.a4', 'aa.a1.a2.a3.a5'], + 'scope' => ['a0'], + 'result' => ['a1' => ['a2' => ['a3' => ['a4' => 'value1']]]], + ], + 'Takes nested scope' => [ + 'data' => [['a4' => 'value1', 'a5' => 'value2']], + 'fields' => ['a0.a1.a2.a3.a4', 'aa.a1.a2.a3.a5'], + 'scope' => ['a0', 'a1', 'a2', 'a3'], + 'result' => [['a4' => 'value1']], + ], + 'Filters if on another branch' => [ + 'data' => [['a4' => 'value1', 'a5' => 'value2']], + 'fields' => ['a1.a2'], + 'scope' => ['a2'], + 'result' => [[]], + ], + ]; } } From 1fb232f8686dddd9a0cf6fc860cf46c04ffae873 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:16:31 +0300 Subject: [PATCH 07/24] Mention short array syntax in changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 247b2db..43746bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 global classes (`ArrayIterator`, `ArrayObject`, `DateTime`, `DateTimeZone`, `Exception` and the SPL exceptions) and fully qualified `Paysera\...` references in docblocks. No behaviour change. +- Replaced long array syntax (`array(...)`) with short syntax (`[...]`) throughout the library. + No behaviour change. ## 3.4.0 ### Added From d7b2c9a10f28140866559e9d75dd7badc68b9c04 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 10:54:45 +0300 Subject: [PATCH 08/24] Address review feedback - 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 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. --- CHANGELOG.md | 31 +++++++++------ composer.json | 2 +- src/Entity/Result.php | 2 +- src/Factory/ContextAwareNormalizerFactory.php | 5 +-- src/Factory/ResponseMapperFactory.php | 2 +- src/Filter/FieldsConfig.php | 2 +- src/Normalizer/BaseDenormalizer.php | 2 +- src/Normalizer/DateNormalizer.php | 2 +- src/Normalizer/DistributedNormalizer.php | 6 +-- tests/Entity/ResultTest.php | 38 +++++++++++++++++++ tests/Filter/FieldsFilterTest.php | 34 ++++++++--------- tests/Normalizer/DateNormalizerTest.php | 25 ++++++++++++ 12 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 tests/Entity/ResultTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 43746bc..a1893c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 3.5.0 -### Fixed -- Declared native `\Traversable` return type on `Result::getIterator()`, resolving the PHP 8.1 - tentative return type deprecation. -- `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, - resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. - -### Removed -- Dropped support for PHP 7.1, 7.2 and 7.3. Minimum supported version is now PHP 7.4. - +## 4.0.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`). +- **BC break:** `Result::getIterator()` now declares a native `\Traversable` return type, + resolving the PHP 8.1 tentative return type deprecation. Any subclass that overrides it must + now declare a compatible return type (`\Traversable` or a subtype such as `\Iterator`). +- Narrowed the `phpunit/phpunit` development requirement to `^9.3` — the version that introduced + the `` configuration element used by `phpunit.xml.dist`. - Replaced leading-backslash class references with `use` statements throughout the library — global classes (`ArrayIterator`, `ArrayObject`, `DateTime`, `DateTimeZone`, `Exception` and the SPL exceptions) and fully qualified `Paysera\...` references in docblocks. No behaviour @@ -24,6 +18,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced long array syntax (`array(...)`) with short syntax (`[...]`) throughout the library. No behaviour change. +### Removed +- **BC break:** dropped support for PHP 7.1, 7.2 and 7.3. Minimum supported version is now + PHP 7.4. + +### Fixed +- `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, + resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. +- `DateNormalizer::mapToEntity()` no longer passes `null` to `DateTime::createFromFormat()`, + resolving a PHP 8.1 deprecation. `null` input still raises `InvalidDataException` as before. +- Iterating a `Result` whose items were never set no longer fails — `$items` now defaults to an + empty array, so `getIterator()` and `getItems()` honour their documented contracts instead of + raising a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4). + ## 3.4.0 ### Added - PHP 8.4 support, removed implicitly nullable parameter declarations. diff --git a/composer.json b/composer.json index 0f29400..624ac9b 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.3" }, "require": { "php": "^7.4 || ^8.0", diff --git a/src/Entity/Result.php b/src/Entity/Result.php index 2ecd019..ad8007f 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -42,7 +42,7 @@ class Result implements IteratorAggregate, ResultInterface /** * @var mixed[] */ - protected $items; + protected $items = []; public function __construct(?Filter $filter = null) diff --git a/src/Factory/ContextAwareNormalizerFactory.php b/src/Factory/ContextAwareNormalizerFactory.php index 1c969bf..9459e5d 100644 --- a/src/Factory/ContextAwareNormalizerFactory.php +++ b/src/Factory/ContextAwareNormalizerFactory.php @@ -4,14 +4,13 @@ use Paysera\Component\Serializer\Filter\FieldsFilter; use Paysera\Component\Serializer\Filter\FieldsParser; +use Paysera\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Paysera\Component\Serializer\Normalizer\DenormalizerInterface; use Paysera\Component\Serializer\Normalizer\DistributedNormalizer; -use Paysera\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Paysera\Component\Serializer\Normalizer\NormalizerInterface; class ContextAwareNormalizerFactory { - /** * @var FieldsFilter */ @@ -44,4 +43,4 @@ public function create($normalizer) $normalizer ); } -} +} diff --git a/src/Factory/ResponseMapperFactory.php b/src/Factory/ResponseMapperFactory.php index 5b0801f..83933c0 100644 --- a/src/Factory/ResponseMapperFactory.php +++ b/src/Factory/ResponseMapperFactory.php @@ -59,4 +59,4 @@ public function createResponseMapper(array $options) } return $key !== null ? $this->mappers[$key] : $this->defaultMapper; } -} +} diff --git a/src/Filter/FieldsConfig.php b/src/Filter/FieldsConfig.php index 132373c..d256c3e 100644 --- a/src/Filter/FieldsConfig.php +++ b/src/Filter/FieldsConfig.php @@ -66,4 +66,4 @@ public function areDefaultsIncluded() { return $this->defaultsIncluded; } -} +} diff --git a/src/Normalizer/BaseDenormalizer.php b/src/Normalizer/BaseDenormalizer.php index 12f6818..d4f8ebf 100644 --- a/src/Normalizer/BaseDenormalizer.php +++ b/src/Normalizer/BaseDenormalizer.php @@ -81,4 +81,4 @@ protected function checkOnlyOneKeyExists($data, $keys) } } } -} +} diff --git a/src/Normalizer/DateNormalizer.php b/src/Normalizer/DateNormalizer.php index f28beb6..585dcfc 100644 --- a/src/Normalizer/DateNormalizer.php +++ b/src/Normalizer/DateNormalizer.php @@ -33,7 +33,7 @@ public function mapToEntity($data) { $date = DateTime::createFromFormat( $this->format, - $data, + $data ?? '', $this->remoteTimezone ); if ($date === false) { diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index b463761..4ec04c0 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -47,9 +47,9 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal protected $fieldNormalizers = []; /** - * @param ContextAwareNormalizerFactory $factory - * @param FieldsParser $fieldsParser - * @param FieldsFilter $fieldsFilter + * @param ContextAwareNormalizerFactory $factory + * @param FieldsParser $fieldsParser + * @param FieldsFilter $fieldsFilter * @param DenormalizerInterface|NormalizerInterface $normalizer */ public function __construct( diff --git a/tests/Entity/ResultTest.php b/tests/Entity/ResultTest.php new file mode 100644 index 0000000..2460042 --- /dev/null +++ b/tests/Entity/ResultTest.php @@ -0,0 +1,38 @@ +assertSame([], iterator_to_array($result)); + } + + public function testGetItemsReturnsArrayWhenItemsNotSet() + { + $result = new Result(); + + $this->assertSame([], $result->getItems()); + } + + public function testIterateResultWithItems() + { + $result = (new Result())->setItems([1, 2, 3]); + + $this->assertSame([1, 2, 3], iterator_to_array($result)); + } + + public function testAddItemWithoutSettingItemsFirst() + { + $result = new Result(); + $result->addItem('a'); + + $this->assertSame(['a'], $result->getItems()); + } +} diff --git a/tests/Filter/FieldsFilterTest.php b/tests/Filter/FieldsFilterTest.php index eaa3f76..ea57665 100644 --- a/tests/Filter/FieldsFilterTest.php +++ b/tests/Filter/FieldsFilterTest.php @@ -217,26 +217,26 @@ public function filterProvider() 'result' => ['a1' => ['a', 'b', 'c']], ], // todo: -// 'Takes curly braces' => array( -// 'data' => array('a1' => '1', 'a2' => '2', 'a3' => array( +// 'Takes curly braces' => [ +// 'data' => ['a1' => '1', 'a2' => '2', 'a3' => [ // 'a31' => '31', // 'a32' => '32', -// 'a33' => array( -// array('a331' => '331a', 'a332' => '332a', 'a333' => '333a', 'a334' => '334a'), -// array('a331' => '331b', 'a332' => '332b', 'a333' => '333b', 'a334' => '334a'), -// array('a331' => '331c', 'a332' => '332c', 'a333' => '333c', 'a334' => '334a'), -// ), -// ), 'a4' => '4'), -// 'fields' => array('a3.{a31,a33.a331,a33.{a333}}', '{a1,a4},a3.a33.a334'), -// 'result' => array('a1' => '1', 'a3' => array( +// 'a33' => [ +// ['a331' => '331a', 'a332' => '332a', 'a333' => '333a', 'a334' => '334a'], +// ['a331' => '331b', 'a332' => '332b', 'a333' => '333b', 'a334' => '334a'], +// ['a331' => '331c', 'a332' => '332c', 'a333' => '333c', 'a334' => '334a'], +// ], +// ], 'a4' => '4'], +// 'fields' => ['a3.{a31,a33.a331,a33.{a333}}', '{a1,a4},a3.a33.a334'], +// 'result' => ['a1' => '1', 'a3' => [ // 'a31' => '31', -// 'a33' => array( -// array('a331' => '331a', 'a333' => '333a', 'a334' => '334a'), -// array('a331' => '331b', 'a333' => '333b', 'a334' => '334a'), -// array('a331' => '331c', 'a333' => '333c', 'a334' => '334a'), -// ), -// ), 'a4' => '4'), -// ), +// 'a33' => [ +// ['a331' => '331a', 'a333' => '333a', 'a334' => '334a'], +// ['a331' => '331b', 'a333' => '333b', 'a334' => '334a'], +// ['a331' => '331c', 'a333' => '333c', 'a334' => '334a'], +// ], +// ], 'a4' => '4'], +// ], ]; } diff --git a/tests/Normalizer/DateNormalizerTest.php b/tests/Normalizer/DateNormalizerTest.php index 3b21ac3..caf4659 100644 --- a/tests/Normalizer/DateNormalizerTest.php +++ b/tests/Normalizer/DateNormalizerTest.php @@ -67,4 +67,29 @@ public function testMapToEntity_invalid_date_throws_exception() $this->expectException(InvalidDataException::class); $service->mapToEntity('2013-02-31 12:00:00'); } + + public function testMapToEntity_null_date_throws_exception_without_deprecation() + { + $service = new DateNormalizer('Y-m-d H:i:s', new DateTimeZone('Etc/GMT+0')); + + $deprecations = []; + set_error_handler( + function ($errno, $errstr) use (&$deprecations) { + $deprecations[] = $errstr; + return true; + }, + E_DEPRECATED + ); + + try { + $service->mapToEntity(null); + $this->fail('Expected InvalidDataException to be thrown'); + } catch (InvalidDataException $exception) { + // expected + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + } } From ca49614fb55fd7f783654bdddd109267127a57bb Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Wed, 29 Jul 2026 11:24:40 +0300 Subject: [PATCH 09/24] Set default property values in constructors 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. --- src/Entity/Filter.php | 7 ++++++- src/Entity/FollowUpFilter.php | 1 + src/Entity/Result.php | 6 ++++-- src/Normalizer/BaseDenormalizer.php | 7 ++++++- src/Normalizer/DateNormalizer.php | 1 + src/Normalizer/DistributedNormalizer.php | 9 ++++++--- src/Normalizer/FilterNormalizer.php | 1 + 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Entity/Filter.php b/src/Entity/Filter.php index 034681f..40ae599 100644 --- a/src/Entity/Filter.php +++ b/src/Entity/Filter.php @@ -20,7 +20,7 @@ class Filter /** * @var int */ - protected $offset = 0; + protected $offset; /** * null means no limit @@ -38,6 +38,11 @@ class Filter */ protected $before; + public function __construct() + { + $this->offset = 0; + } + /** * Sets orderBy * diff --git a/src/Entity/FollowUpFilter.php b/src/Entity/FollowUpFilter.php index c7a7647..9f6232d 100644 --- a/src/Entity/FollowUpFilter.php +++ b/src/Entity/FollowUpFilter.php @@ -16,6 +16,7 @@ class FollowUpFilter extends Filter public function __construct($remainingCount, $offset) { + parent::__construct(); $this->remainingCount = $remainingCount; $this->offset = $offset; } diff --git a/src/Entity/Result.php b/src/Entity/Result.php index ad8007f..cf89883 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -12,7 +12,7 @@ class Result implements IteratorAggregate, ResultInterface /** * @var int */ - protected $totalCount = 0; + protected $totalCount; /** * @var boolean|null @@ -42,12 +42,14 @@ class Result implements IteratorAggregate, ResultInterface /** * @var mixed[] */ - protected $items = []; + protected $items; public function __construct(?Filter $filter = null) { $this->filter = $filter; + $this->totalCount = 0; + $this->items = []; } /** diff --git a/src/Normalizer/BaseDenormalizer.php b/src/Normalizer/BaseDenormalizer.php index d4f8ebf..bb4d0ce 100644 --- a/src/Normalizer/BaseDenormalizer.php +++ b/src/Normalizer/BaseDenormalizer.php @@ -6,7 +6,12 @@ abstract class BaseDenormalizer implements DenormalizerInterface { - protected $availableKeysCheckIgnored = false; + protected $availableKeysCheckIgnored; + + public function __construct() + { + $this->availableKeysCheckIgnored = false; + } /** * Ignores available keys check diff --git a/src/Normalizer/DateNormalizer.php b/src/Normalizer/DateNormalizer.php index 585dcfc..9df2ada 100644 --- a/src/Normalizer/DateNormalizer.php +++ b/src/Normalizer/DateNormalizer.php @@ -20,6 +20,7 @@ class DateNormalizer extends BaseDenormalizer implements NormalizerInterface public function __construct($format, $remoteTimezone = null) { + parent::__construct(); $this->format = $format; $this->remoteTimezone = $remoteTimezone !== null ? $remoteTimezone : $this->getLocalTimezone(); } diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 4ec04c0..24bf652 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors = []; + protected $fieldAccessors; /** * @var array of boolean */ - protected $fieldDefault = []; + protected $fieldDefault; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers = []; + protected $fieldNormalizers; /** * @param ContextAwareNormalizerFactory $factory @@ -62,6 +62,9 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; + $this->fieldAccessors = []; + $this->fieldDefault = []; + $this->fieldNormalizers = []; } /** diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php index 87e2b98..9e40f82 100644 --- a/src/Normalizer/FilterNormalizer.php +++ b/src/Normalizer/FilterNormalizer.php @@ -13,6 +13,7 @@ class FilterNormalizer extends BaseDenormalizer implements NormalizerInterface public function __construct($orderByFields = [], $defaultLimit = 20, $maxLimit = 200) { + parent::__construct(); $this->defaultLimit = $defaultLimit; $this->maxLimit = $maxLimit; $this->orderByFields = $orderByFields; From 039278186b278f5bc56839af3c2651b5f5f715f1 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:07:08 +0300 Subject: [PATCH 10/24] Defer getIterator() return type to 4.0.0, release as 3.5.0 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. --- CHANGELOG.md | 12 ++++++------ src/Entity/Filter.php | 7 +------ src/Entity/FollowUpFilter.php | 1 - src/Entity/Result.php | 11 ++++++----- src/Normalizer/BaseDenormalizer.php | 7 +------ src/Normalizer/DateNormalizer.php | 1 - src/Normalizer/DistributedNormalizer.php | 9 +++------ src/Normalizer/FilterNormalizer.php | 1 - 8 files changed, 17 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1893c0..706a4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 4.0.0 +## 3.5.0 ### Changed -- **BC break:** `Result::getIterator()` now declares a native `\Traversable` return type, - resolving the PHP 8.1 tentative return type deprecation. Any subclass that overrides it must - now declare a compatible return type (`\Traversable` or a subtype such as `\Iterator`). - Narrowed the `phpunit/phpunit` development requirement to `^9.3` — the version that introduced the `` configuration element used by `phpunit.xml.dist`. - Replaced leading-backslash class references with `use` statements throughout the library — @@ -19,10 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 No behaviour change. ### Removed -- **BC break:** dropped support for PHP 7.1, 7.2 and 7.3. Minimum supported version is now - PHP 7.4. +- Dropped support for PHP 7.1, 7.2 and 7.3. Minimum supported version is now PHP 7.4. Projects + still on those versions resolve to 3.4.x and are unaffected. ### Fixed +- `Result::getIterator()` is marked `#[\ReturnTypeWillChange]`, silencing the PHP 8.1 tentative + return type deprecation without changing the signature. The native `\Traversable` return type + is deferred to 4.0.0, where it will be batched with the other type additions. - `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. - `DateNormalizer::mapToEntity()` no longer passes `null` to `DateTime::createFromFormat()`, diff --git a/src/Entity/Filter.php b/src/Entity/Filter.php index 40ae599..034681f 100644 --- a/src/Entity/Filter.php +++ b/src/Entity/Filter.php @@ -20,7 +20,7 @@ class Filter /** * @var int */ - protected $offset; + protected $offset = 0; /** * null means no limit @@ -38,11 +38,6 @@ class Filter */ protected $before; - public function __construct() - { - $this->offset = 0; - } - /** * Sets orderBy * diff --git a/src/Entity/FollowUpFilter.php b/src/Entity/FollowUpFilter.php index 9f6232d..c7a7647 100644 --- a/src/Entity/FollowUpFilter.php +++ b/src/Entity/FollowUpFilter.php @@ -16,7 +16,6 @@ class FollowUpFilter extends Filter public function __construct($remainingCount, $offset) { - parent::__construct(); $this->remainingCount = $remainingCount; $this->offset = $offset; } diff --git a/src/Entity/Result.php b/src/Entity/Result.php index cf89883..d08968e 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -12,7 +12,7 @@ class Result implements IteratorAggregate, ResultInterface /** * @var int */ - protected $totalCount; + protected $totalCount = 0; /** * @var boolean|null @@ -42,14 +42,12 @@ class Result implements IteratorAggregate, ResultInterface /** * @var mixed[] */ - protected $items; + protected $items = []; public function __construct(?Filter $filter = null) { $this->filter = $filter; - $this->totalCount = 0; - $this->items = []; } /** @@ -232,8 +230,11 @@ public function calculateTotalCount($resultCount) /** * Retrieve an external iterator + * + * @return Traversable */ - public function getIterator(): Traversable + #[\ReturnTypeWillChange] + public function getIterator() { return new ArrayIterator($this->items); } diff --git a/src/Normalizer/BaseDenormalizer.php b/src/Normalizer/BaseDenormalizer.php index bb4d0ce..d4f8ebf 100644 --- a/src/Normalizer/BaseDenormalizer.php +++ b/src/Normalizer/BaseDenormalizer.php @@ -6,12 +6,7 @@ abstract class BaseDenormalizer implements DenormalizerInterface { - protected $availableKeysCheckIgnored; - - public function __construct() - { - $this->availableKeysCheckIgnored = false; - } + protected $availableKeysCheckIgnored = false; /** * Ignores available keys check diff --git a/src/Normalizer/DateNormalizer.php b/src/Normalizer/DateNormalizer.php index 9df2ada..585dcfc 100644 --- a/src/Normalizer/DateNormalizer.php +++ b/src/Normalizer/DateNormalizer.php @@ -20,7 +20,6 @@ class DateNormalizer extends BaseDenormalizer implements NormalizerInterface public function __construct($format, $remoteTimezone = null) { - parent::__construct(); $this->format = $format; $this->remoteTimezone = $remoteTimezone !== null ? $remoteTimezone : $this->getLocalTimezone(); } diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 24bf652..4ec04c0 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors; + protected $fieldAccessors = []; /** * @var array of boolean */ - protected $fieldDefault; + protected $fieldDefault = []; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers; + protected $fieldNormalizers = []; /** * @param ContextAwareNormalizerFactory $factory @@ -62,9 +62,6 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; - $this->fieldAccessors = []; - $this->fieldDefault = []; - $this->fieldNormalizers = []; } /** diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php index 9e40f82..87e2b98 100644 --- a/src/Normalizer/FilterNormalizer.php +++ b/src/Normalizer/FilterNormalizer.php @@ -13,7 +13,6 @@ class FilterNormalizer extends BaseDenormalizer implements NormalizerInterface public function __construct($orderByFields = [], $defaultLimit = 20, $maxLimit = 200) { - parent::__construct(); $this->defaultLimit = $defaultLimit; $this->maxLimit = $maxLimit; $this->orderByFields = $orderByFields; From e6c15ec016e9e29a3d72bc3e0bfacb4197538cf8 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:15:21 +0300 Subject: [PATCH 11/24] Add regression tests for default initialisation Follow-up to the review on PR #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 []. --- CHANGELOG.md | 3 -- src/Entity/Result.php | 2 +- src/Normalizer/DistributedNormalizer.php | 9 ++-- tests/Entity/FilterTest.php | 60 ++++++++++++++++++++++++ tests/Entity/ResultTest.php | 49 ++++++++++++++----- 5 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 tests/Entity/FilterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 706a4af..0d5f99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. - `DateNormalizer::mapToEntity()` no longer passes `null` to `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` input still raises `InvalidDataException` as before. -- Iterating a `Result` whose items were never set no longer fails — `$items` now defaults to an - empty array, so `getIterator()` and `getItems()` honour their documented contracts instead of - raising a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4). ## 3.4.0 ### Added diff --git a/src/Entity/Result.php b/src/Entity/Result.php index d08968e..4c87af8 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -42,7 +42,7 @@ class Result implements IteratorAggregate, ResultInterface /** * @var mixed[] */ - protected $items = []; + protected $items; public function __construct(?Filter $filter = null) diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 4ec04c0..24bf652 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors = []; + protected $fieldAccessors; /** * @var array of boolean */ - protected $fieldDefault = []; + protected $fieldDefault; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers = []; + protected $fieldNormalizers; /** * @param ContextAwareNormalizerFactory $factory @@ -62,6 +62,9 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; + $this->fieldAccessors = []; + $this->fieldDefault = []; + $this->fieldNormalizers = []; } /** diff --git a/tests/Entity/FilterTest.php b/tests/Entity/FilterTest.php new file mode 100644 index 0000000..5d7af4d --- /dev/null +++ b/tests/Entity/FilterTest.php @@ -0,0 +1,60 @@ +assertSame(0, (new Filter())->getOffset()); + } + + public function testOffsetDefaultsToZeroForSubclassNotCallingParentConstructor() + { + $this->assertSame(0, (new OwnConstructorFilter('done'))->getOffset()); + } + + public function testOffsetDefaultsToZeroWhenConstructorIsBypassed() + { + $filter = (new ReflectionClass(Filter::class))->newInstanceWithoutConstructor(); + + $this->assertSame(0, $filter->getOffset()); + } + + public function testGetOffsetReturnsNullWhenCursorIsUsed() + { + $this->assertNull((new Filter())->setAfter('cursor')->getOffset()); + $this->assertNull((new Filter())->setBefore('cursor')->getOffset()); + } + + public function testCalculateTotalCountForSubclassNotCallingParentConstructor() + { + $result = (new Result(new OwnConstructorFilter()))->setItems([1, 2]); + + $this->assertSame(2, $result->calculateTotalCount(2)); + $this->assertSame(2, $result->getTotalCount()); + } +} + +/** + * Filter has no constructor and is designed for subclassing, so descendants are + * not obliged to call parent::__construct(). Pins that contract down: the offset + * default has to live on the property declaration for this to keep working. + */ +class OwnConstructorFilter extends Filter +{ + /** + * @var string|null + */ + protected $status; + + public function __construct($status = null) + { + $this->status = $status; + } +} diff --git a/tests/Entity/ResultTest.php b/tests/Entity/ResultTest.php index 2460042..f973f06 100644 --- a/tests/Entity/ResultTest.php +++ b/tests/Entity/ResultTest.php @@ -4,35 +4,60 @@ use Paysera\Component\Serializer\Entity\Result; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; +use ReturnTypeWillChange; class ResultTest extends TestCase { - public function testIterateResultWithoutItems() + public function testIterateResultWithItems() { - $result = new Result(); + $result = (new Result())->setItems([1, 2, 3]); - $this->assertSame([], iterator_to_array($result)); + $this->assertSame([1, 2, 3], iterator_to_array($result)); } - public function testGetItemsReturnsArrayWhenItemsNotSet() + public function testAddItemWithoutSettingItemsFirst() { $result = new Result(); + $result->addItem('a'); - $this->assertSame([], $result->getItems()); + $this->assertSame(['a'], $result->getItems()); } - public function testIterateResultWithItems() + public function testTotalCountDefaultAppliesWhenConstructorIsBypassed() { - $result = (new Result())->setItems([1, 2, 3]); + $result = (new ReflectionClass(Result::class))->newInstanceWithoutConstructor(); - $this->assertSame([1, 2, 3], iterator_to_array($result)); + $this->assertSame(0, $result->getTotalCount()); } - public function testAddItemWithoutSettingItemsFirst() + /** + * The PHP 8.1 tentative return type notice is emitted when the class is + * declared, not when getIterator() is called, so it cannot be caught with an + * error handler from inside a test. Assert the declaration instead: either a + * native return type or the attribute keeps IteratorAggregate quiet. + */ + public function testGetIteratorSuppressesTentativeReturnTypeDeprecation() { - $result = new Result(); - $result->addItem('a'); + $method = new ReflectionMethod(Result::class, 'getIterator'); - $this->assertSame(['a'], $result->getItems()); + if ($method->hasReturnType()) { + $this->assertSame('Traversable', (string)$method->getReturnType()); + return; + } + + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Attributes require PHP 8.0; the notice only exists on PHP 8.1+.'); + } + + $attributes = array_map( + function ($attribute) { + return $attribute->getName(); + }, + $method->getAttributes() + ); + + $this->assertContains(ReturnTypeWillChange::class, $attributes); } } From 99074be132acabe3dbf97dfb66276fabda26518f Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:18:25 +0300 Subject: [PATCH 12/24] Initialise Result::$items in the constructor 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. --- CHANGELOG.md | 5 +++++ src/Entity/Result.php | 1 + tests/Entity/ResultTest.php | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5f99d..9821e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. - `DateNormalizer::mapToEntity()` no longer passes `null` to `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` input still raises `InvalidDataException` as before. +- `Result::__construct()` initialises `$items` to an empty array, so iterating a `Result` whose + items were never set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on + PHP 7.4) and `getItems()` honours its documented `@return mixed[]`. Instances created without + the constructor — `ReflectionClass::newInstanceWithoutConstructor()`, and the ORM hydration and + mocking that build on it — are unaffected and still start with a `null` `$items`. ## 3.4.0 ### Added diff --git a/src/Entity/Result.php b/src/Entity/Result.php index 4c87af8..2df2809 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -48,6 +48,7 @@ class Result implements IteratorAggregate, ResultInterface public function __construct(?Filter $filter = null) { $this->filter = $filter; + $this->items = []; } /** diff --git a/tests/Entity/ResultTest.php b/tests/Entity/ResultTest.php index f973f06..44f065a 100644 --- a/tests/Entity/ResultTest.php +++ b/tests/Entity/ResultTest.php @@ -10,6 +10,20 @@ class ResultTest extends TestCase { + public function testIterateResultWithoutItems() + { + $result = new Result(); + + $this->assertSame([], iterator_to_array($result)); + } + + public function testGetItemsReturnsArrayWhenItemsNotSet() + { + $result = new Result(); + + $this->assertSame([], $result->getItems()); + } + public function testIterateResultWithItems() { $result = (new Result())->setItems([1, 2, 3]); From 69e10575c5952982ea42d1cc901979b025569c54 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:20:10 +0300 Subject: [PATCH 13/24] Report null date input accurately in DateNormalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 ++++-- src/Normalizer/DateNormalizer.php | 6 +++++- tests/Normalizer/DateNormalizerTest.php | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9821e81..908d479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is deferred to 4.0.0, where it will be batched with the other type additions. - `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. -- `DateNormalizer::mapToEntity()` no longer passes `null` to `DateTime::createFromFormat()`, - resolving a PHP 8.1 deprecation. `null` input still raises `InvalidDataException` as before. +- `DateNormalizer::mapToEntity()` rejects `null` up front instead of passing it to + `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` still raises + `InvalidDataException`, now reporting `Date must be provided` rather than a message blaming + the configured format. - `Result::__construct()` initialises `$items` to an empty array, so iterating a `Result` whose items were never set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4) and `getItems()` honours its documented `@return mixed[]`. Instances created without diff --git a/src/Normalizer/DateNormalizer.php b/src/Normalizer/DateNormalizer.php index 585dcfc..b148a66 100644 --- a/src/Normalizer/DateNormalizer.php +++ b/src/Normalizer/DateNormalizer.php @@ -31,9 +31,13 @@ public function __construct($format, $remoteTimezone = null) */ public function mapToEntity($data) { + if ($data === null) { + throw new InvalidDataException('Date must be provided'); + } + $date = DateTime::createFromFormat( $this->format, - $data ?? '', + $data, $this->remoteTimezone ); if ($date === false) { diff --git a/tests/Normalizer/DateNormalizerTest.php b/tests/Normalizer/DateNormalizerTest.php index caf4659..695739f 100644 --- a/tests/Normalizer/DateNormalizerTest.php +++ b/tests/Normalizer/DateNormalizerTest.php @@ -85,7 +85,7 @@ function ($errno, $errstr) use (&$deprecations) { $service->mapToEntity(null); $this->fail('Expected InvalidDataException to be thrown'); } catch (InvalidDataException $exception) { - // expected + $this->assertSame('Date must be provided', $exception->getMessage()); } finally { restore_error_handler(); } From 885064cc9980abeec9551863b306f08ee9a55c56 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:23:44 +0300 Subject: [PATCH 14/24] Move DistributedNormalizer defaults back to property declarations 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. --- src/Normalizer/DistributedNormalizer.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 24bf652..4ec04c0 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors; + protected $fieldAccessors = []; /** * @var array of boolean */ - protected $fieldDefault; + protected $fieldDefault = []; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers; + protected $fieldNormalizers = []; /** * @param ContextAwareNormalizerFactory $factory @@ -62,9 +62,6 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; - $this->fieldAccessors = []; - $this->fieldDefault = []; - $this->fieldNormalizers = []; } /** From 3a1a721797a51a4148e04fe6593a14e340ba8141 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:24:57 +0300 Subject: [PATCH 15/24] Drop redundant $offset redeclaration in FollowUpFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Entity/FollowUpFilter.php | 5 ----- tests/Entity/FilterTest.php | 13 +++++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Entity/FollowUpFilter.php b/src/Entity/FollowUpFilter.php index c7a7647..18ad7f7 100644 --- a/src/Entity/FollowUpFilter.php +++ b/src/Entity/FollowUpFilter.php @@ -4,11 +4,6 @@ class FollowUpFilter extends Filter { - /** - * @var int - */ - protected $offset; - /** * @var int */ diff --git a/tests/Entity/FilterTest.php b/tests/Entity/FilterTest.php index 5d7af4d..39279de 100644 --- a/tests/Entity/FilterTest.php +++ b/tests/Entity/FilterTest.php @@ -3,6 +3,7 @@ namespace Paysera\Component\Serializer\Tests\Entity; use Paysera\Component\Serializer\Entity\Filter; +use Paysera\Component\Serializer\Entity\FollowUpFilter; use Paysera\Component\Serializer\Entity\Result; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -32,6 +33,18 @@ public function testGetOffsetReturnsNullWhenCursorIsUsed() $this->assertNull((new Filter())->setBefore('cursor')->getOffset()); } + public function testFollowUpFilterInheritsOffsetDefault() + { + $filter = (new ReflectionClass(FollowUpFilter::class))->newInstanceWithoutConstructor(); + + $this->assertSame(0, $filter->getOffset()); + } + + public function testFollowUpFilterKeepsConstructorOffset() + { + $this->assertSame(10, (new FollowUpFilter(5, 10))->getOffset()); + } + public function testCalculateTotalCountForSubclassNotCallingParentConstructor() { $result = (new Result(new OwnConstructorFilter()))->setItems([1, 2]); From 367749b1344a32c368cd9f9b2e23c9225e2654f2 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:36:33 +0300 Subject: [PATCH 16/24] Initialise DistributedNormalizer defaults in the constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Normalizer/DistributedNormalizer.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 4ec04c0..24bf652 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors = []; + protected $fieldAccessors; /** * @var array of boolean */ - protected $fieldDefault = []; + protected $fieldDefault; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers = []; + protected $fieldNormalizers; /** * @param ContextAwareNormalizerFactory $factory @@ -62,6 +62,9 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; + $this->fieldAccessors = []; + $this->fieldDefault = []; + $this->fieldNormalizers = []; } /** From 6ab6876b4bebec43c7dd349e9d79f1c1f338b08b Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 13:49:13 +0300 Subject: [PATCH 17/24] Default Result::$items on the property declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 11 ++++++----- src/Entity/Result.php | 4 +--- tests/Entity/ResultTest.php | 13 +++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 908d479..b7db4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,11 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` still raises `InvalidDataException`, now reporting `Date must be provided` rather than a message blaming the configured format. -- `Result::__construct()` initialises `$items` to an empty array, so iterating a `Result` whose - items were never set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on - PHP 7.4) and `getItems()` honours its documented `@return mixed[]`. Instances created without - the constructor — `ReflectionClass::newInstanceWithoutConstructor()`, and the ORM hydration and - mocking that build on it — are unaffected and still start with a `null` `$items`. +- `Result::$items` now defaults to an empty array, so iterating a `Result` whose items were never + set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4) and + `getItems()` honours its documented `@return mixed[]`. 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. ## 3.4.0 ### Added diff --git a/src/Entity/Result.php b/src/Entity/Result.php index 2df2809..eab7c15 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -42,13 +42,11 @@ class Result implements IteratorAggregate, ResultInterface /** * @var mixed[] */ - protected $items; - + protected $items = []; public function __construct(?Filter $filter = null) { $this->filter = $filter; - $this->items = []; } /** diff --git a/tests/Entity/ResultTest.php b/tests/Entity/ResultTest.php index 44f065a..b566e1c 100644 --- a/tests/Entity/ResultTest.php +++ b/tests/Entity/ResultTest.php @@ -46,6 +46,19 @@ public function testTotalCountDefaultAppliesWhenConstructorIsBypassed() $this->assertSame(0, $result->getTotalCount()); } + /** + * Reflection-based instantiation is how ORM hydration, reflection serializers and + * PHPUnit's disableOriginalConstructor() build objects. Pins the defaults to the + * property declarations: a constructor assignment would not cover this path. + */ + public function testItemsDefaultAppliesWhenConstructorIsBypassed() + { + $result = (new ReflectionClass(Result::class))->newInstanceWithoutConstructor(); + + $this->assertSame([], $result->getItems()); + $this->assertSame([], iterator_to_array($result)); + } + /** * The PHP 8.1 tentative return type notice is emitted when the class is * declared, not when getIterator() is called, so it cannot be caught with an From ebb911248f81fa40924d98a2a8d3b5d589b1373e Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:15:26 +0300 Subject: [PATCH 18/24] List the DateNormalizer message change under Changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7db4ca..eeaec6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 3.5.0 ### Changed +- `DateNormalizer::mapToEntity()` rejects `null` up front instead of passing it to + `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` still raises + `InvalidDataException`, so `catch` blocks are unaffected, but the message changes from + `Provided date format is invalid` to `Date must be provided` — the old text blamed the + configured format for what is really a missing input. Consumers that match on + `getMessage()` rather than the exception type, such as API error-mapping layers, need to + account for the new string. - Narrowed the `phpunit/phpunit` development requirement to `^9.3` — the version that introduced the `` configuration element used by `phpunit.xml.dist`. - Replaced leading-backslash class references with `use` statements throughout the library — @@ -25,10 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is deferred to 4.0.0, where it will be batched with the other type additions. - `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. -- `DateNormalizer::mapToEntity()` rejects `null` up front instead of passing it to - `DateTime::createFromFormat()`, resolving a PHP 8.1 deprecation. `null` still raises - `InvalidDataException`, now reporting `Date must be provided` rather than a message blaming - the configured format. - `Result::$items` now defaults to an empty array, so iterating a `Result` whose items were never set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4) and `getItems()` honours its documented `@return mixed[]`. The default lives on the property From 5186422b7a576867446168b02abc419f304961da Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:15:26 +0300 Subject: [PATCH 19/24] Remove stray blank lines after class opening braces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Encoding/Jsonp.php | 1 - src/Filter/FieldsParser.php | 1 - src/Normalizer/ResultMetadataNormalizer.php | 1 - 3 files changed, 3 deletions(-) diff --git a/src/Encoding/Jsonp.php b/src/Encoding/Jsonp.php index 5f72638..b356ebe 100644 --- a/src/Encoding/Jsonp.php +++ b/src/Encoding/Jsonp.php @@ -7,7 +7,6 @@ class Jsonp implements EncoderInterface { - protected $jsonEncoder; protected $callbackValidator; diff --git a/src/Filter/FieldsParser.php b/src/Filter/FieldsParser.php index e886d22..8e5b029 100644 --- a/src/Filter/FieldsParser.php +++ b/src/Filter/FieldsParser.php @@ -6,7 +6,6 @@ class FieldsParser { - /** * @param null|array $fields * @param array $scope diff --git a/src/Normalizer/ResultMetadataNormalizer.php b/src/Normalizer/ResultMetadataNormalizer.php index f9e70e0..1791809 100644 --- a/src/Normalizer/ResultMetadataNormalizer.php +++ b/src/Normalizer/ResultMetadataNormalizer.php @@ -6,7 +6,6 @@ class ResultMetadataNormalizer implements NormalizerInterface { - /** * Maps some structure to raw data. Usually entity object to array * From c524570b32d6a45166d8c2ef357edba641083502 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:15:56 +0300 Subject: [PATCH 20/24] Import ReturnTypeWillChange instead of a leading backslash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Entity/Result.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Entity/Result.php b/src/Entity/Result.php index eab7c15..f752c53 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -5,6 +5,7 @@ use ArrayIterator; use BadMethodCallException; use IteratorAggregate; +use ReturnTypeWillChange; use Traversable; class Result implements IteratorAggregate, ResultInterface @@ -232,7 +233,7 @@ public function calculateTotalCount($resultCount) * * @return Traversable */ - #[\ReturnTypeWillChange] + #[ReturnTypeWillChange] public function getIterator() { return new ArrayIterator($this->items); From e37069671bb345d8e75da5fff45f63af52a567da Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:16:07 +0300 Subject: [PATCH 21/24] Correct calculateTotalCount() return docblock 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) --- src/Entity/Result.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Entity/Result.php b/src/Entity/Result.php index f752c53..d077fc8 100644 --- a/src/Entity/Result.php +++ b/src/Entity/Result.php @@ -206,8 +206,8 @@ public function addItem($item) /** * Try to calculate total result count, in case all results are fetched. * - * @param $resultCount - * @return null + * @param int $resultCount + * @return int|null the calculated total count, or null when it cannot be determined * @throws BadMethodCallException */ public function calculateTotalCount($resultCount) From 85e80dc6cf0be3950b84a0345ddd8931c7d42700 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:17:46 +0300 Subject: [PATCH 22/24] Move the Filter test fixture into its own autoloadable file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/Entity/FilterTest.php | 28 +------------------------ tests/Entity/ResultTest.php | 15 +++++++++++++ tests/Fixtures/OwnConstructorFilter.php | 23 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 27 deletions(-) create mode 100644 tests/Fixtures/OwnConstructorFilter.php diff --git a/tests/Entity/FilterTest.php b/tests/Entity/FilterTest.php index 39279de..296332d 100644 --- a/tests/Entity/FilterTest.php +++ b/tests/Entity/FilterTest.php @@ -4,7 +4,7 @@ use Paysera\Component\Serializer\Entity\Filter; use Paysera\Component\Serializer\Entity\FollowUpFilter; -use Paysera\Component\Serializer\Entity\Result; +use Paysera\Component\Serializer\Tests\Fixtures\OwnConstructorFilter; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -44,30 +44,4 @@ public function testFollowUpFilterKeepsConstructorOffset() { $this->assertSame(10, (new FollowUpFilter(5, 10))->getOffset()); } - - public function testCalculateTotalCountForSubclassNotCallingParentConstructor() - { - $result = (new Result(new OwnConstructorFilter()))->setItems([1, 2]); - - $this->assertSame(2, $result->calculateTotalCount(2)); - $this->assertSame(2, $result->getTotalCount()); - } -} - -/** - * Filter has no constructor and is designed for subclassing, so descendants are - * not obliged to call parent::__construct(). Pins that contract down: the offset - * default has to live on the property declaration for this to keep working. - */ -class OwnConstructorFilter extends Filter -{ - /** - * @var string|null - */ - protected $status; - - public function __construct($status = null) - { - $this->status = $status; - } } diff --git a/tests/Entity/ResultTest.php b/tests/Entity/ResultTest.php index b566e1c..c30d97d 100644 --- a/tests/Entity/ResultTest.php +++ b/tests/Entity/ResultTest.php @@ -3,6 +3,7 @@ namespace Paysera\Component\Serializer\Tests\Entity; use Paysera\Component\Serializer\Entity\Result; +use Paysera\Component\Serializer\Tests\Fixtures\OwnConstructorFilter; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; @@ -59,6 +60,20 @@ public function testItemsDefaultAppliesWhenConstructorIsBypassed() $this->assertSame([], iterator_to_array($result)); } + /** + * Filter carries its offset default on the property declaration, so a descendant + * that declares its own constructor without calling parent::__construct() still + * reports 0 rather than null — which is what keeps this calculation from silently + * skipping and reporting a total of 0 for a non-empty result set. + */ + public function testCalculateTotalCountForFilterSubclassNotCallingParentConstructor() + { + $result = (new Result(new OwnConstructorFilter()))->setItems([1, 2]); + + $this->assertSame(2, $result->calculateTotalCount(2)); + $this->assertSame(2, $result->getTotalCount()); + } + /** * The PHP 8.1 tentative return type notice is emitted when the class is * declared, not when getIterator() is called, so it cannot be caught with an diff --git a/tests/Fixtures/OwnConstructorFilter.php b/tests/Fixtures/OwnConstructorFilter.php new file mode 100644 index 0000000..e6d02ce --- /dev/null +++ b/tests/Fixtures/OwnConstructorFilter.php @@ -0,0 +1,23 @@ +status = $status; + } +} From e228a10287251a3f0f594b08a428a73ff55375d3 Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:17:46 +0300 Subject: [PATCH 23/24] Document the two behavioural changes missing from the changelog 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) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaec6b..87780db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 configured format for what is really a missing input. Consumers that match on `getMessage()` rather than the exception type, such as API error-mapping layers, need to account for the new string. +- `DistributedNormalizer` initialises `$fieldAccessors`, `$fieldDefault` and `$fieldNormalizers` + in the constructor rather than on the property declarations, following the style guide's + default-property-values rule. The constructor takes four required arguments, so no real code + path can skip it; the difference is observable only in test doubles built with + `disableOriginalConstructor()`, where the three properties are `null` instead of `[]` and + iterating them warns rather than being a no-op. - Narrowed the `phpunit/phpunit` development requirement to `^9.3` — the version that introduced the `` configuration element used by `phpunit.xml.dist`. - Replaced leading-backslash class references with `use` statements throughout the library — @@ -32,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is deferred to 4.0.0, where it will be batched with the other type additions. - `CamelCaseToSnakeCaseConverter::convert()` no longer passes `null` to `preg_replace()`, resolving a PHP 8.1 deprecation. Passing `null` still returns an empty string as before. +- `FollowUpFilter` no longer redeclares `$offset` without an initialiser. The shadowing + declaration gave it a `null` default where `Filter` declares `0`; it now inherits the parent + default. Instances built through the constructor were always assigned an offset there and are + unaffected. - `Result::$items` now defaults to an empty array, so iterating a `Result` whose items were never set no longer raises a `TypeError` on PHP 8 (an `InvalidArgumentException` on PHP 7.4) and `getItems()` honours its documented `@return mixed[]`. The default lives on the property From 6da6e475d7b49316f2ac412e9e8a8734fd465f3f Mon Sep 17 00:00:00 2001 From: Andrii Krasnoholovets Date: Fri, 31 Jul 2026 14:57:07 +0300 Subject: [PATCH 24/24] Move DistributedNormalizer defaults back to property declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 6 --- src/Normalizer/DistributedNormalizer.php | 9 ++-- .../Normalizer/DistributedNormalizerTest.php | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 tests/Normalizer/DistributedNormalizerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 87780db..ad5f877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 configured format for what is really a missing input. Consumers that match on `getMessage()` rather than the exception type, such as API error-mapping layers, need to account for the new string. -- `DistributedNormalizer` initialises `$fieldAccessors`, `$fieldDefault` and `$fieldNormalizers` - in the constructor rather than on the property declarations, following the style guide's - default-property-values rule. The constructor takes four required arguments, so no real code - path can skip it; the difference is observable only in test doubles built with - `disableOriginalConstructor()`, where the three properties are `null` instead of `[]` and - iterating them warns rather than being a no-op. - Narrowed the `phpunit/phpunit` development requirement to `^9.3` — the version that introduced the `` configuration element used by `phpunit.xml.dist`. - Replaced leading-backslash class references with `use` statements throughout the library — diff --git a/src/Normalizer/DistributedNormalizer.php b/src/Normalizer/DistributedNormalizer.php index 24bf652..4ec04c0 100644 --- a/src/Normalizer/DistributedNormalizer.php +++ b/src/Normalizer/DistributedNormalizer.php @@ -34,17 +34,17 @@ class DistributedNormalizer implements DenormalizerInterface, ContextAwareNormal /** * @var FieldAccessorInterface[] */ - protected $fieldAccessors; + protected $fieldAccessors = []; /** * @var array of boolean */ - protected $fieldDefault; + protected $fieldDefault = []; /** * @var DenormalizerInterface[]|NormalizerInterface[] */ - protected $fieldNormalizers; + protected $fieldNormalizers = []; /** * @param ContextAwareNormalizerFactory $factory @@ -62,9 +62,6 @@ public function __construct( $this->fieldsParser = $fieldsParser; $this->fieldsFilter = $fieldsFilter; $this->normalizer = $normalizer; - $this->fieldAccessors = []; - $this->fieldDefault = []; - $this->fieldNormalizers = []; } /** diff --git a/tests/Normalizer/DistributedNormalizerTest.php b/tests/Normalizer/DistributedNormalizerTest.php new file mode 100644 index 0000000..700a506 --- /dev/null +++ b/tests/Normalizer/DistributedNormalizerTest.php @@ -0,0 +1,42 @@ +fieldNormalizers[$key]), which is simply false + * for every key. + * + * @dataProvider fieldMapProvider + */ + public function testFieldMapsDefaultToArrayWhenConstructorIsBypassed($property) + { + $normalizer = (new ReflectionClass(DistributedNormalizer::class))->newInstanceWithoutConstructor(); + + $reflectionProperty = new ReflectionProperty(DistributedNormalizer::class, $property); + $reflectionProperty->setAccessible(true); + + $this->assertSame([], $reflectionProperty->getValue($normalizer)); + } + + public function fieldMapProvider() + { + return [ + 'fieldAccessors' => ['fieldAccessors'], + 'fieldDefault' => ['fieldDefault'], + 'fieldNormalizers' => ['fieldNormalizers'], + ]; + } +}