From 28c4ab197cb338037d2841bb5ff7b1d55962b9dc Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Tue, 14 Jan 2025 15:37:20 +0100
Subject: [PATCH 1/9] feat(state): add headers to comply with LDP specification

To ensure compliance with the LDP specification (https://www.w3.org/TR/ldp/):
- Added the "Accept-Post" header with the value "text/turtle, application/ld+json".
- Added the "Allow" header with values based on the allowed operations on the queried resources.
---
 features/main/ldp_resources.feature           |  11 ++
 src/State/Processor/RespondProcessor.php      |  22 +++
 .../Tests/Processor/RespondProcessorTest.php  | 165 ++++++++++++++++++
 .../Resources/config/state/processor.xml      |   1 +
 .../DummyGetPostDeleteOperation.php           |  28 +++
 .../Document/DummyGetPostDeleteOperation.php  |  52 ++++++
 .../Entity/DummyGetPostDeleteOperation.php    |  57 ++++++
 7 files changed, 336 insertions(+)
 create mode 100644 features/main/ldp_resources.feature
 create mode 100644 src/State/Tests/Processor/RespondProcessorTest.php
 create mode 100644 tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
 create mode 100644 tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
 create mode 100644 tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php

diff --git a/features/main/ldp_resources.feature b/features/main/ldp_resources.feature
new file mode 100644
index 00000000000..3ea65ea8eeb
--- /dev/null
+++ b/features/main/ldp_resources.feature
@@ -0,0 +1,11 @@
+Feature: LDP Resources
+  In order to use an API compliant with the LDP specification (https://www.w3.org/TR/ldp/)
+  As a client software developer
+  I must have some specific headers returned by the API
+
+  @createSchema
+  Scenario: Test Accept-Post and Allow headers for a LDP resource
+    When I add "Content-Type" header equal to "application/ld+json"
+    And I send a "GET" request to "/dummy_get_post_delete_operations"
+    Then the header "Accept-Post" should be equal to "text/turtle, application/ld+json"
+    And the header "Allow" should be equal to "OPTIONS, HEAD, GET, POST, DELETE"
diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 58941e437c3..1c910bf2ee0 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -22,6 +22,7 @@
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
 use ApiPlatform\Metadata\Put;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
 use ApiPlatform\Metadata\ResourceClassResolverInterface;
 use ApiPlatform\Metadata\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -45,10 +46,13 @@ final class RespondProcessor implements ProcessorInterface
         'DELETE' => Response::HTTP_NO_CONTENT,
     ];
 
+    private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
+
     public function __construct(
         private ?IriConverterInterface $iriConverter = null,
         private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
         private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
+        private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
     ) {
     }
 
@@ -88,6 +92,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers['Accept-Patch'] = $acceptPatch;
         }
 
+        $headers['Accept-Post'] = 'text/turtle, application/ld+json';
+        $headers['Allow'] = $this->getAllowedMethods($context['resource_class'] ?? null);
+
         $method = $request->getMethod();
         $originalData = $context['original_data'] ?? null;
 
@@ -150,4 +157,19 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers
         );
     }
+
+    private function getAllowedMethods(?string $resourceClass): string
+    {
+        $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
+        if (null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
+            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass);
+            foreach ($resourceMetadataCollection as $resource) {
+                foreach ($resource->getOperations() as $operation) {
+                    $allowedMethods[] = $operation->getMethod();
+                }
+            }
+        }
+
+        return implode(', ', array_unique($allowedMethods));
+    }
 }
diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/RespondProcessorTest.php
new file mode 100644
index 00000000000..ecda76f95ff
--- /dev/null
+++ b/src/State/Tests/Processor/RespondProcessorTest.php
@@ -0,0 +1,165 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\State\Tests\Processor;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\Put;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
+use ApiPlatform\Metadata\ResourceClassResolverInterface;
+use ApiPlatform\State\Processor\RespondProcessor;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class RespondProcessorTest extends TestCase
+{
+    private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory;
+    private RespondProcessor $processor;
+
+    protected function setUp(): void
+    {
+        $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
+        $resourceClassResolver
+            ->method('isResourceClass')
+            ->willReturn(true);
+
+        $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+
+        $this->processor = new RespondProcessor(
+            null,
+            $resourceClassResolver,
+            null,
+            $this->resourceMetadataCollectionFactory
+        );
+    }
+
+    public function testHeadersAcceptPostIsSetCorrectly(): void
+    {
+        $this->resourceMetadataCollectionFactory
+            ->method('create')
+            ->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
+
+        $operation = new HttpOperation('GET');
+        $context = [
+            'resource_class' => 'SomeResourceClass',
+            'request' => $this->createGetRequest(),
+        ];
+
+        /** @var Response $response */
+        $response = $this->processor->process(null, $operation, [], $context);
+
+        $this->assertSame('text/turtle, application/ld+json', $response->headers->get('Accept-Post'));
+    }
+
+    public function testHeaderAllowHasHeadOptionsByDefault(): void
+    {
+        $this->resourceMetadataCollectionFactory
+            ->method('create')
+            ->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
+
+        $operation = new HttpOperation('GET');
+        $context = [
+            'resource_class' => 'SomeResourceClass',
+            'request' => $this->createGetRequest(),
+        ];
+
+        /** @var Response $response */
+        $response = $this->processor->process(null, $operation, [], $context);
+
+        $this->assertSame('OPTIONS, HEAD', $response->headers->get('Allow'));
+    }
+
+    public function testHeaderAllowReflectsResourceAllowedMethods(): void
+    {
+        $this->resourceMetadataCollectionFactory
+            ->method('create')
+            ->willReturn(
+                new ResourceMetadataCollection('DummyResource', [
+                    new ApiResource(operations: [
+                        'get' => new Get(name: 'get'),
+                        'post' => new Post(name: 'post'),
+                        'delete' => new Delete(name: 'delete'),
+                    ]),
+                ])
+            );
+
+        $operation = new HttpOperation('GET');
+        $context = [
+            'resource_class' => 'SomeResourceClass',
+            'request' => $this->createGetRequest(),
+        ];
+
+        /** @var Response $response */
+        $response = $this->processor->process(null, $operation, [], $context);
+
+        $allowHeader = $response->headers->get('Allow');
+        $this->assertStringContainsString('OPTIONS', $allowHeader);
+        $this->assertStringContainsString('HEAD', $allowHeader);
+        $this->assertStringContainsString('GET', $allowHeader);
+        $this->assertStringContainsString('POST', $allowHeader);
+        $this->assertStringContainsString('DELETE', $allowHeader);
+        $this->assertStringNotContainsString('PATCH', $allowHeader);
+        $this->assertStringNotContainsString('PUT', $allowHeader);
+    }
+
+    public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void
+    {
+        $this->resourceMetadataCollectionFactory
+            ->method('create')
+            ->willReturn(
+                new ResourceMetadataCollection('DummyResource', [
+                    new ApiResource(operations: [
+                        'get' => new Get(name: 'get'),
+                        'patch' => new Patch(name: 'patch'),
+                        'put' => new Put(name: 'put'),
+                    ]),
+                ])
+            );
+
+        $operation = new HttpOperation('GET');
+        $context = [
+            'resource_class' => 'SomeResourceClass',
+            'request' => $this->createGetRequest(),
+        ];
+
+        /** @var Response $response */
+        $response = $this->processor->process(null, $operation, [], $context);
+
+        $allowHeader = $response->headers->get('Allow');
+        $this->assertStringContainsString('OPTIONS', $allowHeader);
+        $this->assertStringContainsString('HEAD', $allowHeader);
+        $this->assertStringContainsString('GET', $allowHeader);
+        $this->assertStringContainsString('PATCH', $allowHeader);
+        $this->assertStringContainsString('PUT', $allowHeader);
+        $this->assertStringNotContainsString('POST', $allowHeader);
+        $this->assertStringNotContainsString('DELETE', $allowHeader);
+    }
+
+    private function createGetRequest(): Request
+    {
+        $request = new Request();
+        $request->setMethod('GET');
+        $request->setRequestFormat('json');
+        $request->headers->set('Accept', 'application/ld+json');
+
+        return $request;
+    }
+}
diff --git a/src/Symfony/Bundle/Resources/config/state/processor.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml
index 627d3742957..e5dd2792e5f 100644
--- a/src/Symfony/Bundle/Resources/config/state/processor.xml
+++ b/src/Symfony/Bundle/Resources/config/state/processor.xml
@@ -21,6 +21,7 @@
             <argument type="service" id="api_platform.iri_converter" />
             <argument type="service" id="api_platform.resource_class_resolver" />
             <argument type="service" id="api_platform.metadata.operation.metadata_factory" />
