In part one of this series we’ve setup the API for this project using Symfony and API Platform. In part two we’ll make this API more functional by creating a user entity and allowing users to sign up. The complete application for part 2 can be found in my github repository.
The user entity
First things first, to be able to work with users in our application we’ll need to create the user entity. Let’s start by creating a User.php
file in the src/Entity
directory.
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
const ROLE_ADMIN = 'ROLE_ADMIN';
const ROLE_USER = 'ROLE_USER';
#[ORM\Id()]
#[ORM\Column(type: Types::GUID, unique: true)]
#[ORM\GeneratedValue(strategy: 'UUID')]
private string $id;
#[ORM\Column(type: Types::STRING, length: 1024, nullable: false)]
private string $email;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
private string $password;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
private string $firstName;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
private string $lastName;
#[ORM\Column(type: Types::STRING, length: 20, nullable: false)]
private string $role;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $active;
/**
* User constructor
*/
public function __construct()
{
$this->role = self::ROLE_USER;
$this->active = false;
}
// ... Getters and setters removed for brevity
/**
* @return array
*/
public function getRoles(): array
{
return [$this->role];
}
/**
* @return string|null
*/
public function getSalt(): ?string
{
return null;
}
/**
*
*/
public function eraseCredentials(): void
{
}
/**
* @return string
*/
public function getUsername(): string
{
return $this->email;
}
/**
* @return string
*/
public function getUserIdentifier(): string
{
return $this->getUsername();
}
}
Code language: PHP (php)
This is a very basic User
entity with just a first name and a last name. We can extend this later on with more properties when needed. Let’s create our repository class next by adding a file called UserRepository.php
in the src/Repository
folder.
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @method User|null find($id, $lockMode = null, $lockVersion = null)
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserRepository extends ServiceEntityRepository
{
/**
* UserRepository constructor.
*
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
}
Code language: PHP (php)
This is a very basic repository which can be injected using Dependency Injection and exposes the basic Doctrine repository functions: find
, findOneBy
, findAll
and findBy
.
Because the stof/doctrine-extensions-bundle:v1.6.0
uses gedmo/doctrine-extensions:v3.1.0
which doesn’t use the PHP 8 attributes yet we’ll create the Timestampable
and the SoftDeletable
traits for our entities ourselves. I’ll put these traits in an Infrastructure
folder. Let’s create the following files src/Infrastructure/Doctrine/Trait/SoftDeletableTrait.php
and src/Infrastructure/Doctrine/Trait/TimestampableTrait.php
.
namespace App\Infrastructure\Doctrine\Trait;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
trait SoftDeletableTrait
{
#[ORM\Column(type: "datetime", nullable: true)]
protected ?DateTime $deletedAt;
// Getter and Setter removed for brevity
/**
* @return bool
*/
public function isDeleted(): bool
{
return null !== $this->deletedAt;
}
}
Code language: PHP (php)
namespace App\Infrastructure\Doctrine\Trait;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
trait TimestampableTrait
{
/**
* @Gedmo\Timestampable(on="create")
*/
#[ORM\Column(type: 'datetime')]
protected DateTime $createdAt;
/**
* @Gedmo\Timestampable(on="update")
*/
#[ORM\Column(type: 'datetime')]
protected DateTime $updatedAt;
// Getters and setters removed for brevity
}
Code language: PHP (php)
Now let’s use these traits in our User
entity to make the entity Timestampable
and SoftDeletable
.
namespace App\Entity;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
use TimestampableTrait, SoftDeletableTrait;
// Rest of the properties and methods are the same as above
}
Code language: PHP (php)
Now that we’ve got the User
entity all figured out, let’s update the database by making a new migration and executing that migration by running the following commands.
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
Code language: Bash (bash)
When executed a new file will be created in the migrations
folder. The file will contain all SQL queries necessary to update the database. If you execute these commands and you get a “No mapping information to process” error, you need to check the config/packages/doctrine.yaml
file and make sure the mapping type is set to attribute
like this:
mappings:
App:
is_bundle: false
type: attribute # Needs to be set to attribute for PHP 8 attributes to work
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Code language: YAML (yaml)
The user endpoints
Now that we’ve got our User
entity we can create API endpoints for it by adding the APIResource
attribute to our entity class.
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ApiResource(
collectionOperations: [
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
],
itemOperations: [
'get' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'put' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'delete' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
// The rest of the User Entity is the same as above
}
Code language: PHP (php)
Now lets run our application to see what API Platform does with this attribute. Run composer run dev
to start our development server and go to http://localhost:8000/api/docs
in your browser. You should see something like this.
What you see is the OpenAPI or Swagger documentation generated by the API Platform. In the documentation you can see API Platform generated POST
, GET
, PUT
and DELETE
endpoints for us based on the attribute we’ve added to our entity.
Because we’ve added security constraints to these endpoints only users with a the role ROLE_ADMIN
are able to use these endpoints. The GET
and PUT
endpoints are also usable by the user itself. This is done by checking the JWT token sent via the Authorization
header when you make an request.
When you check the documentation page you might have noticed that all properties of the entity are exposed by default. Of course this is not what we want with for example the password
property. So let’s fix this!
Serialization
API Platform leverages the Serializer component of Symfony to generate the output of the endpoints. So we can make use of the attributes this component uses, like the Groups
attribute. API Platform needs to know which groups to use in which context, so we need to configure this. Then we can add the Groups
attribute to all properties we want to exposes or be able to change via API calls.
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
// Operations removed for brevity, they are the same as above
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id()]
#[ORM\Column(type: Types::GUID, unique: true)]
#[ORM\GeneratedValue(strategy: 'UUID')]
#[Groups(['user:read'])]
private string $id;
#[ORM\Column(type: Types::STRING, length: 1024, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $email;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
private string $password;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $firstName;
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $lastName;
#[ORM\Column(type: Types::STRING, length: 20, nullable: false)]
private string $role;
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['user:read'])]
private bool $active;
}
Code language: PHP (php)
We’ve added a denormalizationContext
to the ApiResource
attribute to control which properties can be written to. The normalizationContext
controls which properties will be returned by the API. These contexts are sent to the serializer so the serializer knows which properties to (de)serialize.
To control the properties we’ve added the Groups
attribute of the serializer which contains a list of serializer groups a property should be used in.
In this example the GET
endpoint will return the properties id
, email
, firstName
, lastName
and active
. The email
, firstName
and lastName
properties are also editable via the endpoints.
The register endpoint
The register endpoint will be a custom endpoint, because we need to implement some custom logic which isn’t handled by the API Platform.
Let’s first create a Data Transfer Object (DTO) to store the necessary properties in to create a new user. We’ll call this file RegisterUserRequest
and we’ll store it in the folder src/Dto/Request/User
.
namespace App\Dto\Request\User;
use Symfony\Component\Validator\Constraints as Assert;
class RegisterUserRequest
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $firstName;
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $lastName;
#[Assert\NotBlank]
#[Assert\Email]
#[Assert\Length(max: 1024)]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password;
}
Code language: PHP (php)
We just need a few properties to create the user, so this DTO can be simple. We’ve also added some constraints for the Symfony Validator component which we’ll use later on in this article.
Next let’s create a service to process this DTO and create the actual User
entity. We’ll name this service UserService
and create it in src/Application/Service
and add a function register
.
namespace App\Application\Service;
use App\Dto\Request\User\RegisterUserRequest;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
private UserPasswordHasherInterface $userPasswordHasher
){}
public function exists(string $email): bool
{
return null !== $this->userRepository->findOneBy(['email' => $email]);
}
public function register(RegisterUserRequest $request): User
{
$user = new User();
$user->setEmail($request->email);
$user->setPassword($this->userPasswordHasher->hashPassword($user, $request->password));
$user->setFirstName($request->firstName);
$user->setLastName($request->lastName);
$user->setRole(User::ROLE_USER);
$user->setActive(false);
return $user;
}
}
Code language: PHP (php)
This service will contain methods to perform operations on the User
entity. Operations like activating and account, resetting the password of a user will all be added to this service later on.
Prepare our application for custom DTO’s
In order to create our custom endpoint with custom DTO’s we need to add this functionality to our application. I’ve covered this in another article about custom DTO’s in API Platform, so I won’t go into detail about how this works.
We need to add an event subscriber which will take our request and turn the data into our DTO. Let’s create a file called DtoSerializerEventSubscriber.php
in src/Infrastructure/Api/Event/Subscriber
.
namespace App\Infrastructure\Api\Event\Subscriber;
use ApiPlatform\Core\EventListener\EventPriorities;
use ApiPlatform\Core\Validator\ValidatorInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Serializes the request data to the given DTO
*/
final class DtoSerializerEventSubscriber implements EventSubscriberInterface
{
/**
* @param SerializerInterface $serializer
* @param ValidatorInterface $validator
*/
public function __construct(private SerializerInterface $serializer, private ValidatorInterface $validator)
{}
/**
* @param RequestEvent $event
*/
public function serializeToDto(RequestEvent $event): void
{
$request = $event->getRequest();
if (false === $request->attributes->has('dto')
|| false === in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH], true)
) {
return;
}
$dto = $this->serializer->deserialize($request->getContent(), $request->attributes->get('dto'), 'json');
$this->validator->validate($dto);
$request->attributes->set('dto', $dto);
}
/**
* @return array[]
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['serializeToDto', EventPriorities::PRE_READ],
];
}
}
Code language: PHP (php)
This will make sure the request data gets serialized into our DTO and validated for use in our UserService
.
Add the endpoint
Because we need a custom endpoint we’ll need to create a custom controller to handle the request. Lets create a file called Register.php
in a folder called src/Controller/User
.
namespace App\Controller\User;
use App\Application\Service\UserService;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
/**
* Handles the registration of a new user
*/
class Register extends AbstractController
{
/**
* @param UserService $userService
*/
public function __construct(private UserService $userService) {}
/**
* @param Request $request
*
* @return User
*/
public function __invoke(Request $request): User
{
return $this->userService->register($request->attributes->get('dto'));
}
}
Code language: PHP (php)
This controller calls our UserService::register
method with the validated DTO we’ve created earlier.
Next up tell API Platform about our newly created endpoint by editing the ApiResource
attribute of our User
entity!
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Controller\User\Register;
use App\Dto\Request\User\RegisterUserRequest;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
collectionOperations: [
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'register' => [
'method' => 'POST',
'status' => 204,
'path' => '/public/users/register',
'controller' => Register::class,
'defaults' => [
'dto' => RegisterUserRequest::class,
],
'openapi_context' => [
'summary' => 'Registers a new user',
'description' => 'Registers a new user',
'requestBody' => [
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'firstName' => [
'type' => 'string',
'example' => 'John',
],
'lastName' => [
'type' => 'string',
'example' => 'Doe',
],
'email' => [
'type' => 'string',
'example' => 'johndoe@example.com',
],
'password' => [
'type' => 'string',
'example' => 'apassword',
],
],
],
],
]
],
'responses' => [
204 => [
'description' => 'The user is registered',
]
]
],
'read' => false,
'deserialize' => false,
'validate' => false,
'write' => false,
],
],
itemOperations: [
'get' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'put' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'delete' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
],
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
// The rest of the User Entity is the same as above
}
Code language: PHP (php)
We’ve added the register
entry to the collectionOperations
part of the ApiResource
attribute. We’ve set the method to POST
and the path to /public/users/register
. This path will be appended to the base path we’ve set for our api in the router configuration in part 1. So the full path for this endpoint would be /api/public/users/register
.
We also specify the controller
we want to handle the requests to /api/public/users/register
. And because it’s a custom endpoint which uses a custom DTO we’ll also specify which DTO we want to use.
The openapi_context
key is used to tell API Platform what to render in the documentation.
The API Platform events read
, deserialize
, validate
and write
are disabled because we handle those actions ourselves in the event subscriber and UserService
we’ve created earlier.
Now let’s check your API documentation by going to http://localhost:8000/api/docs to see if our register
endpoint shows up.
If everything went according to plan you should see something like this. A newly added endpoint with nice documentation ready to be used.
Now we need to make sure this endpoint is public. You might have noticed the /public/
prefix in the url of the endpoint. I did this because it makes adding public endpoints a lot easier. We just need to allow ^/api/public
endpoints for anonymous users by editing the src/config/packages/security.yaml
file.
# Rest of the security.yaml content
access_control:
- { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/public, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/docs, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api, roles: ROLE_USER }
Code language: YAML (yaml)
This entry makes sure the clients won’t need to send a JWT token to use the /api/public
endpoints.
In the next part we’ll be handling user authentication and we’ll be sending emails! Stay tuned!
Awesome. I’m enjoying this series. Learnt a lot.
I also was inspired by your implementation of Request Data to DTO conversion. I’ve been trying to achieve something like that but I just couldn’t put the pieces together.
Great to hear my content is useful!
Very nice – looking forward to the react native part!
Thanks! I’m looking forward to that part as well 😀
very nice!! I’ve been trying to implement something like your request dto, but couldn’t connect all the dots. Thanks!
I’m glad you find my article useful!
Great series indeed!
Thanks for the nice words!