From dc0205ffe39527882ed9cf86916a22f0dd97c84f Mon Sep 17 00:00:00 2001 From: cay89 Date: Fri, 22 May 2026 15:30:44 +0200 Subject: [PATCH 01/10] Add credential handling instructions for Swagger UI (#2285) --- core/openapi.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/core/openapi.md b/core/openapi.md index 0f829740688..c29b020ed5d 100644 --- a/core/openapi.md +++ b/core/openapi.md @@ -924,6 +924,32 @@ return [ > **must** be set according to the > [OpenID Connect specification](https://openid.net/specs/openid-connect-core-1_0.html). +## Sending Credentials with Swagger UI Requests + +When your API is deployed behind a proxy that uses cookie-based authentication (e.g. Cloudflare +Access), Swagger UI's requests may be rejected because the authentication cookie is not forwarded by +default. Enabling `withCredentials` adds a `requestInterceptor` to SwaggerUIBundle that sets +`credentials: 'include'` on every outgoing request, ensuring cookies are sent alongside token and +CORS requests. + +### Sending Credentials with Swagger UI Requests using Symfony + +> [!NOTE] This feature is only available with Laravel. You're welcome to contribute the Symfony +> implementation [on GitHub](https://github.com/api-platform/core). + +### Sending Credentials with Swagger UI Requests using Laravel + +```php + [ + 'with_credentials' => true, + ], +]; +``` + ## Info Object The [info object](https://swagger.io/specification/#info-object) provides metadata about the API From 93df9a9c430b02dc91d85aea14844b62c203ebfe Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 11 Jun 2026 15:18:54 +0200 Subject: [PATCH 02/10] docs: document 422 denormalization error handling (#2286) --- core/upgrade-guide.md | 42 ++++++++++++++ laravel/validation.md | 127 +++++++++++++++++++++++++++++++++++++++++ symfony/validation.md | 128 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) diff --git a/core/upgrade-guide.md b/core/upgrade-guide.md index e1e70b934d6..a0f29957d14 100644 --- a/core/upgrade-guide.md +++ b/core/upgrade-guide.md @@ -1,5 +1,47 @@ # Upgrade Guide +## API Platform 4.3 to 4.4 + +### Backwards-Incompatible Changes + +#### Denormalization Type Errors on Unconstrained BackedEnum Properties Revert to HTTP 400 + +Prior to 4.4, `BackedEnum`-typed properties received special treatment: any serializer type mismatch +during denormalization was unconditionally promoted to HTTP 422. Starting with 4.4, that implicit +promotion is replaced by a constraint-aware check. + +**Who is affected**: code that relied on enum-typed properties producing 422 without any Symfony +Validator constraint (or Laravel rule) on the property. + +**What to do (Symfony)**: add an explicit constraint on the enum property: + +```php +use Symfony\Component\Validator\Constraints as Assert; + +#[Assert\Type(Status::class)] +public Status $status; +``` + +Alternatively, enable Symfony Validator's +[auto-mapping](https://symfony.com/doc/current/validation/auto_mapping.html) on the resource class. +Auto-mapping generates an implicit `Type` constraint from the PHP type declaration, which is +sufficient for the 422 promotion to apply. + +**What to do (Laravel)**: add a rule for the property in `rules`: + +```php +#[ApiResource( + rules: ['status' => 'required'] +)] +``` + +Properties that already carry any constraint or rule are unaffected — they continue to produce 422. + +For the full rule tables and additional details, see +[Constraint-Aware 422 for Denormalization Errors](../symfony/validation.md#constraint-aware-422-for-denormalization-errors) +(Symfony) and the equivalent section in the +[Laravel validation guide](../laravel/validation.md#constraint-aware-422-for-denormalization-errors). + ## API Platform 4.2 to 4.3 ### Breaking Changes diff --git a/laravel/validation.md b/laravel/validation.md index 6c32d37db9a..e147f3323f4 100644 --- a/laravel/validation.md +++ b/laravel/validation.md @@ -19,3 +19,130 @@ class Book extends Model { } ``` + +## Constraint-Aware 422 for Denormalization Errors + +Starting with API Platform 4.4, type mismatches detected during input denormalization (for example, +the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to +HTTP 422 validation responses when the affected property has a matching Laravel validation rule. +When no matching rule exists, API Platform rethrows the original serializer exception as an honest +HTTP 400. + +### How It Works + +`DeserializeProvider` catches denormalization exceptions from the Symfony Serializer. It delegates +to `ApiPlatform\Laravel\State\DenormalizationViolationFactory`, which reads the `rules` declared on +the operation and applies the following rule table: + +| Serializer `currentType` | Matching rule in `rules` | Code | +| ------------------------ | --------------------------------------------------------------------------------- | -------------- | +| `null` | `required`, `filled` | `blank` | +| `null` | `present` | `null` | +| any wrong type | `string`, `integer`, `int`, `numeric`, `boolean`, `bool`, `array`, `date`, `json` | `invalid_type` | +| any wrong type | any other rule (when `nullable` is absent) | `invalid_type` | +| `null` | `nullable` only (no `required`, `present`, or `filled`) | 400 (rethrow) | +| any | (no rule for the property) | 400 (rethrow) | + +Rules may be declared in string pipe-separated form (`'required|integer'`) or array form +(`['required', 'integer']`). Object-based rules (`Rule`, `ValidationRule`) and `FormRequest`-class +rule sets are skipped — `FormRequest` contracts run during the validation phase against the raw +request, not the denormalized body. + +### Example + +```php +// app/Models/Book.php + +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Model; + +#[ApiResource( + rules: [ + 'title' => 'required|string', + 'year' => 'required|integer', + ] +)] +class Book extends Model +{ + protected $fillable = ['title', 'year']; +} +``` + +Sending `null` for the `year` field: + +```http +POST /api/books HTTP/1.1 +Content-Type: application/json + +{"title": "Dune", "year": null} +``` + +Returns 422 with `blank` code because `required` is present: + +```json +{ + "type": "/validation_errors/abc123", + "title": "Validation Error", + "description": "year: This value should not be blank.", + "status": 422, + "violations": [ + { + "propertyPath": "year", + "message": "This value should not be blank.", + "code": "blank" + } + ] +} +``` + +Sending a string for the `year` field: + +```http +POST /api/books HTTP/1.1 +Content-Type: application/json + +{"title": "Dune", "year": "nineteen-sixty-five"} +``` + +Returns 422 with `invalid_type` code because `integer` is present: + +```json +{ + "type": "/validation_errors/def456", + "title": "Validation Error", + "description": "year: This value should be of type integer.", + "status": 422, + "violations": [ + { + "propertyPath": "year", + "message": "This value should be of type integer.", + "code": "invalid_type" + } + ] +} +``` + +If the `year` property had no rule at all, both requests would receive HTTP 400 instead. + +### Nullable Fields + +A field declared as `nullable` without `required`, `present`, or `filled` explicitly permits `null` +values, so a `null` submission for such a field is not promoted to 422 and rethrows the original +400: + +```php +#[ApiResource( + rules: [ + 'publishedAt' => 'nullable|date', + ] +)] +``` + +Sending `null` for `publishedAt` with only `nullable|date` produces HTTP 400, not 422. + +### Relationship with Symfony Validation + +The constraint-aware 422 behavior described above operates on the Laravel rules defined on the +operation. It is independent from the Symfony Validator stack. For the equivalent Symfony +integration, see the +[Validation with Symfony documentation](../symfony/validation.md#constraint-aware-422-for-denormalization-errors). diff --git a/symfony/validation.md b/symfony/validation.md index d4e733b171e..d5fdf1df111 100644 --- a/symfony/validation.md +++ b/symfony/validation.md @@ -648,3 +648,131 @@ If the submitted data has denormalization errors, the HTTP status code will be s You can also enable collecting of denormalization errors globally in the [Global Resources Defaults](https://api-platform.com/docs/core/configuration/#global-resources-defaults). + +## Constraint-Aware 422 for Denormalization Errors + +Starting with API Platform 4.4, type mismatches detected during input denormalization (for example, +the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to +HTTP 422 validation responses when the affected property has a matching Symfony Validator +constraint. When no matching constraint exists, API Platform rethrows the original serializer +exception as an honest HTTP 400. + +### How It Works + +`DeserializeProvider` catches `NotNormalizableValueException` and `PartialDenormalizationException` +from the Symfony Serializer. It delegates to +`ApiPlatform\Validator\DenormalizationViolationFactory`, which reads the Symfony Validator metadata +for the operation's resource class and applies the following rule table: + +| Serializer `currentType` | Matching constraint on the property | HTTP status | Violation code | +| ------------------------ | ----------------------------------- | ----------- | --------------------------- | +| `null` | `NotBlank` | 422 | `NotBlank::IS_BLANK_ERROR` | +| `null` | `NotNull` | 422 | `NotNull::IS_NULL_ERROR` | +| any wrong type | `Type` | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | any other constraint | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | (no constraint) | 400 | original exception rethrown | + +In `collectDenormalizationErrors` mode (where the serializer raises +`PartialDenormalizationException` instead of failing on the first error), properties without any +constraint still emit a generic `Type::INVALID_TYPE_ERROR` violation so the 422 response surface +remains consistent with prior behavior. + +Validation groups set via `Operation::getValidationContext()['groups']` are respected when looking +up constraints. + +### Example + +```php + Date: Thu, 11 Jun 2026 17:52:57 +0200 Subject: [PATCH 03/10] docs(state-providers): document repositoryMethod state option (#2293) --- core/dto.md | 4 + core/state-providers.md | 265 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/core/dto.md b/core/dto.md index bf8c36067aa..fa4b241bada 100644 --- a/core/dto.md +++ b/core/dto.md @@ -30,6 +30,10 @@ You can map a DTO Resource directly to a Doctrine Entity using stateOptions. Thi configures the built-in State Providers and Processors to fetch/persist data using the Entity and map it to your Resource (DTO) using the Symfony Object Mapper. +The Doctrine `stateOptions` also support a `repositoryMethod` parameter to start the provider query +from a custom repository method. See +[Customizing the Doctrine Query via `repositoryMethod`](state-providers.md#customizing-the-doctrine-query-via-repositorymethod-symfony-only). + > [!WARNING] You must apply the #[Map] attribute to your DTO class. This signals API Platform to use > the Object Mapper for transforming data between the Entity and the DTO. diff --git a/core/state-providers.md b/core/state-providers.md index a50db2766c3..97f69f0801b 100644 --- a/core/state-providers.md +++ b/core/state-providers.md @@ -422,6 +422,271 @@ use App\State\BookRepresentationProvider; class Book {} ``` +## Customizing the Doctrine Query via `repositoryMethod` (Symfony only) + +When using the built-in Doctrine ORM or MongoDB ODM state providers, you can instruct them to start +from a custom query builder produced by your entity repository instead of the default +`createQueryBuilder('o')` / `createAggregationBuilder()` call. This keeps all the standard provider +behavior (pagination, filters, link handling, identifier WHERE clauses) intact while giving you full +control over the base query. + +Set `repositoryMethod` on the `stateOptions` of the operation: + +```php + + */ +class ProductRepository extends EntityRepository +{ + public function findAvailable(): QueryBuilder + { + return $this->createQueryBuilder('o') + ->andWhere('o.available = :available') + ->setParameter('available', true); + } +} +``` + +The providers apply identifier resolution (for item operations), pagination, and filters on top of +the returned builder. A custom root alias is supported — the link handler reads the builder's root +alias automatically. + +If the method does not exist on the repository, a `RuntimeException` is thrown: +`The repository method "ProductRepository::findAvailable" does not exist.` + +If the method returns a value that is not the expected builder type, a `RuntimeException` is thrown: +`The repository method "findAvailable" must return a QueryBuilder instance.` + +> [!NOTE] Because the filter applies at the item level too, a `Get` operation using a +> `repositoryMethod` that filters rows will return a 404 response for any item excluded by that +> filter. + +### GraphQL + +`repositoryMethod` works identically for GraphQL queries. Use it on the `ApiResource` or on specific +GraphQL operations: + +```php + $entity, 'fieldAlias' => $scalar]` instead of plain entities. To map the +scalar back onto the entity, combine `repositoryMethod` with a `processor` on the operation. + +A processor only runs on a read operation when `write: true` is set on that operation. Without this +flag the processor stage is skipped and the raw array rows reach normalization, which produces +errors such as "Cannot return null for non-nullable field". Set `write: true` explicitly to enable +the processor. + +**REST example:** + +```php + + */ +class CartRepository extends EntityRepository +{ + public function getCartsWithTotalQuantity(): QueryBuilder + { + return $this->createQueryBuilder('o') + ->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + } +} +``` + +```php +totalQuantity = $row['totalQuantity'] ?? 0; + $row = $cart; + } + + return $data; + } +} +``` + +**GraphQL example:** + +The same `process` method works for GraphQL. Declare it on the `QueryCollection` operation alongside +`write: true`: + +```php +totalQuantity = $row['totalQuantity'] ?? 0; + $row = $cart; + } + + return $data; + } +} +``` + +With `paginationEnabled: false` the GraphQL query returns a plain list: + +```graphql +{ + carts { + totalQuantity + } +} +``` + +With pagination enabled (the default), it returns a Relay connection: + +```graphql +{ + carts { + edges { + node { + totalQuantity + } + } + } +} +``` + ## Registering Services Without Autowiring (only for the Symfony variant) The services in the previous examples are automatically registered because From 4732eb0e15c361d2b9e77c0406fe02f380dc628d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 14 Jun 2026 09:01:52 +0200 Subject: [PATCH 04/10] docs: add 'Using AI Coding Agents with API Platform' guide (#2301) --- core/ai-agents.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++ outline.yaml | 1 + 2 files changed, 150 insertions(+) create mode 100644 core/ai-agents.md diff --git a/core/ai-agents.md b/core/ai-agents.md new file mode 100644 index 00000000000..4242f522da7 --- /dev/null +++ b/core/ai-agents.md @@ -0,0 +1,149 @@ +# Using AI Coding Agents with API Platform + +AI coding agents — Claude Code, Cursor, GitHub Copilot, OpenAI Codex, Gemini, and others — are +trained on snapshots of public code. Those snapshots lag behind the framework: an agent may suggest +the `#[ApiFilter]` attribute, legacy controllers, or a serialization pattern that API Platform 4.x +has since replaced. The result is plausible-looking code that does not match the current canonical +way of doing things. + +This guide describes two complementary ways to keep an agent on current API Platform 4.x APIs while +it works in your project: + +- The [API Platform skillset](#claude-code-the-api-platform-skillset), a Claude Code plugin that + teaches Claude the canonical patterns, verified against `api-platform/core`. +- An agent-agnostic [`AGENTS.md`](#other-agents-cursor-github-copilot-openai-codex-gemini) file that + points any agent at the official documentation as the source of truth. + +> **Note:** This guide covers _dev-time_ guidance — keeping the agent that writes your code on +> current APIs. Exposing your running API _to_ AI agents as callable tools is a different concern, +> covered by the [MCP integration](mcp.md). + +## Claude Code: the API Platform skillset + +The [API Platform skillset](https://github.com/api-platform/skillset) is a +[Claude Code](https://docs.claude.com/en/docs/claude-code/overview) plugin that ships 15 skills for +API Platform 4.x development. Each skill teaches the current canonical way to do one thing, verified +against `api-platform/core`, and covers both the Symfony and Laravel integrations. + +A skill is more than a documentation link: it is a focused set of instructions, code patterns, and +constraints that Claude loads on demand. When you ask Claude to add a filter, the `api-filter` skill +loads and steers it toward `QueryParameter` rather than the legacy `#[ApiFilter]` attribute. + +### Installing the skillset + +The plugin is distributed through a Claude Code marketplace. Add the marketplace, then install the +plugin: + +```console +/plugin marketplace add api-platform/skillset +/plugin install api-platform@api-platform-skillset +``` + +These are [Claude Code slash commands](https://docs.claude.com/en/docs/claude-code/slash-commands) — +run them from inside a Claude Code session, not from your shell. + +### How skills load + +Skills are namespaced as `api-platform:` (for example `api-platform:api-filter`). You do not +invoke them manually: Claude loads a skill automatically when the task at hand is relevant to it. No +configuration is required once the plugin is installed. + +### Available skills + +| Skill | Covers | +| ---------------------- | -------------------------------------------------------------------------------------------- | +| `api-resource` | Resources, DTOs, Object Mapper, nested sub-resources, custom operations | +| `api-filter` | Collection filters with `QueryParameter`, legacy `#[ApiFilter]` migration | +| `state-provider` | Custom read logic, decorating Doctrine providers, computed fields | +| `state-processor` | Custom write logic, soft-delete, file downloads, side effects | +| `operations` | Operation security expressions, validation groups, parameter validation, deprecation | +| `securing-collections` | Multi-tenant isolation with Doctrine extensions and link handlers | +| `custom-validator` | Custom validation constraints for business rules | +| `serialization-groups` | Serialization contexts and `#[Groups]`, with guidance on when DTOs are better | +| `pagination` | Page-based, partial, and cursor pagination | +| `errors` | RFC 7807 Problem Details, `#[ErrorResource]`, exception-to-status mapping | +| `graphql` | GraphQL operations, resolvers, Relay pagination | +| `mercure` | Real-time updates over Mercure (Symfony only) | +| `api-platform-mcp` | Exposing resources to AI agents via MCP: `#[McpTool]`, `McpToolCollection`, `#[McpResource]` | +| `api-docs` | OpenAPI customization, hiding operations, factory decoration | +| `api-test` | Functional tests with `ApiTestCase` (Symfony) and HTTP tests (Laravel) | + +### Keeping the skillset up to date + +The canonical patterns evolve with each release. Update the marketplace to pull the latest skills: + +```console +/plugin marketplace update api-platform-skillset +``` + +## Other agents: Cursor, GitHub Copilot, OpenAI Codex, Gemini + +The skillset is a Claude Code plugin: its `SKILL.md` plus `.claude-plugin/` marketplace format is +specific to Claude Code, so it does not load into other agents. For Cursor, GitHub Copilot, OpenAI +Codex, Gemini, and any other agent, the portable option is an `AGENTS.md` file at the root of your +project. + +[`AGENTS.md`](https://agents.md/) is a convention — a plain Markdown file that most coding agents +read for project-specific instructions. It is lighter-weight than the Claude skillset: it does not +ship verified code patterns, it points the agent at where the current truth lives. The single most +useful instruction is to treat the official documentation as authoritative over training data: + +```markdown +# AGENTS.md + +This project uses [API Platform](https://api-platform.com) 4.x. + +The official documentation at is the source of truth. Prefer it over +patterns from your training data, which may describe older versions of the framework. + +In particular: + +- Declare collection filters with `QueryParameter`, not the legacy `#[ApiFilter]` attribute. +- Read and write logic belongs in state providers and state processors, not in custom controllers. +- Check the documentation for the current canonical pattern before generating API Platform code. +``` + +Tailor the bullet list to the conventions your project cares about. The point is to redirect the +agent to current documentation rather than letting it rely on stale training data. + +Claude Code also reads a `CLAUDE.md` file. If you maintain both an `AGENTS.md` and use Claude Code, +keep a thin `CLAUDE.md` that imports the shared file rather than duplicating its content: + +```markdown +# CLAUDE.md + +@AGENTS.md +``` + +This keeps a single source of project guidance for every agent. For Claude Code specifically, the +[skillset](#claude-code-the-api-platform-skillset) remains the richer option and complements +`AGENTS.md`. + +## Documentation index for LLMs + +The documentation is also published in the [llms.txt](https://llmstxt.org) format, an agent-agnostic +index any LLM can fetch to ground its answers in the current documentation: + +- — a curated index of the documentation, one link per + page, grouped by chapter. +- — the full text of every current-version page, + concatenated. + +Where `AGENTS.md` tells an agent _how_ to behave, `llms.txt` tells it _where_ the current reference +lives. The two are complementary: point your `AGENTS.md` at these URLs so the agent reads the +documentation instead of relying on stale training data. + +## Related: exposing your API to agents with MCP + +This guide is about the agent that _writes_ your code. A separate concern is letting an AI agent +_call_ your running API at runtime — searching your books, creating an order, reading a resource. +That is what the [Model Context Protocol (MCP) integration](mcp.md) provides: it turns your API +Platform resources into MCP tools and resources an agent can discover and invoke. + +The two are independent and can be used together: + +- The skillset and `AGENTS.md` shape the code an agent produces during development. +- MCP exposes your deployed API as callable tools at runtime. + +The `api-platform-mcp` skill in the skillset covers writing those MCP tool definitions; see +[Exposing Your API to AI Agents](mcp.md) for the full MCP reference. diff --git a/outline.yaml b/outline.yaml index 9ae3d63721b..01ea0718f2f 100644 --- a/outline.yaml +++ b/outline.yaml @@ -57,6 +57,7 @@ chapters: - openapi - json-schema - mcp + - ai-agents - mercure - push-relations - errors From c3cf58fc31fe141b2d9b226052781cc75a6cb6c9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 14 Jun 2026 09:02:13 +0200 Subject: [PATCH 05/10] ci: generate llms.txt during docs deploy (#2302) --- .github/workflows/cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a9127a3d145..2eabf974dcc 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -82,6 +82,10 @@ jobs: working-directory: docs-website run: hugo --minify + - name: Generate llms.txt + working-directory: docs-website + run: node tools/llms.mjs + - name: Deploy working-directory: docs-website run: gsutil -q -m rsync -d -r ./public gs://api-platform-website-v3/ From 15a640ad23dd9416ed0243695ddfa1222e8549e8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 14 Jun 2026 10:48:16 +0200 Subject: [PATCH 06/10] chore: drop redundant llms.txt CI step (Hugo generates it now) (#2304) --- .github/workflows/cd.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2eabf974dcc..a9127a3d145 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -82,10 +82,6 @@ jobs: working-directory: docs-website run: hugo --minify - - name: Generate llms.txt - working-directory: docs-website - run: node tools/llms.mjs - - name: Deploy working-directory: docs-website run: gsutil -q -m rsync -d -r ./public gs://api-platform-website-v3/ From 9fdc66aba041477ca0c3bcf58d81ad51c40dec2b Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 15:19:34 +0200 Subject: [PATCH 07/10] docs: fix markdown formatting (prettier) The Lint CI job runs `prettier --check "**/*.md"` repo-wide. Reformat the files it flags: doctrine-filters.md and upgrade-guide.md (prose wrap + table alignment on recently merged content) plus the pre-existing serialization.md and state-providers.md so the gate goes green. --- core/doctrine-filters.md | 4 ++-- core/serialization.md | 38 +++++++++++++++++++------------------- core/state-providers.md | 6 +++--- core/upgrade-guide.md | 13 +++++++------ 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/core/doctrine-filters.md b/core/doctrine-filters.md index 2541fd9fa99..34c86429dbe 100644 --- a/core/doctrine-filters.md +++ b/core/doctrine-filters.md @@ -1373,8 +1373,8 @@ class Offer ``` `DateFilter` is kept: only the declaration style changes. The URL syntax is unchanged -(`?createdAt[before]=2025-01-01`, `?createdAt[after]=2025-01-01`, and the `strictly_*` variants), and -per-property null management still applies. +(`?createdAt[before]=2025-01-01`, `?createdAt[after]=2025-01-01`, and the `strictly_*` variants), +and per-property null management still applies. ### Example: Migrating a RangeFilter diff --git a/core/serialization.md b/core/serialization.md index 4cbcf3c61de..0a0659ccf4f 100644 --- a/core/serialization.md +++ b/core/serialization.md @@ -308,22 +308,22 @@ class Book # api/config/api_platform/resources/Book.yaml App\ApiResource\Book: normalizationContext: - groups: ['get'] + groups: ["get"] operations: ApiPlatform\Metadata\Get: ~ ApiPlatform\Metadata\Get: ~ ApiPlatform\Metadata\Patch: normalizationContext: - groups: ['patch'] + groups: ["patch"] # The YAML syntax is only supported for Symfony # api/config/serializer/Book.yaml App\ApiResource\Book: attributes: name: - groups: ['get', 'patch'] + groups: ["get", "patch"] author: - groups: ['get'] + groups: ["get"] ``` ```xml @@ -452,16 +452,16 @@ class Book # api/config/api_platform/resources/Book.yaml App\ApiResource\Book: normalizationContext: - groups: ['book'] + groups: ["book"] # The YAML syntax is only supported for Symfony # api/config/serializer/Book.yaml App\ApiResource\Book: attributes: name: - groups: ['book'] + groups: ["book"] author: - groups: ['book'] + groups: ["book"] ``` @@ -597,18 +597,18 @@ class Person # api/config/api_platform/resources/Person.yaml App\ApiResource\Person: normalizationContext: - groups: ['person'] + groups: ["person"] denormalizationContext: - groups: ['person'] + groups: ["person"] # The YAML syntax is only supported for Symfony # api/config/serializer/Person.yaml App\ApiResource\Person: attributes: name: - groups: ['person'] + groups: ["person"] parent: - groups: ['person'] + groups: ["person"] ``` @@ -870,18 +870,18 @@ App\Entity\Greeting: operations: ApiPlatform\Metadata\GetCollection: normalizationContext: - groups: 'greeting:collection:get' + groups: "greeting:collection:get" # The YAML syntax is only supported for Symfony # api/config/serializer/Greeting.yaml App\Entity\Greeting: attributes: id: - groups: 'greeting:collection:get' + groups: "greeting:collection:get" name: - groups: 'greeting:collection:get' + groups: "greeting:collection:get" sum: - groups: 'greeting:collection:get' + groups: "greeting:collection:get" ``` @@ -932,18 +932,18 @@ class Book # api/config/api_platform/resources/Book.yaml App\ApiResource\Book: normalizationContext: - groups: ['book:output'] + groups: ["book:output"] denormalizationContext: - groups: ['book:input'] + groups: ["book:input"] # The YAML syntax is only supported for Symfony # api/config/serializer/Book.yaml App\ApiResource\Book: attributes: active: - groups: ['book:output', 'admin:input'] + groups: ["book:output", "admin:input"] name: - groups: ['book:output', 'book:input'] + groups: ["book:output", "book:input"] ``` diff --git a/core/state-providers.md b/core/state-providers.md index a50db2766c3..5da3e85c78e 100644 --- a/core/state-providers.md +++ b/core/state-providers.md @@ -435,13 +435,13 @@ use the following snippet: services: # ... App\State\BlogPostProvider: - tags: [ 'api_platform.state_provider' ] + tags: ["api_platform.state_provider"] # api/config/services.yaml services: # ... App\State\BookRepresentationProvider: arguments: - $itemProvider: '@api_platform.doctrine.orm.state.item_provider' - tags: [ 'api_platform.state_provider' ] + $itemProvider: "@api_platform.doctrine.orm.state.item_provider" + tags: ["api_platform.state_provider"] ``` diff --git a/core/upgrade-guide.md b/core/upgrade-guide.md index 9e8d0cbd5a4..d13787bf4c6 100644 --- a/core/upgrade-guide.md +++ b/core/upgrade-guide.md @@ -12,17 +12,18 @@ upgrade to the next major a no-op. The legacy Doctrine filter API is deprecated in favor of parameter-based filters declared with the `#[QueryParameter]` attribute. The `#[ApiFilter]` attribute, the `Operation::$filters` property, and -the `AbstractFilter` base class (Doctrine ORM and MongoDB ODM) are all deprecated and removed in 6.0. +the `AbstractFilter` base class (Doctrine ORM and MongoDB ODM) are all deprecated and removed in +6.0. There are two kinds of migration: - **Replaced filters** — removed in 6.0, swap the class: - | Legacy filter | Replacement | - | ------------- | ----------- | - | `SearchFilter` | `ExactFilter` / `PartialSearchFilter` / `IriFilter` (depending on the strategy) | - | `BooleanFilter`, `NumericFilter`, `BackedEnumFilter` | `ExactFilter` | - | `OrderFilter` | `SortFilter` | + | Legacy filter | Replacement | + | ---------------------------------------------------- | ------------------------------------------------------------------------------- | + | `SearchFilter` | `ExactFilter` / `PartialSearchFilter` / `IriFilter` (depending on the strategy) | + | `BooleanFilter`, `NumericFilter`, `BackedEnumFilter` | `ExactFilter` | + | `OrderFilter` | `SortFilter` | - **Kept filters** — `DateFilter`, `RangeFilter` and `ExistsFilter` survive. Only the way you _declare_ them is deprecated: move the declaration from `#[ApiFilter]` to `#[QueryParameter]`. The From d948c5270a4e67a68e9684f9abf9de2083c2f932 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 13 Jul 2026 07:30:17 +0200 Subject: [PATCH 08/10] docs(laravel): document the metadata dump command (#2300) --- laravel/index.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/laravel/index.md b/laravel/index.md index 3a3d2dd0db2..a6412939278 100644 --- a/laravel/index.md +++ b/laravel/index.md @@ -957,6 +957,112 @@ drastically. To clear the cache, use `php artisan optimize:clear`. +## Booting Without a Database Connection + +To expose an Eloquent model, API Platform reads its metadata (columns, types, nullability, relations, +identifiers) directly from the database schema. This introspection happens while the resource metadata +is built, which occurs when the service provider boots — including during routing, OpenAPI generation, +and metadata caching. + +As a consequence, **the application cannot boot when no migrated database connection is reachable**. Any +command that boots the framework will fail with a connection error such as: + +```text +SQLSTATE[HY000] [2002] Connection refused +could not find driver (Connection: mariadb, SQL: select ... from information_schema.columns ...) +``` + +This typically happens in setups where the database is not available at the time the app boots: + +- building a Docker image (the database service is not running during `docker build`); +- running `composer install`, which triggers `@php artisan package:discover` and boots the providers; +- running static analysis such as [Larastan](https://github.com/larastan/larastan) in a CI/CD pipeline, + since it boots the Laravel application. + +### Dump the Metadata to a File + +The recommended solution is to pre-compute the resource metadata once, while the database is up, dump it +to a single file, and serve the metadata from that file at boot. The introspection no longer runs, so the +app boots without any database connection. + +Choose where the dump file lives through the `metadata_dump` key of `config/api-platform.php`. The file is +meant to be committed to your VCS or baked into your Docker image, so pick a path inside the project: + +```php +// config/api-platform.php +return [ + // ... + 'metadata_dump' => base_path('api-platform-metadata.dump'), +]; +``` + +Generate the file with a reachable, migrated database: + +```console +php artisan api-platform:metadata:dump +``` + +The command iterates every resource, builds its metadata (this is the step that hits the database) and +serializes the result to the configured path. You can override the destination with `--path`: + +```console +php artisan api-platform:metadata:dump --path=/tmp/api-platform-metadata.dump +``` + +Once the file exists, the metadata is read from it at boot **when `APP_DEBUG` is `false`** — bypassing the +database. It is ignored when `APP_DEBUG` is `true`, so local development always recomputes fresh metadata +(mirroring the [metadata cache](#caching) behavior). Leave `metadata_dump` to `null` to disable the +feature entirely. + +> [!WARNING] +> The dump is a snapshot. It is **not** refreshed automatically when you change a resource or migrate the +> database — you must re-run `php artisan api-platform:metadata:dump` (with the database up) and commit the +> new file. To help you catch a forgotten refresh, API Platform compares the dump against the current state +> and logs a warning when they diverge: +> +> - **at boot**, when your `ApiResource` source files changed since the dump was generated (this check +> needs no database, so it runs during a cacheless boot); +> - **after `php artisan migrate`**, when the database schema no longer matches the dump. +> +> The warning never stops the boot — the (possibly stale) dump is still served so a database-less boot keeps +> working — but it tells you to regenerate the file. + +### Building a Docker Image + +Commit the dump file (or generate it in an earlier build stage that has a database) and copy it into the +image. At runtime, with `APP_DEBUG=false`, the app boots from the dump and never queries the database +during boot. + +If you only want to avoid the failure triggered by Composer scripts during the build, run +`composer install --no-scripts` and run `php artisan api-platform:metadata:dump` later, in a stage or step +where the database is reachable. + +### Static Analysis in CI + +[Larastan](https://github.com/larastan/larastan) boots the Laravel application before analyzing it, so it +hits the same requirement. Commit the dump file and make sure `APP_DEBUG` is `false` during analysis; the +app then boots from the dump without a database. + +### Use SQLite at Build Time + +If you would rather not commit a dump file, point the application to a migrated SQLite database while +building or running analysis, instead of your production database server. SQLite needs no separate service, +so it is always reachable. + +Configure the connection (for example through environment variables) and run the migrations before any +command that boots the app: + +```console +export DB_CONNECTION=sqlite +export DB_DATABASE=/tmp/api-platform.sqlite + +touch /tmp/api-platform.sqlite +php artisan migrate --force + +# now commands that boot the app succeed +php artisan optimize +``` + ## Hooking Your Own Business Logic Now that you learned the basics, be sure to read From ef73d47a1c6a0769251e4dd0b4c83c80ca0d1f6c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 13 Jul 2026 07:45:13 +0200 Subject: [PATCH 09/10] docs: fix markdown formatting (prettier) --- laravel/index.md | 85 ++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/laravel/index.md b/laravel/index.md index 4a98f677df9..dcadc0e36b7 100644 --- a/laravel/index.md +++ b/laravel/index.md @@ -1002,13 +1002,13 @@ To clear the cache, use `php artisan optimize:clear`. ## Booting Without a Database Connection -To expose an Eloquent model, API Platform reads its metadata (columns, types, nullability, relations, -identifiers) directly from the database schema. This introspection happens while the resource metadata -is built, which occurs when the service provider boots — including during routing, OpenAPI generation, -and metadata caching. +To expose an Eloquent model, API Platform reads its metadata (columns, types, nullability, +relations, identifiers) directly from the database schema. This introspection happens while the +resource metadata is built, which occurs when the service provider boots — including during routing, +OpenAPI generation, and metadata caching. -As a consequence, **the application cannot boot when no migrated database connection is reachable**. Any -command that boots the framework will fail with a connection error such as: +As a consequence, **the application cannot boot when no migrated database connection is reachable**. +Any command that boots the framework will fail with a connection error such as: ```text SQLSTATE[HY000] [2002] Connection refused @@ -1018,18 +1018,20 @@ could not find driver (Connection: mariadb, SQL: select ... from information_sch This typically happens in setups where the database is not available at the time the app boots: - building a Docker image (the database service is not running during `docker build`); -- running `composer install`, which triggers `@php artisan package:discover` and boots the providers; -- running static analysis such as [Larastan](https://github.com/larastan/larastan) in a CI/CD pipeline, - since it boots the Laravel application. +- running `composer install`, which triggers `@php artisan package:discover` and boots the + providers; +- running static analysis such as [Larastan](https://github.com/larastan/larastan) in a CI/CD + pipeline, since it boots the Laravel application. ### Dump the Metadata to a File -The recommended solution is to pre-compute the resource metadata once, while the database is up, dump it -to a single file, and serve the metadata from that file at boot. The introspection no longer runs, so the -app boots without any database connection. +The recommended solution is to pre-compute the resource metadata once, while the database is up, +dump it to a single file, and serve the metadata from that file at boot. The introspection no longer +runs, so the app boots without any database connection. -Choose where the dump file lives through the `metadata_dump` key of `config/api-platform.php`. The file is -meant to be committed to your VCS or baked into your Docker image, so pick a path inside the project: +Choose where the dump file lives through the `metadata_dump` key of `config/api-platform.php`. The +file is meant to be committed to your VCS or baked into your Docker image, so pick a path inside the +project: ```php // config/api-platform.php @@ -1045,55 +1047,54 @@ Generate the file with a reachable, migrated database: php artisan api-platform:metadata:dump ``` -The command iterates every resource, builds its metadata (this is the step that hits the database) and -serializes the result to the configured path. You can override the destination with `--path`: +The command iterates every resource, builds its metadata (this is the step that hits the database) +and serializes the result to the configured path. You can override the destination with `--path`: ```console php artisan api-platform:metadata:dump --path=/tmp/api-platform-metadata.dump ``` -Once the file exists, the metadata is read from it at boot **when `APP_DEBUG` is `false`** — bypassing the -database. It is ignored when `APP_DEBUG` is `true`, so local development always recomputes fresh metadata -(mirroring the [metadata cache](#caching) behavior). Leave `metadata_dump` to `null` to disable the -feature entirely. +Once the file exists, the metadata is read from it at boot **when `APP_DEBUG` is `false`** — +bypassing the database. It is ignored when `APP_DEBUG` is `true`, so local development always +recomputes fresh metadata (mirroring the [metadata cache](#caching) behavior). Leave `metadata_dump` +to `null` to disable the feature entirely. -> [!WARNING] -> The dump is a snapshot. It is **not** refreshed automatically when you change a resource or migrate the -> database — you must re-run `php artisan api-platform:metadata:dump` (with the database up) and commit the -> new file. To help you catch a forgotten refresh, API Platform compares the dump against the current state -> and logs a warning when they diverge: +> [!WARNING] The dump is a snapshot. It is **not** refreshed automatically when you change a +> resource or migrate the database — you must re-run `php artisan api-platform:metadata:dump` (with +> the database up) and commit the new file. To help you catch a forgotten refresh, API Platform +> compares the dump against the current state and logs a warning when they diverge: > -> - **at boot**, when your `ApiResource` source files changed since the dump was generated (this check -> needs no database, so it runs during a cacheless boot); +> - **at boot**, when your `ApiResource` source files changed since the dump was generated (this +> check needs no database, so it runs during a cacheless boot); > - **after `php artisan migrate`**, when the database schema no longer matches the dump. > -> The warning never stops the boot — the (possibly stale) dump is still served so a database-less boot keeps -> working — but it tells you to regenerate the file. +> The warning never stops the boot — the (possibly stale) dump is still served so a database-less +> boot keeps working — but it tells you to regenerate the file. ### Building a Docker Image -Commit the dump file (or generate it in an earlier build stage that has a database) and copy it into the -image. At runtime, with `APP_DEBUG=false`, the app boots from the dump and never queries the database -during boot. +Commit the dump file (or generate it in an earlier build stage that has a database) and copy it into +the image. At runtime, with `APP_DEBUG=false`, the app boots from the dump and never queries the +database during boot. If you only want to avoid the failure triggered by Composer scripts during the build, run -`composer install --no-scripts` and run `php artisan api-platform:metadata:dump` later, in a stage or step -where the database is reachable. +`composer install --no-scripts` and run `php artisan api-platform:metadata:dump` later, in a stage +or step where the database is reachable. ### Static Analysis in CI -[Larastan](https://github.com/larastan/larastan) boots the Laravel application before analyzing it, so it -hits the same requirement. Commit the dump file and make sure `APP_DEBUG` is `false` during analysis; the -app then boots from the dump without a database. +[Larastan](https://github.com/larastan/larastan) boots the Laravel application before analyzing it, +so it hits the same requirement. Commit the dump file and make sure `APP_DEBUG` is `false` during +analysis; the app then boots from the dump without a database. ### Use SQLite at Build Time -If you would rather not commit a dump file, point the application to a migrated SQLite database while -building or running analysis, instead of your production database server. SQLite needs no separate service, -so it is always reachable. +If you would rather not commit a dump file, point the application to a migrated SQLite database +while building or running analysis, instead of your production database server. SQLite needs no +separate service, so it is always reachable. -Configure the connection (for example through environment variables) and run the migrations before any -command that boots the app: +Configure the connection (for example through environment variables) and run the migrations before +any command that boots the app: ```console export DB_CONNECTION=sqlite From f37080b654ea3d3daf52ea5bc1149c0838dfc02b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 13 Jul 2026 09:15:57 +0200 Subject: [PATCH 10/10] docs(performance): document HEAD request body-skip optimization (#2312) --- core/configuration.md | 8 ++++++++ core/performance.md | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/core/configuration.md b/core/configuration.md index 039fa67208b..5809e4ca473 100644 --- a/core/configuration.md +++ b/core/configuration.md @@ -68,6 +68,10 @@ api_platform: # Enable the docs. enable_docs: true + # Skip response body construction on HEAD requests so collections are not iterated. + # Disable to process HEAD identically to GET. + enable_head_request_optimization: true + # Enable the data collector and the WebProfilerBundle integration. enable_profiler: true @@ -465,6 +469,10 @@ return [ // Enable the docs. 'enable_docs' => true, + // Skip response body construction on HEAD requests so collections are not iterated. + // Disable to process HEAD identically to GET. + 'enable_head_request_optimization' => true, + // Enable the data collector and the WebProfilerBundle integration. 'enable_profiler' => true, diff --git a/core/performance.md b/core/performance.md index 7c499f72eb0..b86b658eccf 100644 --- a/core/performance.md +++ b/core/performance.md @@ -618,6 +618,50 @@ when `jsonStream` is not enabled, API Platform falls back to the regular Seriali > declarations to build its encoders. Make sure every serialized property is public and typed; > values exposed only through getters or non-public properties will not be streamed. +## HEAD Request Optimization + +Available since API Platform 4.4. + +On `HEAD` requests, API Platform no longer builds the response body: the collection is never +iterated (no row is fetched from the database) and serialization is skipped entirely. This +optimization is **enabled by default** and reduces the cost of `HEAD` requests, especially on large +collections. + +Compared to the previous behavior, where a `HEAD` request was processed identically to its `GET` +counterpart, this introduces two observable changes: + +- The `Content-Length` header is no longer set on `HEAD` responses (this is permitted by + [RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.2)). +- Cache-Tags and `xkey` headers used by the + [HTTP cache invalidation system](#enabling-the-built-in-http-cache-invalidation-system) are no + longer emitted on `HEAD` responses. Previously, a `HEAD` response carried the same tags as its + `GET` counterpart and could be purged by tag; it now expires by TTL only. + +> **Note:** Independently of this optimization, the `Allow` header no longer advertises `HEAD` for +> resources that have no `GET` operation, since `HEAD` is defined as `GET` without a response body. + +If you rely on the previous behavior, for example if a caching layer purges `HEAD` responses by +Cache-Tag, disable the optimization to process `HEAD` identically to `GET`: + +### Disabling the Optimization using Symfony + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + enable_head_request_optimization: false # process HEAD identically to GET +``` + +### Disabling the Optimization using Laravel + +```php + false, // process HEAD identically to GET +]; +``` + ## Profiling with Blackfire.io Blackfire.io allows you to monitor the performance of your applications. For more information, visit