+            <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
         </service>
 
         <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
new file mode 100644
index 00000000000..954bcb29217
--- /dev/null
+++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
@@ -0,0 +1,28 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Post;
+
+#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
+class DummyGetPostDeleteOperation
+{
+    public ?int $id;
+
+    public ?string $name = null;
+}
diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
new file mode 100644
index 00000000000..5ac27a1c728
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
@@ -0,0 +1,52 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Post;
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
+#[ODM\Document]
+class DummyGetPostDeleteOperation
+{
+    #[ODM\Id(strategy: 'INCREMENT', type: 'int')]
+    private ?int $id;
+
+    #[ODM\Field(type: 'string')]
+    private ?string $name = null;
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): void
+    {
+        $this->id = $id;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): void
+    {
+        $this->name = $name;
+    }
+}
diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
new file mode 100644
index 00000000000..bf86bebaacd
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
@@ -0,0 +1,57 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Post;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
+#[ORM\Entity]
+class DummyGetPostDeleteOperation
+{
+    /**
+     * @var int|null The id
+     */
+    #[ORM\Column(type: 'integer')]
+    #[ORM\Id]
+    #[ORM\GeneratedValue(strategy: 'AUTO')]
+    private ?int $id = null;
+
+    #[ORM\Column]
+    private string $name;
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): void
+    {
+        $this->id = $id;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): void
+    {
+        $this->name = $name;
+    }
+}

From eeaf80f32be84a3aeec5a15c66150e3dce193682 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Tue, 14 Jan 2025 16:22:56 +0100
Subject: [PATCH 2/9] fix: handle case injected services are null

