This document summarises the public surface of the JsonMapper package. The entry-point class is
MagicSunday\JsonMapper; everything else lives under the MagicSunday\JsonMapper namespace
(MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration, the MagicSunday\JsonMapper\Attribute\*
attributes, and so on).
The JsonMapper class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is final; prefer composition over inheritance.
<?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.
<?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);$classMapallows overriding resolved target classes. UseaddCustomClassMapEntry()for runtime registration.$typeCacheenables caching of resolved SymfonyTypeinstances. Any PSR-6 cache pool is supported.$configprovides the default configuration that will be cloned for every mapping operation.
The built-in
Value\Strategy\*classes are@internal: they are the conversion chain's implementation, not an extension point, and there is no public way to register one. ATypeHandlerInterfaceadded throughaddTypeHandler()is consulted ahead of the built-in deciding strategies (after null handling), which is the supported way to change how a type converts.
| 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|string $resolver, ?array $allowedTargets = null): self |
Adds or replaces a class map entry. $resolver is a resolver closure — receiving the decoded JSON fragment and optionally the current MappingContext — or a concrete class-string to map to unconditionally. $allowedTargets restricts what a closure may return — see the security note in Type converters. |
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. |
Security:
$classNameselects the class to instantiate — never derive it from request data. The same applies to what a class-map resolver returns; see the note in Type converters.
map()andmapWithReport()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.
What the payload's shape decides. Given both an element class and a collection class, a list becomes the collection and a single object becomes one element — which is how you consume an API that returns one object for a single hit and a list for several. A scalar satisfies neither reading and is reported as a
CollectionMappingExceptionrather than silently yielding a bare element.An empty array without a collection class yields an instance, not an empty list: with
associative: trueboth[]and{}decode to an empty array, and a caller passing associative arrays directly usually means the object. Name the collection class when you mean an empty list —map([], Item::class, ItemCollection::class)returns an emptyItemCollection.
The JsonMapperConfiguration class encapsulates mapping options. All configuration methods return a new instance; treat instances as immutable value objects.
JsonMapperConfiguration::lenient()– default, tolerant configuration.JsonMapperConfiguration::strict()– enables strict mode (missing and unknown properties raiseMappingException).JsonMapperConfiguration::fromArray(array $data)– rebuilds a configuration from persisted values.JsonMapperConfiguration::fromContext(MappingContext $context)– reconstructs a configuration for an existing mapping run. Useful in a custom strategy, which receives a context. Note it reads the mapper's own options only: a key your own code put in the bag does not survive the trip, so read those from the context directly.
Each with* method toggles a single option and returns a clone:
| Method | Purpose |
|---|---|
withStrictMode(bool $enabled) |
Enable strict validation. |
withErrorCollection(bool $collect) |
Collect errors instead of failing fast. mapWithReport() enables it regardless. |
withEmptyStringAsNull(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. |
withDefaultTimezone(string $timezone) |
Timezone assumed when the date format carries none. Defaults to UTC. |
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.
Date properties may be typed with any
DateTimeInterfaceimplementation —DateTimeImmutable, the mutableDateTime, or your own subclass — and the mapper builds whatever class the property declares.DateIntervalis supported the same way. A property typed by the interface is refused: it cannot be instantiated, and choosing an implementation would decide mutability on your behalf.
A date format that carries no timezone —
Y-m-d H:i:s, say — is read as UTC rather than in the host's timezone, so the same payload yields the same instant on every deployment. UsewithDefaultTimezone()when your zoneless payloads are wall-clock times in a known region. A payload that states its own offset always wins; the default format,ATOM, carries one, so this setting does not affect it.
A field the format does not mention — the time of day under
Y-m-d, say — is reset, not taken from the clock, so the same payload maps to the same instant however often it is mapped.
A timestamp may be an integer or a float. A fraction is kept as microseconds (
1700000000.5→.500000), and a timestamp is an absolute instant, so the host's timezone cannot shift it either.
To preserve unmapped keys instead of skipping (
withIgnoreUnknownProperties) or reporting them (strict mode), mark one property with theUnknownPropertyCollectorattribute — it receives the unknown keys as a rawarray<string, mixed>. See Using mapper attributes.
CamelCasePropertyNameConverter implements PropertyNameConverterInterface and is declared final. Instantiate it when JSON keys use snake case:
<?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);Implement Value\TypeHandlerInterface to plug in custom conversion logic:
<?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.
convert() receives the MappingContext of the value being converted, not of the document. These
are the parts a handler is meant to read:
| Method | Returns |
|---|---|
getPath(): string |
JSON path of the value being converted, e.g. $.orders.0.total. Use it when raising a MappingException so the report points at the right place. |
getRootInput(): mixed |
The payload the run started from. The context has moved on to a nested path by the time a handler sees it, so this is how a handler reaches a sibling or top-level field — a discriminator, a currency stated once for the whole document — without the caller threading one through. |
getOption(string $name, mixed $default = null): mixed |
A value from the option bag, including keys your own code put there. |
getDefaultDateFormat(): string, getDefaultTimezone(): string |
The date settings in force, already sanitised. |
isStrictMode(): bool, shouldIgnoreUnknownProperties(): bool, … |
The configuration in force. JsonMapperConfiguration::fromContext() rebuilds the whole object when you need it. |
Read the root input; do not derive a class name from it. Choosing what to instantiate from payload data is object injection by the shortest route — see the security note under Type converters.