Symfony API Platform subresource POST/PUT operations are not working as expected

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!