---
 src/State/Processor/RespondProcessor.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 1c910bf2ee0..4d7e10f3611 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -161,7 +161,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
     private function getAllowedMethods(?string $resourceClass): string
     {
         $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-        if (null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
+        if (null !== $resourceClass && null !== $this->resourceClassResolver && null !== $this->resourceCollectionMetadataFactory && $this->resourceClassResolver->isResourceClass($resourceClass)) {
             $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass);
             foreach ($resourceMetadataCollection as $resource) {
                 foreach ($resource->getOperations() as $operation) {

From 8e798186a57501ea8adcd5fec4a76f48bbfdde44 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Tue, 14 Jan 2025 16:44:16 +0100
Subject: [PATCH 3/9] fix: handle case injected services are null

---
 src/State/Processor/RespondProcessor.php | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 4d7e10f3611..3e92c81bda3 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -23,6 +23,7 @@
 use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
 use ApiPlatform\Metadata\Put;
 use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
 use ApiPlatform\Metadata\ResourceClassResolverInterface;
 use ApiPlatform\Metadata\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -161,8 +162,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
     private function getAllowedMethods(?string $resourceClass): string
     {
         $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-        if (null !== $resourceClass && null !== $this->resourceClassResolver && null !== $this->resourceCollectionMetadataFactory && $this->resourceClassResolver->isResourceClass($resourceClass)) {
-            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass);
+        if (null !== $resourceClass && null !== $this->resourceClassResolver && $this->resourceClassResolver->isResourceClass($resourceClass)) {
+            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
             foreach ($resourceMetadataCollection as $resource) {
                 foreach ($resource->getOperations() as $operation) {
                     $allowedMethods[] = $operation->getMethod();

From aa82e5b95670af564ebeb37a150d077cbf124ab3 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Wed, 15 Jan 2025 18:31:26 +0100
Subject: [PATCH 4/9] fix: added header "allow-post" only on resources with
 POST and header "allow" has now the right method.

---
 features/main/ldp_resources.feature           |  11 --
 src/State/Processor/RespondProcessor.php      |  21 ++-
 .../Tests/Processor/RespondProcessorTest.php  |  78 ++++-------
 .../Entity/DummyGetPostDeleteOperation.php    |   3 -
 .../Functional/State/RespondProcessorTest.php | 125 ++++++++++++++++++
 5 files changed, 165 insertions(+), 73 deletions(-)
 delete mode 100644 features/main/ldp_resources.feature
 create mode 100644 tests/Functional/State/RespondProcessorTest.php

diff --git a/features/main/ldp_resources.feature b/features/main/ldp_resources.feature
deleted file mode 100644
index 3ea65ea8eeb..00000000000
--- a/features/main/ldp_resources.feature
+++ /dev/null
@@ -1,11 +0,0 @@
-Feature: LDP Resources
-  In order to use an API compliant with the LDP specification (https://www.w3.org/TR/ldp/)
-  As a client software developer
-  I must have some specific headers returned by the API
-
-  @createSchema
-  Scenario: Test Accept-Post and Allow headers for a LDP resource
-    When I add "Content-Type" header equal to "application/ld+json"
-    And I send a "GET" request to "/dummy_get_post_delete_operations"
-    Then the header "Accept-Post" should be equal to "text/turtle, application/ld+json"
-    And the header "Allow" should be equal to "OPTIONS, HEAD, GET, POST, DELETE"
diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 3e92c81bda3..bfc63a17556 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -93,8 +93,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers['Accept-Patch'] = $acceptPatch;
         }
 
-        $headers['Accept-Post'] = 'text/turtle, application/ld+json';
-        $headers['Allow'] = $this->getAllowedMethods($context['resource_class'] ?? null);
+        $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate());
+        $headers['Allow'] = implode(', ', $allowedMethods);
+
+        $outputFormats = $operation->getOutputFormats();
+        if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) {
+            $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
+        }
 
         $method = $request->getMethod();
         $originalData = $context['original_data'] ?? null;
@@ -159,18 +164,20 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
         );
     }
 
-    private function getAllowedMethods(?string $resourceClass): string
+    private function getAllowedMethods(?string $resourceClass, ?string $currentUriTemplate): array
     {
         $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-        if (null !== $resourceClass && null !== $this->resourceClassResolver && $this->resourceClassResolver->isResourceClass($resourceClass)) {
-            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
+        if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
+            $resourceMetadataCollection =$this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
             foreach ($resourceMetadataCollection as $resource) {
                 foreach ($resource->getOperations() as $operation) {
-                    $allowedMethods[] = $operation->getMethod();
+                    if ($operation->getUriTemplate() === $currentUriTemplate) {
+                        $allowedMethods[] = $operation->getMethod();
+                    }
                 }
             }
         }
 
-        return implode(', ', array_unique($allowedMethods));
+        return array_unique($allowedMethods);
     }
 }
diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/RespondProcessorTest.php
index ecda76f95ff..b4b92c898a2 100644
--- a/src/State/Tests/Processor/RespondProcessorTest.php
+++ b/src/State/Tests/Processor/RespondProcessorTest.php
@@ -16,8 +16,8 @@
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\Delete;
 use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\Metadata\HttpOperation;
-use ApiPlatform\Metadata\Patch;
 use ApiPlatform\Metadata\Post;
 use ApiPlatform\Metadata\Put;
 use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -42,6 +42,19 @@ protected function setUp(): void
             ->willReturn(true);
 
         $this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+        $this->resourceMetadataCollectionFactory
+            ->method('create')
+            ->willReturn(
+                new ResourceMetadataCollection('DummyResource', [
+                    new ApiResource(operations: [
+                        new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'),
+                        new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'),
+                        new Post(uriTemplate: '/dummy_resources{._format}', name: 'post'),
+                        new Delete(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'delete'),
+                        new Put(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'put'),
+                    ]),
+                ])
+            );
 
         $this->processor = new RespondProcessor(
             null,
@@ -51,57 +64,36 @@ protected function setUp(): void
         );
     }
 
-    public function testHeadersAcceptPostIsSetCorrectly(): void
+    public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
     {
-        $this->resourceMetadataCollectionFactory
-            ->method('create')
-            ->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
-
-        $operation = new HttpOperation('GET');
+        $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json']]));
         $context = [
-            'resource_class' => 'SomeResourceClass',
+            'resource_class' => 'DummyResource',
             'request' => $this->createGetRequest(),
         ];
 
         /** @var Response $response */
         $response = $this->processor->process(null, $operation, [], $context);
-
-        $this->assertSame('text/turtle, application/ld+json', $response->headers->get('Accept-Post'));
+        $this->assertSame('application/ld+json, application/json', $response->headers->get('Accept-Post'));
     }
 
-    public function testHeaderAllowHasHeadOptionsByDefault(): void
+    public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
     {
-        $this->resourceMetadataCollectionFactory
-            ->method('create')
-            ->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
-
-        $operation = new HttpOperation('GET');
+        $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
         $context = [
-            'resource_class' => 'SomeResourceClass',
+            'resource_class' => 'DummyResource',
             'request' => $this->createGetRequest(),
         ];
 
         /** @var Response $response */
         $response = $this->processor->process(null, $operation, [], $context);
 
-        $this->assertSame('OPTIONS, HEAD', $response->headers->get('Allow'));
+        $this->assertNull($response->headers->get('Accept-Post'));
     }
 
     public function testHeaderAllowReflectsResourceAllowedMethods(): void
     {
-        $this->resourceMetadataCollectionFactory
-            ->method('create')
-            ->willReturn(
-                new ResourceMetadataCollection('DummyResource', [
-                    new ApiResource(operations: [
-                        'get' => new Get(name: 'get'),
-                        'post' => new Post(name: 'post'),
-                        'delete' => new Delete(name: 'delete'),
-                    ]),
-                ])
-            );
-
-        $operation = new HttpOperation('GET');
+        $operation = (new HttpOperation('GET', '/dummy_resources{._format}'));
         $context = [
             'resource_class' => 'SomeResourceClass',
             'request' => $this->createGetRequest(),
@@ -115,30 +107,13 @@ public function testHeaderAllowReflectsResourceAllowedMethods(): void
         $this->assertStringContainsString('HEAD', $allowHeader);
         $this->assertStringContainsString('GET', $allowHeader);
         $this->assertStringContainsString('POST', $allowHeader);
-        $this->assertStringContainsString('DELETE', $allowHeader);
-        $this->assertStringNotContainsString('PATCH', $allowHeader);
-        $this->assertStringNotContainsString('PUT', $allowHeader);
-    }
-
-    public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void
-    {
-        $this->resourceMetadataCollectionFactory
-            ->method('create')
-            ->willReturn(
-                new ResourceMetadataCollection('DummyResource', [
-                    new ApiResource(operations: [
-                        'get' => new Get(name: 'get'),
-                        'patch' => new Patch(name: 'patch'),
-                        'put' => new Put(name: 'put'),
-                    ]),
-                ])
-            );
+        $this->assertStringNotContainsString('DELETE', $allowHeader);
 
-        $operation = new HttpOperation('GET');
         $context = [
             'resource_class' => 'SomeResourceClass',
             'request' => $this->createGetRequest(),
         ];
+        $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
 
         /** @var Response $response */
         $response = $this->processor->process(null, $operation, [], $context);
@@ -147,10 +122,9 @@ public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void
         $this->assertStringContainsString('OPTIONS', $allowHeader);
         $this->assertStringContainsString('HEAD', $allowHeader);
         $this->assertStringContainsString('GET', $allowHeader);
-        $this->assertStringContainsString('PATCH', $allowHeader);
         $this->assertStringContainsString('PUT', $allowHeader);
+        $this->assertStringContainsString('DELETE', $allowHeader);
         $this->assertStringNotContainsString('POST', $allowHeader);
-        $this->assertStringNotContainsString('DELETE', $allowHeader);
     }
 
     private function createGetRequest(): Request
diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
index bf86bebaacd..ad5e370e450 100644
--- a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
+++ b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
@@ -24,9 +24,6 @@
 #[ORM\Entity]
 class DummyGetPostDeleteOperation
 {
-    /**
-     * @var int|null The id
-     */
     #[ORM\Column(type: 'integer')]
     #[ORM\Id]
     #[ORM\GeneratedValue(strategy: 'AUTO')]
diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/State/RespondProcessorTest.php
new file mode 100644
index 00000000000..787b938238f
--- /dev/null
+++ b/tests/Functional/State/RespondProcessorTest.php
@@ -0,0 +1,125 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Tests\Functional\State;
+
+use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
+use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGetPostDeleteOperation;
+use ApiPlatform\Tests\RecreateSchemaTrait;
+use ApiPlatform\Tests\SetupClassResourcesTrait;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Tools\SchemaTool;
+
+class RespondProcessorTest extends ApiTestCase
+{
+    use RecreateSchemaTrait;
+    use SetupClassResourcesTrait;
+
+    /**
+     * @return class-string[]
+     */
+    public static function getResources(): array
+    {
+        return [DummyGetPostDeleteOperation::class];
+    }
+
+    public function testAllowHeadersForSingleResourceWithGetDelete(): void
+    {
+        $client = static::createClient();
+        $client->request('GET', '/dummy_get_post_delete_operations/1', [
+            'headers' => [
+                'Content-Type' => 'application/ld+json',
+            ],
+        ]);
+
+        $this->assertResponseHeaderSame('Allow', 'OPTIONS, HEAD, GET, DELETE');
+    }
+
+    public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void
+    {
+        $client = static::createClient();
+        $client->request('GET', '/dummy_get_post_delete_operations', [
+            'headers' => [
+                'Content-Type' => 'application/ld+json',
+            ],
+        ]);
+
+        $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST');
+    }
+
+    public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): void
+    {
+        $client = static::createClient();
+        $client->request('GET', '/dummy_get_post_delete_operations', [
+            'headers' => [
+                'Content-Type' => 'application/ld+json',
+            ],
+        ]);
+
+        $this->assertResponseHeaderSame('accept-post', 'application/ld+json, application/hal+json, application/vnd.api+json, application/xml, text/xml, application/json, text/html, application/graphql, multipart/form-data');
+    }
+
+    public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void
+    {
+        $client = static::createClient();
+        $client->request('OPTIONS', '/dummy_get_post_delete_operations/1', [
+            'headers' => [
+                'Content-Type' => 'application/ld+json',
+            ],
+        ]);
+
+        $this->assertResponseNotHasHeader('accept-post');
+    }
+
+    protected function setUp(): void
+    {
+        self::bootKernel();
+
+        $container = static::getContainer();
+        $registry = $container->get('doctrine');
+        $manager = $registry->getManager();
+        if (!$manager instanceof EntityManagerInterface) {
+            return;
+        }
+
+        $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)];
+
+        try {
+            $schemaTool = new SchemaTool($manager);
+            @$schemaTool->createSchema($classes);
+        } catch (\Exception $e) {
+            return;
+        }
+
+        $dummy = new DummyGetPostDeleteOperation();
+        $dummy->setName('Dummy');
+        $manager->persist($dummy);
+        $manager->flush();
+    }
+
+    protected function tearDown(): void
+    {
+        $container = static::getContainer();
+        $registry = $container->get('doctrine');
+        $manager = $registry->getManager();
+        if (!$manager instanceof EntityManagerInterface) {
+            return;
+        }
+
+        $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)];
+
+        $schemaTool = new SchemaTool($manager);
+        @$schemaTool->dropSchema($classes);
+        parent::tearDown();
+    }
+}

From d560311ac05e79c26953f26820c2741bde856cb5 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Wed, 15 Jan 2025 18:48:18 +0100
Subject: [PATCH 5/9] style: apply php-cs-fixer

---
 src/State/Processor/RespondProcessor.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index bfc63a17556..495ae59dfcf 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -168,7 +168,7 @@ private function getAllowedMethods(?string $resourceClass, ?string $currentUriTe
     {
         $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
         if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
-            $resourceMetadataCollection =$this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
+            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
             foreach ($resourceMetadataCollection as $resource) {
                 foreach ($resource->getOperations() as $operation) {
                     if ($operation->getUriTemplate() === $currentUriTemplate) {

From b7cd2854846ebfb7e0493b285b7446cc09da037d Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Wed, 15 Jan 2025 19:00:19 +0100
Subject: [PATCH 6/9] Headers "Allow" and "Accept-Post" are not added if we
 return an error to avoid sending incorrect values.

---
 src/State/Processor/RespondProcessor.php | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 495ae59dfcf..3be404f63e0 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -93,12 +93,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers['Accept-Patch'] = $acceptPatch;
         }
 
-        $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate());
-        $headers['Allow'] = implode(', ', $allowedMethods);
+        if (!$exception) {
+            $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate());
+            $headers['Allow'] = implode(', ', $allowedMethods);
 
-        $outputFormats = $operation->getOutputFormats();
-        if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) {
-            $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
+            $outputFormats = $operation->getOutputFormats();
+            if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) {
+                $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
+            }
         }
 
         $method = $request->getMethod();

From a6d97c413be6ddf8107b0a9d8d786dadc7722e57 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Thu, 16 Jan 2025 08:27:18 +0100
Subject: [PATCH 7/9] misc fixes and optimization

---
 src/State/Processor/RespondProcessor.php      | 35 ++++++++-----------
 .../Resources/config/symfony/events.xml       |  1 +
 .../Document/DummyGetPostDeleteOperation.php  |  5 +++
 3 files changed, 20 insertions(+), 21 deletions(-)

diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index 3be404f63e0..a897938a9fa 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -23,7 +23,6 @@
 use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
 use ApiPlatform\Metadata\Put;
 use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
-use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
 use ApiPlatform\Metadata\ResourceClassResolverInterface;
 use ApiPlatform\Metadata\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -94,11 +93,22 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
         }
 
         if (!$exception) {
-            $allowedMethods = $this->getAllowedMethods($context['resource_class'] ?? null, $operation->getUriTemplate());
+            $isPostAllowed = false;
+            $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
+            if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) {
+                $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
+                foreach ($resourceMetadataCollection as $resource) {
+                    foreach ($resource->getOperations() as $resourceOperation) {
+                        if ($resourceOperation->getUriTemplate() === $currentUriTemplate) {
+                            $allowedMethods[] = $operationMethod = $resourceOperation->getMethod();
+                            $isPostAllowed = $isPostAllowed || ('POST' === $operationMethod);
+                        }
+                    }
+                }
+            }
             $headers['Allow'] = implode(', ', $allowedMethods);
 
-            $outputFormats = $operation->getOutputFormats();
-            if (\is_array($outputFormats) && [] !== $outputFormats && \in_array('POST', $allowedMethods, true)) {
+            if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) {
                 $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
             }
         }
@@ -165,21 +175,4 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers
         );
     }
-
-    private function getAllowedMethods(?string $resourceClass, ?string $currentUriTemplate): array
-    {
-        $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-        if (null !== $currentUriTemplate && null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
-            $resourceMetadataCollection = $this->resourceCollectionMetadataFactory ? $this->resourceCollectionMetadataFactory->create($resourceClass) : new ResourceMetadataCollection($resourceClass);
-            foreach ($resourceMetadataCollection as $resource) {
-                foreach ($resource->getOperations() as $operation) {
-                    if ($operation->getUriTemplate() === $currentUriTemplate) {
-                        $allowedMethods[] = $operation->getMethod();
-                    }
-                }
-            }
-        }
-
-        return array_unique($allowedMethods);
-    }
 }
diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml
index 4ecdb892921..66fd7278657 100644
--- a/src/Symfony/Bundle/Resources/config/symfony/events.xml
+++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml
@@ -61,6 +61,7 @@
             <argument type="service" id="api_platform.iri_converter" />
             <argument type="service" id="api_platform.resource_class_resolver" />
             <argument type="service" id="api_platform.metadata.operation.metadata_factory" />
+            <argument type="service" id="api_platform.metadata.operation.metadata_collection_factory" />
         </service>
 
         <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
index 5ac27a1c728..f6d6a6244a0 100644
--- a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
+++ b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
@@ -49,4 +49,9 @@ public function setName(string $name): void
     {
         $this->name = $name;
     }
+
+    public function __toString(): string
+    {
+        return (string) $this->getId();
+    }
 }

From 7d27c1ccf6a01b7d262cb6c48db7975a2a0f9af7 Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Tue, 21 Jan 2025 08:46:18 +0100
Subject: [PATCH 8/9] feat(state): improvements and fixes for
 LinkedDataPlatform support

- use LinkedDataPlatformProcessor instead of RespondProcessor to handle the allow and allow-post headers
- add new Processor in symfony events and Laravel
---
 src/Laravel/ApiPlatformProvider.php           |   5 +
 src/Laravel/Tests/LinkedDataPlatformTest.php  |  68 ++++++++++
 .../Processor/LinkedDataPlatformProcessor.php |  77 +++++++++++
 src/State/Processor/RespondProcessor.php      |  25 ----
 ...hp => LinkedDataPlatformProcessorTest.php} | 120 ++++++++++++------
 .../Resources/config/state/processor.xml      |   7 +-
 .../Resources/config/symfony/events.xml       |   5 +-
 .../DummyGetPostDeleteOperation.php           |  37 +++++-
 .../Document/DummyGetPostDeleteOperation.php  |  57 ---------
 .../Entity/DummyGetPostDeleteOperation.php    |  54 --------
 .../Functional/State/RespondProcessorTest.php |  63 +--------
 11 files changed, 285 insertions(+), 233 deletions(-)
 create mode 100644 src/Laravel/Tests/LinkedDataPlatformTest.php
 create mode 100644 src/State/Processor/LinkedDataPlatformProcessor.php
 rename src/State/Tests/Processor/{RespondProcessorTest.php => LinkedDataPlatformProcessorTest.php} (53%)
 delete mode 100644 tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
 delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php

diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php
index 2338ea4b766..5c15cfd2211 100644
--- a/src/Laravel/ApiPlatformProvider.php
+++ b/src/Laravel/ApiPlatformProvider.php
@@ -183,6 +183,7 @@
 use ApiPlatform\State\Pagination\PaginationOptions;
 use ApiPlatform\State\ParameterProviderInterface;
 use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
+use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
 use ApiPlatform\State\Processor\RespondProcessor;
 use ApiPlatform\State\Processor\SerializeProcessor;
 use ApiPlatform\State\Processor\WriteProcessor;
@@ -558,6 +559,10 @@ public function register(): void
             return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
         });
 
+        $this->app->singleton(RespondProcessor::class, function (Application $app) {
+            return new LinkedDataPlatformProcessor(new RespondProcessor(), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
+        });
+
         $this->app->singleton(SerializeProcessor::class, function (Application $app) {
             return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
         });
diff --git a/src/Laravel/Tests/LinkedDataPlatformTest.php b/src/Laravel/Tests/LinkedDataPlatformTest.php
new file mode 100644
index 00000000000..f8123f1d9a4
--- /dev/null
+++ b/src/Laravel/Tests/LinkedDataPlatformTest.php
@@ -0,0 +1,68 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
+use Illuminate\Contracts\Config\Repository;
+use Illuminate\Foundation\Application;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Orchestra\Testbench\Concerns\WithWorkbench;
+use Orchestra\Testbench\TestCase;
+use Workbench\App\Models\Book;
+use Workbench\Database\Factories\BookFactory;
+
+class LinkedDataPlatformTest extends TestCase
+{
+    use ApiTestAssertionsTrait;
+    use RefreshDatabase;
+    use WithWorkbench;
+
+    /**
+     * @param Application $app
+     */
+    protected function defineEnvironment($app): void
+    {
+        tap($app['config'], function (Repository $config): void {
+            $config->set('api-platform.formats', ['jsonld' => ['application/ld+json'], 'turtle' => ['text/turtle']]);
+            $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
+            $config->set('app.debug', true);
+        });
+    }
+
+    public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
+    {
+        $response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
+        $response->assertStatus(200);
+        $response->assertHeader('accept-post', 'application/ld+json, text/turtle, text/html');
+    }
+
+    public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
+    {
+        BookFactory::new()->createOne();
+        $book = Book::first();
+        $response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
+        $response->assertStatus(200);
+        $response->assertHeaderMissing('accept-post');
+    }
+
+    public function testHeaderAllowReflectsResourceAllowedMethods(): void
+    {
+        $response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
+        $response->assertHeader('allow', 'OPTIONS, HEAD, POST, GET');
+
+        BookFactory::new()->createOne();
+        $book = Book::first();
+        $response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
+        $response->assertStatus(200);
+        $response->assertHeader('allow', 'OPTIONS, HEAD, PUT, PATCH, GET, DELETE');
+    }
+}
diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php
new file mode 100644
index 00000000000..223b400a872
--- /dev/null
+++ b/src/State/Processor/LinkedDataPlatformProcessor.php
@@ -0,0 +1,77 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\State\Processor;
+
+use ApiPlatform\Metadata\Error;
+use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\ResourceClassResolverInterface;
+use ApiPlatform\State\ProcessorInterface;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * @template T1
+ * @template T2
+ *
+ * @implements ProcessorInterface<T1, T2>
+ */
+final class LinkedDataPlatformProcessor implements ProcessorInterface
+{
+    private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
+
+    /**
+     * @param ProcessorInterface<T1, T2> $decorated
+     */
+    public function __construct(
+        private readonly ProcessorInterface $decorated, // todo is processor interface nullable
+        private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
+        private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
+    ) {
+    }
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        $response = $this->decorated->process($data, $operation, $uriVariables, $context);
+        if (
+            !$response instanceof Response
+            || !$operation instanceof HttpOperation
+            || $operation instanceof Error
+            || null === $this->resourceCollectionMetadataFactory
+            || !($context['resource_class'] ?? null)
+            || null === $operation->getUriTemplate()
+            || !$this->resourceClassResolver?->isResourceClass($context['resource_class'])
+        ) {
+            return $response;
+        }
+
+        $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
+        $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
+        foreach ($resourceMetadataCollection as $resource) {
+            foreach ($resource->getOperations() as $resourceOperation) {
+                if ($resourceOperation->getUriTemplate() === $operation->getUriTemplate()) {
+                    $operationMethod = $resourceOperation->getMethod();
+                    $allowedMethods[] = $operationMethod;
+                    if ('POST' === $operationMethod && \is_array($outputFormats = $operation->getOutputFormats())) {
+                        $response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats))));
+                    }
+                }
+            }
+        }
+
+        $response->headers->set('Allow', implode(', ', $allowedMethods));
+
+        return $response;
+    }
+}
diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php
index a897938a9fa..58941e437c3 100644
--- a/src/State/Processor/RespondProcessor.php
+++ b/src/State/Processor/RespondProcessor.php
@@ -22,7 +22,6 @@
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
 use ApiPlatform\Metadata\Put;
-use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
 use ApiPlatform\Metadata\ResourceClassResolverInterface;
 use ApiPlatform\Metadata\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -46,13 +45,10 @@ final class RespondProcessor implements ProcessorInterface
         'DELETE' => Response::HTTP_NO_CONTENT,
     ];
 
-    private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
-
     public function __construct(
         private ?IriConverterInterface $iriConverter = null,
         private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
         private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
-        private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
     ) {
     }
 
@@ -92,27 +88,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             $headers['Accept-Patch'] = $acceptPatch;
         }
 
-        if (!$exception) {
-            $isPostAllowed = false;
-            $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-            if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) {
-                $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
-                foreach ($resourceMetadataCollection as $resource) {
-                    foreach ($resource->getOperations() as $resourceOperation) {
-                        if ($resourceOperation->getUriTemplate() === $currentUriTemplate) {
-                            $allowedMethods[] = $operationMethod = $resourceOperation->getMethod();
-                            $isPostAllowed = $isPostAllowed || ('POST' === $operationMethod);
-                        }
-                    }
-                }
-            }
-            $headers['Allow'] = implode(', ', $allowedMethods);
-
-            if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) {
-                $headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
-            }
-        }
-
         $method = $request->getMethod();
         $originalData = $context['original_data'] ?? null;
 
diff --git a/src/State/Tests/Processor/RespondProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
similarity index 53%
rename from src/State/Tests/Processor/RespondProcessorTest.php
rename to src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
index b4b92c898a2..75361a41723 100644
--- a/src/State/Tests/Processor/RespondProcessorTest.php
+++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
@@ -15,6 +15,7 @@
 
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Error;
 use ApiPlatform\Metadata\Get;
 use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\Metadata\HttpOperation;
@@ -23,21 +24,24 @@
 use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
 use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
 use ApiPlatform\Metadata\ResourceClassResolverInterface;
-use ApiPlatform\State\Processor\RespondProcessor;
+use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
+use ApiPlatform\State\ProcessorInterface;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 
-class RespondProcessorTest extends TestCase
+class LinkedDataPlatformProcessorTest extends TestCase
 {
     private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory;
-    private RespondProcessor $processor;
+    private ResourceClassResolverInterface&MockObject $resourceClassResolver;
+
+    private ProcessorInterface&MockObject $decorated;
 
     protected function setUp(): void
     {
-        $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
-        $resourceClassResolver
+        $this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
+        $this->resourceClassResolver
             ->method('isResourceClass')
             ->willReturn(true);
 
@@ -45,7 +49,7 @@ protected function setUp(): void
         $this->resourceMetadataCollectionFactory
             ->method('create')
             ->willReturn(
-                new ResourceMetadataCollection('DummyResource', [
+                new ResourceMetadataCollection('DummyResource', [ // todo mock $dummy_resource
                     new ApiResource(operations: [
                         new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'),
                         new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'),
@@ -56,37 +60,39 @@ protected function setUp(): void
                 ])
             );
 
-        $this->processor = new RespondProcessor(
-            null,
-            $resourceClassResolver,
-            null,
-            $this->resourceMetadataCollectionFactory
-        );
+        $this->decorated = $this->createMock(ProcessorInterface::class);
+        $this->decorated->method('process')->willReturn(new Response());
     }
 
     public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
     {
-        $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json']]));
-        $context = [
-            'resource_class' => 'DummyResource',
-            'request' => $this->createGetRequest(),
-        ];
+        $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']]));
 
+        $context = $this->getContext();
+
+        $processor = new LinkedDataPlatformProcessor(
+            $this->decorated,
+            $this->resourceClassResolver,
+            $this->resourceMetadataCollectionFactory
+        );
         /** @var Response $response */
-        $response = $this->processor->process(null, $operation, [], $context);
-        $this->assertSame('application/ld+json, application/json', $response->headers->get('Accept-Post'));
+        $response = $processor->process(null, $operation, [], $context);
+
+        $this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post'));
     }
 
     public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
     {
         $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
-        $context = [
-            'resource_class' => 'DummyResource',
-            'request' => $this->createGetRequest(),
-        ];
+        $context = $this->getContext();
 
+        $processor = new LinkedDataPlatformProcessor(
+            $this->decorated,
+            $this->resourceClassResolver,
+            $this->resourceMetadataCollectionFactory
+        );
         /** @var Response $response */
-        $response = $this->processor->process(null, $operation, [], $context);
+        $response = $processor->process(null, $operation, [], $context);
 
         $this->assertNull($response->headers->get('Accept-Post'));
     }
@@ -94,37 +100,67 @@ public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
     public function testHeaderAllowReflectsResourceAllowedMethods(): void
     {
         $operation = (new HttpOperation('GET', '/dummy_resources{._format}'));
-        $context = [
-            'resource_class' => 'SomeResourceClass',
-            'request' => $this->createGetRequest(),
-        ];
+        $context = $this->getContext();
 
+        $processor = new LinkedDataPlatformProcessor(
+            $this->decorated,
+            $this->resourceClassResolver,
+            $this->resourceMetadataCollectionFactory
+        );
         /** @var Response $response */
-        $response = $this->processor->process(null, $operation, [], $context);
-
+        $response = $processor->process(null, $operation, [], $context);
         $allowHeader = $response->headers->get('Allow');
         $this->assertStringContainsString('OPTIONS', $allowHeader);
         $this->assertStringContainsString('HEAD', $allowHeader);
         $this->assertStringContainsString('GET', $allowHeader);
         $this->assertStringContainsString('POST', $allowHeader);
-        $this->assertStringNotContainsString('DELETE', $allowHeader);
 
-        $context = [
-            'resource_class' => 'SomeResourceClass',
-            'request' => $this->createGetRequest(),
-        ];
         $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
 
         /** @var Response $response */
-        $response = $this->processor->process(null, $operation, [], $context);
-
+        $processor = new LinkedDataPlatformProcessor(
+            $this->decorated,
+            $this->resourceClassResolver,
+            $this->resourceMetadataCollectionFactory
+        );
+        /** @var Response $response */
+        $response = $processor->process('data', $operation, [], $this->getContext());
         $allowHeader = $response->headers->get('Allow');
         $this->assertStringContainsString('OPTIONS', $allowHeader);
         $this->assertStringContainsString('HEAD', $allowHeader);
         $this->assertStringContainsString('GET', $allowHeader);
         $this->assertStringContainsString('PUT', $allowHeader);
         $this->assertStringContainsString('DELETE', $allowHeader);
-        $this->assertStringNotContainsString('POST', $allowHeader);
+    }
+
+    public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void
+    {
+        $operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
+
+        // No collection factory
+        $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, null);
+        /** @var Response $response */
+        $response = $processor->process(null, $operation, [], $this->getContext());
+
+        $this->assertNull($response->headers->get('Allow'));
+
+        // No uri variable in context
+        $processor = new LinkedDataPlatformProcessor($this->decorated, null, $this->resourceMetadataCollectionFactory);
+        $response = $processor->process(null, $operation, [], $this->getContext());
+        $this->assertNull($response->headers->get('Allow'));
+
+        // Operation is an Error
+        $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory);
+        $response = $processor->process(null, new Error(), $this->getContext());
+        $this->assertNull($response->headers->get('Allow'));
+
+        // Not a resource class
+        $this->resourceClassResolver
+            ->method('isResourceClass')
+            ->willReturn(false);
+        $processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory);
+        $response = $processor->process(null, $operation, []);
+        $this->assertNull($response->headers->get('Allow'));
     }
 
     private function createGetRequest(): Request
@@ -136,4 +172,12 @@ private function createGetRequest(): Request
 
         return $request;
     }
+
+    private function getContext(): array
+    {
+        return [
+            'resource_class' => 'DummyResource',
+            'request' => $this->createGetRequest(),
+        ];
+    }
 }
diff --git a/src/Symfony/Bundle/Resources/config/state/processor.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml
index e5dd2792e5f..b448f4c3cf6 100644
--- a/src/Symfony/Bundle/Resources/config/state/processor.xml
+++ b/src/Symfony/Bundle/Resources/config/state/processor.xml
@@ -21,11 +21,16 @@
             <argument type="service" id="api_platform.iri_converter" />
             <argument type="service" id="api_platform.resource_class_resolver" />
             <argument type="service" id="api_platform.metadata.operation.metadata_factory" />
-            <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
         </service>
 
         <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
             <argument type="service" id="api_platform.state_processor.add_link_header.inner" />
         </service>
+
+        <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
+            <argument type="service" id="api_platform.state_processor.linked_data_platform.inner" />
+            <argument type="service" id="api_platform.resource_class_resolver" />
+            <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
+        </service>
     </services>
 </container>
diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml
index 66fd7278657..fbd905bf15b 100644
--- a/src/Symfony/Bundle/Resources/config/symfony/events.xml
+++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml
@@ -61,13 +61,16 @@
             <argument type="service" id="api_platform.iri_converter" />
             <argument type="service" id="api_platform.resource_class_resolver" />
             <argument type="service" id="api_platform.metadata.operation.metadata_factory" />
