Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Serialization Event Subscriber

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I think the best part of doing API magic in Symfony is the serializer we've been using. We just give it objects - whether those are entities or something else - and it takes care of turning its properties into JSON. And we have control too: by using the exclusion policy and other annotations like @SerializedName that lets us control the JSON key a property becomes.

When does the Serializer Fail?

Heck, we can even add virtual properties! Just add a function inside your class, add the @VirtualProperty() annotation above it... and bam! You now have another field in your JSON response that's not actually a property on the class. That's great! It handles 100% of what we need! Right... right?

Ah, ok: there's still this last, nasty 1% of use-cases where virtual property won't work. Why? Well, imagine you want to include the URL to the programmer in its JSON representation. To make that URL, you need the router service. But can you access services from within a method in Programmer? No! We're in trouble!

This is usually where I get really mad and say "Never mind, I'm not using the stupid serializer anymore!" Then I stomp off to my bedroom to play video games.

But come on, we can definitely overcome this. In fact, there are two ways. The more interesting is with an event subscriber on the serializer.

Creating a Serializer Event Subscriber

In AppBundle, create a new directory called Serializer and put a fancy new class inside called LinkSerializationSubscriber. Set the namespace to AppBundle\Serializer:

... lines 1 - 2
namespace AppBundle\Serializer;
use JMS\Serializer\EventDispatcher\EventSubscriberInterface;
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 9 - 11
}

To create a subscriber with the JMSSerializer, you need to implement EventSubscriberInterface... and make sure you choose the one from JMS\Serializer. There's also a core interface that, unfortunately, has the exact same name.

In PhpStorm, I'll open the Generate shortcut and select "Implement Methods". This will tell me all the methods that the interface requires. And, it's just one: getSubscribedEvents:

... lines 1 - 6
class LinkSerializationSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
}
}

Stop: here's the goal. Whenever we serialize something, there are a few events we can hook into to customize that process. In this method, we'll tell the serializer exactly which events we want to hook into. One of those will allow us to add a new field... which will be the URL to whatever Programmer is being serialized.

Return an array with another array inside: we'll need a few keys here. The first is event - the event name we need to hook into. There are two for serialization: serializer.pre_serialize and serializer.post_serialize:

... lines 1 - 8
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 11 - 17
public static function getSubscribedEvents()
{
return array(
array(
'event' => 'serializer.post_serialize',
... lines 23 - 25
)
);
}
}

We need the second because it lets you change the data that's being turned into JSON.

Add a method key and set it to onPostSerialize - we'll create that in a second:

... lines 1 - 21
'event' => 'serializer.post_serialize',
'method' => 'onPostSerialize',
... lines 24 - 30

Next, add format set to JSON:

... lines 1 - 21
'event' => 'serializer.post_serialize',
'method' => 'onPostSerialize',
'format' => 'json',
... lines 25 - 30

This means the method will only be called when we're serializing into JSON... which is fine - that's all our API does.

Finally, add a class key set to AppBundle\Entity\Programmer:

... lines 1 - 21
'event' => 'serializer.post_serialize',
'method' => 'onPostSerialize',
'format' => 'json',
'class' => 'AppBundle\Entity\Programmer'
... lines 26 - 30

This says, "Hey! Only call onPostSerialize for Programmer classes!".

Adding a Custom Serialized Field

Setup, done! Create that public function onPostSerialize(). Just like with core Symfony events, you'll be passed an event argument, which in this case is an instance of ObjectEvent:

... lines 1 - 8
class LinkSerializationSubscriber implements EventSubscriberInterface
{
public function onPostSerialize(ObjectEvent $event)
{
... lines 13 - 15
}
... lines 17 - 28
}

Now, we can start messing with the serialization process.

Before we go any further, go back to our test. The goal is for each Programmer to have a new field that is a link to itself. In testGETProgrammer(), add a new assert that checks that we have a uri property that's equal to /api/programmers/UnitTester:

... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 35
public function testGETProgrammer()
{
... lines 38 - 51
$this->asserter()->assertResponsePropertyEquals($response, 'uri', '/api/programmers/UnitTester');
}
... lines 54 - 227
}

Ok, let's see how we can use the fancy subscriber to add this field automatically.

Leave a comment!

11
Login or Register to join the conversation
Skander B. Avatar
Skander B. Avatar Skander B. | posted 3 years ago

Hi,

I'm trying to do the pagination with knpPaginationBundle. The paginated object has also properties like links etc. But when I serialize the objectwith the Symfony serializer, a json is created which only contains the items. I've tried different things, but I don't really understand where the problem is with serializing. By default it works with the jms serializer and the json result is as expected.

My question now is, where exactly does the problem occur and how can I customize the process, so that symfony serialize the object correctly? I would really like to serialize without jms

1 Reply

Hey Skander B.!

Excellent question :). First, when you serialize an object with the Symfony serializer, "basically" the fields that will be included are all the fields that have a getter method. If I remember correctly, with the paginator, you're dealing with a SlidingPagination instance (https://symfonycasts.com/screencast/api-platform) which extends AbstractPagination https://github.com/KnpLabs/knp-components/blob/master/src/Knp/Component/Pager/Pagination/AbstractPagination.php. As you can see - if you search for public function get, you'll find methods for the current page, the current items, total item count and a few others - but not links. My guess is that the fields you ARE seeing are these fields.

To improve this, you have a few options:

1) Create your own class that has all the "getter" methods you want for the fields and manually take the SlidingPagination object and populate your object with its data. Then serializer your object.

2) Create a custom serializer "normalizer". This is basically a class that will take complete control over the normalization process of just the SlidingPagination class and will give you control over including whatever fields you want. For example, here is a "core normalizer" whose job is to make sure that ConstraintViolationList objects (that's what Symfony's validator returns, and it includes validation errors) is normalized into a useful structure: https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php

Cheers!

Reply
Skander B. Avatar
Skander B. Avatar Skander B. | weaverryan | posted 3 years ago | edited

Thank you very much for your quick and detailed answer! :D I also had the idea to write my own normalizer for it.
And you are definitely right, I get a <u>SlidingPagination</u> object. I also suspected that "links" had no public getter. However, I really only get the current items, so by the <u><i>getItems()</i></u> method in <u>AbstractPagination</u> class, right?
All other getters are ignored. This includes for example <i><u>getTotalItemCount()</u></i>. I have noticed that in SlidingPagination the <i><u>getPaginationData()</u></i> method exposes all data that I am missing:

...
`
$viewData = [

        'last' => $pageCount,
        'current' => $current,
        'numItemsPerPage' => $this->numItemsPerPage,
        'first' => 1,
        'pageCount' => $pageCount,
        'totalCount' => $this->totalCount,
    ];

$viewData = array_merge($viewData, $this->paginatorOptions, $this->customParameters);

    if ($current - 1 > 0) {
        $viewData['previous'] = $current - 1;
    }

    if ($current + 1 <= $pageCount) {
        $viewData['next'] = $current + 1;
    }
    $viewData['pagesInRange'] = $pages;
    $viewData['firstPageInRange'] = min($pages);
    $viewData['lastPageInRange']  = max($pages);

    if ($this->getItems() !== null) {
        $viewData['currentItemCount'] = $this->count();
        $viewData['firstItemNumber'] = (($current - 1) * $this->numItemsPerPage) + 1;
        $viewData['lastItemNumber'] = $viewData['firstItemNumber'] + $viewData['currentItemCount'] - 1;
    }

    return $viewData;

`
I just wonder why of all methods only <i><u>getItems()</u></i> is captured by the symfony normalizer and other getters of <u>AbstractPagination</u> are not? Or have I looked at the wrong classes for this? It is generally difficult for me to debug during the serialization process. Do you have any tips?
Just for completion I'm attaching you an example json of a serialized <u>SlidingPagination</u> object:

