Entity should always be valid

Very often in projects using Doctrine, in entity there are mapping for fields also for each field there are getters and setters. In addition, for each field, we have validation annotations, and forms are validating on the entity. Is this the right approach?

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * Class User
 * @package App\Entity
 *
 * @ORM\Table(name="users")
 * @ORM\Entity(repositoryClass="App\Repository\User\UserRepository")
 * @UniqueEntity(fields={"email"})
 */
class User
{
    /**
     * @var int
     * @ORM\Column(type="integer")
     * @ORM\Id
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=64)
     * @Assert\Length(min="8", max="4096")
     */
    private $password;

    /**
     * @var string
     * @ORM\Column(type="string", length=80, unique=true)
     * @Assert\Email()
     */
    private $email;

    /**
     * User constructor.
     */
    public function __construct()
    {
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    /**
     * @param string $password
     */
    public function setPassword(string $password): void
    {
        $this->password = $password;
    }

    /**
     * @return string
     */
    public function getEmail(): string
    {
        return $this->email;
    }

    /**
     * @param string $email
     */
    public function setEmail(string $email): void
    {
        $this->email = $email;
    }
}
<?php

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class UserType
 * @package App
 */
final class UserType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email', EmailType::class)
            ->add('password', RepeatedType::class, [
                'type' => PasswordType::class
            ])
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => User::class
        ]);
    }

}

The code above shows how look a doctrine entity implementation in the most of projects.

Entity is not data structure

In the example presented object is something like the array. It doesn’t have any behaviors, just common storing, setting and getting values. Maybe to behaviors we can include changing password, so setPassword is fine, but I think that the name changePassword¬†is better than setPassword. We assume that the email is setting only once, during registration process, so setter for email is useless.

Validation

The entity should always be valid for avoiding situations when invalid data are saving in the database. Wherefore data validation should belong to helper object e.g. DTO (Data Transfer Object).

In addition, if we want to use Symfony forms with entities, we have to modify type hints to allow them to accept invalid data in order to validate them. It’s another reason to move validation from the entity to a helper object.

To sum up, validation annotations we keep in another object. We are using forms by assigning as data_class this object, not entity. The entity is creating from this object after its validation, so we are sure that at any moment of the application working it’s valid.

Constructors

Due to that, the entity should always be valid, it shouldn’t be created by the constructor and then filling specific fields by setters. The constructor of the entity should accept all necessary parameters to create a valid object. In Php we don’t have method overloading, so for creating objects in a different way, from different parameters, it’s good to create so-called named constructors i.e. static methods returning the object of a class in which they are contained.

The right implementation

 

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use App\User\Command\RegisterUserCommand;

/**
 * Class User
 * @package App\Entity
 *
 * @ORM\Table(name="users")
 * @ORM\Entity(repositoryClass="App\Repository\User\UserRepository")
 */
class User
{
    /**
     * @var string
     * @ORM\Column(type="string")
     * @ORM\Id
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=64)
     */
    private $password;

    /**
     * @var string
     * @ORM\Column(type="string", length=80, unique=true)
     */
    private $email;

    /**
     * User constructor.
     * @param string $password
     * @param string $email
     */
    public function __construct(string $password, string $email)
    {
        $this->id = Uuid::uuid4();
        $this->password = $password;
        $this->email = $email;
    }

    /**
     * @param RegisterUserCommand $registerUserCommand
     * @return User
     */
    public static function fromRegisterUserCommand(RegisterUserCommand $registerUserCommand): User
    {
        return new self($registerUserCommand->email, $registerUserCommand->password);
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    /**
     * @param string $password
     */
    public function changePassword(string $password): void
    {
        $this->password = $password;
    }

    /**
     * @return string
     */
    public function getEmail(): string
    {
        return $this->email;
    }
}
<?php

namespace App\Form;

use App\Entity\User;
use App\User\Command\RegisterUserCommand;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class UserType
 * @package App
 */
final class UserType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email', EmailType::class)
            ->add('password', RepeatedType::class, [
                'type' => PasswordType::class
            ])
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => RegisterUserCommand::class
        ]);
    }

}
<?php

namespace App\User\Command;

use Symfony\Component\Validator\Constraints as Assert;
use App\Common\Validator\Constraint\UniqueField\UniqueField;

/**
 * Class RegisterUserCommand
 * @package App\User\Command
 */
final class RegisterUserCommand
{
    /**
     * @var string
     *
     * @Assert\Email()
     * @UniqueField(entityClass="App\Entity\User\User", field="email")
     */
    public $email;

    /**
     * @var string
     *
     * @Assert\Length(min="8", max="4096")
     */
    public $password;
}

Now the entity doesn’t contain validation annotations. Instead of auto increment id, there is uuid, what is necessary to make the entity valid at any moment of application working. In the case when we are using auto increment, id, which have to be unique, it’s granting during saving record in the database, so right after creating an object, it’s invalid because doesn’t contain any id. Also to create a very good model, we should also assert that object contains only valid data, so additionally should be checked that the email is valid,¬† the password is of the specific length.

Now in the form we have assigned data_class to RegisterUserCommand. Like it was mentioned earlier, it can be DTO, also in this example we have presented command usage.

Annotations were moved to RegisterUserCommand, but now we can’t use UniqueEntity annotation, so it’s necessary to write custom constraint and validator to assert that the email address is unique.

use Symfony\Component\Validator\Constraint;

/**
 * Class UniqueField
 * @package Gdata\CoreBundle\Validator\Constraint\UniqueField
 *
 * @Annotation
 */
class UniqueField extends Constraint
{
    /**
     * @var string
     */
    public $message = 'This value is already used.';

    /**
     * @var string
     */
    public $entityClass;

    /**
     * @var string
     */
    public $field;

    /**
     * @return string[]
     */
    public function getRequiredOptions(): array
    {
        return ['entityClass', 'field'];
    }

    /**
     * @return string
     */
    public function getTargets(): string
    {
        return self::PROPERTY_CONSTRAINT;
    }

    /**
     * @return string
     */
    public function validatedBy(): string
    {
        return get_class($this).'Validator';
    }
}
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Class UniqueFieldValidator
 * @package Gdata\CoreBundle\Validator\Constraint\UniqueField
 */
class UniqueFieldValidator extends ConstraintValidator
{
    /**
     * @var EntityManagerInterface
     */
    private $entityManager;

    /**
     * UniqueFieldValidator constructor.
     * @param EntityManagerInterface $entityManager
     */
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * @param mixed $value
     * @param Constraint $constraint
     */
    public function validate($value, Constraint $constraint): void
    {
        $entityRepository = $this->entityManager->getRepository($constraint->entityClass);

        if (!is_scalar($constraint->field)) {
            throw new \InvalidArgumentException('"field" parameter should be any scalar type');
        }

        $searchResults = $entityRepository->findBy([
            $constraint->field => $value
        ]);

        if (count($searchResults) > 0) {
            $this->context->buildViolation($constraint->message)
                ->addViolation();
        }
    }
}

 

 

 

Share: