Skip to content

Instantly share code, notes, and snippets.

@sanmai
Last active October 20, 2025 11:41
Show Gist options
  • Save sanmai/db0db14ef49faa5a941ba3b0a0ddb866 to your computer and use it in GitHub Desktop.
Save sanmai/db0db14ef49faa5a941ba3b0a0ddb866 to your computer and use it in GitHub Desktop.
composer.lock
vendor

JMS Serializer ScalarOrObject Handler

scalar_or<My\Type>
class MyDto
{
    /**
     * @JMS\Type("scalar_or_object<App\Dto\SomeObject>")
     */
    public bool|SomeObject $value;
}

A custom JMS type handler for bool|SomeObject union.


✅ Step-by-step (Minimal + Lawful)

1. Create the handler

namespace App\Serializer\Handler;

use JMS\Serializer\Context;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\JsonDeserializationVisitor;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\VisitorInterface;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;

final class BoolOrObjectHandler implements SubscribingHandlerInterface
{
    public static function getSubscribingMethods(): array
    {
        return [
            [
                'type' => 'bool_or_object<App\\Dto\\SomeObject>',
                'format' => 'json',
                'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
                'method' => 'deserialize',
            ],
            [
                'type' => 'bool_or_object<App\\Dto\\SomeObject>',
                'format' => 'json',
                'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
                'method' => 'serialize',
            ],
        ];
    }

    public function deserialize(
        JsonDeserializationVisitor $visitor,
        $data,
        array $type,
        DeserializationContext $context
    ) {
        if (is_bool($data)) {
            return $data;
        }

        return $context->getNavigator()->accept($data, [
            'name' => 'App\\Dto\\SomeObject'
        ], $context);
    }

    public function serialize(
        JsonSerializationVisitor $visitor,
        $data,
        array $type,
        SerializationContext $context
    ) {
        if (is_bool($data)) {
            return $data;
        }

        return $context->getNavigator()->accept($data, [
            'name' => 'App\\Dto\\SomeObject'
        ], $context);
    }
}

2. Register the handler

use App\Serializer\Handler\BoolOrObjectHandler;
use JMS\Serializer\SerializerBuilder;

$serializer = SerializerBuilder::create()
    ->configureHandlers(function($handlerRegistry) {
        $handlerRegistry->registerSubscribingHandler(new BoolOrObjectHandler());
    })
    ->build();

3. Use it in DTO

use JMS\Serializer\Annotation as JMS;

class MyDto
{
    /**
     * @JMS\Type("bool_or_object<App\Dto\SomeObject>")
     */
    public bool|SomeObject $value;
}
{
"name": "sanmai/jms-scalar-or-object-handler",
"description": "JMS Serializer custom handler for scalar|object union types",
"type": "library",
"license": "Apache-2.0",
"require": {
"php": "^8.2",
"jms/serializer": "^3.30"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"ScalarObjectHandler\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"ScalarObjectHandler\\Tests\\": "tests/"
}
}
}
<?xml version="1.0" encoding="UTF-8"?><phpunit bootstrap="vendor/autoload.php"><testsuites><testsuite name="default"><directory>tests</directory></testsuite></testsuites></phpunit>
<?php
declare(strict_types=1);
namespace ScalarObjectHandler;
use JMS\Serializer\Context;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\JsonDeserializationVisitor;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;
/**
* Handles union types "scalar|object<T>" for JMS Serializer.
*
* Supported scalars: int, float, string, bool.
*/
final class ScalarOrObjectHandler implements SubscribingHandlerInterface
{
private const TYPE_NAME = 'scalar_or_object';
private const ALLOWED_SCALARS = ['integer', 'double', 'string', 'boolean'];
public static function getSubscribingMethods(): array
{
$formats = ['json', 'xml', 'yml'];
$methods = [];
foreach ($formats as $format) {
$methods[] = [
'type' => self::TYPE_NAME,
'format' => $format,
'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
'method' => 'deserialize',
];
$methods[] = [
'type' => self::TYPE_NAME,
'format' => $format,
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
'method' => 'serialize',
];
}
return $methods;
}
/** @param mixed $data */
public function deserialize(
JsonDeserializationVisitor $visitor,
$data,
array $type,
DeserializationContext $context
) {
if (\is_scalar($data)) {
$kind = \gettype($data);
if (!\in_array($kind, self::ALLOWED_SCALARS, true)) {
throw new \UnexpectedValueException(sprintf('Unsupported scalar type "%s".', $kind));
}
return $data;
}
if (\is_array($data)) {
// Expect generic parameter T
if (empty($type['params'][0]['name'] ?? null)) {
throw new \RuntimeException(self::TYPE_NAME . '<T> requires a generic type parameter.');
}
$targetType = $type['params'][0]; // ['name' => 'App\Dto\Foo', ...]
return $context->getNavigator()->accept($data, $targetType, $context);
}
throw new \UnexpectedValueException(sprintf(
'Unsupported data type "%s" for %s.',
\get_debug_type($data),
self::TYPE_NAME
));
}
/** @param mixed $data */
public function serialize(
JsonSerializationVisitor $visitor,
$data,
array $type,
SerializationContext $context
) {
if (\is_scalar($data)) {
return $data;
}
if (empty($type['params'][0]['name'] ?? null)) {
throw new \RuntimeException(self::TYPE_NAME . '<T> requires a generic type parameter.');
}
$targetType = $type['params'][0];
return $context->getNavigator()->accept($data, $targetType, $context);
}
}
<?php
declare(strict_types=1);
namespace ScalarObjectHandler\Tests;
use JMS\Serializer\Annotation\Type;
use JMS\Serializer\SerializerBuilder;
use PHPUnit\Framework\TestCase;
use ScalarObjectHandler\ScalarOrObjectHandler;
final class ScalarOrObjectHandlerTest extends TestCase
{
private function buildSerializer(): \JMS\Serializer\Serializer
{
return SerializerBuilder::create()
->configureHandlers(function ($registry) {
$registry->registerSubscribingHandler(new ScalarOrObjectHandler());
})
->build();
}
public function testDeserializeScalarInt(): void
{
$serializer = $this->buildSerializer();
$json = '{"value": 123}';
$dto = $serializer->deserialize($json, Holder::class, 'json');
self::assertSame(123, $dto->value);
}
public function testDeserializeObject(): void
{
$serializer = $this->buildSerializer();
$json = '{"value":{"bar":"baz"}}';
$dto = $serializer->deserialize($json, Holder::class, 'json');
self::assertInstanceOf(Foo::class, $dto->value);
self::assertSame('baz', $dto->value->bar);
}
}
class Holder
{
#[Type("scalar_or_object<ScalarObjectHandler\\Tests\\Foo>")]
public $value;
}
class Foo
{
public string $bar;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment