DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

Serializing api outputs with Symfony serializer

I've seen api's where, after getting results from a doctrine query, the resulted entities are serialized and returned as the http response.

Following that way could generate some problems because you are coupling your database schema to your views and you would return information client should not see. Let's see an example

Let's imagine we have an entity like this:

  #[ORM\Entity(repositoryClass: UserRepository::class)]
  class User {

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $name;

    #[ORM\Column(length: 255)]
    private string $lastName;

    #[ORM\Column]
    private \DateTimeImmutable $birthDate;

    // getters & setters
}
Enter fullscreen mode Exit fullscreen mode

And now, let's imagine we query UserRepository and return it to client

   class UserController extends AbstractController {

      #[Route('/users', name: 'get_users', methods: ['GET'])]
      public function getUsersAction(EntityManagerInterface $em, SerializerInterface $serializer): JsonResponse
      {
          $users = $em->getRepository(UserRepository::class)->findAll();
          return new JsonResponse($serializer->normalize($users), 200);
      }

   }
Enter fullscreen mode Exit fullscreen mode

Following this way we would be exposing all user properties even those that we do not want to expose.

To fix this, we can relie on two ways:

1.- Adding serializer groups to entity properties
2.- Using a service which builds an output

As an example, let's consider we don't want to expose id property.

Adding serializer groups to entity properties

In this case, we only have to add serializer groups to entity properties we want to expose. Then, when serializing results we must indicate serializer group in order to serialize only properties which match that group

   #[ORM\Entity(repositoryClass: UserRepository::class)]
   class User {

      #[ORM\Id]
      #[ORM\GeneratedValue]
      #[ORM\Column]
      private ?int $id = null;

      #[ORM\Column(length: 255)]
      #[Groups(['api_output'])]
      private string $name;

      #[ORM\Column(length: 255)]
      #[Groups(['api_output'])]
      private string $lastName;

      #[ORM\Column]
      #[Groups(['api_output'])]
      private \DateTimeImmutable $birthDate;

      // getters & setters
  }
Enter fullscreen mode Exit fullscreen mode

We've added api_output group to name, lastName and birthDate properties. Let's serialize now telling serializer which group to use.

   class UserController extends AbstractController {

      #[Route('/users', name: 'get_users', methods: ['GET'])]
      public function getUsersAction(EntityManagerInterface $em, SerializerInterface $serializer): JsonResponse
      {
          $users = $em->getRepository(UserRepository::class)->findAll();

          $context = (new ObjectNormalizerContextBuilder())->withGroups('api_output')->toArray();
          return new JsonResponse($serializer->normalize($users, null, $context), 200);
      }

   }
Enter fullscreen mode Exit fullscreen mode

I don't really like that approach since you are still coupling your database schema to your outputs. If your schema starts growing and have associations, managing groups can get complicated and it would be easy to get circular reference errors.

So, Let's see the second approach

Using a service which builds an output

This last approach consist in creating a separate service which will be in charge of building the output. To do this, we will need:

  • A separate model (UserOutput) which will act as an output model
  • A service which will receive an array of users and will return an array or UserOutput

Let's see it:

   class UserOutput {

       public function __construct(
          public readonly string $name,
          public readonly string $lastName,
          public readonly string $birthDate
       ){}
   }
Enter fullscreen mode Exit fullscreen mode
   class UserOutputBuilder
   {
       /**
        * @param User[] $users
        * @return UserOutput[]
        */
       public function buildOutput(array $users): array
       {
           $targetUsers = [];
           foreach ($users as $user){
              $targetUsers[] = new UserOutput(
                 $user->getName(),
                 $user->getLastname(),
                 $user->getBirthDate()->format('d/m/Y')
              );
           }

           return $targetUsers;
       }
   }
Enter fullscreen mode Exit fullscreen mode

Now we only have to delegate responsibility of building the output on UserOutputBuilder.

   class UserController extends AbstractController {

       #[Route('/users', name: 'get_users', methods: ['GET'])]
       public function getUsersAction(EntityManagerInterface $em, UserOutputBuilder $userOutputBuilder, SerializerInterface $serializer): JsonResponse
       {
           $users = $em->getRepository(UserRepository::class)->findAll();
           return new JsonResponse($serializer->normalize($userOutputBuilder->buildOutput($users)), 200);
       }

  }
Enter fullscreen mode Exit fullscreen mode

With this approach, any change of our schema will not affect to our output since there is a builder which builds the output from doctrine results and it uses a separate model. If our schema change and we would want to expose more properties, we will have to add it to the output model and make the changes needed on the builder.

Top comments (0)