`[

{
    "id": 2001,
    "name": "Kshlerin PLC",
    "website": "greenholt.biz",
    "phone": "12",
    "mobile": "(537) 548-0008 x879",
    "email": "Lavinia Carter"
},
{
    "id": 2002,
    "name": "Connelly-Gerhold",
    "website": "kreiger.org",
    "phone": "12",
    "mobile": "1-813-685-8416 x71342",
    "email": "Dr. Mekhi Willms II"
},
{
    "id": 2003,
    "name": "White-Corkery",
    "website": "bergstrom.com",
    "phone": "12",
    "mobile": "542.630.5984 x9532",
    "email": "Rogers Roob"
},
{
    "id": 2004,
    "name": "Heaney-Fay",
    "website": "purdy.com",
    "phone": "12",
    "mobile": "1-642-747-8689",
    "email": "Leta Bailey"
},
{
    "id": 2005,
    "name": "Abshire-Mosciski",
    "website": "turcotte.com",
    "phone": "12",
    "mobile": "1-662-999-6986 x832",
    "email": "Dixie Weber"
}

]`

Reply

Hey Skander B.!

Hmm. Indeed, this looks weird to me - I would expect all the getters to be called by default. But, it's even stranger than that. If this follows the normal normalization rules, then the "getItems()" method would cause an items key to be added in the JSON. But in your JSON example, all the results are at the root level, not under an "items" key. So, that's a mystery :).

It makes me wonder if the project already has a custom normalizer for this class. I could totally be wrong.. but I don't see any "core" normalizer for that pagination class that comes with KnpPaginator... so I don't think it's being added by that bundle. Your question on how to debug is an excellent one :). Unfortunately, there isn't a spot on the web debug toolbar/profiler for the serializer... which would be super handy. But, you can at least see what normalizers exist on your project by running:


php bin/console debug:container --tag=serializer.normalizer

I would check there to see if any of those classes might be responsible.

... Wait! I think I just figured it out! AbstractPagination implements Iterator. If there is no normalizer for a specific class that implements Iterator, then the Serializer automatically "loops over" the object and normalizes each item (https://github.com/symfony/symfony/blob/46e94d94255ea785f5b138312e6f664da2ccfbe9/src/Symfony/Component/Serializer/Serializer.php#L159). THAT is what explains the result :).

So, creating a custom normalizer is still a great option... and we can now explain what's going on ;).

Cheers!

Reply
Paweł C. Avatar
Paweł C. Avatar Paweł C. | posted 5 years ago

Hi, Is `@VirtualProperty` outdated? Can't find anything about that in documentation.

1 Reply

Hi Pawel,

AFAIK, it isn't. Probably you don't see it in the docs for bundle, try to look at docs for serializer package itself:
https://jmsyst.com/libs/ser...

Cheers!

4 Reply
Paweł C. Avatar

thanks, (sorry, somehow I forgot it's not about Symfony The Serializer Component, and I searched there)

2 Reply
Tim V. Avatar

Hi,

I don't know if this is the tutorial to ask this, but I have the following question.

I have an Entity JobOffer. This Entity contains an image from the Sonata MediaBundle like this:

Entity JobOffer
...
/**
* @Serializer\Expose()
* @ORM\ManyToOne(targetEntity="Application\Sonata\MediaBundle\Entity\Media", cascade={"all"})
*/
private $image;
...

When I get the response I can see the property like this:

Response from GET /api/joboffer/1
...
["image"]=>
object(stdClass)#6405 (0) {
}
...

Unfortunately I'm not able to get the details like the name or reference of the Image.

What do I have to change to get this information?

Reply

Hey Tim,

Hm, could you tell us to what exactly are you trying to serialize JobOffer? Are you trying to serialize JobOffer as JSON? And your response looks like a var_dump()' output. What code gives you this output? And how did you expose Media entity properties? As I understand this entity comes from third-party bundle, so you can't add annotation to it, right? Do you use some YAML/XML configuration for it?

Cheers!

Reply
Tim V. Avatar
Tim V. Avatar Tim V. | Victor | posted 4 years ago | edited

Hey Victor,

I'm trying to serialize my JobOffer Entity just like Programmer in the tutorial. This is in my showAction in my jobOfferController.php:


    /**
     * @Route("/api/joboffers/{id}", name="api_joboffer_show")
     * @Method("GET")
     */
    public function showAction($id)
    {
        /**
         * @var JobOffer $jobOffer
         */
        $jobOffer = $this->getDoctrine()
            ->getRepository('AppBundle:JobOffer\JobOffer')
            ->findOneById($id);

        if(!$jobOffer)
        {
            throw $this->createNotFoundException('No job Offer found with ID: ' . $id);
        }

        $response = $this->createApiResponse($jobOffer);

        return $response;
    }

The createApiResponse lives in my BaseController.php like in the tutorial:


class BaseController extends Controller
{
    protected function createApiResponse($data, $statusCode = 200)
    {
        $json = $this->serialize($data);

        return new Response($json, $statusCode, [
            'Content-Type' => 'application/json'
        ]);
    }

    protected function serialize($data)
    {
        $context = new SerializationContext();
        $context->setSerializeNull(true);

        $request = $this->get('request_stack')->getCurrentRequest();
        $groups = array("Default");

        if ($request->query->get('deep')){
            $groups[] = 'deep';
        }

        $context->setGroups($groups);

        return $this->container->get('jms_serializer')
            ->serialize($data, 'json', $context);
    }
}

For the image I use the Sonata Media Bundle (I have the same issue with the Sonata Classification Bundle)
And I use a media.yml to configure it (config.yml imports: - { resource: Sonata/media.yml }) sonata_madia: ...
Full configuration:
https://sonata-project.org/bundles/media/3-x/doc/reference/advanced_configuration.html

I hope this makes things more clear.

Reply

Hey Tim V.!

Ok, I might have some info for you, though I have almost zero experience with the Sonata libraries :).

