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

Super Custom Serialization Fields

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

The Serialization Visitor

Back in the subscriber, create a new variable called $visitor and set it to $event->getVisitor(). The visitor is kind of in charge of the serialization process. And since we know we're serializing to JSON, this will be an instance of JsonSerializationVisitor. Write an inline doc for that and add a use statement up top. That will give us autocompletion:

... lines 1 - 6
use JMS\Serializer\JsonSerializationVisitor;
class LinkSerializationSubscriber implements EventSubscriberInterface
{
public function onPostSerialize(ObjectEvent $event)
{
/** @var JsonSerializationVisitor $visitor */
$visitor = $event->getVisitor();
... line 15
}
... lines 17 - 28
}

Oh, hey, look at this - that class has a method on it called addData(). We can use it to add whatever cool custom fields we want. Add that new uri field, but just set it to the classic FOO value for now:

... lines 1 - 12
/** @var JsonSerializationVisitor $visitor */
$visitor = $event->getVisitor();
$visitor->addData('uri', 'FOO');
... lines 16 - 30

Registering the Subscriber

The last thing we need to do - which you can probably guess - is register this as a service. In services.yml, add the service - how about link_serialization_subscriber. Add the class and skip arguments - we don't have any yet. But we do need a tag so that the JMS Serializer knows about our class. Set the tag name to jms_serializer.event_subscriber:

... lines 1 - 5
services:
... lines 7 - 29
link_serialization_subscriber:
class: AppBundle\Serializer\LinkSerializationSubscriber
tags:
- { name: jms_serializer.event_subscriber }

Ok, try the test! Copy the method name, head to the terminal and run:

./bin/phpunit -c app --filter testGETProgrammer

and then paste in the name. This method name matches a few tests, so we'll see more than just our one test run. Yes, it fails... but in a good way!

FOO does not match /api/programmers/UnitTester.

Above, we do have the new, custom uri field.

Making the URI Dynamic

This means we're almost done. To generate the real URI, we need the router. Add the __construct() method with a RouterInterface argument. I'll use the option+enter shortcut to create that property and set it:

... lines 1 - 7
use Symfony\Component\Routing\RouterInterface;
... lines 9 - 10
class LinkSerializationSubscriber implements EventSubscriberInterface
{
private $router;
public function __construct(RouterInterface $router)
{
$this->router = $router;
}
... lines 19 - 45
}

In onPostSerialize() say $programmer = $event->getObject();. Because of our configuration below, we know this will only be called when the object is a Programmer. Add some inline documentation for the programmer and plug in its use statement:

... lines 1 - 8
use AppBundle\Entity\Programmer;
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 13 - 19
public function onPostSerialize(ObjectEvent $event)
{
/** @var JsonSerializationVisitor $visitor */
$visitor = $event->getVisitor();
/** @var Programmer $programmer */
$programmer = $event->getObject();
... lines 26 - 32
}
... lines 34 - 45
}

Finally, for the data type $this->router->generate() and pass it api_programmers_show and an array containing nickname set to $programmer->getNickname():

... lines 1 - 10
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 13 - 19
public function onPostSerialize(ObjectEvent $event)
{
... lines 22 - 26
$visitor->addData(
'uri',
$this->router->generate('api_programmers_show', [
'nickname' => $programmer->getNickname()
])
);
}
... lines 34 - 45
}

Cool! Now, go back to services.yml and add an arguments key with just @router:

... lines 1 - 5
services:
... lines 7 - 29
link_serialization_subscriber:
class: AppBundle\Serializer\LinkSerializationSubscriber
arguments: ['@router']
tags:
- { name: jms_serializer.event_subscriber }

Ok, moment of truth! Run the test!

./bin/phpunit -c app --filter testGETProgrammer

And... it's failing. Ah, the URL has ?nickname=UnitTester. Woh woh. I bet that's my fault. The name of the route in onPostSerialize() should be api_programmers_show:

... lines 1 - 10
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 13 - 19
public function onPostSerialize(ObjectEvent $event)
{
... lines 22 - 26
$visitor->addData(
'uri',
$this->router->generate('api_programmers_show', [
'nickname' => $programmer->getNickname()
])
);
}
... lines 34 - 45
}

Re-run the test:

./bin/phpunit -c app --filter testGETProgrammer

It's still failing, but for a new reason. This time it doesn't like the app_test.php at the beginning of the link URI. Where's that coming from?

The test class extends an ApiTestCase: we made this in an earlier episode. This app already has a test environment and it configures a test database connection. If we can force every URL through app_test.php, it'll use that test database, and we'll be really happy:

... lines 1 - 20
class ApiTestCase extends KernelTestCase
{
... lines 23 - 46
public static function setUpBeforeClass()
{
... lines 49 - 59
// guaranteeing that /app_test.php is prefixed to all URLs
self::$staticClient->getEmitter()
->on('before', function(BeforeEvent $event) {
$path = $event->getRequest()->getPath();
if (strpos($path, '/api') === 0) {
$event->getRequest()->setPath('/app_test.php'.$path);
}
});
... lines 68 - 69
}
... lines 71 - 295
}

We did a cool thing with Guzzle to accomplish this: automatically prefixing our requests with app_test.php. But because of that, when we generate URLs, they will also have app_test.php. That's a good thing in general, just not when we're comparing URLs in a test.

Copy that path and create a helper function at the bottom of ApiTestCase called protected function adjustUri(). Make this return /app_test.php plus the $uri. This method can help when comparing expected URI's:

