Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
7d25bfb
Refactor mapper responsibilities into dedicated services
magicsunday Nov 12, 2025
37329f5
Run rector and align mapping type guards
magicsunday Nov 12, 2025
5b8f122
Add PSR-6 caching for type resolution
magicsunday Nov 12, 2025
99c5fe5
Refine error handling while preserving legacy context APIs
magicsunday Nov 12, 2025
f5eec80
Add PHP 8+ attribute support and modern type conversions
magicsunday Nov 12, 2025
1527180
Support generic collection mapping
magicsunday Nov 12, 2025
526ebe3
Add extensible type handler support
magicsunday Nov 12, 2025
9faf61b
Refine object conversion guards and CPD command
magicsunday Nov 12, 2025
b10f1b6
Ensure JsonMapper defaults avoid nullable config
magicsunday Nov 13, 2025
7c4cda7
Merge pull request #11 from magicsunday/codex/run-composer-ci-rector-…
magicsunday Nov 13, 2025
36ae4b0
refactor: unify mapper configuration
magicsunday Nov 13, 2025
bb197de
Merge pull request #12 from magicsunday/codex/konsolidiere-config-obj…
magicsunday Nov 13, 2025
f0a3ce1
Refactor mapper responsibilities into dedicated services
magicsunday Nov 12, 2025
c591d1a
Run rector and align mapping type guards
magicsunday Nov 12, 2025
24fa087
Add PSR-6 caching for type resolution
magicsunday Nov 12, 2025
63648e4
Refine error handling while preserving legacy context APIs
magicsunday Nov 12, 2025
4acdb90
Add PHP 8+ attribute support and modern type conversions
magicsunday Nov 12, 2025
8fb15c7
Support generic collection mapping
magicsunday Nov 12, 2025
3c0d1d1
Add extensible type handler support
magicsunday Nov 12, 2025
5833736
Refine object conversion guards and CPD command
magicsunday Nov 12, 2025
44f6f43
Ensure JsonMapper defaults avoid nullable config
magicsunday Nov 13, 2025
55078c0
refactor: unify mapper configuration
magicsunday Nov 13, 2025
8ae844d
Add robustness tests for JsonMapper edge cases
magicsunday Nov 13, 2025
2d65e28
Merge branch 'codex/refactor-jsonmapper-for-single-responsibility' in…
magicsunday Nov 13, 2025
389d339
Merge pull request #14 from magicsunday/codex/improve-test-strategy-a…
magicsunday Nov 13, 2025
22590c8
fix: specify generics for phpstan compliance
magicsunday Nov 13, 2025
a2f96bb
Merge pull request #15 from magicsunday/codex/perform-phpstan-static-…
magicsunday Nov 13, 2025
dce0ee1
chore(project): enforce final mapper and expand docs
magicsunday Nov 13, 2025
6ca9ecc
Merge pull request #17 from magicsunday/codex/revise-coding-style-and…
magicsunday Nov 13, 2025
408356e
Minor fix
magicsunday Nov 13, 2025
79ba1e1
Minor fixes
magicsunday Nov 13, 2025
b8797bf
Remove obsolete stuff
magicsunday Nov 14, 2025
d926ae8
docs(mapper): clarify mapping phpdoc and flow
magicsunday Nov 14, 2025
0dcf4b1
Merge pull request #18 from magicsunday/codex/add-phpdoc-and-inline-c…
magicsunday Nov 14, 2025
e4b8230
docs(value): expand phpdoc for conversion components
magicsunday Nov 14, 2025
edcef47
Merge pull request #19 from magicsunday/codex/update-phpdoc-for-jsonm…
magicsunday Nov 14, 2025
7ca9b6c
docs(context): expand mapping phpdoc coverage
magicsunday Nov 14, 2025
0e11d28
Merge pull request #20 from magicsunday/codex/add-phpdoc-annotations-…
magicsunday Nov 14, 2025
6a29eae
docs: clarify phpdoc for collection resolvers
magicsunday Nov 14, 2025
b81ffc4
Merge pull request #21 from magicsunday/codex/enhance-phpdoc-in-jsonm…
magicsunday Nov 14, 2025
d2d0cc5
docs: reorder nested example types
magicsunday Nov 14, 2025
c28f2a4
Merge pull request #22 from magicsunday/codex/verify-examples-in-read…
magicsunday Nov 14, 2025
dd660c8
docs(attribute): document replace property constructor
magicsunday Nov 14, 2025
8264c7c
Merge pull request #23 from magicsunday/codex/add-phpdoc-block-for-co…
magicsunday Nov 14, 2025
7d7a94e
docs(converter): clarify property name conversion phpdoc
magicsunday Nov 14, 2025
2127428
Merge pull request #24 from magicsunday/codex/add-phpdoc-to-converter…
magicsunday Nov 14, 2025
206e844
docs(value): document closure type handler callables
magicsunday Nov 14, 2025
ba86ae6
Merge pull request #25 from magicsunday/codex/add-phpdoc-blocks-to-cl…
magicsunday Nov 14, 2025
8fd4c53
docs(resolver): document helper behaviours
magicsunday Nov 14, 2025
3f40295
Merge pull request #26 from magicsunday/codex/add-phpdoc-blocks-for-m…
magicsunday Nov 14, 2025
26b6ec6
docs(report): clarify mapping report phpdocs
magicsunday Nov 14, 2025
9067a39
Merge pull request #27 from magicsunday/codex/add-phpdoc-to-mappingre…
magicsunday Nov 14, 2025
d6005dc
docs(exception): clarify exception phpdoc
magicsunday Nov 14, 2025
eb84f26
Merge pull request #28 from magicsunday/codex/add-phpdoc-blocks-for-e…
magicsunday Nov 14, 2025
9bba24c
docs(mapper): clarify JsonMapper PHPDoc blocks
magicsunday Nov 14, 2025
d3b0c87
Merge pull request #29 from magicsunday/codex/add-and-expand-phpdoc-b…
magicsunday Nov 14, 2025
207f443
fix(value): document custom type aliases as non-empty
magicsunday Nov 14, 2025
892e0a4
Merge pull request #30 from magicsunday/codex/run-tests-and-fix-errors
magicsunday Nov 14, 2025
16075fb
test(docs): cover README and recipe examples
magicsunday Nov 14, 2025
a4d4975
Merge pull request #31 from magicsunday/codex/check-example-tests-in-…
magicsunday Nov 14, 2025
54fdd74
fix(ci): satisfy composer ci:test
magicsunday Nov 14, 2025
37dd6e9
Merge pull request #32 from magicsunday/codex/fuhre-composer-ci-test-…
magicsunday Nov 14, 2025
6146c7d
docs: align documentation examples with tests
magicsunday Nov 14, 2025
c02ea14
Merge pull request #33 from magicsunday/codex/verify-readme-examples-…
magicsunday Nov 14, 2025
e804dc6
feat(jsonmapper): add factory helper for default services
magicsunday Nov 14, 2025
1961373
Merge pull request #34 from magicsunday/codex/add-optional-factory-he…
magicsunday Nov 14, 2025
5caf028
refactor(mapper): extract mapping helpers
magicsunday Nov 14, 2025
2ee6960
Merge pull request #35 from magicsunday/codex/refactor-map-method-int…
magicsunday Nov 14, 2025
118cc40
Update
magicsunday Nov 14, 2025
1e2997e
Minor update
magicsunday Nov 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 362 additions & 78 deletions README.md

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@
"symfony/property-info": "^7.3",
"symfony/property-access": "^7.3",
"symfony/type-info": "^7.3",
"doctrine/inflector": "^2.0",
"doctrine/annotations": "^2.0"
"doctrine/inflector": "^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.65",
Expand Down Expand Up @@ -77,7 +76,7 @@
"@ci:rector --dry-run"
],
"ci:test:php:cpd": [
"npx -y jscpd@latest --config .jscpd.json"
"npx jscpd --config .jscpd.json"
],
"ci:test:php:unit": [
"phpunit --configuration phpunit.xml"
Expand Down
149 changes: 149 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# JsonMapper API reference

This document summarises the public surface of the JsonMapper package. All classes are namespaced under `MagicSunday\\JsonMapper` unless stated otherwise.

## JsonMapper (final)
The `JsonMapper` class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is `final`; prefer composition over inheritance.

### Factory helper
```php
<?php
declare(strict_types=1);

use MagicSunday\JsonMapper;

$mapper = JsonMapper::createWithDefaults();
```

The helper wires the Symfony `PropertyInfoExtractor` (reflection + PhpDoc) and a default `PropertyAccessor`. Use the constructor described below when you need custom extractors, caches, or accessors.

### Constructor
```php
<?php
declare(strict_types=1);

use MagicSunday\JsonMapper;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;

// Describe DTO metadata and wiring through Symfony extractors.
$propertyInfo = new PropertyInfoExtractor(
listExtractors: [new ReflectionExtractor()],
typeExtractors: [new PhpDocExtractor()],
);
$propertyAccessor = PropertyAccess::createPropertyAccessor();

// Cache resolved Type metadata for subsequent mappings.
$typeCache = new ArrayAdapter();

$mapper = new JsonMapper($propertyInfo, $propertyAccessor, classMap: [], typeCache: $typeCache);

var_dump($mapper::class);
```

* `$classMap` allows overriding resolved target classes. Use `addCustomClassMapEntry()` for runtime registration.
* `$typeCache` enables caching of resolved Symfony `Type` instances. Any PSR-6 cache pool is supported.
* `$config` provides the default configuration that will be cloned for every mapping operation.

### Methods

| Method | Description |
| --- | --- |
| `addTypeHandler(TypeHandlerInterface $handler): self` | Registers a reusable conversion strategy for a specific type. |
| `addType(string $type, Closure $closure): self` | Deprecated shortcut for registering closure-based handlers. Prefer `addTypeHandler()`. |
| `addCustomClassMapEntry(string $className, Closure $resolver): self` | Adds or replaces a class map entry. The resolver receives JSON data (and optionally the current `MappingContext`). |
| `map(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, ?JsonMapperConfiguration $configuration = null): mixed` | Maps the provided JSON payload to the requested class or collection. |
| `mapWithReport(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?JsonMapperConfiguration $configuration = null): MappingResult` | Maps data and returns a `MappingResult` containing both the mapped value and an error report. |

> `map()` and `mapWithReport()` accept JSON decoded into arrays or objects (`json_decode(..., associative: false)` is recommended). Collections require either an explicit collection class name or collection PHPDoc (`@extends`) metadata.

## JsonMapperConfiguration (final)
The `JsonMapperConfiguration` class encapsulates mapping options. All configuration methods return a **new** instance; treat instances as immutable value objects.

### Factory helpers
* `JsonMapperConfiguration::lenient()` – default, tolerant configuration.
* `JsonMapperConfiguration::strict()` – enables strict mode (missing and unknown properties raise `MappingException`).
* `JsonMapperConfiguration::fromArray(array $data)` – rebuilds a configuration from persisted values.
* `JsonMapperConfiguration::fromContext(MappingContext $context)` – reconstructs a configuration for an existing mapping run.

### Withers
Each `with*` method toggles a single option and returns a clone:

| Method | Purpose |
| --- | --- |
| `withStrictMode(bool $enabled)` | Enable strict validation. |
| `withCollectErrors(bool $enabled)` | Collect errors instead of failing fast. Required for `mapWithReport()`. |
| `withTreatEmptyStringAsNull(bool $enabled)` | Map empty strings to `null`. |
| `withIgnoreUnknownProperties(bool $enabled)` | Skip unmapped JSON keys. |
| `withTreatNullAsEmptyCollection(bool $enabled)` | Replace `null` collections with their default value. |
| `withDefaultDateFormat(string $format)` | Configure the default `DateTimeInterface` parsing format. |
| `withScalarToObjectCasting(bool $enabled)` | Allow casting scalar values to object types when possible. |

Use `toOptions()` to feed configuration data into a `MappingContext`, or `toArray()` to persist settings.

## Property name converters
`CamelCasePropertyNameConverter` implements `PropertyNameConverterInterface` and is declared `final`. Instantiate it when JSON keys use snake case:

```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use MagicSunday\JsonMapper;
use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter;

// Translate snake_case JSON keys into camelCase DTO properties.
$nameConverter = new CamelCasePropertyNameConverter();

$mapper = JsonMapper::createWithDefaults($nameConverter);

var_dump($mapper::class);
```

## Custom type handlers
Implement `Value\TypeHandlerInterface` to plug in custom conversion logic:

```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use MagicSunday\JsonMapper\Context\MappingContext;
use MagicSunday\JsonMapper\Value\TypeHandlerInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\ObjectType;

final class FakeUuid
{
private function __construct(private string $value)
{
}

public static function fromString(string $value): self
{
return new self($value);
}
}

final class UuidTypeHandler implements TypeHandlerInterface
{
public function supports(Type $type, mixed $value): bool
{
// Only handle FakeUuid targets to keep conversion focused.
return $type instanceof ObjectType && $type->getClassName() === FakeUuid::class;
}

public function convert(Type $type, mixed $value, MappingContext $context): FakeUuid
{
// Build the value object from the incoming scalar payload.
return FakeUuid::fromString((string) $value);
}
}
```

Register handlers via `JsonMapper::addTypeHandler()` to make them available for all mappings.
49 changes: 49 additions & 0 deletions docs/recipes/custom-name-converter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Using a custom name converter

Property name converters translate JSON keys to PHP property names. JsonMapper provides `CamelCasePropertyNameConverter` out of the box and allows you to supply your own implementation of `PropertyNameConverterInterface`.

```php
<?php
declare(strict_types=1);

namespace App\Converter;

use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface;

final class UpperSnakeCaseConverter implements PropertyNameConverterInterface
{
public function convert(string $name): string
{
// Normalise keys by removing underscores and lowercasing them.
return strtolower(str_replace('_', '', $name));
}
}
```

```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use App\Converter\UpperSnakeCaseConverter;
use MagicSunday\JsonMapper;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;

// Collect metadata and provide the custom converter.
$propertyInfo = new PropertyInfoExtractor(
listExtractors: [new ReflectionExtractor()],
typeExtractors: [new PhpDocExtractor()],
);
$propertyAccessor = PropertyAccess::createPropertyAccessor();
$converter = new UpperSnakeCaseConverter();

$mapper = new JsonMapper($propertyInfo, $propertyAccessor, $converter);
```

Name converters are stateless and should be declared `final`. They are applied to every property access during mapping, so keep the implementation idempotent and efficient.

Test coverage: `tests/JsonMapper/DocsCustomNameConverterTest.php`.
60 changes: 60 additions & 0 deletions docs/recipes/mapping-with-enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Mapping JSON to PHP enums

JsonMapper can map backed enums transparently when the target property is typed with the enum class. The built-in `EnumValueConversionStrategy` handles the conversion from scalars to enum cases.

```php
<?php
declare(strict_types=1);

namespace App\Dto;

enum Status: string
{
case Draft = 'draft';
case Published = 'published';
}

final class Article
{
public string $title;
public Status $status;
}
```

```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use App\Dto\Article;
use App\Dto\Status;
use MagicSunday\JsonMapper;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;

// Decode the incoming JSON payload into an object.
$json = json_decode('{
"title": "Enum mapping",
"status": "published"
}', associative: false, flags: JSON_THROW_ON_ERROR);

// Wire up the mapper with reflection and PhpDoc metadata.
$propertyInfo = new PropertyInfoExtractor(
listExtractors: [new ReflectionExtractor()],
typeExtractors: [new PhpDocExtractor()],
);
$propertyAccessor = PropertyAccess::createPropertyAccessor();

$mapper = new JsonMapper($propertyInfo, $propertyAccessor);
$article = $mapper->map($json, Article::class);

assert($article instanceof Article);
assert($article->status === Status::Published);
```

The mapper validates enum values. In strict mode (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException` instead of populating the property.

Test coverage: `tests/JsonMapperTest.php::mapBackedEnumFromString` and `tests/JsonMapper/JsonMapperErrorHandlingTest.php::itReportsInvalidEnumValuesInLenientMode`.
92 changes: 92 additions & 0 deletions docs/recipes/nested-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Mapping nested collections

Collections of collections require explicit metadata so JsonMapper can determine the element types at every level.

```php
<?php
declare(strict_types=1);

namespace App\Dto;

use ArrayObject;

final class Tag
{
public string $name;
}

/**
* @extends ArrayObject<int, Tag>
*/
final class TagCollection extends ArrayObject
{
}

/**
* @extends ArrayObject<int, TagCollection>
*/
final class NestedTagCollection extends ArrayObject
{
}

final class Article
{
/** @var NestedTagCollection */
public NestedTagCollection $tags;
}

/**
* @extends ArrayObject<int, Article>
*/
final class ArticleCollection extends ArrayObject
{
}
```

```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use App\Dto\Article;
use App\Dto\ArticleCollection;
use App\Dto\NestedTagCollection;
use App\Dto\TagCollection;
use MagicSunday\JsonMapper;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;

// Decode an array of nested tag collections.
$json = json_decode('[
{
"tags": [
[{"name": "php"}],
[{"name": "json"}]
]
}
]', associative: false, flags: JSON_THROW_ON_ERROR);

// Configure JsonMapper with the extractors that understand collection metadata.
$propertyInfo = new PropertyInfoExtractor(
listExtractors: [new ReflectionExtractor()],
typeExtractors: [new PhpDocExtractor()],
);
$propertyAccessor = PropertyAccess::createPropertyAccessor();


// Map the nested structure into DTOs and typed collections.
$mapper = new JsonMapper($propertyInfo, $propertyAccessor);
$articles = $mapper->map($json, Article::class, ArticleCollection::class);

assert($articles instanceof ArticleCollection);
assert($articles[0] instanceof Article);
assert($articles[0]->tags instanceof NestedTagCollection);
```

Each custom collection advertises its value type through the `@extends` PHPDoc annotation, allowing the mapper to recurse through nested structures.

Test coverage: `tests/JsonMapper/DocsNestedCollectionsTest.php`.

Loading