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
}
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);
}
}
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
}
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);
}
}
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
){}
}
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;
}
}
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);
}
}
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.
We've learned the importance of decoupling entities of our api outputs so that database schema changes does not transfer problems to our outputs (for instance serializing circular references). In my recently published book, I show the how to serialize api outputs and also how to deserialize request api inputs into an operation request model. You can find the book here to learn more.