... lines 1 - 20
class ApiTestCase extends KernelTestCase
{
... lines 23 - 282
/**
* Call this when you want to compare URLs in a test
*
* (since the returned URL's will have /app_test.php in front)
*
* @param string $uri
* @return string
*/
protected function adjustUri($uri)
{
return '/app_test.php'.$uri;
}
}

Now, in ProgrammerControllerTest, just wrap the expected URI in $this->adjustUri():

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

This isn't a particularly incredible solution, but now we can properly test things. Run the tests again...

./bin/phpunit -c app --filter testGETProgrammer

And... It's green! Awesome!

Method 2: Adding Custom Fields

One last thing! I mentioned that there are two ways to add super-custom fields like uri. Using a serializer subscriber is the first. But sometimes, your API representation will look much different than your entity. Imagine we had some crazy endpoint that returned info about a Programmer mixed with details about their last 3 battles, the last time they fought and the current weather in their hometown.

Can you imagine trying to do this? You'll need multiple @VirtualProperty methods and probably some craziness inside an event subscriber. It might work, but it'll look ugly and be confusing.

In this case, there's a much better way: create a new class with the exact properties you need. Then, instantiate it, populate the object in your controller and serialize it. This class isn't an entity - it's just there to model your API response. I love this approach and recommend it as soon as you're doing more than just a few serialization customizations to a class.

Leave a comment!

11
Login or Register to join the conversation

Hi,
I have just a little question about the LinkSerializer, Imagine you want to add the other field 'uri' in the second entity.
Do you make an other subscriber or do you update the first one to handle the 2 cases?
In the case you decide to manage with the same, is it correct to add a second array in the getSubscribedEvent ?


return [
            [
                'event'  => 'serializer.post_serialize',
                'method' => 'onPostSerialize',
                'format' => 'json',
                'class'  => 'AppBundle\Entity\Programmer',
            ],
            [
                'event'  => 'serializer.post_serialize',
                'method' => 'onPostSerialize',
                'format' => 'json',
                'class'  => 'AppBundle\Entity\OtherEntity',
            ],
        ];

thanks again for all your works

Cheers.

Reply

Hey Gregory!

If you want to do it via a "subscriber" it would be better to create another one, because our LinkSerializer is coupled to the Programmer entity, but you may end up with a lot of subscribers if you keep repeating this pattern, so you might want to go with the other approach that Ryan showed at the end of the video. Creating a model class which maps all the fields you want to return

Cheers!

Reply

Hi Diego,

Thanks for your answer, you're right it is an idea but Ryan shows us an other way in a future video.
Just need to remove the param "class" in the getSubscribedEvents

So next time, I'll waiting to finish all the videos before to ask ;)

Cheers

1 Reply

Awesome, let's keep learning!

Reply
Default user avatar

Hi I have a problem with the eventsubscriber , it seems that it never get fired so when I am running the tests I get the following error:

`Symfony\Component\PropertyAccess\Exception\AccessException: Error reading property "uri" from available keys (nickname, avatarNumber, tagLine, powerLevel)`

Reply

Hey George!

Let's check if your eventsubscriber is working fine, put a dump with wathever value you want inside your method "onPostSerialize()" and lets see if it's printed when you hit the API, if not, there can be 2 things wrong:

1) Something in your service.yml file is wrong (probably the tag field), it should look like this:


services:
    link_serialization_subscriber:
        class: AppBundle\Serializer\LinkSerializationSubscriber
        tags:
            - { name: jms_serializer.event_subscriber }

2) The "getSubscribedEvents" method, it should look like this:


return array(
            array(
                'event' => 'serializer.post_serialize',
                'method' => 'onPostSerialize',
                'format' => 'json',
                'class' => 'AppBundle\Entity\Programmer'
            )
        );

Have a nice day!

Reply
Default user avatar
Default user avatar Jonathan Keen | posted 5 years ago

Just a comment, because I've found this to be REALLY helpful when I'm getting some specific data about my Entities, including aggregate values which cause mixed results. I've been creating Data Transformer Objects (DTO) that I can use within my DQL queries to populate that new DTO with the data. Like you said in the video, the separation helps me a lot in thinking of exactly what I'm exposing to the world.

Within that DTO, we can do all sorts of transformations with the data before serialization, including adding those extra fields like we do with the Serialization Event Subscribers or how you did within the controller.

Can see the documentation on this here:
http://doctrine-orm.readthe...

When my head gets all foggy from thinking about how to arrange those mixed results the way I want them, this helps in getting rid of that issue...

Also, cause I know you're reading this Ryan (because you're a shining example of not only the best teacher, but a fantastic businessman who cares about his customers!), if you have any cautions or comments on what I'm doing, I'd love your feedback!

Reply
Default user avatar

I am trying to understand this method. It seems to me, and please correct me if I am wrong that this is a way to specify a database view (as in CREATE VIEW ... in SQL) using the Doctrine ORM. I have a situation where that is exactly what I need but I want to keep the data in contained classes (which I think of models of the entities) instead of doing a lot of work in the controller. This seems to be exactly what I am looking for. Thank you Jonathan.

Reply
Default user avatar

Alberto, glad to help. Yes, I basically create models based off the entities when I'm using this "NEW" operator. It allows me to create a specific VIEW to use. If you have any questions or want to see any of my implementations of it, let me know!

64 Reply
Default user avatar

I'd love to see your implementation of this. Let me know how you want to share it. Thank you!

Reply

Hi Jonathan!

That "new" operator in DQL is rad - how did I not know about this???!!! And ha, I have no cautions to give you - this looks really cool, and I can't agree more about "removing the foggy" by using these DTO's. Obviously, keep your queries organized in your repositories... but that's all I can say about this.

Thanks for sharing!

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