I was looking for a way to serialize domain objects into JSON strings and back again into objects of the original class. This is essential for webservices, especially those built around the JSON-RPC specification that allow clients to make calls to their server and, for example add new personal data to their database of contacts. It makes life a lot easier when this data matches the field names and inner structure of your own domain objects. For example, a JSON string like {"name":"Matthias Noback"} should be easily translatable to an object of class Acme\Webservice\Entity\Person with a private property name. But how do you know which class to instantiate, when you only have the simple JSON string above?

The JSON-RPC specifies under the title "JSON Class hinting" a way you could add a clue about which class would be appropriate (and also which constructor arguments should be used), as part of your JSON object. Following this specification, the example above should look like this:

{"__jsonclass__":["Acme\\WebserviceBundle\\Entity\\Person",[]],"name":"Matthias Noback"}

We have a specification, now we need some tools. Symfony2 already gives us the beautiful Serializer component, which is not widely used inside the framework itself (as far as I can see, nowhere?), but is an essential tool when creating webservices. The Serializer receives a set of Encoder objects and a set of Normalizer objects. Encoders take care of the conversion between simple PHP data types (like null, scalar and array) and an XML or JSON string for example. Normalizers are there to convert more complicated data types (like objects) to the simple ones. Writing a custom normalizer allows you to make this conversion very specific for your own objects. Because we are going to implement the "JSON Class hinting" specification, we will create a JsonClassHintingNormalizer.

Creating the custom Normalizer

First we implement the normalize() method. This method receives an object of (for example) class Acme\WebserviceBundle\Entity\Person. It will eventually return an array containing the data the may be encoded directly into JSON.

namespace Acme\WebserviceBundle\Normalizer;

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class JsonClassHintingNormalizer implements NormalizerInterface
{
    public function normalize($object, $format = null)
    {
        $data = array();

        $reflectionClass = new \ReflectionClass($object);

        $data['__jsonclass__'] = array(
            get_class($object),
            array(), // constructor arguments
        );

        foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
            if (strtolower(substr($reflectionMethod->getName(), 0, 3)) !== 'get') {
                continue;
            }

            if ($reflectionMethod->getNumberOfRequiredParameters() > 0) {
                continue;
            }

            $property = lcfirst(substr($reflectionMethod->getName(), 3));
            $value = $reflectionMethod->invoke($object);

            $data[$property] = $value;
        }

        return $data;
    }
}

As you can see, the $data array will be filled by calling all the original object's getter methods that have no required arguments. The normalize() method also adds a key called "jsonclass" to the array, which contains the class name of the object and an array of constructor arguments (I leave the implementation for this to the reader).

Next, we define the denormalize() method. This method receives data that is decoded from a JSON string into an array.

class JsonClassHintingNormalizer implements NormalizerInterface
{
    ...

    public function denormalize($data, $class, $format = null)
    {
        $class = $data['__jsonclass__'][0];
        $reflectionClass = new \ReflectionClass($class);

        $constructorArguments = $data['__jsonclass__'][1] ?: array();

        $object = $reflectionClass->newInstanceArgs($constructorArguments);

        unset($data['__jsonclass__']);

        foreach ($data as $property => $value) {
            $setter = 'set' . $property;
            if (method_exists($object, $setter)) {
                $object->$setter($value);
            }
        }

        return $object;
    }
}

It looks for the key "jsonclass" and based on it's value, it instantiates the proper class. Then it walks through all the values in the data array and tries to find a corresponding setter method for them.

Finally, the Serializer needs some other methods, so it can ask the JsonClassHintingNormalizer if it supports normalization and/or denormalization for the given object and/or data:

class JsonClassHintingNormalizer implements NormalizerInterface
{
    ...

    public function supportsNormalization($data, $format = null)
    {
        return is_object($data) && 'json' === $format;
    }

    public function supportsDenormalization($data, $type, $format = null)
    {
        return isset($data['__jsonclass__']) && 'json' === $format;
    }
}

This means the normalizer works only for the "json" format and can only denormalize if a "jsonclass" is specified.

Using Serializer with the new Normalizer

All this may be put to work by creating an instance of Serializer and providing it with our new normalizer and a JSON encoder:

use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Acme\WebserviceBundle\Normalizer\JsonClassHintingNormalizer;
use Acme\WebserviceBundle\Entity\Person;

$serializer = new Serializer(array(
    new JsonClassHintingNormalizer(),
), array(
    'json' => new JsonEncoder(),
));

$person = new Person();
$person->setName('Matthias Noback');

$serialized = $serializer->serialize($person, 'json');
echo $serialized;

The result of this will be:

{"__jsonclass__":["Acme\\WebserviceBundle\\Entity\\Person",[]],"name":"Matthias Noback"}

And when we deserialize this string:

$deserialized = $serializer->deserialize($serialized, null, 'json');
var_dump($deserialized);

This is what we will get (and what we really wanted!):

object(Acme\WebserviceBundle\Entity\Person)#619 (1) {
  ["name":"Acme\WebserviceBundle\Entity\Person":private]=>
  string(15) "Matthias Noback"
}

What about recursion?

It is fairly easy to adapt the implementation given above to allow for recursively defined objects. I will demonstrate this in another post, yet to write.

PHP Symfony2 reflection serializer
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Florian Klein

this is incredibly interesting. I try to do the same using jms serilazer. Any clue ?

Matthias Noback

Thanks Florian! The JMSSerializer offers ways to influence object construction (look in the library, there are some interfaces for it). You might also be able to influence the process using serialization event listeners.

Florian Klein

Thanks. I tried with many different ways, (custom visitors, listeners, handlers).

I finally opted out for a type handler. Not very generic, since I know the type internals but it works! see https://github.com/docteurk...

Matthias Noback

Cool, interesting project.

cordoval

for instance there is a guy on the mailing list tryign to do an encoder to just nest the data json into a key like "message": { data here }, something just like that very custom

Matthias Noback

Ah, I see. I answered your question there.

cordoval

when will you write the example for a custom encoder? please :)

Matthias Noback

Of course, which encoder do you mean?

Markus Lanthaler

Interesting post. You might be interested to have a look at JSON-LD - an upcoming W3C standard - and it's @type element. The specs are available at http://json-ld.org/

Matthias Noback

Thanks for your suggestion! JSON-LD seems to be a great addition to the otherwise "untyped" JSON standard. It is more general then the single __jsonclass__ property.

cordoval

When working on an API this is really meaningful.

thanks

cordoval

change this     public function supportsNormalization($data, $format = null)
    {
        return is_object($data) && 'json' === $format;
into

    public function supportsNormalization($object, $format = null)
    {
        return is_object($object) && 'json' === $format;

Matthias Noback

Actually, the signature of the supportsNormalization method should match the one in the interface. Anyway, we don't know yet if $data is an object, it may also be an array, or a scalar value.