json-mapper/symfony-bundle
Symfony bundle that integrates JSON Mapper into your Symfony application, providing service configuration and dependency injection for mapping JSON data to PHP objects. Simplifies setup so you can configure mappers and use them via the container.
Install and register
composer require json-mapper/symfony-bundle:^2.2.1
Require at least 2.2.1 — see the security note in Gotchas. Without Flex, add it to config/bundles.php manually:
JsonMapper\SymfonyBundle\JsonMapperBundle::class => ['all' => true],
The bundle is thin glue: its only real job is registering the core json-mapper/json-mapper service in the container so you never hand-build a JsonMapperBuilder in application code.
Look here first
vendor/json-mapper/symfony-bundle/src/DependencyInjection/Configuration.php — the source of truth for config keys (the README lags behind; the bundle hasn't shipped a functional change since April 2023).vendor/json-mapper/symfony-bundle/src/DependencyInjection/JsonMapperExtension.php and any compiler pass — tells you which service ids and tags exist.JsonMapperBuilder — that's where 90% of real behaviour (middleware stack) lives.First use case: stop hand-decoding API responses
You get an autowirable JsonMapper\JsonMapperInterface:
final class Address
{
public string $street;
public string $city;
}
final class User
{
public int $id;
public string $name;
public ?string $email = null;
/** @var Address */
public Address $address;
/** @var Address[] */
public array $previousAddresses = [];
}
use JsonMapper\JsonMapperInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class UserApiClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly JsonMapperInterface $mapper,
) {}
public function get(int $id): User
{
$json = $this->httpClient->request('GET', "/users/$id")->getContent();
/** @var User $user */
$user = $this->mapper->map(User::class, $json);
return $user;
}
/** @return User[] */
public function list(): array
{
return $this->mapper->mapMultiple(User::class, $this->httpClient->request('GET', '/users')->getContent());
}
}
This is the day-to-day win: typed DTOs instead of array<string, mixed>, with nested objects and collections populated by reflection.
Configure the middleware stack once, in the builder. Defaults are minimal; most projects immediately want docblock/attribute support and nested-class resolution. The safe, version-proof pattern is to define your own builder service and hand it to the mapper, rather than betting on bundle config keys:
# config/services.yaml
services:
App\Json\JsonMapperBuilderFactory:
factory: ['JsonMapper\JsonMapperBuilder', 'new']
JsonMapper\JsonMapperInterface:
factory: ['@App\Json\JsonMapperBuilderFactory', 'build']
(If you'd rather use the bundle's own service, decorate/replace json_mapper.builder or feed the middleware through the tags below — but verify against the bundle's Configuration.php first.)
Custom middleware via tags. The bundle's compiler pass collects tagged services into the mapper's middleware stack — this is the intended extension point:
services:
App\Json\Middleware\MoneyMiddleware:
tags: ['json_mapper.middleware']
final class MoneyMiddleware implements MiddlewareInterface
{
public function handle(
\stdClass $json,
\JsonMapper\Wrapper\ObjectWrapper $object,
\JsonMapper\ValueObjects\PropertyMap $map,
\JsonMapper\JsonMapperInterface $mapper
): void {
// normalise "12,50 EUR" into a Money value object before property mapping
}
}
Handlers (per-property-type conversion, e.g. DateTimeImmutable, UUIDs, enums) go in the same way using the handler tag. Prefer handlers over middleware for single-type coercions — they're cheaper and easier to test.
Debug middleware in dev only. Enable the library's DebugMiddleware with a Monolog channel in config/packages/dev/ to log every property it writes. It is genuinely useful for "why is this nested object empty?", and genuinely too slow for production.
Keep mapping at the boundary. Do the map() call in your HTTP client / message consumer / controller argument resolver, then pass fully-typed objects inward. Don't scatter map() calls across services — it makes refactoring DTOs painful because you lose the compile-time signal from constructor injection.
Testing. For DTO mapping, unit-test against a builder you construct in the test — no kernel needed, fast:
$mapper = JsonMapperBuilder::new()
->withDocBlockAnnotationsMiddleware()
->withNamespaceResolverMiddleware()
->build();
For the bundle wiring itself, one KernelTestCase asserting JsonMapperInterface is autowirable is enough regression coverage.
Version 2.2.1 is a security-only patch — upgrade, but read what it actually did. The release resolves CVE-2022-24894 (Symfony's HttpCache storing cookie headers), and the bundle's fix came via a dependency constraint rather than changes to its own DI code (PR#8). So the patch does not introduce API changes, new config keys, or breaking behaviour — nothing in this assessment changes because of it. What you should do: bump to ^2.2.1, run composer update --with-dependencies, and then verify the resolved symfony/http-kernel / symfony/http-foundation versions in composer.lock, because a constraint bump only helps if the resolver actually moves. Audit transitive constraints (composer why symfony/http-kernel) rather than assuming the CVE is gone. If you're on the 2.2.0 bundle pinned by an old lockfile, you're exposed and the upgrade is a no-brainer.
No constructor mapping. JsonMapper populates properties by reflection, not constructors. DTOs with required constructor arguments, promoted readonly properties, or final readonly class will fail or be silently partially filled. Write DTOs as plain mutable property bags (public string $id;) and make them final for safety — but not readonly.
Uninitialised typed properties are the #1 error. If the JSON lacks a key for a non-nullable typed property, you get an Error: must not be accessed before initialization far away from the mapping site. Either make everything nullable with defaults, or enable the typed-properties middleware, or add a validating middleware that throws at the boundary. Wrap map() in a try/catch and rethrow as a domain exception — JsonMapper's exceptions are not Symfony HttpExceptions and won't render nicely.
Nested objects need the namespace resolver. /** @var Address */ on a property is meaningless until the namespace-resolver middleware is active and the DTO's namespace is resolvable. If nested objects come back as empty stdClass or null, this is why. Also note that docblock @var only works for array collections if the item type is spelled out (Address[]).
Case conversion is global. Enabling the underscore→camelCase middleware applies to every mapping in the app. If you talk to both a snake_case API and a camelCase one, either keep two mapper services with different builders or use per-property annotations. Don't flip the global setting per request.
Middleware order matters and the mapper is a shared singleton. Builders are immutable and build() returns a configured mapper; registering middleware at runtime on a shared service is a bug waiting to happen. Configure at container build time.
Attributes/docblocks cost reflection. Cache what you can (the library caches property maps internally, but a fresh process per request in PHP-FPM still pays for it). For hot paths with large payloads, benchmark against a hand-written hydrator.
Maintenance risk is real. Zero stars, zero dependents, and the only release since April 2023 is a one-line CVE patch — no features, no bugfixes, no PHP 8.4 support work. Treat the bundle as ~50 lines of DI glue: pin it in composer.lock, and be ready to replace it with four lines of services.yaml (factory on JsonMapperBuilder::new) if it stops being installable. That fallback is worth setting up the moment you hit a version conflict — it's cheaper than fighting the bundle. It also means the security patch is the last thing you'll get from upstream, so plan accordingly.
Don't mix it with Symfony Serializer. #[SerializedName] and JsonMapper's annotations are unrelated; running both on the same DTO produces baffling results. Pick one per boundary.
How can I help you explore Laravel packages today?