-            <argument type="service" id="api_platform.metadata.operation.metadata_collection_factory" />
         </service>
 
         <service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
             <argument type="service" id="api_platform.state_processor.add_link_header.inner" />
         </service>
 
+        <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
+            <argument type="service"  id="api_platform.state_processor.linked_data_platform.inner" />
+        </service>
+
         <service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener">
             <argument type="service" id="api_platform.state_processor.write" />
             <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
index 954bcb29217..d46018fb298 100644
--- a/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
+++ b/tests/Fixtures/TestBundle/ApiResource/DummyGetPostDeleteOperation.php
@@ -17,12 +17,47 @@
 use ApiPlatform\Metadata\Delete;
 use ApiPlatform\Metadata\Get;
 use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
 use ApiPlatform\Metadata\Post;
 
-#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
+#[ApiResource(operations: [
+    new Get(
+        uriTemplate: '/dummy_get_post_delete_operations/{id}',
+        provider: [self::class, 'provideItem'],
+    ),
+    new GetCollection(
+        uriTemplate: '/dummy_get_post_delete_operations',
+        provider: [self::class, 'provide'], ),
+    new Post(
+        uriTemplate: '/dummy_get_post_delete_operations',
+        provider: [self::class, 'provide'], ),
+    new Delete(
+        uriTemplate: '/dummy_get_post_delete_operations/{id}',
+        provider: [self::class, 'provideItem'], ),
+])]
 class DummyGetPostDeleteOperation
 {
     public ?int $id;
 
     public ?string $name = null;
+
+    public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array
+    {
+        $dummyResource = new self();
+        $dummyResource->id = 1;
+        $dummyResource->name = 'Dummy name';
+
+        return [
+            $dummyResource,
+        ];
+    }
+
+    public static function provideItem(Operation $operation, array $uriVariables = [], array $context = []): self
+    {
+        $dummyResource = new self();
+        $dummyResource->id = $uriVariables['id'];
+        $dummyResource->name = 'Dummy name';
+
+        return $dummyResource;
+    }
 }
diff --git a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
deleted file mode 100644
index f6d6a6244a0..00000000000
--- a/tests/Fixtures/TestBundle/Document/DummyGetPostDeleteOperation.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-/*
- * This file is part of the API Platform project.
- *
- * (c) Kévin Dunglas <dunglas@gmail.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-declare(strict_types=1);
-
-namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
-
-use ApiPlatform\Metadata\ApiResource;
-use ApiPlatform\Metadata\Delete;
-use ApiPlatform\Metadata\Get;
-use ApiPlatform\Metadata\GetCollection;
-use ApiPlatform\Metadata\Post;
-use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
-
-#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
-#[ODM\Document]
-class DummyGetPostDeleteOperation
-{
-    #[ODM\Id(strategy: 'INCREMENT', type: 'int')]
-    private ?int $id;
-
-    #[ODM\Field(type: 'string')]
-    private ?string $name = null;
-
-    public function getId(): ?int
-    {
-        return $this->id;
-    }
-
-    public function setId(?int $id): void
-    {
-        $this->id = $id;
-    }
-
-    public function getName(): string
-    {
-        return $this->name;
-    }
-
-    public function setName(string $name): void
-    {
-        $this->name = $name;
-    }
-
-    public function __toString(): string
-    {
-        return (string) $this->getId();
-    }
-}
diff --git a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php b/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
deleted file mode 100644
index ad5e370e450..00000000000
--- a/tests/Fixtures/TestBundle/Entity/DummyGetPostDeleteOperation.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php
-
-/*
- * This file is part of the API Platform project.
- *
- * (c) Kévin Dunglas <dunglas@gmail.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-declare(strict_types=1);
-
-namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
-
-use ApiPlatform\Metadata\ApiResource;
-use ApiPlatform\Metadata\Delete;
-use ApiPlatform\Metadata\Get;
-use ApiPlatform\Metadata\GetCollection;
-use ApiPlatform\Metadata\Post;
-use Doctrine\ORM\Mapping as ORM;
-
-#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
-#[ORM\Entity]
-class DummyGetPostDeleteOperation
-{
-    #[ORM\Column(type: 'integer')]
-    #[ORM\Id]
-    #[ORM\GeneratedValue(strategy: 'AUTO')]
-    private ?int $id = null;
-
-    #[ORM\Column]
-    private string $name;
-
-    public function getId(): ?int
-    {
-        return $this->id;
-    }
-
-    public function setId(?int $id): void
-    {
-        $this->id = $id;
-    }
-
-    public function getName(): string
-    {
-        return $this->name;
-    }
-
-    public function setName(string $name): void
-    {
-        $this->name = $name;
-    }
-}
diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/State/RespondProcessorTest.php
index 787b938238f..f529023e588 100644
--- a/tests/Functional/State/RespondProcessorTest.php
+++ b/tests/Functional/State/RespondProcessorTest.php
@@ -14,15 +14,11 @@
 namespace ApiPlatform\Tests\Functional\State;
 
 use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
-use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGetPostDeleteOperation;
-use ApiPlatform\Tests\RecreateSchemaTrait;
+use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation;
 use ApiPlatform\Tests\SetupClassResourcesTrait;
-use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\Tools\SchemaTool;
 
 class RespondProcessorTest extends ApiTestCase
 {
-    use RecreateSchemaTrait;
     use SetupClassResourcesTrait;
 
     /**
@@ -33,28 +29,25 @@ public static function getResources(): array
         return [DummyGetPostDeleteOperation::class];
     }
 
-    public function testAllowHeadersForSingleResourceWithGetDelete(): void
+    public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void
     {
         $client = static::createClient();
-        $client->request('GET', '/dummy_get_post_delete_operations/1', [
+        $client->request('GET', '/dummy_get_post_delete_operations', [
             'headers' => [
                 'Content-Type' => 'application/ld+json',
             ],
         ]);
 
-        $this->assertResponseHeaderSame('Allow', 'OPTIONS, HEAD, GET, DELETE');
-    }
+        $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST');
 
-    public function testAllowHeadersForResourceCollectionReflectsAllowedMethods(): void
-    {
         $client = static::createClient();
-        $client->request('GET', '/dummy_get_post_delete_operations', [
+        $client->request('GET', '/dummy_get_post_delete_operations/1', [
             'headers' => [
                 'Content-Type' => 'application/ld+json',
             ],
         ]);
 
-        $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, POST');
+        $this->assertResponseHeaderSame('allow', 'OPTIONS, HEAD, GET, DELETE');
     }
 
     public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): void
@@ -72,7 +65,7 @@ public function testAcceptPostHeaderForResourceWithPostReflectsAllowedTypes(): v
     public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void
     {
         $client = static::createClient();
-        $client->request('OPTIONS', '/dummy_get_post_delete_operations/1', [
+        $client->request('GET', '/dummy_get_post_delete_operations/1', [
             'headers' => [
                 'Content-Type' => 'application/ld+json',
             ],
@@ -80,46 +73,4 @@ public function testAcceptPostHeaderDoesNotExistResourceWithoutPost(): void
 
         $this->assertResponseNotHasHeader('accept-post');
     }
-
-    protected function setUp(): void
-    {
-        self::bootKernel();
-
-        $container = static::getContainer();
-        $registry = $container->get('doctrine');
-        $manager = $registry->getManager();
-        if (!$manager instanceof EntityManagerInterface) {
-            return;
-        }
-
-        $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)];
-
-        try {
-            $schemaTool = new SchemaTool($manager);
-            @$schemaTool->createSchema($classes);
-        } catch (\Exception $e) {
-            return;
-        }
-
-        $dummy = new DummyGetPostDeleteOperation();
-        $dummy->setName('Dummy');
-        $manager->persist($dummy);
-        $manager->flush();
-    }
-
-    protected function tearDown(): void
-    {
-        $container = static::getContainer();
-        $registry = $container->get('doctrine');
-        $manager = $registry->getManager();
-        if (!$manager instanceof EntityManagerInterface) {
-            return;
-        }
-
-        $classes = [$manager->getClassMetadata(DummyGetPostDeleteOperation::class)];
-
-        $schemaTool = new SchemaTool($manager);
-        @$schemaTool->dropSchema($classes);
-        parent::tearDown();
-    }
 }

From d072fd558ba887728974712eb85e1c4a3dd6b9ac Mon Sep 17 00:00:00 2001
From: LaurentHuzard <laurent@les-tilleuls.coop>
Date: Mon, 27 Jan 2025 06:08:14 +0100
Subject: [PATCH 9/9] feat(state): fixes

---
 src/Laravel/ApiPlatformProvider.php           | 16 +++++++++---
 .../Processor/LinkedDataPlatformProcessor.php | 25 +++++++++----------
 .../LinkedDataPlatformProcessorTest.php       |  4 +--
 .../Resources/config/symfony/events.xml       |  2 ++
 ...essorTest.php => LinkDataPlatformTest.php} |  4 +--
 5 files changed, 30 insertions(+), 21 deletions(-)
 rename tests/Functional/{State/RespondProcessorTest.php => LinkDataPlatformTest.php} (96%)

diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php
index 5c15cfd2211..d909169a500 100644
--- a/src/Laravel/ApiPlatformProvider.php
+++ b/src/Laravel/ApiPlatformProvider.php
@@ -556,15 +556,23 @@ public function register(): void
         });
 
         $this->app->singleton(RespondProcessor::class, function () {
-            return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
+            return new RespondProcessor();
         });
 
-        $this->app->singleton(RespondProcessor::class, function (Application $app) {
-            return new LinkedDataPlatformProcessor(new RespondProcessor(), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
+        $this->app->singleton(AddLinkHeaderProcessor::class, function (Application $app) {
+            return new AddLinkHeaderProcessor($app->make(RespondProcessor::class), new HttpHeaderSerializer());
+        });
+
+        $this->app->singleton(LinkedDataPlatformProcessor::class, function (Application $app) {
+            return new LinkedDataPlatformProcessor(
+                $app->make(AddLinkHeaderProcessor::class), // Original service
+                $app->make(ResourceClassResolverInterface::class),
+                $app->make(ResourceMetadataCollectionFactoryInterface::class)
+            );
         });
 
         $this->app->singleton(SerializeProcessor::class, function (Application $app) {
-            return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
+            return new SerializeProcessor($app->make(LinkedDataPlatformProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
         });
 
         $this->app->singleton(WriteProcessor::class, function (Application $app) {
diff --git a/src/State/Processor/LinkedDataPlatformProcessor.php b/src/State/Processor/LinkedDataPlatformProcessor.php
index 223b400a872..a9fddb85a38 100644
--- a/src/State/Processor/LinkedDataPlatformProcessor.php
+++ b/src/State/Processor/LinkedDataPlatformProcessor.php
@@ -29,15 +29,15 @@
  */
 final class LinkedDataPlatformProcessor implements ProcessorInterface
 {
-    private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
+    private const DEFAULT_ALLOWED_METHODS = ['OPTIONS', 'HEAD'];
 
     /**
      * @param ProcessorInterface<T1, T2> $decorated
      */
     public function __construct(
-        private readonly ProcessorInterface $decorated, // todo is processor interface nullable
+        private readonly ProcessorInterface $decorated,
         private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
-        private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
+        private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
     ) {
     }
 
@@ -48,22 +48,21 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
             !$response instanceof Response
             || !$operation instanceof HttpOperation
             || $operation instanceof Error
-            || null === $this->resourceCollectionMetadataFactory
+            || !$this->resourceMetadataCollectionFactory
             || !($context['resource_class'] ?? null)
-            || null === $operation->getUriTemplate()
+            || !$operation->getUriTemplate()
             || !$this->resourceClassResolver?->isResourceClass($context['resource_class'])
         ) {
             return $response;
         }
 
