diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php
index 32922afb1d..5d136533d8 100644
--- a/src/Metadata/ApiResource.php
+++ b/src/Metadata/ApiResource.php
@@ -978,6 +978,7 @@ public function __construct(
protected ?bool $strictQueryParameterValidation = null,
protected ?bool $hideHydraOperation = null,
protected ?bool $jsonStream = null,
+ protected ?bool $throwOnNotFound = null,
protected array $extraProperties = [],
?bool $map = null,
protected ?array $mcp = null,
@@ -1026,6 +1027,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map,
);
diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php
index 3674a5d6fe..61744470fd 100644
--- a/src/Metadata/Delete.php
+++ b/src/Metadata/Delete.php
@@ -102,6 +102,7 @@ public function __construct(
?bool $strictQueryParameterValidation = null,
protected ?bool $hideHydraOperation = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
) {
@@ -187,6 +188,7 @@ class: $class,
parameters: $parameters,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
+ throwOnNotFound: $throwOnNotFound,
stateOptions: $stateOptions,
map: $map
);
diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php
index 4d3c3206b5..d439c1e981 100644
--- a/src/Metadata/Extractor/XmlResourceExtractor.php
+++ b/src/Metadata/Extractor/XmlResourceExtractor.php
@@ -149,6 +149,7 @@ private function buildBase(\SimpleXMLElement $resource): array
'write' => $this->phpize($resource, 'write', 'bool'),
'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'),
'map' => $this->phpize($resource, 'map', 'bool'),
+ 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'),
];
}
diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php
index 67848c5694..38ac28e057 100644
--- a/src/Metadata/Extractor/YamlResourceExtractor.php
+++ b/src/Metadata/Extractor/YamlResourceExtractor.php
@@ -176,6 +176,7 @@ private function buildBase(array $resource): array
'write' => $this->phpize($resource, 'write', 'bool'),
'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'),
'map' => $this->phpize($resource, 'map', 'bool'),
+ 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'),
];
}
diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd
index 8a1644c479..6019722d6b 100644
--- a/src/Metadata/Extractor/schema/resources.xsd
+++ b/src/Metadata/Extractor/schema/resources.xsd
@@ -526,6 +526,7 @@
+
diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php
index 4c59d7ab95..82a01f83dc 100644
--- a/src/Metadata/Get.php
+++ b/src/Metadata/Get.php
@@ -102,6 +102,7 @@ public function __construct(
?bool $strictQueryParameterValidation = null,
protected ?bool $hideHydraOperation = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
) {
@@ -186,6 +187,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php
index 6256366bd2..a94ae24099 100644
--- a/src/Metadata/GetCollection.php
+++ b/src/Metadata/GetCollection.php
@@ -103,6 +103,7 @@ public function __construct(
protected ?bool $hideHydraOperation = null,
?bool $jsonStream = null,
array $extraProperties = [],
+ ?bool $throwOnNotFound = null,
private ?string $itemUriTemplate = null,
?bool $map = null,
) {
@@ -181,6 +182,7 @@ class: $class,
processor: $processor,
parameters: $parameters,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
rules: $rules,
policy: $policy,
diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php
index 32dfa15bb7..a8f28f22d8 100644
--- a/src/Metadata/HttpOperation.php
+++ b/src/Metadata/HttpOperation.php
@@ -222,6 +222,7 @@ public function __construct(
array|string|null $middleware = null,
?bool $queryParameterValidationEnabled = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
) {
@@ -283,6 +284,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php
index 009612c990..35e039c85a 100644
--- a/src/Metadata/Metadata.php
+++ b/src/Metadata/Metadata.php
@@ -82,6 +82,7 @@ public function __construct(
protected ?bool $hideHydraOperation = null,
protected ?bool $jsonStream = null,
protected ?bool $map = null,
+ protected ?bool $throwOnNotFound = null,
protected array $extraProperties = [],
) {
if (\is_array($parameters) && $parameters) {
@@ -655,6 +656,19 @@ public function withMiddleware(string|array $middleware): static
return $self;
}
+ public function getThrowOnNotFound(): ?bool
+ {
+ return $this->throwOnNotFound;
+ }
+
+ public function withThrowOnNotFound(bool $throwOnNotFound): static
+ {
+ $self = clone $this;
+ $self->throwOnNotFound = $throwOnNotFound;
+
+ return $self;
+ }
+
public function getExtraProperties(): ?array
{
return $this->extraProperties;
diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php
index cbd53751e5..343915a673 100644
--- a/src/Metadata/Operation.php
+++ b/src/Metadata/Operation.php
@@ -814,6 +814,7 @@ public function __construct(
protected ?bool $strictQueryParameterValidation = null,
protected ?bool $hideHydraOperation = null,
protected ?bool $jsonStream = null,
+ protected ?bool $throwOnNotFound = null,
protected array $extraProperties = [],
?bool $map = null,
) {
@@ -862,6 +863,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php
index e6147a18da..100ac370e7 100644
--- a/src/Metadata/Patch.php
+++ b/src/Metadata/Patch.php
@@ -102,6 +102,7 @@ public function __construct(
?bool $strictQueryParameterValidation = null,
?bool $hideHydraOperation = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
) {
@@ -187,6 +188,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php
index e68e4b0ec6..61a4a059c7 100644
--- a/src/Metadata/Post.php
+++ b/src/Metadata/Post.php
@@ -100,6 +100,7 @@ public function __construct(
?string $policy = null,
array|string|null $middleware = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
private ?string $itemUriTemplate = null,
?bool $strictQueryParameterValidation = null,
@@ -188,6 +189,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php
index 73632c786b..87529e9587 100644
--- a/src/Metadata/Put.php
+++ b/src/Metadata/Put.php
@@ -100,6 +100,7 @@ public function __construct(
?string $policy = null,
array|string|null $middleware = null,
?bool $jsonStream = null,
+ ?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $strictQueryParameterValidation = null,
?bool $hideHydraOperation = null,
@@ -188,6 +189,7 @@ class: $class,
strictQueryParameterValidation: $strictQueryParameterValidation,
hideHydraOperation: $hideHydraOperation,
jsonStream: $jsonStream,
+ throwOnNotFound: $throwOnNotFound,
extraProperties: $extraProperties,
map: $map
);
diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
index 6e3a1296f1..4bebfa435e 100644
--- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
+++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php
@@ -66,6 +66,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface
'stateOptions',
'collectDenormalizationErrors',
'jsonStream',
+ 'throwOnNotFound',
'links',
'parameters',
];
diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml
index 06c90ebfd2..1588395319 100644
--- a/src/Metadata/Tests/Extractor/Adapter/resources.xml
+++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml
@@ -1,3 +1,3 @@
-
+
diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml
index 6dc74676c4..fe1595bf15 100644
--- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml
+++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml
@@ -343,6 +343,7 @@ resources:
strictQueryParameterValidation: false
hideHydraOperation: false
jsonStream: true
+ throwOnNotFound: true
extraProperties:
custom_property: 'Lorem ipsum dolor sit amet'
another_custom_property:
diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
index fb4f177d81..0accd28d7c 100644
--- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
+++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php
@@ -168,6 +168,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase
],
],
'jsonStream' => true,
+ 'throwOnNotFound' => true,
'mercure' => true,
'stateOptions' => [
'elasticsearchOptions' => [
@@ -481,6 +482,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase
'order',
'extraProperties',
'jsonStream',
+ 'throwOnNotFound',
];
private const EXTENDED_BASE = [
'uriTemplate',
diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php
index b9d4dc2359..3447e2417e 100644
--- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php
+++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php
@@ -107,6 +107,8 @@ public function testValidXML(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
[
'uriTemplate' => '/users/{author}/comments{._format}',
@@ -285,6 +287,8 @@ public function testValidXML(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
[
'name' => null,
@@ -400,6 +404,8 @@ public function testValidXML(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
'graphQlOperations' => null,
@@ -414,6 +420,8 @@ public function testValidXML(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
], $extractor->getResources());
diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php
index 7d58abe6ba..de25bcf7a9 100644
--- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php
+++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php
@@ -106,6 +106,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
Program::class => [
@@ -180,6 +182,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
[
'uriTemplate' => '/users/{author}/programs{._format}',
@@ -325,6 +329,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
[
'name' => null,
@@ -413,6 +419,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
'graphQlOperations' => null,
@@ -427,6 +435,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
SingleFileConfigDummy::class => [
@@ -501,6 +511,8 @@ public function testValidYaml(): void
'jsonStream' => null,
'map' => null,
'jsonldContext' => null,
+
+ 'throwOnNotFound' => null,
],
],
], $extractor->getResources());
diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php
index c6c65a3888..734229d4ae 100644
--- a/src/State/Provider/ReadProvider.php
+++ b/src/State/Provider/ReadProvider.php
@@ -88,14 +88,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$data = null;
}
- if (
- null === $data
- && 'POST' !== $operation->getMethod()
- && ('PUT' !== $operation->getMethod()
- || ($operation instanceof Put && !($operation->getAllowCreate() ?? false))
- )
- ) {
- throw new NotFoundHttpException('Not Found', $e ?? null);
+ if (null === $data) {
+ $throwOnNotFound = $operation->getThrowOnNotFound();
+ if (null === $throwOnNotFound) {
+ $throwOnNotFound = 'POST' !== $operation->getMethod()
+ && ('PUT' !== $operation->getMethod()
+ || ($operation instanceof Put && !($operation->getAllowCreate() ?? false))
+ );
+ }
+
+ if ($throwOnNotFound) {
+ throw new NotFoundHttpException('Not Found', $e ?? null);
+ }
}
$request?->attributes->set('data', $data);
diff --git a/src/State/Tests/Provider/ReadProviderTest.php b/src/State/Tests/Provider/ReadProviderTest.php
index 92cfce527d..3b5f6ee209 100644
--- a/src/State/Tests/Provider/ReadProviderTest.php
+++ b/src/State/Tests/Provider/ReadProviderTest.php
@@ -15,11 +15,14 @@
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\Put;
use ApiPlatform\State\Provider\ReadProvider;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ReadProviderTest extends TestCase
{
@@ -61,4 +64,71 @@ public function testWithoutRequest(): void
$readProvider = new ReadProvider($provider, $serializerContextBuilder);
$this->assertEquals($readProvider->provide($operation), ['ok']);
}
+
+ public function testThrowOnNotFoundExplicitTrueThrowsForPost(): void
+ {
+ $operation = new Post(read: true, throwOnNotFound: true);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $this->expectException(NotFoundHttpException::class);
+ $provider->provide($operation, ['id' => 1], ['request' => new Request()]);
+ }
+
+ public function testThrowOnNotFoundExplicitFalseSkipsThrowForGet(): void
+ {
+ $operation = new Get(read: true, throwOnNotFound: false);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $request = new Request();
+ $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request]));
+ $this->assertNull($request->attributes->get('data'));
+ }
+
+ public function testThrowOnNotFoundDefaultThrowsForGet(): void
+ {
+ $operation = new Get(read: true);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $this->expectException(NotFoundHttpException::class);
+ $provider->provide($operation, ['id' => 1], ['request' => new Request()]);
+ }
+
+ public function testThrowOnNotFoundDefaultSkipsThrowForPost(): void
+ {
+ $operation = new Post(read: true);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $request = new Request();
+ $this->assertNull($provider->provide($operation, [], ['request' => $request]));
+ }
+
+ public function testThrowOnNotFoundDefaultThrowsForPutWithoutAllowCreate(): void
+ {
+ $operation = new Put(read: true);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $this->expectException(NotFoundHttpException::class);
+ $provider->provide($operation, ['id' => 1], ['request' => new Request()]);
+ }
+
+ public function testThrowOnNotFoundDefaultSkipsThrowForPutWithAllowCreate(): void
+ {
+ $operation = new Put(read: true, allowCreate: true);
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(null);
+
+ $provider = new ReadProvider($decorated);
+ $request = new Request();
+ $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request]));
+ }
}
diff --git a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php
new file mode 100644
index 0000000000..2456a1d148
--- /dev/null
+++ b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php
@@ -0,0 +1,40 @@
+
+ *
+ * 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\ThrowOnNotFound;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+
+#[ApiResource(operations: [
+ new Post(
+ uriTemplate: '/throw_on_not_found_feeders/{id}/feed',
+ throwOnNotFound: true,
+ provider: [Feeder::class, 'provide'],
+ read: true,
+ ),
+ new Post(
+ uriTemplate: '/throw_on_not_found_feeders/{id}/feed_default',
+ provider: [Feeder::class, 'provide'],
+ read: true,
+ ),
+])]
+final class Feeder
+{
+ public ?int $id = null;
+
+ public static function provide(): null
+ {
+ return null;
+ }
+}
diff --git a/tests/Functional/ThrowOnNotFoundTest.php b/tests/Functional/ThrowOnNotFoundTest.php
new file mode 100644
index 0000000000..060f1cedf4
--- /dev/null
+++ b/tests/Functional/ThrowOnNotFoundTest.php
@@ -0,0 +1,50 @@
+
+ *
+ * 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;
+
+use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
+use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ThrowOnNotFound\Feeder;
+use ApiPlatform\Tests\SetupClassResourcesTrait;
+
+final class ThrowOnNotFoundTest extends ApiTestCase
+{
+ use SetupClassResourcesTrait;
+
+ protected static ?bool $alwaysBootKernel = false;
+
+ public static function getResources(): array
+ {
+ return [Feeder::class];
+ }
+
+ public function testPostWithThrowOnNotFoundReturns404WhenProviderReturnsNull(): void
+ {
+ self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed', [
+ 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'],
+ 'body' => '{}',
+ ]);
+
+ $this->assertResponseStatusCodeSame(404);
+ }
+
+ public function testPostDefaultDoesNotReturn404WhenProviderReturnsNull(): void
+ {
+ self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed_default', [
+ 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'],
+ 'body' => '{}',
+ ]);
+
+ $this->assertNotSame(404, self::getClient()->getResponse()->getStatusCode());
+ }
+}