Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

User API & the Serializer

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 $12.00

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

Login Subscribe

Most of our pages so far have been normal HTML pages. So let's create a pure API endpoint that returns JSON data about the currently-authenticated user. This might be an endpoint that we call from our own JavaScript... or maybe you're creating an API for someone else to consume. More on that later.

Let's create a new controller for this called UserController... and make it extend our BaseController class:

... lines 1 - 2
namespace App\Controller;
... lines 4 - 7
class UserController extends BaseController
{
... lines 10 - 17
}

Inside, add a method called apiMe(). Give this an @Route() - autocomplete the one from the Symfony Component - and set the URL to /api/me:

... lines 1 - 5
use Symfony\Component\Routing\Annotation\Route;
class UserController extends BaseController
{
/**
* @Route("/api/me")
... line 12
*/
public function apiMe()
{
... line 16
}
}

This isn't a very restful endpoint, but it's often a convenient one to have. To require authentication to use this endpoint, add @IsGranted("IS_AUTHENTICATED_REMEMBERED"):

... lines 1 - 4
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
... lines 6 - 7
class UserController extends BaseController
{
/**
... line 11
* @IsGranted("IS_AUTHENTICATED_REMEMBERED")
*/
public function apiMe()
{
... line 16
}
}

I'm using a mixture of annotations and PHP code to deny access in this project. Choose whichever one you like better for your app. Inside the method, we can just say: return $this->json() and pass it the current user: $this->getUser():

... lines 1 - 7
class UserController extends BaseController
{
/**
* @Route("/api/me")
* @IsGranted("IS_AUTHENTICATED_REMEMBERED")
*/
public function apiMe()
{
return $this->json($this->getUser());
}
}

That's beautiful! Let's try it. We are logged in right now... so we can go to /api/me and see... absolutely nothing! Just empty braces!

By default, when you call $this->json(), it passes the data to Symfony's JsonResponse class. And then that class calls PHP's json_encode() function on our User object. In PHP, unless you do extra work, when you pass an object to json_encode(), all it does is include the public properties. Since our User class doesn't have any public properties:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 15 - 19
private $id;
... lines 21 - 24
private $email;
... lines 26 - 29
private $roles = [];
... lines 31 - 34
private $firstName;
... lines 36 - 39
private $password;
private $plainPassword;
... lines 43 - 168
}

We get a boring response back.

Leveraging the Serializer

This... isn't good enough. So instead, let's leverage Symfony's serializer component. To get it installed, at your terminal, run:

composer require "serializer:1.0.4"

This installs the serializer pack, which includes Symfony's Serializer component as well as a few other libraries that help it work in a really smart way. But it doesn't have a recipe that does anything fancy: it just installs code.

One of the cool things about using $this->json() is that as soon as the Symfony serializer is installed, it will automatically start using it to serialize the data instead of the normal json_encode(). In other words, when we refresh the endpoint, it works!

Adding Serialization Groups

We're not going to talk too much about how the Symfony serializer works - we talk a lot about it in our API Platform tutorials. But let's at least get some basics.

By default, the serializer will serialize any public property or any property that has a "getter" on it. Heck, it will even serialize displayName - which is not a real property - because there is a getDisplayName() method.

In reality... this is too much info to include in the endpoint. So let's take more control. We can do this by telling the serializer to only serialize fields that are in a specific group. Pass 200 for the status code, an empty headers array - both of which are the default values - so that we can get to the fourth $context argument:

... lines 1 - 7
class UserController extends BaseController
{
... lines 10 - 13
public function apiMe()
{
return $this->json($this->getUser(), 200, [], [
... line 17
]);
}
}

This is sort of like "options" that you pass to the serializer. Pass one called groups set to an array. I'm going to invent a group called user:read... because we're "reading" from "user":

... lines 1 - 7
class UserController extends BaseController
{
... lines 10 - 13
public function apiMe()
{
return $this->json($this->getUser(), 200, [], [
'groups' => ['user:read']
]);
}
}

Copy that group name. Now, inside the User entity, we need to add this group to every field that we want to include in the API. For example, let's include id. Above the property, add an annotation or PHP attribute: @Groups(). Make sure you auto-complete the one from Symfony's serializer to get the use statement on top. Inside, I'll paste user:read:

... lines 1 - 8
use Symfony\Component\Serializer\Annotation\Groups;
... lines 10 - 13
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
... lines 17 - 19
* @Groups("user:read")
*/
private $id;
... lines 23 - 175
}

Copy that and... let's expose email, we don't want to expose roles, yes to firstName and... that's it:

... lines 1 - 13
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
... lines 17 - 19
* @Groups("user:read")
*/
private $id;
/**
... line 25
* @Groups("user:read")
*/
private $email;
... lines 29 - 34
/**
... line 36
* @Groups("user:read")
*/
private $firstName;
... lines 40 - 175
}

We could also put the group above getDisplayName() if we wanted to include that... or getAvatarUri()... actually I will add it there:

... lines 1 - 13
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 16 - 159
/**
* @Groups("user:read")
*/
public function getAvatarUri(int $size = 32): string
{
... lines 165 - 169
}
... lines 171 - 175
}

Let's try it! Refresh and... super cool! We have those 4 fields!

And notice one thing: even though this is an "API endpoint"... and our API endpoint requires us to be logged in, we can totally access this... even though we don't have a fancy API token authentication system. We have access thanks to our normal session cookie.

So that leads us to our next question: if you have API endpoints like this, do you need an API token authentication system or not? Let's tackle that topic next.

Leave a comment!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}