How to create a simple application using Symfony and React Native – Part 2

Stockportfolio part 2

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.

The endpoints are visible in the Swagger documentation!

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.

How to create a simple application using Symfony and React Native - Part 2

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!

8 comments

  1. 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.

  2. very nice!! I’ve been trying to implement something like your request dto, but couldn’t connect all the dots. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *