diff --git a/.github/workflows/guides.yaml b/.github/workflows/guides.yaml new file mode 100644 index 00000000000..3f910047eef --- /dev/null +++ b/.github/workflows/guides.yaml @@ -0,0 +1,62 @@ +name: Guides + +on: + push: + pull_request: + +env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERAGE: '0' + SYMFONY_DEPRECATIONS_HELPER: max[self]=0 + +jobs: + docs: + name: Test guides + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup PHP with pre-release PECL extension + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite + coverage: none + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + shell: bash + - name: Global require pdg + run: | + cd $(composer -n config --global home) + echo "{\"repositories\":[{\"type\":\"vcs\",\"url\":\"https://github.com/php-documentation-generator/php-documentation-generator\"}]}" > composer.json + composer global config --no-plugins allow-plugins.symfony/runtime true + composer global require php-documentation-generator/php-documentation-generator:dev-main + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install project dependencies + working-directory: docs + run: composer install --no-interaction --no-progress --ansi + - name: Test guides + working-directory: docs + env: + APP_DEBUG: 0 + PDG_AUTOLOAD: ${{ github.workspace }}/docs/vendor/autoload.php + KERNEL_CLASS: \ApiPlatform\Playground\Kernel + run: | + for d in guides/*.php; do + rm -f var/data.db + echo "Testing guide $d" + pdg-phpunit $d + code=$? + if [[ $code -ne 0 ]]; then + break + fi + done + exit $code diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index abfd314adcc..fdee8b99b3d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -16,6 +16,7 @@ ->exclude([ 'src/Core/Bridge/Symfony/Maker/Resources/skeleton', 'tests/Fixtures/app/var', + 'docs/guides', ]) ->notPath('src/Symfony/Bundle/DependencyInjection/Configuration.php') ->notPath('src/Annotation/ApiFilter.php') // temporary diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000000..64cb047953f --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,15 @@ +pages/guide/**/*.mdx +pages/reference/**/*.mdx +pages/tutorial/**/*.mdx +pages/core +pages/create-client +pages/deployment +pages/distribution +pages/extra +pages/schema-generator +pages/sidebar.mdx +.next +node_modules +composer.lock +vendor +var diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000000..0c9ff0086d7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# API Platform documentation + +## Guides + +A guide is a PHP executable file that will be transformed into documentation. It follows [Diataxis How-To Guides](https://diataxis.fr/how-to-guides/) practice which is a must read before writing a guide. + +Guides are transformed to Markdown using [php-documentation-generator](https://github.com/php-documentation-generator/php-documentation-generator) which is merely a version of [docco](https://ashkenas.com/docco/) in PHP adapted to output markdown. + +## WASM + +Guides are executable in a browser environment and need to be preloaded using: + +``` +docker run -v $(pwd):/src -v $(pwd)/public/php-wasm:/public -w /public php-wasm python3 /emsdk/upstream/emscripten/tools/file_packager.py php-web.data --preload "/src" --js-output=php-web.data.js --no-node --exclude '*Tests*' '*features*' '*public*' '*/.*' +``` + +A build of [php-wasm](https://github.com/soyuka/php-wasm) is needed in the `public/php-wasm` directory to try it out. diff --git a/docs/composer.json b/docs/composer.json new file mode 100644 index 00000000000..87039d13aa1 --- /dev/null +++ b/docs/composer.json @@ -0,0 +1,57 @@ +{ + "name": "api-platform/playground", + "description": "API Platform wasm playground", + "type": "project", + "license": "MIT", + "autoload": { + "psr-4": { + "ApiPlatform\\Playground\\": "src/" + } + }, + "authors": [ + { + "name": "soyuka", + "email": "soyuka@users.noreply.github.com" + } + ], + "require": { + "api-platform/core": "*", + "symfony/expression-language": "6.2.*", + "nelmio/cors-bundle": "^2.2", + "phpstan/phpdoc-parser": "^1.15", + "symfony/framework-bundle": "6.2.*", + "symfony/property-access": "6.2.*", + "symfony/property-info": "6.2.*", + "symfony/runtime": "6.2.*", + "symfony/security-bundle": "6.2.*", + "symfony/serializer": "6.2.*", + "symfony/validator": "6.2.*", + "symfony/yaml": "^6.2", + "doctrine/orm": "^2.14", + "doctrine/doctrine-migrations-bundle": "^3.2", + "doctrine/doctrine-bundle": "^2.9", + "doctrine/doctrine-fixtures-bundle": "^3.4", + "zenstruck/foundry": "^1.31", + "symfony/http-client": "^6.2", + "symfony/browser-kit": "^6.2", + "justinrainbow/json-schema": "^5.2" + }, + "repositories": [ + { + "type": "path", + "url": "../", + "options": { + "symlink": false + } + } + ], + "config": { + "allow-plugins": { + "symfony/runtime": true + } + }, + "require-dev": { + "phpunit/phpunit": "^10" + }, + "minimum-stability": "dev" +} diff --git a/docs/config/bundles.php b/docs/config/bundles.php new file mode 100644 index 00000000000..efff49d50a8 --- /dev/null +++ b/docs/config/bundles.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +return [ + Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + // Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['all' => true], +]; diff --git a/docs/config/packages/doctrine.yaml b/docs/config/packages/doctrine.yaml new file mode 100644 index 00000000000..2e58fa97242 --- /dev/null +++ b/docs/config/packages/doctrine.yaml @@ -0,0 +1,7 @@ +doctrine: + dbal: + url: 'sqlite:///%kernel.project_dir%/var/data.db' + orm: + auto_generate_proxy_classes: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: false diff --git a/docs/config/packages/doctrine_migrations.yaml b/docs/config/packages/doctrine_migrations.yaml new file mode 100644 index 00000000000..4cfb18f0905 --- /dev/null +++ b/docs/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/docs/config/packages/framework.yaml b/docs/config/packages/framework.yaml new file mode 100644 index 00000000000..fe5f429a970 --- /dev/null +++ b/docs/config/packages/framework.yaml @@ -0,0 +1,3 @@ +when@test: + framework: + test: true diff --git a/docs/config/preload.php b/docs/config/preload.php new file mode 100644 index 00000000000..a165a45ebba --- /dev/null +++ b/docs/config/preload.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) { + require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php'; +} diff --git a/docs/config/routes/api_platform.yaml b/docs/config/routes/api_platform.yaml new file mode 100644 index 00000000000..14f6e99921e --- /dev/null +++ b/docs/config/routes/api_platform.yaml @@ -0,0 +1,4 @@ +api_platform: + resource: . + type: api_platform + prefix: / diff --git a/docs/config/routes/framework.yaml b/docs/config/routes/framework.yaml new file mode 100644 index 00000000000..0fc74bbac4b --- /dev/null +++ b/docs/config/routes/framework.yaml @@ -0,0 +1,4 @@ +when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + prefix: /_error diff --git a/docs/config/services.yaml b/docs/config/services.yaml new file mode 100644 index 00000000000..8ed41090bf9 --- /dev/null +++ b/docs/config/services.yaml @@ -0,0 +1,11 @@ +services: + _defaults: + autowire: true + autoconfigure: true + App\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Kernel.php' + - '../src/guide.php' diff --git a/docs/guides/collect-denormalization-errors.php b/docs/guides/collect-denormalization-errors.php new file mode 100644 index 00000000000..19351dbe22d --- /dev/null +++ b/docs/guides/collect-denormalization-errors.php @@ -0,0 +1,46 @@ + strategy + */ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + { + /* + * Otherwise this filter is applied to order and page as well. + */ + if ( + !$this->isPropertyEnabled($property, $resourceClass) || + !$this->isPropertyMapped($property, $resourceClass) + ) { + return; + } + + /* + * Generate a unique parameter name to avoid collisions with other filters. + */ + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder + ->andWhere(sprintf('REGEXP(o.%s, :%s) = 1', $property, $parameterName)) + ->setParameter($parameterName, $value); + } + + /* + * This function is only used to hook in documentation generators (supported by Swagger and Hydra). + */ + public function getDescription(string $resourceClass): array + { + if (!$this->properties) { + return []; + } + + $description = []; + foreach ($this->properties as $property => $strategy) { + $description["regexp_$property"] = [ + 'property' => $property, + 'type' => Type::BUILTIN_TYPE_STRING, + 'required' => false, + 'description' => 'Filter using a regex. This will appear in the OpenAPI documentation!', + 'openapi' => [ + 'example' => 'Custom example that will be in the documentation and be the default value of the sandbox', + /* + * If true, query parameters will be not percent-encoded + */ + 'allowReserved' => false, + 'allowEmptyValue' => true, + /* + * To be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product[]=blue&product[]=green + */ + 'explode' => false, + ], + ]; + } + + return $description; + } + } +} + +namespace App\Entity { + use ApiPlatform\Metadata\ApiFilter; + use ApiPlatform\Metadata\ApiResource; + use App\Filter\RegexpFilter; + use Doctrine\ORM\Mapping as ORM; + + #[ORM\Entity] + #[ApiResource] + #[ApiFilter(RegexpFilter::class, properties: ['title'])] + class Book + { + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\Column] + public string $title; + + #[ORM\Column] + #[ApiFilter(RegexpFilter::class)] + public string $author; + } +} + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/books.jsonld?regexp_title=^[Found]', 'GET'); + } +} + +namespace DoctrineMigrations { + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL)'); + } + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testAsAnonymousICanAccessTheDocumentation(): void + { + static::createClient()->request('GET', '/books.jsonld?regexp_title=^[Found]'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection'); + $this->assertJsonContains([ + 'hydra:search' => [ + '@type' => 'hydra:IriTemplate', + 'hydra:template' => '/books.jsonld{?regexp_title,regexp_author}', + 'hydra:variableRepresentation' => 'BasicRepresentation', + 'hydra:mapping' => [ + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'regexp_title', + 'property' => 'title', + 'required' => false, + ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'regexp_author', + 'property' => 'author', + 'required' => false, + ], + ], + ], + ]); + } + } +} diff --git a/docs/guides/create-a-custom-elasticsearch-filter.php b/docs/guides/create-a-custom-elasticsearch-filter.php new file mode 100644 index 00000000000..089a58c5fdd --- /dev/null +++ b/docs/guides/create-a-custom-elasticsearch-filter.php @@ -0,0 +1,35 @@ + $context['filters']['title'], + 'operator' => 'and', + ]; + + return $requestBody; + } + } +} diff --git a/docs/guides/declare-a-resource.php b/docs/guides/declare-a-resource.php new file mode 100644 index 00000000000..6723d43c714 --- /dev/null +++ b/docs/guides/declare-a-resource.php @@ -0,0 +1,48 @@ + 422 + ] +)] +// If a property named `id` is found it is the property used in your URI template +// we recommend to use public properties to declare API resources. +class Book +{ + public string $id; +} +// Select the [next example](./hook-a-persistence-layer-with-a-processor) to see how to hook a persistence layer. diff --git a/docs/guides/doctrine-orm-and-mongodb-odm-attribute-filters.php b/docs/guides/doctrine-orm-and-mongodb-odm-attribute-filters.php new file mode 100644 index 00000000000..6d93206e6f4 --- /dev/null +++ b/docs/guides/doctrine-orm-and-mongodb-odm-attribute-filters.php @@ -0,0 +1,123 @@ +addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE book'); + } + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testAsAnonymousICanAccessTheDocumentation(): void + { + static::createClient()->request('GET', '/books.jsonld'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); + $this->assertJsonContains([ + 'hydra:search' => [ + '@type' => 'hydra:IriTemplate', + 'hydra:template' => '/books.jsonld{?title,title[],author,author[]}', + 'hydra:variableRepresentation' => 'BasicRepresentation', + 'hydra:mapping' => [ + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'title', + 'property' => 'title', + 'required' => false, + ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'title[]', + 'property' => 'title', + 'required' => false, + ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'author', + 'property' => 'author', + 'required' => false, + ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'author[]', + 'property' => 'author', + 'required' => false, + ], + ], + ], + ]); + } + } +} diff --git a/docs/guides/doctrine-orm-and-mongodb-odm-service-filters.php b/docs/guides/doctrine-orm-and-mongodb-odm-service-filters.php new file mode 100644 index 00000000000..9692edb94b0 --- /dev/null +++ b/docs/guides/doctrine-orm-and-mongodb-odm-service-filters.php @@ -0,0 +1,114 @@ +services() + ->set('book.search_filter') + ->parent('api_platform.doctrine.orm.search_filter') + ->args([['title' => null]]) + ->tag('api_platform.filter') + ->autowire(false) + ->autoconfigure(false) + ->public(false) + ; + } +} + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/books.jsonld', 'GET'); + } +} + +namespace DoctrineMigrations { + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE book'); + } + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testAsAnonymousICanAccessTheDocumentation(): void + { + static::createClient()->request('GET', '/books.jsonld'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); + $this->assertJsonContains([ + 'hydra:search' => [ + '@type' => 'hydra:IriTemplate', + 'hydra:template' => '/books.jsonld{?title,title[]}', + 'hydra:variableRepresentation' => 'BasicRepresentation', + 'hydra:mapping' => [ + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'title', + 'property' => 'title', + 'required' => false, + ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'title[]', + 'property' => 'title', + 'required' => false, + ], + ], + ], + ]); + } + } +} diff --git a/docs/guides/extend-openapi-documentation.php b/docs/guides/extend-openapi-documentation.php new file mode 100644 index 00000000000..6061a3a057b --- /dev/null +++ b/docs/guides/extend-openapi-documentation.php @@ -0,0 +1,60 @@ +decorated = $decorated; + } + + public function __invoke(array $context = []): OpenApi + { + $openApi = $this->decorated->__invoke($context); + $pathItem = $openApi->getPaths()->getPath('/api/grumpy_pizzas/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/api/grumpy_pizzas/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + return $openApi; + } + } +} + +namespace App\Configurator { + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + function configure(ContainerConfigurator $configurator) { + $services = $configurator->services(); + $services->set(App\OpenApi\OpenApiFactory::class)->decorate('api_platform.openapi.factory'); + }; +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + + class OpenApiTestCase extends ApiTestCase { + + } +} diff --git a/docs/guides/handle-a-pagination-on-a-custom-collection.php b/docs/guides/handle-a-pagination-on-a-custom-collection.php new file mode 100644 index 00000000000..836892f8e57 --- /dev/null +++ b/docs/guides/handle-a-pagination-on-a-custom-collection.php @@ -0,0 +1,177 @@ +createQueryBuilder('b') + ->where('b.published = :isPublished') + ->setParameter('isPublished', true) + ->addCriteria( + Criteria::create() + ->setFirstResult(($page - 1) * $itemsPerPage) + ->setMaxResults($itemsPerPage) + ) + ); + } + } +} + +namespace App\State { + + use ApiPlatform\Doctrine\Orm\Paginator; + use ApiPlatform\Metadata\Operation; + use ApiPlatform\State\Pagination\Pagination; + use ApiPlatform\State\ProviderInterface; + use App\Repository\BookRepository; + + class BooksListProvider implements ProviderInterface + { + public function __construct(private readonly BookRepository $bookRepository, private readonly Pagination $pagination) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): Paginator + { + /* Retrieve the pagination parameters from the context thanks to the Pagination object */ + [$page, , $limit] = $this->pagination->getPagination($operation, $context); + + /* Decorates the Doctrine Paginator object to the API Platform Paginator one */ + return new Paginator($this->bookRepository->getPublishedBooks($page, $limit)); + } + } +} + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/books.jsonld', 'GET'); + } +} + +namespace DoctrineMigrations { + + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, is_published SMALLINT NOT NULL)'); + } + } +} + +namespace App\Fixtures { + use App\Entity\Book; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use Zenstruck\Foundry\AnonymousFactory; + use function Zenstruck\Foundry\faker; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + /* Create books published or not */ + $factory = AnonymousFactory::new(Book::class); + $factory->many(5)->create(static function (int $i): array { + return [ + 'title' => faker()->title(), + 'published' => false, + ]; + }); + $factory->many(35)->create(static function (int $i): array { + return [ + 'title' => faker()->title(), + 'published' => true, + ]; + }); + } + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testTheCustomCollectionIsPaginated(): void + { + $response = static::createClient()->request('GET', '/books.jsonld'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); + $this->assertNotSame(0, $response->toArray(false)['hydra:totalItems'], 'The collection is empty.'); + $this->assertJsonContains([ + 'hydra:totalItems' => 35, + 'hydra:view' => [ + '@id' => '/books.jsonld?page=1', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => '/books.jsonld?page=1', + 'hydra:last' => '/books.jsonld?page=2', + 'hydra:next' => '/books.jsonld?page=2', + ], + ]); + } + } +} diff --git a/docs/guides/hook-a-persistence-layer-with-a-processor.php b/docs/guides/hook-a-persistence-layer-with-a-processor.php new file mode 100644 index 00000000000..903e3fa8bcd --- /dev/null +++ b/docs/guides/hook-a-persistence-layer-with-a-processor.php @@ -0,0 +1,42 @@ +id = '1'; + // As an exercise you can edit the code and add a second book in the collection. + return [$book]; + } + + $book = new Book(); + // The value at `$uriVariables['id']` is the one that matches the `{id}` variable of the **[URI template](/explanation/uri#uri-template)**. + $book->id = $uriVariables['id']; + return $book; + } + } +} + + diff --git a/docs/guides/secure-a-resource-access.php b/docs/guides/secure-a-resource-access.php new file mode 100644 index 00000000000..6b7dc2f540b --- /dev/null +++ b/docs/guides/secure-a-resource-access.php @@ -0,0 +1,57 @@ +security = $security; + } + + protected function supports($attribute, $subject): bool + { + // It supports several attributes related to our Resource access control. + $supportsAttribute = in_array($attribute, ['BOOK_CREATE', 'BOOK_READ', 'BOOK_EDIT', 'BOOK_DELETE']); + $supportsSubject = $subject instanceof Book; + + return $supportsAttribute && $supportsSubject; + } + + /** + * @param string $attribute + * @param Book $subject + * @param TokenInterface $token + * @return bool + */ + protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool + { + /** ... check if the user is anonymous ... **/ + + switch ($attribute) { + case 'BOOK_CREATE': + if ( $this->security->isGranted(Role::ADMIN) ) { return true; } // only admins can create books + break; + case 'BOOK_READ': + /** ... other autorization rules ... **/ + } + + return false; + } + } +} + +namespace App\ApiResource { + use ApiPlatform\Metadata\ApiResource; + use ApiPlatform\Metadata\Delete; + use ApiPlatform\Metadata\Get; + use ApiPlatform\Metadata\GetCollection; + use ApiPlatform\Metadata\Post; + use ApiPlatform\Metadata\Put; + + #[ApiResource(security: "is_granted('ROLE_USER')")] + // We can then use the `is_granted` expression with our access control attributes: + #[Get(security: "is_granted('BOOK_READ', object)")] + #[Put(security: "is_granted('BOOK_EDIT', object)")] + #[Delete(security: "is_granted('BOOK_DELETE', object)")] + // On a collection, you need to [implement a Provider](provide-the-resource-state) to filter the collection manually. + #[GetCollection] + // `object` is empty uppon creation, we use `securityPostDenormalize` to get the denormalized object. + #[Post(securityPostDenormalize: "is_granted('BOOK_CREATE', object)")] + class Book + { + // ... + } +} diff --git a/docs/guides/test-your-api.php b/docs/guides/test-your-api.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/guides/use-doctrine-orm-filters.php b/docs/guides/use-doctrine-orm-filters.php new file mode 100644 index 00000000000..71edd8b8762 --- /dev/null +++ b/docs/guides/use-doctrine-orm-filters.php @@ -0,0 +1,261 @@ +id; + } + } + + /* + * Each Book is related to a User, supposedly allowed to authenticate. + */ + #[ApiResource] + #[ORM\Entity] + /* + * This entity is restricted by current user: only current user books will be shown (cf. UserFilter). + */ + #[UserAware(userFieldName: 'user_id')] + class Book + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\ManyToOne(User::class)] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')] + public User $user; + + #[ORM\Column] + public ?string $title = null; + + public function getId(): ?int + { + return $this->id; + } + } +} + +namespace App\Attribute { + use Attribute; + + /* + * The UserAware attribute restricts entities to the current user. + */ + #[Attribute(Attribute::TARGET_CLASS)] + final class UserAware + { + public ?string $userFieldName = null; + } +} + +namespace App\Filter { + use App\Attribute\UserAware; + use Doctrine\ORM\Mapping\ClassMetadata; + use Doctrine\ORM\Query\Filter\SQLFilter; + + /* + * The UserFilter adds a `AND user_id = :user_id` in the SQL query. + */ + final class UserFilter extends SQLFilter + { + public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string + { + /* + * The Doctrine filter is called for any query on any entity. + * Check if the current entity is "user aware" (marked with an attribute). + */ + $userAware = $targetEntity->getReflectionClass()->getAttributes(UserAware::class)[0] ?? null; + + $fieldName = $userAware?->getArguments()['userFieldName'] ?? null; + if ('' === $fieldName || is_null($fieldName)) { + return ''; + } + + try { + /* + * Don't worry, getParameter automatically escapes parameters + */ + $userId = $this->getParameter('id'); + } catch (\InvalidArgumentException $e) { + /* + * No user ID has been defined + */ + return ''; + } + + if (empty($fieldName) || empty($userId)) { + return ''; + } + + return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId); + } + } +} + +namespace App\EventSubscriber { + use App\Entity\User; + use Doctrine\Persistence\ObjectManager; + use Symfony\Component\EventDispatcher\EventSubscriberInterface; + use Symfony\Component\HttpKernel\KernelEvents; + + /* + * Retrieve the current user id and set it as SQL query parameter. + */ + final class UserAwareEventSubscriber implements EventSubscriberInterface + { + public function __construct(private readonly ObjectManager $em) + { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => 'onKernelRequest', + ]; + } + + public function onKernelRequest(): void + { + /* + * You should retrieve the current user using the TokenStorage service. + * In this example, the user is forced by username to keep this guide simple. + */ + $user = $this->em->getRepository(User::class)->findOneBy(['username' => 'jane.doe']); + $filter = $this->em->getFilters()->enable('user_filter'); + $filter->setParameter('id', $user->getId()); + } + } +} + + namespace App\DependencyInjection { + + use App\EventSubscriber\UserAwareEventSubscriber; + use App\Filter\UserFilter; + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + use function Symfony\Component\DependencyInjection\Loader\Configurator\service; + + function configure(ContainerConfigurator $configurator) { + $services = $configurator->services(); + $services->set(UserAwareEventSubscriber::class) + ->args([service('doctrine.orm.default_entity_manager')]) + ->tag('kernel.event_subscriber') + ; + $configurator->extension('doctrine', [ + 'orm' => [ + 'filters' => [ + 'user_filter' => [ + 'class' => UserFilter::class, + 'enabled' => true, + ], + ], + ], + ]); + + } + } + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/books.jsonld', 'GET'); + } +} + +namespace DoctrineMigrations { + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL)'); + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES user (id))'); + } + } +} + +namespace App\Fixtures { + use App\Entity\Book; + use App\Entity\User; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $userFactory = anonymous(User::class); + $johnDoe = $userFactory->create(['username' => 'john.doe']); + $janeDoe = $userFactory->create(['username' => 'jane.doe']); + + $bookFactory = anonymous(Book::class); + $bookFactory->many(10)->create([ + 'title' => 'title', + 'user' => $johnDoe + ]); + $bookFactory->many(10)->create([ + 'title' => 'title', + 'user' => $janeDoe + ]); + } + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testAsAnonymousICanAccessTheDocumentation(): void + { + $response = static::createClient()->request('GET', '/books.jsonld'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); + $this->assertNotSame(0, $response->toArray(false)['hydra:totalItems'], 'The collection is empty.'); + $this->assertJsonContains([ + 'hydra:totalItems' => 10, + ]); + } + } +} diff --git a/docs/guides/use-messenger-with-an-input-object.php b/docs/guides/use-messenger-with-an-input-object.php new file mode 100644 index 00000000000..92e95757bcd --- /dev/null +++ b/docs/guides/use-messenger-with-an-input-object.php @@ -0,0 +1,178 @@ +addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, isbn VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE book'); + } + } +} + +namespace App\Dto { + use Symfony\Component\Validator\Constraints as Assert; + + /* + * This Input object only aims to import a Book from its ISBN. + */ + final class ImportBookRequest + { + #[Assert\NotBlank] + public string $isbn; + } +} + +namespace App\Handler { + use App\Dto\ImportBookRequest; + use App\Entity\Book; + use Doctrine\ORM\EntityManagerInterface; + use Symfony\Component\Messenger\Attribute\AsMessageHandler; + + #[AsMessageHandler] + final class ImportBookRequestHandler + { + public function __construct(private readonly EntityManagerInterface $entityManager) + { + } + + /* + * When a `POST` request is issued on `/books/import`, this Message Handler will receive an + * `App\Dto\ImportBookRequest` object instead of a `Book` one because we specified it as `input` + * and set `messenger=input`. + */ + public function __invoke(ImportBookRequest $request) + { + /* + * Create the real Book object from the Input one. + * (you should probably want to import the Book data from a public API) + */ + $book = new Book(); + $book->isbn = $request->isbn; + $book->title = 'Le problème à trois corps'; + $book->author = 'Cixin Liu'; + + /* + * Save the real Book object in the database. + */ + $this->entityManager->persist($book); + $this->entityManager->flush(); + } + } +} + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/books/import', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], <<request('POST', '/books/import', [ + 'json' => [ + 'isbn' => '9782330113551', + ], + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED); + + /* + * Check the Message Handler has been successfully called. + * The Book should have been imported and created in the database. + */ + static::getContainer()->get('doctrine')->getRepository(Book::class)->findOneBy([ + 'isbn' => '9782330113551', + ]); + } + } +} diff --git a/docs/guides/use-validation-groups.php b/docs/guides/use-validation-groups.php new file mode 100644 index 00000000000..8bbb2a051da --- /dev/null +++ b/docs/guides/use-validation-groups.php @@ -0,0 +1,67 @@ + ['a', 'b']], + operations: [ + // When configured on a specific operation the configuration takes precedence over the one declared on the ApiResource. + // You can use a [callable](https://www.php.net/manual/en/language.types.callable.php) instead of strings. + new Get(validationContext: ['groups' => [Book::class, 'validationGroups']]), + new GetCollection(), + // You sometimes want to specify in which order groups must be tested against. On the Post operation, we use a Symfony service + // to use a [group sequence](http://symfony.com/doc/current/validation/sequence_provider.html). + new Post(validationContext: ['groups' => MySequencedGroup::class]) + ] + )] + final class Book + { + #[Assert\NotBlank(groups: ['a'])] + public string $name; + + #[Assert\NotNull(groups: ['b'])] + public string $author; + + /** + * Return dynamic validation groups. + * + * @param self $book Contains the instance of Book to validate. + * + * @return string[] + */ + public static function validationGroups(self $book) + { + return ['a']; + } + } +} + +namespace App\Validator { + use Symfony\Component\Validator\Constraints\GroupSequence; + + final class MySequencedGroup + { + public function __invoke(): GroupSequence + { + return new GroupSequence(['a', 'b']); // now, no matter which is first in the class declaration, it will be tested in this order. + } + } +} + +// To go further, read the guide on [Validating data on a Delete operation](./validate-data-on-a-delete-operation) diff --git a/docs/guides/validate-data-on-a-delete-operation.php b/docs/guides/validate-data-on-a-delete-operation.php new file mode 100644 index 00000000000..e287166b582 --- /dev/null +++ b/docs/guides/validate-data-on-a-delete-operation.php @@ -0,0 +1,91 @@ + ['deleteValidation']], processor: BookRemoveProcessor::class)] + // Here we use the previously created constraint on the class directly. + #[AssertCanDelete(groups: ['deleteValidation'])] + class Book + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + public string $title = ''; + } +} + +// Then, we will trigger the validation within a processor. +// the removal into the Database. +namespace App\State { + use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor; + use ApiPlatform\Metadata\Operation; + use ApiPlatform\State\ProcessorInterface; + use ApiPlatform\Validator\ValidatorInterface; + use Symfony\Component\DependencyInjection\Attribute\Autowire; + + class BookRemoveProcessor implements ProcessorInterface + { + public function __construct( + // We're decorating API Platform's Doctrine processor to persist the removal. + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private DoctrineRemoveProcessor $doctrineProcessor, + private ValidatorInterface $validator, + ) { + } + + public function process($data, Operation $operation, array $uriVariables = [], array $context = []) + { + // First step is to trigger Symfony's validation. + $this->validator->validate($data, ['groups' => ['deleteValidation']]); + // Then we persist the data. + $this->doctrineProcessor->process($data, $operation, $uriVariables, $context); + } + } +} + +// TODO move this to reference somehow +// This operation uses a Callable as group so that you can vary the Validation according to your dataset +// new Get(validationContext: ['groups' =>]) +// ## Sequential Validation Groups +// If you need to specify the order in which your validation groups must be tested against, you can use a [group sequence](http://symfony.com/doc/current/validation/sequence_provider.html). diff --git a/docs/guides/validate-incoming-data.php b/docs/guides/validate-incoming-data.php new file mode 100644 index 00000000000..62d90ff5a23 --- /dev/null +++ b/docs/guides/validate-incoming-data.php @@ -0,0 +1,103 @@ + Deserialization +//Deserialization --> Validation +//Validation --> Persister +//Persister --> Serialization +//Serialization --> Response + +// In this guide we're going to use [Symfony's built-in constraints](http://symfony.com/doc/current/reference/constraints.html) and a [custom constraint](http://symfony.com/doc/current/validation/custom_constraint.html). Let's start by shaping our to-be-validated resource: + +namespace App\Entity { + use ApiPlatform\Metadata\ApiResource; + // A custom constraint. + use App\Validator\Constraints\MinimalProperties; + use Doctrine\ORM\Mapping as ORM; + // Symfony's built-in constraints + use Symfony\Component\Validator\Constraints as Assert; + + /** + * A product. + */ + #[ORM\Entity] + #[ApiResource] + class Product + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + #[Assert\NotBlank] + public string $name; + + /** + * @var string[] Describe the product + */ + #[MinimalProperties] + #[ORM\Column(type: 'json')] + public $properties; + } +} + +// The `MinimalProperties` constraint will check that the `properties` data holds at least two values: description and price. +// We start by creating the constraint: +namespace App\Validator\Constraints { + use Symfony\Component\Validator\Constraint; + + #[\Attribute] + class MinimalProperties extends Constraint + { + public $message = 'The product must have the minimal properties required ("description", "price")'; + } +} + +// Then the validator following [Symfony's naming conventions](https://symfony.com/doc/current/validation/custom_constraint.html#creating-the-validator-itself) + +namespace App\Validator\Constraints { + use Symfony\Component\Validator\Constraint; + use Symfony\Component\Validator\ConstraintValidator; + + final class MinimalPropertiesValidator extends ConstraintValidator + { + public function validate($value, Constraint $constraint): void + { + if (!array_diff(['description', 'price'], $value)) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } + } +} + +//If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): +// ```json +// { +// "@context": "/contexts/ConstraintViolationList", +// "@type": "ConstraintViolationList", +// "hydra:title": "An error occurred", +// "hydra:description": "properties: The product must have the minimal properties required (\"description\", \"price\")", +// "violations": [ +// { +// "propertyPath": "properties", +// "message": "The product must have the minimal properties required (\"description\", \"price\")" +// } +// ] +// } +// ``` +// +// Take a look at the [Errors Handling guide](errors.md) to learn how API Platform converts PHP exceptions like validation +// errors to HTTP errors. diff --git a/docs/pdg.config.yaml b/docs/pdg.config.yaml new file mode 100644 index 00000000000..7e079b3d41b --- /dev/null +++ b/docs/pdg.config.yaml @@ -0,0 +1,13 @@ +pdg: + guides: + base_url: '/docs/guide' + output: 'dist/guides' + src: './guides' + references: + base_url: '/docs/reference' + exclude: ['*Factory.php', '*.tpl.php'] + exclude_path: ['JsonSchema/Tests', 'Metadata/Tests', 'OpenApi/Tests'] + namespace: 'ApiPlatform' + output: 'dist/reference' + src: '../src' + tags_to_ignore: ['@experimental', '@internal'] diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 00000000000..e6870241330 Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/public/index.html b/docs/public/index.html new file mode 100644 index 00000000000..0c458fd114b --- /dev/null +++ b/docs/public/index.html @@ -0,0 +1,82 @@ +

+
+
+
diff --git a/docs/src/DependencyInjection/Compiler/AttributeFilterPass.php b/docs/src/DependencyInjection/Compiler/AttributeFilterPass.php
new file mode 100644
index 00000000000..a6bf1432e3d
--- /dev/null
+++ b/docs/src/DependencyInjection/Compiler/AttributeFilterPass.php
@@ -0,0 +1,84 @@
+
+ *
+ * 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\Playground\DependencyInjection\Compiler;
+
+use ApiPlatform\Util\AttributeFilterExtractorTrait;
+use Symfony\Component\DependencyInjection\ChildDefinition;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
+
+/**
+ * @author Antoine Bluchet 
+ */
+final class AttributeFilterPass implements CompilerPassInterface
+{
+    use AttributeFilterExtractorTrait;
+
+    private const TAG_FILTER_NAME = 'api_platform.filter';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function process(ContainerBuilder $container): void
+    {
+        foreach (get_declared_classes() as $class) {
+            $this->createFilterDefinitions(new \ReflectionClass($class), $container);
+        }
+    }
+
+    /**
+     * @throws InvalidArgumentException
+     */
+    private function createFilterDefinitions(\ReflectionClass $resourceReflectionClass, ContainerBuilder $container): void
+    {
+        foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass]) {
+            if ($container->has($id)) {
+                continue;
+            }
+
+            if (null === $filterReflectionClass = $container->getReflectionClass($filterClass, false)) {
+                throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $filterClass, $id));
+            }
+
+            if ($container->has($filterClass) && ($parentDefinition = $container->findDefinition($filterClass))->isAbstract()) {
+                $definition = new ChildDefinition($parentDefinition->getClass());
+            } else {
+                $definition = new Definition($filterReflectionClass->getName());
+                $definition->setAutoconfigured(true);
+            }
+
+            $definition->addTag(self::TAG_FILTER_NAME);
+            $definition->setAutowired(true);
+
+            $parameterNames = [];
+            if (null !== $constructorReflectionMethod = $filterReflectionClass->getConstructor()) {
+                foreach ($constructorReflectionMethod->getParameters() as $reflectionParameter) {
+                    $parameterNames[$reflectionParameter->name] = true;
+                }
+            }
+
+            foreach ($arguments as $key => $value) {
+                if (!isset($parameterNames[$key])) {
+                    throw new InvalidArgumentException(sprintf('Class "%s" does not have argument "$%s".', $filterClass, $key));
+                }
+
+                $definition->setArgument("$$key", $value);
+            }
+
+            $container->setDefinition($id, $definition);
+        }
+    }
+}
diff --git a/docs/src/DependencyInjection/Compiler/FilterPass.php b/docs/src/DependencyInjection/Compiler/FilterPass.php
new file mode 100644
index 00000000000..826e3db7938
--- /dev/null
+++ b/docs/src/DependencyInjection/Compiler/FilterPass.php
@@ -0,0 +1,39 @@
+
+ *
+ * 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\Playground\DependencyInjection\Compiler;
+
+use ApiPlatform\Exception\RuntimeException;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+
+/**
+ * Injects API Platform filters.
+ */
+final class FilterPass implements CompilerPassInterface
+{
+    use PriorityTaggedServiceTrait;
+
+    /**
+     * {@inheritdoc}
+     *
+     * @throws RuntimeException
+     */
+    public function process(ContainerBuilder $container): void
+    {
+        $container
+            ->getDefinition('api_platform.filter_locator')
+            ->addArgument($this->findAndSortTaggedServices('api_platform.filter', $container));
+    }
+}
diff --git a/docs/src/Doctrine/StaticMappingDriver.php b/docs/src/Doctrine/StaticMappingDriver.php
new file mode 100644
index 00000000000..48a48ed133c
--- /dev/null
+++ b/docs/src/Doctrine/StaticMappingDriver.php
@@ -0,0 +1,33 @@
+
+ *
+ * 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\Playground\Doctrine;
+
+use Doctrine\ORM\Mapping\Driver\AttributeDriver;
+use Doctrine\ORM\Mapping\Driver\AttributeReader;
+
+final class StaticMappingDriver extends AttributeDriver
+{
+    /**
+     * @param class-string[] $classes
+     */
+    public function __construct(private readonly array $classes)
+    {
+        $this->reader = new AttributeReader();
+    }
+
+    public function getAllClassNames(): array
+    {
+        return $this->classes;
+    }
+}
diff --git a/docs/src/Kernel.php b/docs/src/Kernel.php
new file mode 100644
index 00000000000..30f4e109d57
--- /dev/null
+++ b/docs/src/Kernel.php
@@ -0,0 +1,202 @@
+
+ *
+ * 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\Playground;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Playground\DependencyInjection\Compiler\AttributeFilterPass;
+use ApiPlatform\Playground\DependencyInjection\Compiler\FilterPass;
+use ApiPlatform\Playground\Doctrine\StaticMappingDriver;
+use ApiPlatform\Playground\Metadata\Resource\Factory\ClassResourceNameCollectionFactory;
+use Doctrine\Migrations\Configuration\Configuration;
+use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
+use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration;
+use Doctrine\Migrations\DependencyFactory;
+use Doctrine\Migrations\Metadata\Storage\TableMetadataStorageConfiguration;
+use Doctrine\Migrations\Version\Direction;
+use Doctrine\Migrations\Version\Version;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Component\Config\Loader\LoaderInterface;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\DependencyInjection\Compiler\PassConfig;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Kernel as BaseKernel;
+
+use function App\DependencyInjection\configure; // @phpstan-ignore-line
+use function App\Playground\request;
+
+class Kernel extends BaseKernel
+{
+    use MicroKernelTrait;
+    private $declaredClasses = [];
+
+    public function __construct(string $environment, bool $debug, private string $guide = '')
+    {
+        parent::__construct($environment, $debug);
+        $this->guide = $_ENV['GUIDE_NAME'] ?? $guide ?? 'test';
+        require_once "{$this->getProjectDir()}/guides/{$this->guide}.php";
+    }
+
+    private function configureContainer(ContainerConfigurator $container, LoaderInterface $loader, ContainerBuilder $builder): void
+    {
+        $configDir = $this->getConfigDir();
+
+        $container->import($configDir.'/{packages}/*.{php,yaml}');
+
+        $services = $container->services()
+            ->defaults()
+            ->autowire()
+            ->autoconfigure();
+
+        $resources = [];
+        $entities = [];
+
+        foreach ($this->getDeclaredClasses() as $class) {
+            $refl = new \ReflectionClass($class);
+            $ns = $refl->getNamespaceName();
+            if (!str_starts_with($ns, 'App')) {
+                continue;
+            }
+
+            if (!str_starts_with($ns, 'App\\Entity')) {
+                $entities[] = $class;
+            }
+
+            if ($refl->getAttributes(ApiResource::class, \ReflectionAttribute::IS_INSTANCEOF)) {
+                $resources[] = $class;
+                continue;
+            }
+
+            $services->set($class);
+        }
+
+        $services->set(ClassResourceNameCollectionFactory::class)->args(['$classes' => $resources]);
+
+        $builder->addCompilerPass(new AttributeFilterPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 101);
+        $builder->addCompilerPass(new FilterPass());
+
+        $container->parameters()->set(
+            'database_url',
+            sprintf('sqlite:///%s/%s', $this->getCacheDir(), 'data.db')
+        );
+
+        $services->set('doctrine.orm.default_metadata_driver', StaticMappingDriver::class)->args(['$classes' => $resources]);
+
+        if (\function_exists('App\DependencyInjection\configure')) {
+            configure($container);
+        }
+    }
+
+    public function request(Request $request = null): Response
+    {
+        if (null === $request && \function_exists('App\Playground\request')) {
+            $request = request();
+        }
+
+        $request = $request ?? Request::create('/docs.json');
+        $response = $this->handle($request);
+        $response->send();
+        $this->terminate($request, $response);
+
+        return $response;
+    }
+
+    public function getCacheDir(): string
+    {
+        return parent::getCacheDir().\DIRECTORY_SEPARATOR.$this->guide;
+    }
+
+    public function executeMigrations(string $direction = Direction::UP): void
+    {
+        $migrationClasses = $this->getDeclaredClassesForNamespace('DoctrineMigrations');
+
+        if (!$migrationClasses) {
+            return;
+        }
+
+        $this->boot();
+
+        foreach ($migrationClasses as $migrationClass) {
+            if ("Doctrine\Migrations\AbstractMigration" !== (new \ReflectionClass($migrationClass))->getParentClass()->getName()) {
+                continue;
+            }
+            $conf = new Configuration();
+            $conf->addMigrationClass($migrationClass);
+            $conf->setTransactional(true);
+            $conf->setCheckDatabasePlatform(true);
+            $meta = new TableMetadataStorageConfiguration();
+            $meta->setTableName('doctrine_migration_versions');
+            $conf->setMetadataStorageConfiguration($meta);
+
+            $confLoader = new ExistingConfiguration($conf);
+            /** @var EntityManagerInterface $em */
+            $em = $this->getContainer()->get('doctrine.orm.entity_manager');
+            $loader = new ExistingEntityManager($em);
+            $dependencyFactory = DependencyFactory::fromEntityManager($confLoader, $loader);
+
+            $dependencyFactory->getMetadataStorage()->ensureInitialized();
+            $executed = $dependencyFactory->getMetadataStorage()->getExecutedMigrations();
+
+            if ($executed->hasMigration(new Version($migrationClass)) && Direction::DOWN !== $direction) {
+                continue;
+            }
+
+            $planCalculator = $dependencyFactory->getMigrationPlanCalculator();
+            $plan = $planCalculator->getPlanForVersions([new Version($migrationClass)], $direction);
+            $migrator = $dependencyFactory->getMigrator();
+            $migratorConfigurationFactory = $dependencyFactory->getConsoleInputMigratorConfigurationFactory();
+            $migratorConfiguration = $migratorConfigurationFactory->getMigratorConfiguration(new ArrayInput([]));
+
+            $migrator->migrate($plan, $migratorConfiguration);
+        }
+    }
+
+    public function loadFixtures(): void
+    {
+        $fixtureClasses = $this->getDeclaredClassesForNamespace('App\Fixtures');
+        if (!$fixtureClasses) {
+            return;
+        }
+        $this->boot();
+        $em = $this->getContainer()->get('doctrine.orm.entity_manager');
+        foreach ($fixtureClasses as $class) {
+            if ("Doctrine\Bundle\FixturesBundle\Fixture" !== (new \ReflectionClass($class))->getParentClass()->getName()) {
+                continue;
+            }
+            (new $class())->load($em);
+        }
+    }
+
+    private function getDeclaredClassesForNamespace(string $namespace): array
+    {
+        return array_filter($this->getDeclaredClasses(), static function (string $class) use ($namespace): bool {
+            return str_starts_with($class, $namespace);
+        });
+    }
+
+    /**
+     * @return class-string[]
+     */
+    public function getDeclaredClasses(): array
+    {
+        if (!$this->declaredClasses) {
+            $this->declaredClasses = get_declared_classes();
+        }
+
+        return $this->declaredClasses;
+    }
+}
diff --git a/docs/src/Metadata/Resource/Factory/ClassResourceNameCollectionFactory.php b/docs/src/Metadata/Resource/Factory/ClassResourceNameCollectionFactory.php
new file mode 100644
index 00000000000..19d7c3001c9
--- /dev/null
+++ b/docs/src/Metadata/Resource/Factory/ClassResourceNameCollectionFactory.php
@@ -0,0 +1,45 @@
+
+ *
+ * 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\Playground\Metadata\Resource\Factory;
+
+use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\ResourceNameCollection;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
+
+#[AsDecorator(decorates: 'api_platform.metadata.resource.name_collection_factory')]
+final class ClassResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface
+{
+    /**
+     * @param class-string[] $classes
+     */
+    public function __construct(private readonly array $classes, #[AutowireDecorated] private readonly ?ResourceNameCollectionFactoryInterface $decorated = null)
+    {
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function create(): ResourceNameCollection
+    {
+        $classes = $this->classes;
+        if ($this->decorated) {
+            foreach ($this->decorated->create() as $resourceClass) {
+                $classes[] = $resourceClass;
+            }
+        }
+
+        return new ResourceNameCollection($this->classes);
+    }
+}
diff --git a/docs/src/Test/TestGuideTrait.php b/docs/src/Test/TestGuideTrait.php
new file mode 100644
index 00000000000..880b2258489
--- /dev/null
+++ b/docs/src/Test/TestGuideTrait.php
@@ -0,0 +1,24 @@
+
+ *
+ * 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\Playground\Test;
+
+trait TestGuideTrait
+{
+    protected function setUp(): void
+    {
+        $kernel = static::createKernel();
+        $kernel->executeMigrations();
+        $kernel->loadFixtures();
+    }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 22bc63f8b1a..42d3a440cc9 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -21,7 +21,7 @@
 
     
         
-            .
+            src
         
         
             features
diff --git a/src/Action/ExceptionAction.php b/src/Action/ExceptionAction.php
index 38387fff84e..81db1eb7520 100644
--- a/src/Action/ExceptionAction.php
+++ b/src/Action/ExceptionAction.php
@@ -25,7 +25,7 @@
 use Symfony\Component\Serializer\SerializerInterface;
 
 /**
- * Renders a normalized exception for a given {@see FlattenException}.
+ * Renders a normalized exception for a given see [FlattenException](https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/ErrorHandler/Exception/FlattenException.php).
  *
  * @author Baptiste Meyer 
  * @author Kévin Dunglas 
diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php
index 6586a66c212..ed3a16ccceb 100644
--- a/src/Doctrine/Odm/Filter/BooleanFilter.php
+++ b/src/Doctrine/Odm/Filter/BooleanFilter.php
@@ -19,13 +19,84 @@
 use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
 
 /**
- * Filters the collection by boolean values.
+ * The boolean filter allows you to search on boolean fields and values.
  *
- * Filters collection on equality of boolean properties. The value is specified
- * as one of ( "true" | "false" | "1" | "0" ) in the query.
+ * Syntax: `?property=`.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not one of ( "true" | "false" | "1" | "0" ) the property is ignored.
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.boolean_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?published=true`.
  *
  * @author Amrouche Hamza 
  * @author Teoh Han Hui 
diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php
index cdf81559491..0aa81df608f 100644
--- a/src/Doctrine/Odm/Filter/DateFilter.php
+++ b/src/Doctrine/Odm/Filter/DateFilter.php
@@ -21,7 +21,95 @@
 use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
 
 /**
- * Filters the collection by date intervals.
+ * The date filter allows to filter a collection by date intervals.
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * The value can take any date format supported by the [`\DateTime` constructor](https://www.php.net/manual/en/datetime.construct.php).
+ *
+ * The `after` and `before` filters will filter including the value whereas `strictly_after` and `strictly_before` will filter excluding the value.
+ *
+ * The date filter is able to deal with date properties having `null` values. Four behaviors are available at the property level of the filter:
+ * - Use the default behavior of the DBMS: use `null` strategy
+ * - Exclude items: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::EXCLUDE_NULL` (`exclude_null`) strategy
+ * - Consider items as oldest: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_BEFORE` (`include_null_before`) strategy
+ * - Consider items as youngest: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_AFTER` (`include_null_after`) strategy
+ * - Always include items: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_BEFORE_AND_AFTER` (`include_null_before_and_after`) strategy
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.date_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by date with the following query: `/books?createdAt[after]=2018-03-19`.
  *
  * @author Kévin Dunglas 
  * @author Théo FIDRY 
diff --git a/src/Doctrine/Odm/Filter/ExistsFilter.php b/src/Doctrine/Odm/Filter/ExistsFilter.php
index a5915c7b621..81ed2bced35 100644
--- a/src/Doctrine/Odm/Filter/ExistsFilter.php
+++ b/src/Doctrine/Odm/Filter/ExistsFilter.php
@@ -23,14 +23,84 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Filters the collection by whether a property value exists or not.
+ * The exists filter allows you to select items based on a nullable field value. It will also check the emptiness of a collection association.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not one of ( "true" | "false" | "1" | "0" ) the property is ignored.
+ * Syntax: `?exists[property]=`.
  *
- * A query parameter with key but no value is treated as `true`, e.g.:
- * Request: GET /products?exists[brand]
- * Interpretation: filter products which have a brand
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.exist_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`.
  *
  * @author Teoh Han Hui 
  * @author Alan Poulain 
diff --git a/src/Doctrine/Odm/Filter/NumericFilter.php b/src/Doctrine/Odm/Filter/NumericFilter.php
index b6650c45e62..47f72b79676 100644
--- a/src/Doctrine/Odm/Filter/NumericFilter.php
+++ b/src/Doctrine/Odm/Filter/NumericFilter.php
@@ -19,12 +19,84 @@
 use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
 
 /**
- * Filters the collection by numeric values.
+ * The numeric filter allows you to search on numeric fields and values.
  *
- * Filters collection by equality of numeric properties.
+ * Syntax: `?property=`.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not numeric, the property is ignored.
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.numeric_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price=10`.
  *
  * @author Amrouche Hamza 
  * @author Teoh Han Hui 
diff --git a/src/Doctrine/Odm/Filter/OrderFilter.php b/src/Doctrine/Odm/Filter/OrderFilter.php
index 10d0cd447e6..f34dcb25b95 100644
--- a/src/Doctrine/Odm/Filter/OrderFilter.php
+++ b/src/Doctrine/Odm/Filter/OrderFilter.php
@@ -22,14 +22,171 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Order the collection by given properties.
+ * The order filter allows to sort a collection against the given properties.
  *
- * The ordering is done in the same sequence as they are specified in the query,
- * and for each property a direction value can be specified.
+ * Syntax: `?order[property]=`.
  *
- * For each property passed, if the resource does not have such property or if the
- * direction value is different from "asc" or "desc" (case insensitive), the property
- * is ignored.
+ * 
+ * ```php
+ *  'order'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.order_filter:
+ *         parent: 'api_platform.doctrine.odm.order_filter'
+ *         arguments: [ $properties: { id: ~, title: ~ }, $orderParameterName: order ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.order_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                 
+ *             
+ *             order
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.order_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by title in ascending order and then by ID in descending order with the following query: `/books?order[title]=desc&order[id]=asc`.
+ *
+ * By default, whenever the query does not specify the direction explicitly (e.g.: `/books?order[title]&order[id]`), filters will not be applied unless you configure a default order direction to use:
+ *
+ * 
+ * ```php
+ *  'ASC', 'title' => 'DESC'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.order_filter:
+ *         parent: 'api_platform.doctrine.odm.order_filter'
+ *         arguments: [ { id: ASC, title: DESC } ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.order_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 ASC
+ *                 DESC
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.order_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * When the property used for ordering can contain `null` values, you may want to specify how `null` values are treated in the comparison:
+ * - Use the default behavior of the DBMS: use `null` strategy
+ * - Exclude items: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_SMALLEST` (`nulls_smallest`) strategy
+ * - Consider items as oldest: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_LARGEST` (`nulls_largest`) strategy
+ * - Consider items as youngest: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_ALWAYS_FIRST` (`nulls_always_first`) strategy
+ * - Always include items: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_ALWAYS_LAST` (`nulls_always_last`) strategy
  *
  * @author Kévin Dunglas 
  * @author Théo FIDRY 
diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php
index 16982b9f2c1..4ef168a95ad 100644
--- a/src/Doctrine/Odm/Filter/RangeFilter.php
+++ b/src/Doctrine/Odm/Filter/RangeFilter.php
@@ -19,7 +19,85 @@
 use Doctrine\ODM\MongoDB\Aggregation\Builder;
 
 /**
- * Filters the collection by range.
+ * The range filter allows you to filter by a value lower than, greater than, lower than or equal, greater than or equal and between two values.
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.range_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`.
  *
  * @author Lee Siong Chan 
  * @author Alan Poulain 
diff --git a/src/Doctrine/Odm/Filter/SearchFilter.php b/src/Doctrine/Odm/Filter/SearchFilter.php
index 9be9e355499..ea6c725c830 100644
--- a/src/Doctrine/Odm/Filter/SearchFilter.php
+++ b/src/Doctrine/Odm/Filter/SearchFilter.php
@@ -31,7 +31,103 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Filter the collection by given properties.
+ * The search filter allows to filter a collection by given properties.
+ *
+ * The search filter supports `exact`, `partial`, `start`, `end`, and `word_start` matching strategies:
+ * - `exact` strategy searches for fields that exactly match the value
+ * - `partial` strategy uses `LIKE %value%` to search for fields that contain the value
+ * - `start` strategy uses `LIKE value%` to search for fields that start with the value
+ * - `end` strategy uses `LIKE %value` to search for fields that end with the value
+ * - `word_start` strategy uses `LIKE value% OR LIKE % value%` to search for fields that contain words starting with the value
+ *
+ * Note: it is possible to filter on properties and relations too.
+ *
+ * Prepend the letter `i` to the filter if you want it to be case-insensitive. For example `ipartial` or `iexact`.
+ * Note that this will use the `LOWER` function and *will* impact performance if there is no proper index.
+ *
+ * Case insensitivity may already be enforced at the database level depending on the [collation](https://en.wikipedia.org/wiki/Collation) used.
+ * If you are using MySQL, note that the commonly used `utf8_unicode_ci` collation (and its sibling `utf8mb4_unicode_ci`)
+ * are already case-insensitive, as indicated by the `_ci` part in their names.
+ *
+ * Note: Search filters with the `exact` strategy can have multiple values for the same property (in this case the
+ * condition will be similar to a SQL IN clause).
+ *
+ * Syntax: `?property[]=foo&property[]=bar`.
+ *
+ * 
+ * ```php
+ *  'exact', 'description' => 'partial'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.search_filter:
+ *         parent: 'api_platform.doctrine.odm.search_filter'
+ *         arguments: [ { isbn: 'exact', description: 'partial' } ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.search_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 exact
+ *                 partial
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.search_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
  *
  * @author Kévin Dunglas 
  * @author Alan Poulain 
diff --git a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php
index 929f4d88e1b..f1f6ac4c76b 100644
--- a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php
+++ b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php
@@ -31,7 +31,7 @@ public function __construct(private readonly ManagerRegistry $managerRegistry, p
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function create(string $resourceClass): ResourceMetadataCollection
     {
diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php
index 9727e1b4bdb..e0edd0098d0 100644
--- a/src/Doctrine/Orm/Filter/BooleanFilter.php
+++ b/src/Doctrine/Orm/Filter/BooleanFilter.php
@@ -21,13 +21,85 @@
 use Doctrine\ORM\QueryBuilder;
 
 /**
- * Filters the collection by boolean values.
+ * The boolean filter allows you to search on boolean fields and values.
  *
- * Filters collection on equality of boolean properties. The value is specified
- * as one of ( "true" | "false" | "1" | "0" ) in the query.
+ * Syntax: `?property=`.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not one of ( "true" | "false" | "1" | "0" ) the property is ignored.
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.boolean_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?published=true`.
  *
  * @author Amrouche Hamza 
  * @author Teoh Han Hui 
diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php
index c77f0e9fdc3..0317b854354 100644
--- a/src/Doctrine/Orm/Filter/DateFilter.php
+++ b/src/Doctrine/Orm/Filter/DateFilter.php
@@ -24,7 +24,96 @@
 use Doctrine\ORM\QueryBuilder;
 
 /**
- * Filters the collection by date intervals.
+ * The date filter allows to filter a collection by date intervals.
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * The value can take any date format supported by the [`\DateTime` constructor](https://www.php.net/manual/en/datetime.construct.php).
+ *
+ * The `after` and `before` filters will filter including the value whereas `strictly_after` and `strictly_before` will filter excluding the value.
+ *
+ * The date filter is able to deal with date properties having `null` values. Four behaviors are available at the property level of the filter:
+ * - Use the default behavior of the DBMS: use `null` strategy
+ * - Exclude items: use `ApiPlatform\Doctrine\Orm\Filter\DateFilter::EXCLUDE_NULL` (`exclude_null`) strategy
+ * - Consider items as oldest: use `ApiPlatform\Doctrine\Orm\Filter\DateFilter::INCLUDE_NULL_BEFORE` (`include_null_before`) strategy
+ * - Consider items as youngest: use `ApiPlatform\Doctrine\Orm\Filter\DateFilter::INCLUDE_NULL_AFTER` (`include_null_after`) strategy
+ * - Always include items: use `ApiPlatform\Doctrine\Orm\Filter\DateFilter::INCLUDE_NULL_BEFORE_AND_AFTER` (`include_null_before_and_after`) strategy
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.date_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by date with the following query: `/books?createdAt[after]=2018-03-19`.
  *
  * @author Kévin Dunglas 
  * @author Théo FIDRY 
diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php
index 46f8aa5ce2e..a222e166f7b 100644
--- a/src/Doctrine/Orm/Filter/ExistsFilter.php
+++ b/src/Doctrine/Orm/Filter/ExistsFilter.php
@@ -26,14 +26,85 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Filters the collection by whether a property value exists or not.
+ * The exists filter allows you to select items based on a nullable field value. It will also check the emptiness of a collection association.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not one of ( "true" | "false" | "1" | "0" ) the property is ignored.
+ * Syntax: `?exists[property]=`.
  *
- * A query parameter with key but no value is treated as `true`, e.g.:
- * Request: GET /products?exists[brand]
- * Interpretation: filter products which have a brand
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.exist_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`.
  *
  * @author Teoh Han Hui 
  */
diff --git a/src/Doctrine/Orm/Filter/NumericFilter.php b/src/Doctrine/Orm/Filter/NumericFilter.php
index 5ec71bdd0bb..a2033d4c0cf 100644
--- a/src/Doctrine/Orm/Filter/NumericFilter.php
+++ b/src/Doctrine/Orm/Filter/NumericFilter.php
@@ -21,12 +21,85 @@
 use Doctrine\ORM\QueryBuilder;
 
 /**
- * Filters the collection by numeric values.
+ * The numeric filter allows you to search on numeric fields and values.
  *
- * Filters collection by equality of numeric properties.
+ * Syntax: `?property=`.
  *
- * For each property passed, if the resource does not have such property or if
- * the value is not numeric, the property is ignored.
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.numeric_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price=10`.
  *
  * @author Amrouche Hamza 
  * @author Teoh Han Hui 
diff --git a/src/Doctrine/Orm/Filter/OrderFilter.php b/src/Doctrine/Orm/Filter/OrderFilter.php
index 815b980fb4e..5a24e6c7a76 100644
--- a/src/Doctrine/Orm/Filter/OrderFilter.php
+++ b/src/Doctrine/Orm/Filter/OrderFilter.php
@@ -24,14 +24,169 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Order the collection by given properties.
+ * The order filter allows to sort a collection against the given properties.
  *
- * The ordering is done in the same sequence as they are specified in the query,
- * and for each property a direction value can be specified.
+ * Syntax: `?order[property]=`.
  *
- * For each property passed, if the resource does not have such property or if the
- * direction value is different from "asc" or "desc" (case insensitive), the property
- * is ignored.
+ * 
+ * ```php
+ *  'order'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.order_filter:
+ *         parent: 'api_platform.doctrine.orm.order_filter'
+ *         arguments: [ $properties: { id: ~, title: ~ }, $orderParameterName: order ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.order_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                 
+ *             
+ *             order
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.order_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by title in ascending order and then by ID in descending order with the following query: `/books?order[title]=desc&order[id]=asc`.
+ *
+ * By default, whenever the query does not specify the direction explicitly (e.g.: `/books?order[title]&order[id]`), filters will not be applied unless you configure a default order direction to use:
+ *
+ * [codeSelector]
+ * ```php
+ *  'ASC', 'title' => 'DESC'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.order_filter:
+ *         parent: 'api_platform.doctrine.orm.order_filter'
+ *         arguments: [ { id: ASC, title: DESC } ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.order_filter']
+ * ```
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 ASC
+ *                 DESC
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.order_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * [/codeSelector]
+ *
+ * When the property used for ordering can contain `null` values, you may want to specify how `null` values are treated in the comparison:
+ * - Use the default behavior of the DBMS: use `null` strategy
+ * - Exclude items: use `ApiPlatform\Doctrine\Orm\Filter\OrderFilter::NULLS_SMALLEST` (`nulls_smallest`) strategy
+ * - Consider items as oldest: use `ApiPlatform\Doctrine\Orm\Filter\OrderFilter::NULLS_LARGEST` (`nulls_largest`) strategy
+ * - Consider items as youngest: use `ApiPlatform\Doctrine\Orm\Filter\OrderFilter::NULLS_ALWAYS_FIRST` (`nulls_always_first`) strategy
+ * - Always include items: use `ApiPlatform\Doctrine\Orm\Filter\OrderFilter::NULLS_ALWAYS_LAST` (`nulls_always_last`) strategy
  *
  * @author Kévin Dunglas 
  * @author Théo FIDRY 
diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php
index 5b8511d613b..4d44ad4dde5 100644
--- a/src/Doctrine/Orm/Filter/RangeFilter.php
+++ b/src/Doctrine/Orm/Filter/RangeFilter.php
@@ -21,7 +21,85 @@
 use Doctrine\ORM\QueryBuilder;
 
 /**
- * Filters the collection by range.
+ * The range filter allows you to filter by a value lower than, greater than, lower than or equal, greater than or equal and between two values.
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.range_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`.
  *
  * @author Lee Siong Chan 
  */
diff --git a/src/Doctrine/Orm/Filter/SearchFilter.php b/src/Doctrine/Orm/Filter/SearchFilter.php
index 3ae351fec19..961345e8d73 100644
--- a/src/Doctrine/Orm/Filter/SearchFilter.php
+++ b/src/Doctrine/Orm/Filter/SearchFilter.php
@@ -31,7 +31,103 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Filter the collection by given properties.
+ * The search filter allows to filter a collection by given properties.
+ *
+ * The search filter supports `exact`, `partial`, `start`, `end`, and `word_start` matching strategies:
+ * - `exact` strategy searches for fields that exactly match the value
+ * - `partial` strategy uses `LIKE %value%` to search for fields that contain the value
+ * - `start` strategy uses `LIKE value%` to search for fields that start with the value
+ * - `end` strategy uses `LIKE %value` to search for fields that end with the value
+ * - `word_start` strategy uses `LIKE value% OR LIKE % value%` to search for fields that contain words starting with the value
+ *
+ * Note: it is possible to filter on properties and relations too.
+ *
+ * Prepend the letter `i` to the filter if you want it to be case-insensitive. For example `ipartial` or `iexact`.
+ * Note that this will use the `LOWER` function and *will* impact performance if there is no proper index.
+ *
+ * Case insensitivity may already be enforced at the database level depending on the [collation](https://en.wikipedia.org/wiki/Collation) used.
+ * If you are using MySQL, note that the commonly used `utf8_unicode_ci` collation (and its sibling `utf8mb4_unicode_ci`)
+ * are already case-insensitive, as indicated by the `_ci` part in their names.
+ *
+ * Note: Search filters with the `exact` strategy can have multiple values for the same property (in this case the
+ * condition will be similar to a SQL IN clause).
+ *
+ * Syntax: `?property[]=foo&property[]=bar`.
+ *
+ * 
+ * ```php
+ *  'exact', 'description' => 'partial'])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.search_filter:
+ *         parent: 'api_platform.doctrine.orm.search_filter'
+ *         arguments: [ { isbn: 'exact', description: 'partial' } ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.search_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 exact
+ *                 partial
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.search_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
  *
  * @author Kévin Dunglas 
  */
diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php
index a30b1924f6f..3da905f449d 100644
--- a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php
+++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php
@@ -32,7 +32,7 @@ public function __construct(private readonly ManagerRegistry $managerRegistry, p
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function create(string $resourceClass): ResourceMetadataCollection
     {
diff --git a/src/Elasticsearch/Filter/MatchFilter.php b/src/Elasticsearch/Filter/MatchFilter.php
index 28776f96c0f..24b5bc7c6b7 100644
--- a/src/Elasticsearch/Filter/MatchFilter.php
+++ b/src/Elasticsearch/Filter/MatchFilter.php
@@ -14,7 +14,85 @@
 namespace ApiPlatform\Elasticsearch\Filter;
 
 /**
- * Filter the collection by given properties using a full text query.
+ * The match filter allows to find resources that [match](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html) the specified text on full text fields.
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.match_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by title content with the following query: `/books?title=Foundation`.
  *
  * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html
  *
diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php
index bdbedbdf029..b00f36a3f0d 100644
--- a/src/Elasticsearch/Filter/OrderFilter.php
+++ b/src/Elasticsearch/Filter/OrderFilter.php
@@ -20,7 +20,86 @@
 use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
 
 /**
- * Order the collection by given properties.
+ * The order filter allows to [sort](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html) a collection against the given properties.
+ *
+ * Syntax: `?order[property]=`.
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.order_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by ID and date in ascending or descending order: `/books?order[id]=asc&order[date]=desc`.
  *
  * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html
  *
diff --git a/src/Elasticsearch/Filter/TermFilter.php b/src/Elasticsearch/Filter/TermFilter.php
index 3cec8f7210e..2dd4fc29469 100644
--- a/src/Elasticsearch/Filter/TermFilter.php
+++ b/src/Elasticsearch/Filter/TermFilter.php
@@ -14,7 +14,84 @@
 namespace ApiPlatform\Elasticsearch\Filter;
 
 /**
- * Filter the collection by given properties using a term level query.
+ * The term filter allows to find resources that contain the exact specified [terms](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html).
+ *
+ * Syntax: `?property[]=value`.
+ *
+ * 
+ * ```php
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.term_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by title with the following query: `/books?title=Foundation`.
  *
  * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html
  * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html
diff --git a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php
index e94eeba4786..6fbb5de58f3 100644
--- a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php
+++ b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php
@@ -35,7 +35,7 @@ public function __construct(private readonly Client $client, private readonly Re
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function create(string $resourceClass): ResourceMetadataCollection
     {
diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php
index 8541dc210af..c9fa67f6692 100644
--- a/src/Metadata/ApiProperty.php
+++ b/src/Metadata/ApiProperty.php
@@ -51,6 +51,50 @@ public function __construct(
         private ?bool $identifier = null,
         private $default = null,
         private mixed $example = null,
+        /**
+         * The `deprecationReason` option deprecates the current operation with a deprecation message.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * - With JSON-lD / Hydra, [an `owl:deprecated` annotation property](https://www.w3.org/TR/owl2-syntax/#Annotation_Properties) will be added to the appropriate data structure
+         * - With Swagger / OpenAPI, [a `deprecated` property](https://swagger.io/docs/specification/2-0/paths-and-operations/) will be added
+         * - With GraphQL, the [`isDeprecated` and `deprecationReason` properties](https://facebook.github.io/graphql/June2018/#sec-Deprecation) will be added to the schema
+         */
         private ?string $deprecationReason = null,
         private ?bool $fetchable = null,
         private ?bool $fetchEager = null,
diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php
index 8b7a83c1deb..b0b65a04336 100644
--- a/src/Metadata/ApiResource.php
+++ b/src/Metadata/ApiResource.php
@@ -20,7 +20,11 @@
 /**
  * Resource metadata attribute.
  *
- * @Annotation
+ * The API Resource attribute declares the behaviors attached to a Resource inside API Platform.
+ * This class is immutable, and if you set a value yourself, API Platform will not override the value.
+ * The API Resource helps sharing options with operations.
+ *
+ * Read more about how metadata works [here](/docs/in-depth/metadata).
  *
  * @author Antoine Bluchet 
  */
@@ -30,75 +34,202 @@ class ApiResource
     use WithResourceTrait;
 
     protected ?Operations $operations;
+
     /**
      * @var string|callable|null
      */
     protected $provider;
+
     /**
      * @var string|callable|null
      */
     protected $processor;
 
     /**
-     * @param array|string|null                                               $types                          The RDF types of this resource
-     * @param mixed|null                                                      $operations
-     * @param array|string|null                                               $formats                        https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation
-     * @param array|string|null                                               $inputFormats                   https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation
-     * @param array|string|null                                               $outputFormats                  https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation
-     * @param array|array|string[]|string|null $uriVariables
-     * @param string|null                                                     $routePrefix                    https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations
-     * @param string|null                                                     $sunset                         https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed
-     * @param string|null                                                     $deprecationReason              https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties
-     * @param array|null                                                      $cacheHeaders                   https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers
-     * @param array|null                                                      $normalizationContext           https://api-platform.com/docs/core/serialization/#using-serialization-groups
-     * @param array|null                                                      $denormalizationContext         https://api-platform.com/docs/core/serialization/#using-serialization-groups
-     * @param string[]|null                                                   $hydraContext                   https://api-platform.com/docs/core/extending-jsonld-context/#hydra
-     * @param array|null                                                      $openapiContext                 https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
-     * @param bool|OpenApiOperation|null                                      $openapi                        https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
-     * @param array|null                                                      $validationContext              https://api-platform.com/docs/core/validation/#using-validation-groups
-     * @param string[]                                                        $filters                        https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters
-     * @param bool|null                                                       $elasticsearch                  https://api-platform.com/docs/core/elasticsearch/
-     * @param mixed|null                                                      $mercure                        https://api-platform.com/docs/core/mercure
-     * @param mixed|null                                                      $messenger                      https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus
-     * @param mixed|null                                                      $input                          https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation
-     * @param mixed|null                                                      $output                         https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation
-     * @param array|null                                                      $order                          https://api-platform.com/docs/core/default-order/#overriding-default-order
-     * @param bool|null                                                       $fetchPartial                   https://api-platform.com/docs/core/performance/#fetch-partial
-     * @param bool|null                                                       $forceEager                     https://api-platform.com/docs/core/performance/#force-eager
-     * @param bool|null                                                       $paginationClientEnabled        https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1
-     * @param bool|null                                                       $paginationClientItemsPerPage   https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3
-     * @param bool|null                                                       $paginationClientPartial        https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6
-     * @param array|null                                                      $paginationViaCursor            https://api-platform.com/docs/core/pagination/#cursor-based-pagination
-     * @param bool|null                                                       $paginationEnabled              https://api-platform.com/docs/core/pagination/#for-a-specific-resource
-     * @param bool|null                                                       $paginationFetchJoinCollection  https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator
-     * @param int|null                                                        $paginationItemsPerPage         https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page
-     * @param int|null                                                        $paginationMaximumItemsPerPage  https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page
-     * @param bool|null                                                       $paginationPartial              https://api-platform.com/docs/core/performance/#partial-pagination
-     * @param string|null                                                     $paginationType                 https://api-platform.com/docs/core/graphql/#using-the-page-based-pagination
-     * @param string|null                                                     $security                       https://api-platform.com/docs/core/security
-     * @param string|null                                                     $securityMessage                https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
-     * @param string|null                                                     $securityPostDenormalize        https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization
-     * @param string|null                                                     $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
-     * @param string                                                          $securityPostValidation         https://api-platform.com/docs/core/security/#executing-access-control-rules-after-validtion
-     * @param string                                                          $securityPostValidationMessage  https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message
-     * @param mixed|null                                                      $provider
-     * @param mixed|null                                                      $processor
+     * @param array|array|Operations|null $operations   Operations is a list of HttpOperation
+     * @param array|array|string[]|string|null        $uriVariables
+     * @param string|callable|null                                                   $provider
+     * @param string|callable|null                                                   $processor
+     * @param mixed|null                                                             $mercure
+     * @param mixed|null                                                             $messenger
+     * @param mixed|null                                                             $input
+     * @param mixed|null                                                             $output
      */
     public function __construct(
+        /**
+         * The URI template represents your resource IRI with optional variables. It follows [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.html).
+         * API Platform generates this URL for you if you leave this empty.
+         */
         protected ?string $uriTemplate = null,
+
+        /**
+         * The short name of your resource is a unique name that identifies your resource.
+         * It is used within the documentation and for url generation if the `uriTemplate` is not filled. By default, this will be the name of your PHP class.
+         */
         protected ?string $shortName = null,
+
+        /**
+         * A description for this resource that will show on documentations.
+         */
         protected ?string $description = null,
+
+        /**
+         * The RDF types of this resource.
+         * An RDF type is usually a URI referencing how your resource is structured for the outside world. Values can be a string `https://schema.org/Book`
+         * or an array of string `['https://schema.org/Flight', 'https://schema.org/BusTrip']`.
+         */
         protected string|array|null $types = null,
+
+        /**
+         * Operations is a list of [HttpOperation](./HttpOperation).
+         *
+         * By default API Platform declares operations representing CRUD routes if you don't specify this parameter:
+         *
+         * ```php
+         * #[ApiResource(
+         *     operations: [
+         *         new Get(uriTemplate: '/books/{id}'),
+         *         // The GetCollection operation returns a list of Books.
+         *         new GetCollection(uriTemplate: '/books'),
+         *         new Post(uriTemplate: '/books'),
+         *         new Patch(uriTemplate: '/books/{id}'),
+         *         new Delete(uriTemplate: '/books/{id}'),
+         *     ]
+         * )]
+         *
+         * ```
+         *
+         * Try this live at [play.api-platform.com/api-resource](play.api-platform.com).
+         */
         $operations = null,
-        protected $formats = null,
-        protected $inputFormats = null,
-        protected $outputFormats = null,
+
+        /**
+         * The `formats` option allows you to customize content negotiation. By default API Platform supports JsonLd, Hal, JsonAPI.
+         * For other formats we use the Symfony Serializer.
+         *
+         * ```php
+         * #[ApiResource(
+         *   formats: [
+         *       'jsonld' => ['application/ld+json'],
+         *       'jsonhal' => ['application/hal+json'],
+         *       'jsonapi' => ['application/vnd.api+json'],
+         *       'json' =>    ['application/json'],
+         *       'xml' =>     ['application/xml', 'text/xml'],
+         *       'yaml' =>    ['application/x-yaml'],
+         *       'csv' =>     ['text/csv'],
+         *       'html' =>    ['text/html'],
+         *       'myformat' =>['application/vnd.myformat'],
+         *   ]
+         * )]
+         * ```
+         *
+         * Learn more about custom formats in the [dedicated guide](/guides/custom-formats).
+         */
+        protected array|string|null $formats = null,
+        /**
+         * The `inputFormats` option allows you to customize content negotiation for HTTP bodies:.
+         *
+         * ```php
+         *  #[ApiResource(formats: ['jsonld', 'csv' => ['text/csv']], operations: [
+         *      new Patch(inputFormats: ['json' => ['application/merge-patch+json']]),
+         *      new GetCollection(),
+         *      new Post(),
+         *  ])]
+         * ```
+         */
+        protected array|string|null $inputFormats = null,
+        /**
+         * The `outputFormats` option allows you to customize content negotiation for HTTP responses.
+         */
+        protected array|string|null $outputFormats = null,
+        /**
+         * The `uriVariables` configuration allows to configure to what each URI Variable.
+         * With [simple string expansion](https://www.rfc-editor.org/rfc/rfc6570.html#section-3.2.2), we read the input
+         * value and match this to the given `Link`. Note that this setting is usually used on an operation directly:.
+         *
+         * ```php
+         *   #[ApiResource(
+         *       uriTemplate: '/companies/{companyId}/employees/{id}',
+         *       uriVariables: [
+         *           'companyId' => new Link(fromClass: Company::class, toProperty: 'company']),
+         *           'id' => new Link(fromClass: Employee::class)
+         *       ],
+         *       operations: [new Get()]
+         *   )]
+         * ```
+         *
+         * For more examples, read our guide on [subresources](/guides/subresources).
+         */
         protected $uriVariables = null,
+        /**
+         * The `routePrefix` allows you to configure a prefix that will apply to this resource.
+         *
+         * ```php
+         *   #[ApiResource(
+         *       routePrefix: '/books',
+         *       operations: [new Get(uriTemplate: '/{id}')]
+         *   )]
+         * ```
+         *
+         * This resource will be accessible through `/books/{id}`.
+         */
         protected ?string $routePrefix = null,
+        /**
+         * The `defaults` option adds up to [Symfony's route defaults](https://github.com/symfony/routing/blob/8f068b792e515b25e26855ac8dc7fe800399f3e5/Route.php#L41). You can override [API Platform's defaults](https://github.com/api-platform/core/blob/6abd0fe0a69d4842eb6d5c31ef2bd6dce0e1d372/src/Symfony/Routing/ApiLoader.php#L87) if needed.
+         */
         protected ?array $defaults = null,
+        /**
+         * The `requirements` option configures the Symfony's Route requirements.
+         */
         protected ?array $requirements = null,
+        /**
+         * The `options` option configures the Symfony's Route options.
+         */
         protected ?array $options = null,
+        /**
+         * The `stateless` option configures the Symfony's Route stateless option.
+         */
         protected ?bool $stateless = null,
+        /**
+         * The `sunset` option indicates when a deprecated operation will be removed.
+         *
+         * 
+         *
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         *
+         * 
+         */
         protected ?string $sunset = null,
         protected ?string $acceptPatch = null,
         protected ?int $status = null,
@@ -107,7 +238,87 @@ public function __construct(
         protected ?string $condition = null,
         protected ?string $controller = null,
         protected ?string $class = null,
+        /**
+         * The `urlGenerationStrategy` option configures the url generation strategy.
+         *
+         * See: [UrlGeneratorInterface::class](/reference/Api/UrlGeneratorInterface)
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?int $urlGenerationStrategy = null,
+        /**
+         * The `deprecationReason` option deprecates the current resource with a deprecation message.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * - With JSON-lD / Hydra, [an `owl:deprecated` annotation property](https://www.w3.org/TR/owl2-syntax/#Annotation_Properties) will be added to the appropriate data structure
+         * - With Swagger / OpenAPI, [a `deprecated` property](https://swagger.io/docs/specification/2-0/paths-and-operations/) will be added
+         * - With GraphQL, the [`isDeprecated` and `deprecationReason` properties](https://facebook.github.io/graphql/June2018/#sec-Deprecation) will be added to the schema
+         */
         protected ?string $deprecationReason = null,
         protected ?array $cacheHeaders = null,
         protected ?array $normalizationContext = null,
@@ -116,26 +327,604 @@ public function __construct(
         protected ?array $hydraContext = null,
         protected ?array $openapiContext = null, // TODO Remove in 4.0
         protected bool|OpenApiOperation|null $openapi = null,
+        /**
+         * The `validationContext` option configures the context of validation for the current ApiResource.
+         * You can, for instance, describe the validation groups that will be used:.
+         *
+         * ```php
+         * #[ApiResource(validationContext: ['groups' => ['a', 'b']])]
+         * ```
+         *
+         * For more examples, read our guide on [validation](/guides/validation).
+         */
         protected ?array $validationContext = null,
+        /**
+         * The `filters` option configures the filters (declared as services) available on the collection routes for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             app.filters.book.search
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?array $filters = null,
         protected ?bool $elasticsearch = null,
         protected $mercure = null,
+        /**
+         * The `messenger` option dispatches the current resource through the Message Bus.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * Note: when using `messenger=true` on a Doctrine entity, the Doctrine Processor is not called. If you want it
+         * to be called, you should [decorate a built-in state processor](/docs/guide/hook-a-persistence-layer-with-a-processor)
+         * and implement your own logic.
+         *
+         * Read [how to use Messenger with an Input object](/docs/guide/using-messenger-with-an-input-object).
+         *
+         * @var string|bool|null
+         */
         protected $messenger = null,
         protected $input = null,
         protected $output = null,
+        /**
+         * Override the default order of items in your collection. Note that this is handled by our doctrine filters such as
+         * the [OrderFilter](/docs/reference/Doctrine/Orm/Filter/OrderFilter).
+         *
+         * By default, items in the collection are ordered in ascending (ASC) order by their resource identifier(s). If you want to
+         * customize this order, you must add an `order` attribute on your ApiResource annotation:
+         *
+         * 
+         *
+         * ```php
+         *  'ASC'])]
+         * class Book
+         * {
+         * }
+         * ```
+         *
+         * ```yaml
+         * # api/config/api_platform/resources/Book.yaml
+         * App\Entity\Book:
+         *     order:
+         *         foo: ASC
+         * ```
+         *
+         * 
+         *
+         * This `order` attribute is used as an array: the key defines the order field, the values defines the direction.
+         * If you only specify the key, `ASC` direction will be used as default.
+         */
         protected ?array $order = null,
         protected ?bool $fetchPartial = null,
         protected ?bool $forceEager = null,
+        /**
+         * The `paginationClientEnabled` option allows (or disallows) the client to enable (or disable) the pagination for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The pagination can now be enabled (or disabled) by adding a query parameter named `pagination`:
+         * - `GET /books?pagination=false`: disabled
+         * - `GET /books?pagination=true`: enabled
+         */
         protected ?bool $paginationClientEnabled = null,
+        /**
+         * The `paginationClientItemsPerPage` option allows (or disallows) the client to set the number of items per page for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The number of items can now be set by adding a query parameter named `itemsPerPage`:
+         * - `GET /books?itemsPerPage=50`
+         */
         protected ?bool $paginationClientItemsPerPage = null,
+        /**
+         * The `paginationClientPartial` option allows (or disallows) the client to enable (or disable) the partial pagination for the current resource.
+         *
+         * 
+         *
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The partial pagination can now be enabled (or disabled) by adding a query parameter named `partial`:
+         * - `GET /books?partial=false`: disabled
+         * - `GET /books?partial=true`: enabled
+         */
         protected ?bool $paginationClientPartial = null,
+        /**
+         * The `paginationViaCursor` option configures the cursor-based pagination for the current resource.
+         * Select your unique sorted field as well as the direction you'll like the pagination to go via filters.
+         * Note that for now you have to declare a `RangeFilter` and an `OrderFilter` on the property used for the cursor-based pagination:.
+         *
+         * 
+         * ```php
+         *  'id', 'direction' => 'DESC']])]
+         * #[ApiFilter(RangeFilter::class, properties: ["id"])]
+         * #[ApiFilter(OrderFilter::class, properties: ["id" => "DESC"])]
+         * class Book
+         * {
+         *     // ...
+         * }
+         * ```
+         *
+         * ```yaml
+         * # api/config/api_platform/resources.yaml
+         * resources:
+         *     App\Entity\Book:
+         *         - paginationPartial: true
+         *           paginationViaCursor:
+         *               - { field: 'id', direction: 'DESC' }
+         *           filters: [ 'app.filters.book.range', 'app.filters.book.order' ]
+         * ```
+         *
+         * ```xml
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             app.filters.book.range
+         *             app.filters.book.order
+         *         
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * To know more about cursor-based pagination take a look at [this blog post on medium (draft)](https://medium.com/@sroze/74fd1d324723).
+         */
         protected ?array $paginationViaCursor = null,
+        /**
+         * The `paginationEnabled` option enables (or disables) the pagination for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?bool $paginationEnabled = null,
+        /**
+         * The PaginationExtension of API Platform performs some checks on the `QueryBuilder` to guess, in most common
+         * cases, the correct values to use when configuring the Doctrine ORM Paginator: `$fetchJoinCollection`
+         * argument, whether there is a join to a collection-valued association.
+         *
+         * When set to `true`, the Doctrine ORM Paginator will perform an additional query, in order to get the
+         * correct number of results. You can configure this using the `paginationFetchJoinCollection` option:
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * For more information, please see the [Pagination](https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/pagination.html) entry in the Doctrine ORM documentation.
+         */
         protected ?bool $paginationFetchJoinCollection = null,
+        /**
+         * The PaginationExtension of API Platform performs some checks on the `QueryBuilder` to guess, in most common
+         * cases, the correct values to use when configuring the Doctrine ORM Paginator: `$setUseOutputWalkers` setter,
+         * whether to use output walkers.
+         *
+         * When set to `true`, the Doctrine ORM Paginator will use output walkers, which are compulsory for some types
+         * of queries. You can configure this using the `paginationUseOutputWalkers` option:
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         * 
+         * ```
+         * 
+         *
+         * For more information, please see the [Pagination](https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/pagination.html) entry in the Doctrine ORM documentation.
+         */
         protected ?bool $paginationUseOutputWalkers = null,
+        /**
+         * The `paginationItemsPerPage` option defines the number of items per page for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?int $paginationItemsPerPage = null,
+        /**
+         * The `paginationMaximumItemsPerPage` option defines the maximum number of items per page for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         * 
+         * ```
+         *
+         * 
+         */
         protected ?int $paginationMaximumItemsPerPage = null,
+        /**
+         * The `paginationPartial` option enables (or disables) the partial pagination for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?bool $paginationPartial = null,
+        /**
+         * The `paginationType` option defines the type of pagination (`page` or `cursor`) to use for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?string $paginationType = null,
         protected ?string $security = null,
         protected ?string $securityMessage = null,
@@ -236,10 +1025,7 @@ public function getFormats()
         return $this->formats;
     }
 
-    /**
-     * @param mixed|null $formats
-     */
-    public function withFormats($formats): self
+    public function withFormats(mixed $formats): self
     {
         $self = clone $this;
         $self->formats = $formats;
diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php
index 8dd3aa74bfb..009db7b56cc 100644
--- a/src/Metadata/HttpOperation.php
+++ b/src/Metadata/HttpOperation.php
@@ -89,6 +89,50 @@ public function __construct(
         protected ?array $requirements = null,
         protected ?array $options = null,
         protected ?bool $stateless = null,
+        /**
+         * The `sunset` option indicates when a deprecated operation will be removed.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?string $sunset = null,
         protected ?string $acceptPatch = null,
         protected $status = null,
diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php
index 64830678c8c..844d7098d4d 100644
--- a/src/Metadata/Operation.php
+++ b/src/Metadata/Operation.php
@@ -63,16 +63,527 @@ abstract class Operation
     public function __construct(
         protected ?string $shortName = null,
         protected ?string $class = null,
+        /**
+         * The `paginationEnabled` option enables (or disables) the pagination for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?bool $paginationEnabled = null,
+        /**
+         * The `paginationType` option defines the type of pagination (`page` or `cursor`) to use for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?string $paginationType = null,
+        /**
+         * The `paginationItemsPerPage` option defines the number of items per page for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?int $paginationItemsPerPage = null,
+        /**
+         * The `paginationMaximumItemsPerPage` option defines the maximum number of items per page for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?int $paginationMaximumItemsPerPage = null,
+        /**
+         * The `paginationPartial` option enables (or disables) the partial pagination for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?bool $paginationPartial = null,
+        /**
+         * The `paginationClientEnabled` option allows (or disallows) the client to enable (or disable) the pagination for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The pagination can now be enabled (or disabled) by adding a query parameter named `pagination`:
+         * - `GET /books?pagination=false`: disabled
+         * - `GET /books?pagination=true`: enabled
+         */
         protected ?bool $paginationClientEnabled = null,
+        /**
+         * The `paginationClientItemsPerPage` option allows (or disallows) the client to set the number of items per page for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The number of items can now be set by adding a query parameter named `itemsPerPage`:
+         * - `GET /books?itemsPerPage=50`
+         */
         protected ?bool $paginationClientItemsPerPage = null,
+        /**
+         * The `paginationClientPartial` option allows (or disallows) the client to enable (or disable) the partial pagination for the current collection operation.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * The partial pagination can now be enabled (or disabled) by adding a query parameter named `partial`:
+         * - `GET /books?partial=false`: disabled
+         * - `GET /books?partial=true`: enabled
+         */
         protected ?bool $paginationClientPartial = null,
+        /**
+         * The PaginationExtension of API Platform performs some checks on the `QueryBuilder` to guess, in most common
+         * cases, the correct values to use when configuring the Doctrine ORM Paginator: `$fetchJoinCollection`
+         * argument, whether there is a join to a collection-valued association.
+         *
+         * When set to `true`, the Doctrine ORM Paginator will perform an additional query, in order to get the
+         * correct number of results. You can configure this using the `paginationFetchJoinCollection` option:
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * For more information, please see the [Pagination](https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/pagination.html) entry in the Doctrine ORM documentation.
+         */
         protected ?bool $paginationFetchJoinCollection = null,
+        /**
+         * The PaginationExtension of API Platform performs some checks on the `QueryBuilder` to guess, in most common
+         * cases, the correct values to use when configuring the Doctrine ORM Paginator: `$setUseOutputWalkers` setter,
+         * whether to use output walkers.
+         *
+         * When set to `true`, the Doctrine ORM Paginator will use output walkers, which are compulsory for some types
+         * of queries. You can configure this using the `paginationUseOutputWalkers` option:
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * For more information, please see the [Pagination](https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/pagination.html) entry in the Doctrine ORM documentation.
+         */
         protected ?bool $paginationUseOutputWalkers = null,
+        /**
+         * The `paginationViaCursor` option configures the cursor-based pagination for the current resource.
+         * Select your unique sorted field as well as the direction you'll like the pagination to go via filters.
+         * Note that for now you have to declare a `RangeFilter` and an `OrderFilter` on the property used for the cursor-based pagination:.
+         *
+         * 
+         * ```php
+         *  'id', 'direction' => 'DESC']])]
+         * #[ApiFilter(RangeFilter::class, properties: ["id"])]
+         * #[ApiFilter(OrderFilter::class, properties: ["id" => "DESC"])]
+         * class Book
+         * {
+         *     // ...
+         * }
+         * ```
+         *
+         * ```yaml
+         * # api/config/api_platform/resources.yaml
+         * resources:
+         *     App\Entity\Book:
+         *         - operations:
+         *               ApiPlatform\Metadata\GetCollection:
+         *                   paginationPartial: true
+         *                   paginationViaCursor:
+         *                       - { field: 'id', direction: 'DESC' }
+         *                   filters: [ 'app.filters.book.range', 'app.filters.book.order' ]
+         * ```
+         *
+         * ```xml
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *                 
+         *                     app.filters.book.range
+         *                     app.filters.book.order
+         *                 
+         *                 
+         *                     
+         *                 
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * To know more about cursor-based pagination take a look at [this blog post on medium (draft)](https://medium.com/@sroze/74fd1d324723).
+         */
+        protected ?array $paginationViaCursor = null,
         protected ?array $order = null,
         protected ?string $description = null,
         protected ?array $normalizationContext = null,
@@ -84,12 +595,167 @@ public function __construct(
         protected ?string $securityPostDenormalizeMessage = null,
         protected ?string $securityPostValidation = null,
         protected ?string $securityPostValidationMessage = null,
+        /**
+         * The `deprecationReason` option deprecates the current operation with a deprecation message.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * - With JSON-lD / Hydra, [an `owl:deprecated` annotation property](https://www.w3.org/TR/owl2-syntax/#Annotation_Properties) will be added to the appropriate data structure
+         * - With Swagger / OpenAPI, [a `deprecated` property](https://swagger.io/docs/specification/2-0/paths-and-operations/) will be added
+         * - With GraphQL, the [`isDeprecated` and `deprecationReason` properties](https://facebook.github.io/graphql/June2018/#sec-Deprecation) will be added to the schema
+         */
         protected ?string $deprecationReason = null,
+        /**
+         * The `filters` option configures the filters (declared as services) available on the collection routes for the current resource.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         * 
+         *     
+         *         
+         *             
+         *                 
+         *                     app.filters.book.search
+         *                 
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         */
         protected ?array $filters = null,
+        /**
+         * The `validationContext` option configure the context of validation for the current Operation.
+         * You can, for instance, describe the validation groups that will be used :.
+         *
+         * ```php
+         *   #[Put(validationContext: ['groups' => ['Default', 'putValidation']])]
+         *   #[Post(validationContext: ['groups' => ['Default', 'postValidation']])]
+         * ```
+         *
+         * For more examples, read our guide on [validation](/guides/validation).
+         */
         protected ?array $validationContext = null,
         protected $input = null,
         protected $output = null,
         protected $mercure = null,
+        /**
+         * The `messenger` option dispatches the current resource through the Message Bus.
+         *
+         * 
+         * ```php
+         * 
+         * 
+         *
+         * 
+         *     
+         *         
+         *             
+         *         
+         *     
+         * 
+         * ```
+         * 
+         *
+         * Note: when using `messenger=true` on a Doctrine entity, the Doctrine Processor is not called. If you want it
+         * to be called, you should [decorate a built-in state processor](/docs/guide/hook-a-persistence-layer-with-a-processor)
+         * and implement your own logic.
+         *
+         * Read [how to use Messenger with an Input object](/docs/guide/using-messenger-with-an-input-object).
+         *
+         * @var string|bool|null
+         */
         protected $messenger = null,
         protected ?bool $elasticsearch = null,
         protected ?int $urlGenerationStrategy = null,
diff --git a/src/Metadata/Operations.php b/src/Metadata/Operations.php
index ea0d322fa57..a08e53870ba 100644
--- a/src/Metadata/Operations.php
+++ b/src/Metadata/Operations.php
@@ -13,6 +13,9 @@
 
 namespace ApiPlatform\Metadata;
 
+/**
+ * An Operation dictionnary.
+ */
 final class Operations implements \IteratorAggregate, \Countable
 {
     private array $operations = [];
diff --git a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php
index 25c7c0cae3e..93ff0007743 100644
--- a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php
+++ b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php
@@ -40,7 +40,7 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn
     /**
      * Adds the formats attributes.
      *
-     * @see OperationResourceMetadataFactory
+     * @see UriTemplateResourceMetadataCollectionFactory
      *
      * @throws ResourceClassNotFoundException
      */
diff --git a/src/Metadata/Tests/Resource/Factory/OperationNameResourceMetadataFactoryTest.php b/src/Metadata/Tests/Resource/Factory/OperationNameResourceMetadataFactoryTest.php
index e0398450853..ef767d047c9 100644
--- a/src/Metadata/Tests/Resource/Factory/OperationNameResourceMetadataFactoryTest.php
+++ b/src/Metadata/Tests/Resource/Factory/OperationNameResourceMetadataFactoryTest.php
@@ -15,7 +15,7 @@
 
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\Get;
-use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\HttpOperation;
 use ApiPlatform\Metadata\Resource\Factory\OperationNameResourceMetadataCollectionFactory;
 use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
 use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
@@ -29,7 +29,7 @@ class OperationNameResourceMetadataFactoryTest extends TestCase
     /**
      * @dataProvider operationProvider
      */
-    public function testGeneratesName(Operation $operation, string $expectedOperationName): void
+    public function testGeneratesName(HttpOperation $operation, string $expectedOperationName): void
     {
         $decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
         $decorated->create('a')->willReturn(new ResourceMetadataCollection('a', [
diff --git a/src/Serializer/Filter/GroupFilter.php b/src/Serializer/Filter/GroupFilter.php
index 0e96fe49bcb..6d7f16d1bb7 100644
--- a/src/Serializer/Filter/GroupFilter.php
+++ b/src/Serializer/Filter/GroupFilter.php
@@ -17,7 +17,94 @@
 use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 
 /**
- * Group filter.
+ * The group filter allows you to filter by serialization groups.
+ *
+ * Syntax: `?groups[]=`.
+ *
+ * You can add as many groups as you need.
+ *
+ * Three arguments are available to configure the filter:
+ * - `parameterName` is the query parameter name (default: `groups`)
+ * - `overrideDefaultGroups` allows to override the default serialization groups (default: `false`)
+ * - `whitelist` groups whitelist to avoid uncontrolled data exposure (default: `null` to allow all groups)
+ *
+ * 
+ * ```php
+ *  'groups', 'overrideDefaultGroups' => false, 'whitelist' => ['allowed_group']])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.group_filter:
+ *         parent: 'api_platform.serializer.group_filter'
+ *         arguments: [ $parameterName: 'groups', $overrideDefaultGroups: false, $whitelist: ['allowed_group'] ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.group_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             groups
+ *             false
+ *             
+ *                 allowed_group
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.group_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter books by serialization groups with the following query: `/books?groups[]=read&groups[]=write`.
  *
  * @author Baptiste Meyer 
  */
diff --git a/src/Serializer/Filter/PropertyFilter.php b/src/Serializer/Filter/PropertyFilter.php
index ff5282a5efe..53576291ed4 100644
--- a/src/Serializer/Filter/PropertyFilter.php
+++ b/src/Serializer/Filter/PropertyFilter.php
@@ -18,7 +18,96 @@
 use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 
 /**
- * Property filter.
+ * The property filter adds the possibility to select the properties to serialize (sparse fieldsets).
+ *
+ * Note: We strongly recommend using [Vulcain](https://vulcain.rocks/) instead of this filter. Vulcain is faster, allows a better hit rate, and is supported out of the box in the API Platform distribution.
+ *
+ * Syntax: `?properties[]=&properties[][]=`.
+ *
+ * You can add as many properties as you need.
+ *
+ * Three arguments are available to configure the filter:
+ * - `parameterName` is the query parameter name (default: `properties`)
+ * - `overrideDefaultProperties` allows to override the default serialization properties (default: `false`)
+ * - `whitelist` properties whitelist to avoid uncontrolled data exposure (default: `null` to allow all properties)
+ *
+ * 
+ * ```php
+ *  'properties', 'overrideDefaultProperties' => false, 'whitelist' => ['allowed_property']])]
+ * class Book
+ * {
+ *     // ...
+ * }
+ * ```
+ *
+ * ```yaml
+ * # config/services.yaml
+ * services:
+ *     book.property_filter:
+ *         parent: 'api_platform.serializer.property_filter'
+ *         arguments: [ $parameterName: 'properties', $overrideDefaultGroups: false, $whitelist: ['allowed_property'] ]
+ *         tags:  [ 'api_platform.filter' ]
+ *         # The following are mandatory only if a _defaults section is defined with inverted values.
+ *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
+ *         autowire: false
+ *         autoconfigure: false
+ *         public: false
+ *
+ * # api/config/api_platform/resources.yaml
+ * resources:
+ *     App\Entity\Book:
+ *         - operations:
+ *               ApiPlatform\Metadata\GetCollection:
+ *                   filters: ['book.property_filter']
+ * ```
+ *
+ * ```xml
+ * 
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             properties
+ *             false
+ *             
+ *                 allowed_property
+ *             
+ *             
+ *         
+ *     
+ * 
+ * 
+ * 
+ *     
+ *         
+ *             
+ *                 
+ *                     book.property_filter
+ *                 
+ *             
+ *         
+ *     
+ * 
+ * ```
+ * 
+ *
+ * Given that the collection endpoint is `/books`, you can filter the serialization properties with the following query: `/books?properties[]=title&properties[]=author`. If you want to include some properties of the nested "author" document, use: `/books?properties[]=title&properties[author][]=name`.
  *
  * @author Baptiste Meyer 
  */
diff --git a/src/State/CallableProcessor.php b/src/State/CallableProcessor.php
index 2e1c5c88b8b..eff8f5633ef 100644
--- a/src/State/CallableProcessor.php
+++ b/src/State/CallableProcessor.php
@@ -24,7 +24,7 @@ public function __construct(private readonly ContainerInterface $locator)
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
     {
diff --git a/src/State/CallableProvider.php b/src/State/CallableProvider.php
index f669c079f6a..80c548b99ef 100644
--- a/src/State/CallableProvider.php
+++ b/src/State/CallableProvider.php
@@ -24,7 +24,7 @@ public function __construct(private readonly ContainerInterface $locator)
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
     {
diff --git a/src/State/Pagination/Pagination.php b/src/State/Pagination/Pagination.php
index 919f67514e6..e680a474db6 100644
--- a/src/State/Pagination/Pagination.php
+++ b/src/State/Pagination/Pagination.php
@@ -142,9 +142,9 @@ public function getLimit(Operation $operation = null, array $context = []): int
      * Gets info about the pagination.
      *
      * Returns an array with the following info as values:
-     *   - the page {@see Pagination::getPage()}
-     *   - the offset {@see Pagination::getOffset()}
-     *   - the limit {@see Pagination::getLimit()}
+     *   - the page {@see Pagination::getPage}
+     *   - the offset {@see Pagination::getOffset}
+     *   - the limit {@see Pagination::getLimit}
      *
      * @throws InvalidArgumentException
      */
diff --git a/src/Symfony/Messenger/Metadata/MessengerResourceMetadataCollectionFactory.php b/src/Symfony/Messenger/Metadata/MessengerResourceMetadataCollectionFactory.php
index 533ed47d547..bb21ba2ba85 100644
--- a/src/Symfony/Messenger/Metadata/MessengerResourceMetadataCollectionFactory.php
+++ b/src/Symfony/Messenger/Metadata/MessengerResourceMetadataCollectionFactory.php
@@ -24,7 +24,7 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn
     }
 
     /**
-     * {@inheritDoc}
+     * {@inheritdoc}
      */
     public function create(string $resourceClass): ResourceMetadataCollection
     {
diff --git a/src/Symfony/Routing/Router.php b/src/Symfony/Routing/Router.php
index e5b4556eb07..ce15e95add1 100644
--- a/src/Symfony/Routing/Router.php
+++ b/src/Symfony/Routing/Router.php
@@ -24,7 +24,7 @@
 /**
  * Symfony router decorator.
  *
- * Kévin Dunglas 
+ * @author Kévin Dunglas 
  */
 final class Router implements RouterInterface, UrlGeneratorInterface
 {
diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php
index a1c5e0b8b19..1e087ec2928 100644
--- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php
+++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php
@@ -18,6 +18,8 @@
 
 /**
  * Interface PropertySchemaRestrictionsInterface.
+ * This interface is autoconfigured with the `api_platform.metadata.property_schema_restriction` tag.
+ * It is used to generate a Resource schema using property restrictions based on the Symfony’s built-in validator. For example, the [Regex](https://symfony.com/doc/current/reference/constraints/Regex.html) constraint uses a [pattern](https://swagger.io/docs/specification/data-models/data-types/#pattern) type within the JSON schema.
  *
  * @author Andrii Penchuk penja7@gmail.com
  */
diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php
index 490d74ec1d9..8a08548d5a0 100644
--- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php
+++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php
@@ -32,7 +32,22 @@
  *
  * @author Kévin Dunglas 
  */
-#[ApiResource(operations: [new Get(security: 'is_granted(\'ROLE_USER\') and object.getOwner() == user'), new Put(securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user', extraProperties: ['standard_put' => false]), new GetCollection(security: 'is_granted(\'ROLE_USER\') or is_granted(\'ROLE_ADMIN\')'), new GetCollection(uriTemplate: 'custom_data_provider_generator', security: 'is_granted(\'ROLE_USER\')'), new Post(security: 'is_granted(\'ROLE_ADMIN\')')], graphQlOperations: [new Query(name: 'item_query', security: 'is_granted(\'ROLE_ADMIN\') or (is_granted(\'ROLE_USER\') and object.getOwner() == user)'), new QueryCollection(name: 'collection_query', security: 'is_granted(\'ROLE_ADMIN\')'), new Mutation(name: 'delete'), new Mutation(name: 'update', securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user'), new Mutation(name: 'create', security: 'is_granted(\'ROLE_ADMIN\')', securityMessage: 'Only admins can create a secured dummy.')], security: 'is_granted(\'ROLE_USER\')')]
+#[ApiResource(operations: [
+    new Get(security: 'is_granted(\'ROLE_USER\') and object.getOwner() == user'),
+    new Put(securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user', extraProperties: ['standard_put' => false]),
+    new GetCollection(security: 'is_granted(\'ROLE_USER\') or is_granted(\'ROLE_ADMIN\')'),
+    new GetCollection(uriTemplate: 'custom_data_provider_generator', security: 'is_granted(\'ROLE_USER\')'),
+    new Post(security: 'is_granted(\'ROLE_ADMIN\')'),
+],
+    graphQlOperations: [
+        new Query(name: 'item_query', security: 'is_granted(\'ROLE_ADMIN\') or (is_granted(\'ROLE_USER\') and object.getOwner() == user)'),
+        new QueryCollection(name: 'collection_query', security: 'is_granted(\'ROLE_ADMIN\')'),
+        new Mutation(name: 'delete'),
+        new Mutation(name: 'update', securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user'),
+        new Mutation(name: 'create', security: 'is_granted(\'ROLE_ADMIN\')', securityMessage: 'Only admins can create a secured dummy.'),
+    ],
+    security: 'is_granted(\'ROLE_USER\')'
+)]
 #[ORM\Entity]
 class SecuredDummy
 {