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' => 'api@renthelp.io',
],
]
)]
#[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, test@test.com) 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... :(