2

I am unable to get subresource POST operations working as expected in API Platform 4.1 with subresources; top-level resources work fine.

Expected: new subresources can be created as needed. Actual: only one subresource can be created; additional attempts simply overwrite the original subresource.

Minimal example

I have entities User and UserEmail in a one-to-many relationship:

User.php:

#[Groups(['user:read'])]
#[ORM\OneToMany(targetEntity: UserEmail::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $emails;

UserEmail.php:

#[ApiResource(
    uriTemplate: '/users/{userUuid}/emails',
    uriVariables: [
        'userUuid' => new Link(fromClass: User::class, fromProperty: 'emails'),
    ],
    operations: [
        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,
        ),
    ]
)]

#[ORM\Entity(repositoryClass: UserEmailRepository::class)]
#[ORM\Table(name: 'user_email')]
#[UniqueEntity(fields: ['email'], message: 'This email is already in use.')]
final class UserEmail
{
    #[ApiProperty(identifier: false)]
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'SEQUENCE')]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ApiProperty(identifier: true)]
    #[Assert\Uuid(versions: [4], groups: ['userEmail:read', 'userEmail:write'])]
    #[Groups(['userEmail:read', 'user:read'])]
    #[ORM\Column(type: 'string', length: 36, unique: true)]
    private string $uuid;

    #[ApiProperty]
    #[Assert\NotBlank]
    #[Assert\Regex(pattern: '/^\d+$/')]
    #[Groups(['userEmail:read', 'userEmail:write', 'user:read'])]
    #[ORM\Column(type: 'string', length: 20, unique: true)]
    private string $email;

    #[ApiProperty]
    #[Groups(['userEmail:read'])]
    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'emails')]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    public function __construct(?UuidInterface $uuid = null)
    {
        $this->uuid = $uuid?->toString() ?? Uuid::uuid4()->toString();
    }

    // ...
}

The problem

Request:

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 constraint\nDETAIL:  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. However, I can use a state processor to set the UserEmail::$user field manually. I must attach this state processor to the POST operation.

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 try the same request:

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! 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. I 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. If I try this with a different user's UUID it will work the first time, but follow-up attempts show the same problem.

2
  • 2
    The UUID is tagged as the identifier with #[ApiProperty(identifier: true)] Commented Sep 28 at 17:52
  • If you change the operations block to operations: [new Post(),] (i.e. removing normalizationContext, security and processor), what happens ? Commented Oct 6 at 5:39

1 Answer 1

1

I've run into this exact issue before and it drove me crazy for a while. Here's what's actually happening and how to fix it properly.

What's causing this mess

So the problem is that API Platform isn't creating new entities like you'd expect - it's actually grabbing an existing one and updating it every time you POST. Here's what's going on under the hood:

When you hit /users/{userUuid}/emails, API Platform looks at your Link configuration and tries to figure out which UserEmail entity you're working with. But since you're POSTing to a collection endpoint (no specific email ID in the URL), it gets confused and ends up pulling an existing entity from that user's email collection instead of giving
you a fresh one.

Your custom processor is manually persisting and flushing, but by the time your processor runs, API Platform has
already handed you an existing entity instance. So you're just modifying the same record over and over - that's why
you keep seeing the same UUID.

The right way to fix it --->

Don't try to handle the persistence yourself. Instead, let API Platform's default Doctrine processor do its job and
just decorate it to inject the user relationship. Here's what I mean:

final readonly class UserEmailProcessor implements ProcessorInterface
  {
      public function __construct(
          private ProcessorInterface $persistProcessor,
          private UserRepository $userRepository,
      ) {}

      public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed    
      {
          if ($data instanceof UserEmail && $operation instanceof Post) {
              $userUuid = $uriVariables['userUuid'] ?? null;
              $user = $this->userRepository->findOneBy(['uuid' => $userUuid]);

              if (!$user) {
                  throw new NotFoundHttpException();
              }

              $data->setUser($user);
          }

          // Let the default processor handle the actual persistence
          return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
      }
  }

Then in your services.yaml:

App\State\UserEmailProcessor: decorates: 'api_platform.doctrine.orm.state.persist_processor' arguments: $persistProcessor: '@.inner'

This way you're working with API Platform's flow instead of fighting against it.

What about PUT and other operations?

If you want to do PUT requests on Subresources, you need to include the specific email identifier in your URI template. The difference is important:

  • POST creates new stuff → /users/{userUuid}/emails (no email ID)
  • PUT updates existing stuff → /users/{userUuid}/emails/{emailUuid} (includes email ID)

Your ApiResource config would look like:

#[ApiResource(
      uriTemplate: '/users/{userUuid}/emails/{emailUuid}',
      uriVariables: [
          'userUuid' => new Link(fromClass: User::class, fromProperty: 'emails'),
          'emailUuid' => new Link(fromClass: UserEmail::class),
      ],
      operations: [new Put(/* ... */)]
  )]

That second Link tells API Platform exactly which email entity to load for updating.

The main thing to remember is that POST operations shouldn't have the subresource ID in the URL (you're creating it,
after all), but PUT/PATCH/DELETE need it so they know what to modify or remove.

Hope this helps - the API Platform subresource docs are pretty sparse on this stuff unfortunately.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.