I am unable to get subresource write operations (POST
, PUT
) working in API Platform 4.1.
I will walk you through the issues step-by-step. However, to summarize upfront, below are the expected behaviors as well as the actual results:
POST
- Expected: new subresources can be created ad infinitum.
- Actual: only one subresource can be created; additional attempts simply overwrite the original subresource.
PUT
- Expected: subresources can be created at a specific IRI ad infinitum.
- Actual: subresources can only be created at a specific IRI once.
Minimal Example
Assume we have two entities: User
and UserEmail
in a one-to-many relationship, like so:
User.php
#[Groups(['user:read'])]
#[ORMOneToMany(targetEntity: UserEmail::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $emails;
UserEmail.php
#[ApiResource(
uriTemplate: '/users/{userUuid}/emails/{uuid}',
uriVariables: [
'userUuid' => new Link(fromClass: User::class, fromProperty: 'emails'),
'uuid' => new Link(fromClass: UserEmail::class),
],
operations: [
new Get(
normalizationContext: ['groups' => ['userEmail:read']],
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and user.getUuid() == request.attributes.get('userUuid'))",
),
new Put(
normalizationContext: ['groups' => ['userEmail:read']],
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and user.getUuid() == request.attributes.get('userUuid'))",
processor: UserEmailProcessor::class,
allowCreate: true,
),
new Delete(
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and user.getUuid() == request.attributes.get('userUuid'))",
),
]
)]
#[ApiResource(
uriTemplate: '/users/{userUuid}/emails',
uriVariables: [
'userUuid' => new Link(fromClass: User::class, fromProperty: 'emails'),
],
extraProperties: [
'standard_put' => true,
],
operations: [
new GetCollection(
normalizationContext: ['groups' => ['userEmail:read']],
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and user.getUuid() == request.attributes.get('userUuid'))",
),
new Post(
normalizationContext: ['groups' => ['userEmail:read']],
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and user.getUuid() == request.attributes.get('userUuid'))",
processor: UserEmailProcessor::class,
),
]
)]
// @formatter:on
#[ORMEntity(repositoryClass: UserEmailRepository::class)]
#[ORMTable(name: 'user_email')]
#[UniqueEntity(fields: ['emailNumber'], message: 'This email number is already in use.')]
final class UserEmail
{
#[ApiProperty(identifier: false)]
#[ORMId]
#[ORMGeneratedValue(strategy: 'SEQUENCE')]
#[ORMColumn(type: 'integer')]
private int $id;
#[ApiProperty(identifier: true)]
#[AssertUuid(versions: [4], groups: ['userEmail:read', 'userEmail:write'])]
#[Groups(['userEmail:read', 'user:read'])]
#[ORMColumn(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ApiProperty]
#[AssertNotBlank]
#[AssertEmail]
#[Groups(['userEmail:read', 'userEmail:write', 'user:read'])]
#[ORMColumn(type: 'string', length: 20, unique: true)]
private string $email;
#[ApiProperty]
#[Groups(['userEmail:read'])]
#[ORMManyToOne(targetEntity: User::class, inversedBy: 'emails')]
#[ORMJoinColumn(nullable: false)]
private User $user;
public function __construct(?UuidInterface $uuid = null)
{
$this->uuid = $uuid?->toString() ?? Uuid::uuid4()->toString();
}
// ...
}
POST
First, let’s discuss POST
.
POST /users/00000000-0000-0000-0000-000000000001/emails
{
"email": "[email protected]"
}
{
"title": "An error occurred",
"detail": "An exception occurred while executing a query: SQLSTATE[23502]: Not null violation: 7 ERROR: null value in column "user_id" of relation "user_email" violates not-null constraintnDETAIL: Failing row contains (1, 6b63235a-8c25-468c-881f-b4ce80618c56, 2222222, null).",
"status": 500,
"type": "/errors/500"
}
UserEmail::$user
is not being set from the URI as expected.
We can use a state processor to set the UserEmail::$user
field manually. We must attach this state processor to the POST
operation.
Question #1
Is this something we can solve without a state processor?
UserEmail.php
#[ApiResource(
operations: new Post(
processor: UserEmailProcessor::class,
// ...
)
// ...
)]
UserEmailProcessor.php
final readonly class UserEmailProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
private RequestStack $requestStack,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof UserEmail) {
return $data;
}
$userUuid = $uriVariables['userUuid'] ?? null;
$user = $this->userRepository->findOneBy(['uuid' => $userUuid]);
if (!$user) {
throw new NotFoundHttpException();
}
$data->setUser($user);
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}
Now let’s try the same request as before:
POST /users/00000000-0000-0000-0000-000000000001/emails
{
"email": "[email protected]"
}
201 Created
{
"uuid": "988a50a6-f77f-47aa-b5de-eaa5029fb1f2",
"email": "[email protected]",
"user": "/users/00000000-0000-0000-0000-000000000001"
}
It worked! Let’s test it one more time with a different email:
POST /users/00000000-0000-0000-0000-000000000001/emails
{
"email": "[email protected]"
}
201 Created
{
"uuid": "988a50a6-f77f-47aa-b5de-eaa5029fb1f2",
"email": "[email protected]",
"user": "/users/00000000-0000-0000-0000-000000000001"
}
WRONG. We received a 201 Created
response code and the email is correct. However, the UUID is the exact same. The previous resource was updated or recreated. Note that if we try this with a different user’s UUID, it will also work the first time, but follow-up attempts will show the same problem.
Question #2
What is causing this, and how do we resolve it?
PUT
In the past, API Platform’s PUT
operation was notorious for having incorrect behavior, functioning similarly to PATCH
. However, with API Platform 4.0, these issues are purportedly resolved.
Let’s try a PUT
request.
PUT /users/00000000-0000-0000-0000-000000000001/emails/cccccccc-cccc-cccc-cccc-cccccccccccc
{
"email": "[email protected]"
}
{
"uuid": "b797c3fd-2d31-4452-8aaa-b87a0644cc28",
"email": "[email protected]",
"user": "/users/00000000-0000-0000-0000-000000000001"
}
The email was created. However, the UUID is incorrect (and by extension, so is the IRI).
Let’s try it again.
PUT /users/00000000-0000-0000-0000-000000000001/emails/dddddddd-dddd-dddd-dddd-dddddddddddd
{
"email": "[email protected]"
}
{
"uuid": "92bde1cf-d103-406c-895d-9e96393089f6",
"email": "[email protected]",
"user": "/users/00000000-0000-0000-0000-000000000001"
}
The same issue occurred. However, the UUID is different! That means PUT
is behaving how we expected POST
to originally.
We can use a state processor to set the correct UUID. Since we already have a state processor from before, let’s update it as follows:
Question #3
Again—is this something we can solve without a state processor?
UserEmail.php
#[ApiResource(
operations: new Put(
processor: UserEmailProcessor::class,
// ...
)
// ...
)]
UserEmailProcessor.php
// For PUT operations, ensure the UUID from the URI is used
if ($operation instanceof Put) {
$emailUuid = $uriVariables['uuid'] ?? null;
if ($emailUuid && $data->getUuid() !== $emailUuid) {
$data->setUuid($emailUuid);
}
}
Here, we are manually setting the UUID in the state processor. I don’t like having setters for my identifiers, but we’ll ignore that for now. Let’s try a request:
PUT /users/00000000-0000-0000-0000-000000000001/emails/eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee
{
"email": "[email protected]"
}
{
"uuid": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
"email": "[email protected]",
"user": "/users/00000000-0000-0000-0000-000000000001"
}
It worked! This is exactly what we expected to see. Now let’s try replacing that same resource with a different email:
PUT /users/00000000-0000-0000-0000-000000000001/emails/eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee
{
"email": "[email protected]"
}
{
"title": "An error occurred",
"detail": "An exception occurred while executing a query: SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "uniq_a68d6c85d17f50a6"nDETAIL: Key (uuid)=(eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee) already exists.",
"status": 500,
"type": "/errors/500"
}
Yet another problem. PUT
is neither updating the existing database rows, nor deleting and repopulating them. Instead, it is simply trying to create a new row with the specified UUID.
Question #4
Yet again—what is causing this, and how do we resolve it?
Thank you for your help!