1) SonataMediaBundle itself comes with some serialization config that tells JMSSerializer how to serialize the Media entity: https://github.com/sonata-project/SonataMediaBundle/blob/3.x/src/Resources/config/serializer/Model.Media.xml. That's great news! However, notice that every field is assigned a few groups like "sonata_api_read,sonata_api_write,sonata_search" for the "name" field. When you serialize, I believe you will need to include one of these groups so that these fields setup. I would add a new, optional 2nd $groups argument to the serialize() so that you can pass in a custom group (you'll also need to add a 3rd argument to createApiResponse() with this same argument).

2) But, there is one other thing that is troubling me. You said:

>Response from GET /api/joboffer/1
>...
>["image"]=>
>object(stdClass)#6405 (0) {
>}
>...

The weird part about this is the stdClass object. The image property should be a Media object - not an stdClass. I would double-check (by var_dump($jobOffer)) to make sure that this property is what you expect. I also found it strange that it printed this string "object(stdClass)#6405 (0) {" in ISON. That's not JSON - that's the result of var_dump(). What does the full JSON response look like? I think something else is not quite right...

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of REST and serialization are still valid, but I recommend using API Platform in new Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.3.3",
        "symfony/symfony": "2.6.*", // v2.6.11
        "doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
        "doctrine/dbal": "<2.5", // v2.4.4
        "doctrine/doctrine-bundle": "~1.2", // v1.4.0
        "twig/extensions": "~1.0", // v1.2.0
        "symfony/assetic-bundle": "~2.3", // v2.6.1
        "symfony/swiftmailer-bundle": "~2.3", // v2.3.8
        "symfony/monolog-bundle": "~2.4", // v2.7.1
        "sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
        "sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.0
        "hautelook/alice-bundle": "0.2.*", // 0.2
        "jms/serializer-bundle": "0.13.*", // 0.13.0
        "white-october/pagerfanta-bundle": "^1.0" // v1.2.4
    },
    "require-dev": {
        "sensio/generator-bundle": "~2.3", // v2.5.3
        "behat/behat": "~3.0", // v3.0.15
        "behat/mink-extension": "~2.0.1", // v2.0.1
        "behat/mink-goutte-driver": "~1.1.0", // v1.1.0
        "behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
        "phpunit/phpunit": "~4.6.0" // 4.6.4
    }
}
userVoice