-        $allowedMethods = self::DEFAULT_ALLOWED_METHOD;
-        $resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
-        foreach ($resourceMetadataCollection as $resource) {
-            foreach ($resource->getOperations() as $resourceOperation) {
-                if ($resourceOperation->getUriTemplate() === $operation->getUriTemplate()) {
-                    $operationMethod = $resourceOperation->getMethod();
-                    $allowedMethods[] = $operationMethod;
-                    if ('POST' === $operationMethod && \is_array($outputFormats = $operation->getOutputFormats())) {
+        $allowedMethods = self::DEFAULT_ALLOWED_METHODS;
+        $resourceCollection = $this->resourceMetadataCollectionFactory->create($context['resource_class']);
+        foreach ($resourceCollection as $resource) {
+            foreach ($resource->getOperations() as $op) {
+                if ($op->getUriTemplate() === $operation->getUriTemplate()) {
+                    $allowedMethods[] = $method = $op->getMethod();
+                    if ('POST' === $method && \is_array($outputFormats = $op->getOutputFormats())) {
                         $response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats))));
                     }
                 }
diff --git a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
index 75361a41723..8018b8ba23d 100644
--- a/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
+++ b/src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
@@ -53,7 +53,7 @@ protected function setUp(): void
                     new ApiResource(operations: [
                         new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'),
                         new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'),
-                        new Post(uriTemplate: '/dummy_resources{._format}', name: 'post'),
+                        new Post(uriTemplate: '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], name: 'post'),
                         new Delete(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'delete'),
                         new Put(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'put'),
                     ]),
@@ -66,7 +66,7 @@ protected function setUp(): void
 
     public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
     {
-        $operation = (new HttpOperation('GET', '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']]));
+        $operation = (new HttpOperation('GET', '/dummy_resources{._format}'));
 
         $context = $this->getContext();
 
diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml
index fbd905bf15b..2ee684128f7 100644
--- a/src/Symfony/Bundle/Resources/config/symfony/events.xml
+++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml
@@ -69,6 +69,8 @@
 
         <service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
             <argument type="service"  id="api_platform.state_processor.linked_data_platform.inner" />
+            <argument type="service" id="api_platform.resource_class_resolver" />
+            <argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
         </service>
 
         <service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener">
diff --git a/tests/Functional/State/RespondProcessorTest.php b/tests/Functional/LinkDataPlatformTest.php
similarity index 96%
rename from tests/Functional/State/RespondProcessorTest.php
rename to tests/Functional/LinkDataPlatformTest.php
index f529023e588..b6ef9c608ba 100644
--- a/tests/Functional/State/RespondProcessorTest.php
+++ b/tests/Functional/LinkDataPlatformTest.php
@@ -11,13 +11,13 @@
 
 declare(strict_types=1);
 
-namespace ApiPlatform\Tests\Functional\State;
+namespace ApiPlatform\Tests\Functional;
 
 use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
 use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation;
 use ApiPlatform\Tests\SetupClassResourcesTrait;
 
-class RespondProcessorTest extends ApiTestCase
+class LinkDataPlatformTest extends ApiTestCase
 {
     use SetupClassResourcesTrait;