I have a problem related to using Symphony (v5.4) with API Platform based on PHP 8.1.
I have an entity called Customer
which has email
, phoneNumber
fields that are unique per tenantId
(two fields per unique db index).
Part of the Doctrine mapping (XML) re. to these indexes:
<unique-constraints>
<unique-constraint columns="tenant_id,email" name="unique_customer_email"/>
<unique-constraint columns="tenant_id,phone_number" name="unique_customer_phone_number"/>
</unique-constraints>
Also, the entity has UniqueEntity
annotation (use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
) like below
<?php
declare(strict_types=1);
namespace App\Modules\RentalCustomers\Application\Query;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiSubresource;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use App\Shared\Address\Address;
use App\Shared\Email\Email;
use App\Shared\Email\Serializer\EmailNormalizer;
use App\Shared\TaxNumber\TaxNumber;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as AssertPhoneNumber;
use Ramsey\Uuid\UuidInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Context;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
collectionOperations: [
'get' => [
'security_get_denormalize' => "is_granted('CUSTOMER_VIEW', object)",
],
'post' => [
'security_post_denormalize' => "is_granted('CUSTOMER_CREATE', object)",
],
],
itemOperations: [
'get' => [
'normalization_context' => [
'groups' => [
'customer:read',
'with:address',
],
],
'security' => "is_granted('CUSTOMER_VIEW', object)",
],
'put' => [
'normalization_context' => [
'groups' => [
'customers:write',
'with:address',
],
],
'security' => "is_granted('CUSTOMER_UPDATE', object)",
],
],
attributes: [
'validation_groups' => [self::class, 'validationGroups'],
],
denormalizationContext: [
'groups' => [
'customers:write',
'with:address',
],
],
normalizationContext: [
'groups' => [
'customers:read',
'with:address',
],
],
routePrefix: '/rental-customers',
)]
#[UniqueEntity(['tenantId', 'email'], null, null, null, null, null, 'email')]
#[UniqueEntity(['tenantId', 'phoneNumber'], null, null, null, null, null, 'phoneNumber')]
#[ApiFilter(SearchFilter::class, properties: ['lastName' => 'partial', 'email' => 'partial', 'phoneNumber' => 'partial'])]
class Customer
{
#[ApiProperty(writable: false, identifier: true)]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public UuidInterface $id;
#[ApiProperty(writable: true, required: true)]
#[Assert\NotBlank(groups: ['company', 'Default'])]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public ?string $companyName;
#[Assert\NotBlank(groups: ['company', 'Default'])]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
#[ApiProperty(
writable: true,
required: true,
attributes: [
'openapi_context' => [
'type' => 'string',
'example' => '542453244242',
],
]
)]
public ?TaxNumber $companyTaxNumber;
#[ApiProperty(required: true)]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
#[Assert\NotBlank(groups: ['person', 'Default'])]
public string $firstName;
#[ApiProperty(required: true)]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
#[Assert\NotBlank(groups: ['person', 'Default'])]
public string $lastName;
/**
* @AssertPhoneNumber
*/
#[ApiProperty(
attributes: [
'openapi_context' => [
'type' => 'string',
'example' => '+48500600700',
],
]
)]
#[Assert\NotBlank]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public PhoneNumber $phoneNumber;
#[ApiProperty(
required: true,
attributes: [
'openapi_context' => [
'type' => 'string',
'example' => '[email protected]',
],
]
)]
#[Assert\Email, Assert\NotBlank]
#[Context([EmailNormalizer::class])]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public Email $email;
#[Assert\NotNull(groups: ['person', 'Default']), Assert\NotBlank(groups: ['person', 'Default'])]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public string $nationalIdentificationNumber;
#[ApiSubresource]
#[Groups(['customer:read', 'customers:read'])]
public Collection $documents;
#[Assert\Valid]
#[Groups(['customer:read', 'customers:write', 'customers:read'])]
#[ORM\Embedded(Address::class, 'address_')]
public Address $address;
#[ApiProperty(readable: false, writable: false)]
public UuidInterface $tenantId;
#[ApiProperty(writable: false)]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
public DateTimeImmutable $createdAt;
#[Assert\NotBlank]
#[Groups(['customer:read', 'customers:read', 'customers:write'])]
#[ApiProperty(
writable: true,
required: true,
attributes: [
'openapi_context' => [
'type' => 'string',
'example' => 'person or company',
],
]
)]
public CustomerType $type;
public function __construct()
{
$this->documents = new ArrayCollection();
}
public function toArray(): array
{
return [
'email' => $this->email->value(),
'firstName' => $this->firstName,
'lastName' => $this->lastName,
'phoneNumber' => $this->phoneNumber->getRawInput(),
'address' => $this->address->toArray(),
'nationalIdentificationNumber' => $this->nationalIdentificationNumber,
'type' => (string) $this->type,
'companyName' => $this->companyName ?? '',
'companyTaxNumber' => $this->companyTaxNumber !== null ? (string) $this->companyTaxNumber : '',
];
}
public static function validationGroups(self $customer): array
{
if ($customer->type->isCompany()) {
return ['Default', 'company'];
}
return ['Default', 'person'];
}
}
And... Symfony is not catching the constraint violation. It only works when I have an index on a single column, not for more (like here, for two). The endpoint is returning an exception (based on PostgreSQL) instead of 422 status code with a field-related conflict error:
Uncaught PHP Exception Doctrine\DBAL\Exception\UniqueConstraintViolationException: "An exception occurred while executing a query: SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate
key value violates unique constraint "unique_customer_email" DETAIL: Key (tenant_id, email)=(99ca30b3-56e6-4177-87a1-f5bd6e956ea4, [email protected]) already exists."
Any ideas? I was googling this problem a few times in a row, and still I'm here...
Worth adding is that I have objects for thoese fields like: PhoneNumber
, Email
and TenantId
, they are not scalars! BUT, for a single field it works, so I assume that's not the problem.
I was trying to use different order of these fields. I was also trying to specyfiy errorPath
but that's something different... :(
My Answer:
One possible solution to this issue is to create a custom validation constraint that checks for uniqueness based on multiple fields. You can create a custom validator class that implements Symfony's ConstraintValidatorInterface and use it to validate the uniqueness of the email and phoneNumber fields based on the tenantId.
Here's an example of how you can create a custom validator for this scenario:
1. Create a new directory in your project, for example, src/Validator/Constraints.
2. Create a new PHP class for your custom constraint, for example, UniqueCustomer.php:
php
// src/Validator/Constraints/UniqueCustomer.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class UniqueCustomer extends Constraint
{
public $message = 'The combination of "{{ fields }}" is already in use.';
}
3. Create a new PHP class for your custom validator, for example, UniqueCustomerValidator.php:
php
// src/Validator/Constraints/UniqueCustomerValidator.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class UniqueCustomerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof UniqueCustomer) {
throw new UnexpectedTypeException($constraint, UniqueCustomer::class);
}
if (null === $value || '' === $value) {
return;
}
// Add your custom validation logic here to check for uniqueness based on tenantId, email, and phoneNumber
// You can access the entity manager or repository to perform the validation
// If the combination of fields is not unique, add a violation
if (!$isUnique) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ fields }}', 'tenantId, email, phoneNumber')
->addViolation();
}
}
}
4. Register your custom validator as a service in your Symfony configuration:
yaml
# config/services.yaml
services:
App\Validator\Constraints\UniqueCustomerValidator:
tags: ['validator.constraint_validator']
5. Update your Customer entity to use the custom UniqueCustomer constraint:
php
// Customer.php
use App\Validator\Constraints as AppAssert;
#[AppAssert\UniqueCustomer]
class Customer
{
// Your entity properties and methods
}
By creating a custom validator, you can implement the logic to check for uniqueness based on multiple fields and handle the validation accordingly. This approach allows you to have more control over the validation process and handle complex uniqueness constraints in your Symfony application.
Rate this post
3 of 5 based on 6440 votesComments