From 8df157eb022420fd19ad3b14a56dcc14e5193793 Mon Sep 17 00:00:00 2001 From: Nozarashi <15169250+nozarashi20@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:17:53 +0200 Subject: [PATCH 01/13] docs(symfony): document repeated global parameters (#2319) --- core/filters.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/core/filters.md b/core/filters.md index d462693c0ce..f07f9a1026a 100644 --- a/core/filters.md +++ b/core/filters.md @@ -126,8 +126,9 @@ Instead of repeating the same parameter configuration on every resource, you can default parameters that are automatically applied to all resources. This is done via the `defaults` key in your API Platform configuration. -Add a `parameters` map under `defaults` in your API Platform configuration. Each entry maps a -fully-qualified parameter class name to its options. +Add a `parameters` map under `defaults` in your API Platform configuration. In Symfony, entries can +either use the fully-qualified parameter class name as their key, or use a custom name and specify +the class explicitly with the `class` option. ```yaml # Symfony: api/config/packages/api_platform.yaml @@ -145,6 +146,30 @@ api_platform: description: "API version" ``` +To define multiple global parameters using the same parameter class, use named entries with an +explicit `class`: + +```yaml +# Symfony: api/config/packages/api_platform.yaml +api_platform: + defaults: + parameters: + api_token: + class: ApiPlatform\Metadata\HeaderParameter + key: "API-Token" + required: true + description: "API authentication token" + + request_id: + class: ApiPlatform\Metadata\HeaderParameter + key: "Request-ID" + required: false + description: "A unique request identifier" +``` + +The name of a named entry is only a configuration identifier. The `key` option defines the parameter +name exposed at runtime. + ```php Date: Sat, 12 Sep 2026 15:18:14 +0200 Subject: [PATCH 02/13] docs(mercure): document 1.0 subscriber-side subscribe (match/match_urlpattern) (#2323) --- core/mercure.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/core/mercure.md b/core/mercure.md index 2f8493ace4e..2e2ddd14684 100644 --- a/core/mercure.md +++ b/core/mercure.md @@ -16,6 +16,14 @@ Mercure hub. Then, the Mercure hub dispatches the updates to all connected clien ![Mercure subscriptions](images/mercure-subscriptions.png) +## Which Protocol Version Does Your Hub Speak? + +Mercure has a 1.0 protocol version that changes how subscribers pick which updates to receive: +`match=` and `match_urlpattern=` query parameters replace `topic=`. API Platform talks to 0.x hubs +by default; switching a hub to 1.0 is done by configuring `protocol_version: '1.0'` on the +[MercureBundle](https://symfony.com/doc/current/mercure.html) side, not on this page. The examples +below cover both; check with whoever operates your hub if you're not sure which version it runs. + ## Installing Mercure Support Mercure support is already installed, configured and enabled in @@ -67,6 +75,54 @@ automatically subscribe to Mercure updates when available: [Learn how to use the discovery capabilities of Mercure in your own clients](https://mercure.rocks/docs/ecosystem/awesome). +## Subscribing to Updates + +API Platform publishes each update on the resource's absolute IRI — the same value as the `@id` of +the JSON-LD document the API serves for that resource. This means the topic to subscribe to is +nothing you need to build: it's the `@id` you already have from fetching the resource. + +A minimal browser client fetches the resource, discovers the hub from the `Link: rel="mercure"` +header, and subscribes using the resource's `@id` as the topic: + +```javascript +const response = await fetch("/books/1", { headers: { Accept: "application/ld+json" } }); +const book = await response.json(); + +// Discover the hub through the Link header API Platform adds to the response +const linkHeader = response.headers.get("Link"); +const [, hubUrlString] = linkHeader.match(/<([^>]+)>;\s*rel="mercure"/); +const hubUrl = new URL(hubUrlString); + +hubUrl.searchParams.append("topic", book["@id"]); // Mercure 0.x + +const eventSource = new EventSource(hubUrl); +eventSource.onmessage = (event) => { + console.log(JSON.parse(event.data)); +}; +``` + +If your hub speaks the Mercure 1.0 protocol, subscribe with `match` instead of `topic`: + +```javascript +hubUrl.searchParams.append("match", book["@id"]); // Mercure 1.0 +``` + +To subscribe to every book instead of a single one, 1.0 hubs also accept `match_urlpattern`, with a +[URL Pattern](https://mercure.rocks/docs/1.0/concepts/topics-and-matchers) matching the resources' +IRIs: + +```javascript +hubUrl.searchParams.append("match_urlpattern", "https://api.example.com/books/:id"); // Mercure 1.0 +``` + +`match_urlpattern` has no 0.x equivalent: under 0.x, subscribing to a family of resources at once +means either subscribing to each IRI individually, or using the alternate-topic technique described +below. + +See the [Mercure protocol reference](https://mercure.rocks/docs/1.0/reference/protocol) (1.0) or +[the spec's subscribers section](https://mercure.rocks/spec#subscribers) (0.x) for the full +`topic`/`match`/`match_urlpattern` semantics. + ## Dispatching Private Updates (Authorized Mode) Mercure allows dispatching From 928bf80941f10e2ad85ef1d4fb6edca937abedaf Mon Sep 17 00:00:00 2001 From: Gourab Sahu Date: Sat, 12 Sep 2026 15:18:32 +0200 Subject: [PATCH 03/13] Improve doc: In the absence of a nativeType, the constraints recive an array value (#2322) --- core/filters.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/filters.md b/core/filters.md index f07f9a1026a..1f8b82a7777 100644 --- a/core/filters.md +++ b/core/filters.md @@ -523,6 +523,8 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\QueryParameter; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource(operations: [ new GetCollection( @@ -535,7 +537,8 @@ use Symfony\Component\Validator\Constraints as Assert; 'X-Request-ID' => new HeaderParameter( description: 'A unique request identifier.', required: true, - constraints: [new Assert\Uuid()] + constraints: [new Assert\Uuid()], + nativeType: new BuiltinType(typeIdentifier: TypeIdentifier::STRING) ) ] ) @@ -546,6 +549,9 @@ class User {} > [!NOTE] When `castToNativeType` is enabled, API Platform infers type validation from the JSON > Schema. +If `constraints` are used then a valid type needs to be passed using `nativeType` named argument. +Otherwise, the values will be passed as an array to each constraints. + The `ApiPlatform\Validator\Util\ParameterValidationConstraints` trait can be used to automatically infer validation constraints from the JSON Schema and OpenAPI definitions of a parameter. From 42636dd26c6a0cd63dab44ee6f07368c56af7cd2 Mon Sep 17 00:00:00 2001 From: Leighton Thomas Date: Sat, 12 Sep 2026 14:19:10 +0100 Subject: [PATCH 04/13] Update parameter security documentation to reflect that it throws AccessDeniedException (#2320) --- core/filters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/filters.md b/core/filters.md index 1f8b82a7777..724f6ffb19c 100644 --- a/core/filters.md +++ b/core/filters.md @@ -970,7 +970,7 @@ class User {} ## Parameter Security You can secure individual parameters using Symfony expression language. When a security expression -evaluates to `false`, the parameter will be ignored and treated as if it wasn't provided. +evaluates to `false`, a context appropriate `AccessDeniedException` will be thrown. ```php Date: Sun, 13 Sep 2026 08:16:59 +0200 Subject: [PATCH 05/13] chore: merge up 4.3 into 4.4 (#2325) Co-authored-by: Nozarashi <15169250+nozarashi20@users.noreply.github.com> Co-authored-by: Maxime Valin Co-authored-by: Gourab Sahu Co-authored-by: Leighton Thomas --- core/filters.md | 39 ++++++++++++++++++++++++++++++---- core/mercure.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/core/filters.md b/core/filters.md index d462693c0ce..724f6ffb19c 100644 --- a/core/filters.md +++ b/core/filters.md @@ -126,8 +126,9 @@ Instead of repeating the same parameter configuration on every resource, you can default parameters that are automatically applied to all resources. This is done via the `defaults` key in your API Platform configuration. -Add a `parameters` map under `defaults` in your API Platform configuration. Each entry maps a -fully-qualified parameter class name to its options. +Add a `parameters` map under `defaults` in your API Platform configuration. In Symfony, entries can +either use the fully-qualified parameter class name as their key, or use a custom name and specify +the class explicitly with the `class` option. ```yaml # Symfony: api/config/packages/api_platform.yaml @@ -145,6 +146,30 @@ api_platform: description: "API version" ``` +To define multiple global parameters using the same parameter class, use named entries with an +explicit `class`: + +```yaml +# Symfony: api/config/packages/api_platform.yaml +api_platform: + defaults: + parameters: + api_token: + class: ApiPlatform\Metadata\HeaderParameter + key: "API-Token" + required: true + description: "API authentication token" + + request_id: + class: ApiPlatform\Metadata\HeaderParameter + key: "Request-ID" + required: false + description: "A unique request identifier" +``` + +The name of a named entry is only a configuration identifier. The `key` option defines the parameter +name exposed at runtime. + ```php new HeaderParameter( description: 'A unique request identifier.', required: true, - constraints: [new Assert\Uuid()] + constraints: [new Assert\Uuid()], + nativeType: new BuiltinType(typeIdentifier: TypeIdentifier::STRING) ) ] ) @@ -521,6 +549,9 @@ class User {} > [!NOTE] When `castToNativeType` is enabled, API Platform infers type validation from the JSON > Schema. +If `constraints` are used then a valid type needs to be passed using `nativeType` named argument. +Otherwise, the values will be passed as an array to each constraints. + The `ApiPlatform\Validator\Util\ParameterValidationConstraints` trait can be used to automatically infer validation constraints from the JSON Schema and OpenAPI definitions of a parameter. @@ -939,7 +970,7 @@ class User {} ## Parameter Security You can secure individual parameters using Symfony expression language. When a security expression -evaluates to `false`, the parameter will be ignored and treated as if it wasn't provided. +evaluates to `false`, a context appropriate `AccessDeniedException` will be thrown. ```php ]+)>;\s*rel="mercure"/); +const hubUrl = new URL(hubUrlString); + +hubUrl.searchParams.append("topic", book["@id"]); // Mercure 0.x + +const eventSource = new EventSource(hubUrl); +eventSource.onmessage = (event) => { + console.log(JSON.parse(event.data)); +}; +``` + +If your hub speaks the Mercure 1.0 protocol, subscribe with `match` instead of `topic`: + +```javascript +hubUrl.searchParams.append("match", book["@id"]); // Mercure 1.0 +``` + +To subscribe to every book instead of a single one, 1.0 hubs also accept `match_urlpattern`, with a +[URL Pattern](https://mercure.rocks/docs/1.0/concepts/topics-and-matchers) matching the resources' +IRIs: + +```javascript +hubUrl.searchParams.append("match_urlpattern", "https://api.example.com/books/:id"); // Mercure 1.0 +``` + +`match_urlpattern` has no 0.x equivalent: under 0.x, subscribing to a family of resources at once +means either subscribing to each IRI individually, or using the alternate-topic technique described +below. + +See the [Mercure protocol reference](https://mercure.rocks/docs/1.0/reference/protocol) (1.0) or +[the spec's subscribers section](https://mercure.rocks/spec#subscribers) (0.x) for the full +`topic`/`match`/`match_urlpattern` semantics. + ## Dispatching Private Updates (Authorized Mode) Mercure allows dispatching From 517170a0799be6a4502119604915a6801331df3b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:17:15 +0200 Subject: [PATCH 06/13] docs: fix filter deprecations removed in 6.0, not 5.0 (#2326) --- core/doctrine-filters.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core/doctrine-filters.md b/core/doctrine-filters.md index 34c86429dbe..6ad898bc7b8 100644 --- a/core/doctrine-filters.md +++ b/core/doctrine-filters.md @@ -1529,7 +1529,7 @@ Multiple parameters targeting the same relation path share the same JOIN (ORM) o ### Nested Properties with the Legacy ApiFilter Syntax (deprecated) > [!WARNING] The legacy method using the `ApiFilter` attribute is **deprecated** and scheduled for -> **removal** in API Platform **5.0**. We strongly recommend migrating to the new `QueryParameter` +> **removal** in API Platform **6.0**. We strongly recommend migrating to the new `QueryParameter` > syntax described above. For legacy code, the built-in filters that extend `AbstractFilter` support nested properties using @@ -1595,7 +1595,7 @@ The above allows you to find offers by their respective product's color: ## Enabling a Filter for All Properties of a Resource > [!WARNING] The legacy method using the `ApiFilter` attribute is **deprecated** and scheduled for -> **removal** in API Platform **5.0**. We strongly recommend migrating to the new `QueryParameter` +> **removal** in API Platform **6.0**. We strongly recommend migrating to the new `QueryParameter` > syntax, which is detailed in the [Introduction](#introduction). You can use the `:property` > placeholder instead and it is recommended to use a filter for each type of data you are filtering. @@ -1961,7 +1961,7 @@ use Doctrine\ORM\QueryBuilder; class MyCustomFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -1982,6 +1982,12 @@ class MyCustomFilter implements FilterInterface } ``` +`BackwardCompatibleFilterDescriptionTrait` supplies a `getDescription()` method that returns an +empty array, satisfying the legacy `FilterInterface::getDescription()` requirement without you +having to implement it by hand. It lets a custom filter keep working with the deprecated filter +chain while you migrate it to `#[QueryParameter]`, and it will be removed in API Platform 6.0 +together with `getDescription()` itself. + #### Implementing a Custom ORM Filter Let's create a concrete filter that allows fetching entities based on the month of a date field (for @@ -2008,7 +2014,7 @@ use Doctrine\ORM\QueryBuilder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -2252,7 +2258,7 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { @@ -2294,7 +2300,7 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { From 569d07d32609ce6ea5ca734a171b22de3c2720e9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:18:18 +0200 Subject: [PATCH 07/13] docs: backport 4.4-feature docs stranded on main (#2335) Co-authored-by: cay89 --- core/configuration.md | 8 ++ core/doctrine-filters.md | 151 +++++++++++++++++++--- core/dto.md | 4 + core/filters.md | 10 ++ core/openapi.md | 30 +++++ core/performance.md | 44 +++++++ core/state-providers.md | 265 +++++++++++++++++++++++++++++++++++++++ core/upgrade-guide.md | 46 ++++++- laravel/index.md | 107 ++++++++++++++++ laravel/validation.md | 127 +++++++++++++++++++ symfony/validation.md | 128 +++++++++++++++++++ 11 files changed, 899 insertions(+), 21 deletions(-) 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/doctrine-filters.md b/core/doctrine-filters.md index 34c86429dbe..64d1d9fe423 100644 --- a/core/doctrine-filters.md +++ b/core/doctrine-filters.md @@ -137,10 +137,13 @@ To add some search filters, choose over this new list: - [PartialSearchFilter](#partial-search-filter) (filter using a `LIKE %value%`; supports nested properties via dot notation) - [ComparisonFilter](#comparison-filter) (filter with comparison operators `gt`, `gte`, `lt`, `lte`, - `ne`; replaces `DateFilter`, `NumericFilter`, and `RangeFilter`) + `ne`; replaces `NumericFilter` and `RangeFilter` — it is not a replacement for `DateFilter`, which + is kept) - [FreeTextQueryFilter](#free-text-query-filter) (allows you to apply multiple filters to multiple properties of a resource at the same time, using a single parameter in the URL) - [OrFilter](#or-filter) (apply a filter using `orWhere` instead of `andWhere`) +- [ChainFilter](#chain-filter) (compose several filters on a single parameter key, each + self-selecting by value shape) ### SearchFilter @@ -561,10 +564,13 @@ parameter key, one per operator. For a parameter named `price`, the generated pa ## Date Filter -> [!TIP] Consider using [`ComparisonFilter`](#comparison-filter) wrapping `ExactFilter` as a modern -> replacement. `ComparisonFilter` does not extend `AbstractFilter`, works natively with -> `QueryParameter`, and supports the same date comparison use cases with `gt`, `gte`, `lt`, `lte` -> operators. +> [!NOTE] `DateFilter` is a kept filter: there is no modern replacement for it. +> [`ComparisonFilter`](#comparison-filter) only performs plain `gt`/`gte`/`lt`/`lte`/`ne` +> comparisons and does not replicate `DateFilter`'s per-property +> [`null` management](#managing-null-values), its automatic `\DateTime`/`\DateTimeImmutable` binding +> based on the Doctrine column type, its tolerant handling of invalid or empty date values, or its +> inclusive `before`/`after` versus exclusive `strictly_before`/`strictly_after` URL vocabulary. +> Keep `DateFilter` and declare it through a `QueryParameter`. The date filter allows filtering a collection by date intervals. @@ -599,8 +605,15 @@ class Offer } ``` -> [!TIP] For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take -> a look [in the Introduction section](#introduction). +> [!NOTE] Instantiating a legacy filter with `new` (e.g. `new DateFilter()`) directly inside a +> `QueryParameter` logs a cosmetic `ManagerRegistry must be initialized before accessing it.` ALERT +> at cache warmup — filtering still works correctly. To silence it, reference the filter by its +> service id (a string) instead of an inline object, or wrap it in a [`ChainFilter`](#chain-filter), +> which suppresses the warmup call. See +> [issue #7361](https://github.com/api-platform/core/issues/7361). +> +> For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take a look +> [in the Introduction section](#introduction). ### Date Filter using the ApiFilter Attribute Syntax (not recommended) @@ -719,13 +732,91 @@ class Offer } ``` -> [!TIP] For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take -> a look [in the Introduction section](#introduction). +> [!NOTE] The inline `new DateFilter(...)` instances above trigger the same cosmetic warmup ALERT +> described [above](#date-filter-using-the-queryparameter-syntax-recommended) +> ([#7361](https://github.com/api-platform/core/issues/7361)). Use a service id or a +> [`ChainFilter`](#chain-filter) to silence it. +> +> For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take a look +> [in the Introduction section](#introduction). + +## Chain Filter + +> [!NOTE] Since API Platform 4.4, `ChainFilter` is available for both Doctrine ORM +> (`ApiPlatform\Doctrine\Orm\Filter\ChainFilter`) and MongoDB ODM +> (`ApiPlatform\Doctrine\Odm\Filter\ChainFilter`). + +`ChainFilter` is a decorator that composes several filters on a single query parameter key. Each +wrapped filter self-selects by the shape of the incoming value: `ComparisonFilter` and `DateFilter` +ignore plain scalar values (they only react to their operator-map syntax, e.g. `[gt]`/`[lt]` or +`[before]`/`[after]`), while `ExactFilter` and `PartialSearchFilter` ignore operator-map arrays. +This lets you combine, for instance, an exact match and a full `DateFilter` on the same property +without changing the URL vocabulary of either. + +The canonical use case is adding exact-match filtering to a date property while keeping +`DateFilter`'s null management and `before`/`after`/`strictly_before`/`strictly_after` semantics +intact: + +```php + new QueryParameter( + property: 'birthdate', + filter: new ChainFilter([ + new ExactFilter(), + new DateFilter(), + ]), + ), + ], +)] +class Person +{ + // ... +} +``` + +Given that the collection endpoint is `/people`, both of the following queries work on the same +`birthdate` parameter: + +- `/people?birthdate=1999-12-31` — exact match, handled by `ExactFilter` +- `/people?birthdate[after]=1999-01-01` — date interval, handled by `DateFilter` +- `/people?birthdate[strictly_before]=2000-01-01` — date interval, handled by `DateFilter` + +`ChainFilter` forwards `ManagerRegistry` and `Logger` to any wrapped filter that needs them, and +merges the OpenAPI parameters documented by every wrapped filter. Because it manages this injection +itself, wrapping a legacy filter in a `ChainFilter` also suppresses the cosmetic +`ManagerRegistry must be initialized before accessing it.` ALERT described in the +[#7361 note](#date-filter-using-the-queryparameter-syntax-recommended) — it is a valid alternative +to referencing the filter by service id when you need to compose it with another filter on the same +key. ## Boolean Filter > [!TIP] Consider using [`ExactFilter`](#exact-filter) as a modern replacement. `ExactFilter` does -> not extend `AbstractFilter` and works natively with `QueryParameter`. +> not extend `AbstractFilter` and works natively with `QueryParameter`. For a boolean field, declare +> the parameter type and disable the array variant so a single `?field=true` parameter is exposed +> (without `castToArray: false`, `ExactFilter` also documents a `field[]` array variant): +> +> ```php +> 'isAvailableGenericallyInMyCountry' => new QueryParameter( +> filter: new ExactFilter(), +> schema: ['type' => 'boolean'], +> castToNativeType: true, +> castToArray: false, +> ), +> ``` The boolean filter allows you to search on boolean fields and values. @@ -849,8 +940,12 @@ class Offer } ``` -> [!TIP] For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take -> a look [in the Introduction section](#introduction). +> [!NOTE] `new RangeFilter()` inline triggers the same cosmetic warmup ALERT described in the +> [#7361 note](#date-filter-using-the-queryparameter-syntax-recommended). Use a service id or a +> [`ChainFilter`](#chain-filter) to silence it. +> +> For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take a look +> [in the Introduction section](#introduction). ### Result using the Range Filter @@ -893,8 +988,12 @@ class Offer } ``` -> [!TIP] For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take -> a look [in the Introduction section](#introduction). +> [!NOTE] `new ExistsFilter()` inline triggers the same cosmetic warmup ALERT described in the +> [#7361 note](#date-filter-using-the-queryparameter-syntax-recommended). Use a service id or a +> [`ChainFilter`](#chain-filter) to silence it. +> +> For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take a look +> [in the Introduction section](#introduction). ### Result using the Exists Filter @@ -1326,7 +1425,13 @@ There are two kinds of migration: _declare_ them is deprecated (via `#[ApiFilter]` / extending `AbstractFilter`). Move the declaration to a `QueryParameter`; the class name and the URL syntax stay the same. -> [!TIP] When instantiating a filter inside a `QueryParameter`, always use named arguments +> [!NOTE] Declaring a kept filter as an inline `new` instance inside a `QueryParameter` (as shown in +> the examples below) logs a cosmetic `ManagerRegistry must be initialized before accessing it.` +> ALERT at cache warmup — filtering still works. Reference the filter by its service id (a string) +> instead, or wrap it in a [`ChainFilter`](#chain-filter) to silence it. See +> [issue #7361](https://github.com/api-platform/core/issues/7361). +> +> Also, when instantiating a filter inside a `QueryParameter`, always use named arguments > (`new DateFilter(nullManagement: ...)` rather than positional). Filter constructors are refined > across versions; named arguments keep your declarations forward-compatible. @@ -1376,6 +1481,11 @@ class Offer (`?createdAt[before]=2025-01-01`, `?createdAt[after]=2025-01-01`, and the `strictly_*` variants), and per-property null management still applies. +> [!NOTE] The `new DateFilter()` instance in the "modern" example above triggers the same cosmetic +> warmup ALERT described in the +> [#7361 note](#date-filter-using-the-queryparameter-syntax-recommended). Use a service id or a +> [`ChainFilter`](#chain-filter) to silence it. + ### Example: Migrating a RangeFilter Before (legacy): @@ -1424,6 +1534,11 @@ class Product > [!TIP] Since API Platform 5.0, `ComparisonFilter` covers the full range syntax (including a native > `[between]=X..Y`), and `RangeFilter` is deprecated in favor of it. When you upgrade to 5.0, switch > `new RangeFilter()` to `new ComparisonFilter(new ExactFilter())` — the URL syntax is preserved. +> +> Also, the `new RangeFilter()` instance in the "modern" example above triggers the same cosmetic +> warmup ALERT described in the +> [#7361 note](#date-filter-using-the-queryparameter-syntax-recommended). Use a service id or a +> [`ChainFilter`](#chain-filter) to silence it. ### MongoDB ODM @@ -1440,13 +1555,13 @@ use ApiPlatform\Metadata\QueryParameter; #[ApiResource] #[GetCollection( parameters: [ - 'createdAt' => new QueryParameter( + 'price' => new QueryParameter( filter: new ComparisonFilter(new ExactFilter()), - property: 'createdAt', + property: 'price', ), ], )] -class Event +class Product { // ... } 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/filters.md b/core/filters.md index 724f6ffb19c..afc3c6fc145 100644 --- a/core/filters.md +++ b/core/filters.md @@ -66,6 +66,11 @@ a new instance: - **`OrFilter`**: A decorator that forces a filter to combine criteria with `OR` instead of `AND`. - Usage: `new QueryParameter(filter: new OrFilter(new ExactFilter()), properties: ['name', 'ean'])` +- **`ChainFilter`** (Doctrine ORM/ODM only): Composes several filters on a single parameter key; + each wrapped filter self-selects by the shape of the value. See the + [Doctrine Filters documentation](doctrine-filters.md#chain-filter) for details. + - Usage: + `new QueryParameter(filter: new ChainFilter([new ExactFilter(), new DateFilter()]), property: 'birthdate')` - **`BooleanFilter`**: For boolean field filtering (legacy, `ExactFilter` is recommended instead). - Usage: `new QueryParameter(filter: BooleanFilter::class)` - **`NumericFilter`**: For numeric field filtering (legacy, `ExactFilter` or `ComparisonFilter` is @@ -282,6 +287,11 @@ This configuration allows clients to filter events by date ranges using queries - `/events?endDate[lt]=2023-12-31` — events ending before December 31st 2023 - `/events?startDate[gte]=2023-01-01&endDate[lte]=2023-12-31` — events within a date range +> [!NOTE] This is a plain comparison, distinct from [`DateFilter`](doctrine-filters.md#date-filter): +> it does not provide per-property `null` management, tolerant handling of invalid or empty values, +> or the `before`/`after` versus `strictly_before`/`strictly_after` vocabulary. Use `DateFilter` +> when you need those behaviors. + ### Filtering a Single Property Most of the time, a parameter maps directly to a property on your resource. For example, a diff --git a/core/openapi.md b/core/openapi.md index 0f829740688..13e5eda927a 100644 --- a/core/openapi.md +++ b/core/openapi.md @@ -924,6 +924,36 @@ 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 + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + swagger: + with_credentials: true +``` + +### 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 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 diff --git a/core/state-providers.md b/core/state-providers.md index 5da3e85c78e..8533803e5d6 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 diff --git a/core/upgrade-guide.md b/core/upgrade-guide.md index d13787bf4c6..c7a1e77e843 100644 --- a/core/upgrade-guide.md +++ b/core/upgrade-guide.md @@ -2,9 +2,49 @@ ## API Platform 4.3 to 4.4 -4.4 is the last 4.x minor. It introduces no breaking changes: everything deprecated here keeps -working until it is removed in a later major (5.0 or 6.0). Fixing the deprecations now makes the -upgrade to the next major a no-op. +4.4 is the last 4.x minor. It ships a single backwards-incompatible change (below); everything else +is a deprecation that keeps working until it is removed in a later major (5.0 or 6.0). Fixing the +deprecations now makes the upgrade to the next major a no-op. + +### 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). ### Deprecations diff --git a/laravel/index.md b/laravel/index.md index dfe58f2b890..dcadc0e36b7 100644 --- a/laravel/index.md +++ b/laravel/index.md @@ -1000,6 +1000,113 @@ 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 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 94e4e1c864d..517ac9717b3 100644 --- a/symfony/validation.md +++ b/symfony/validation.md @@ -706,3 +706,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: Sun, 13 Sep 2026 08:21:07 +0200 Subject: [PATCH 08/13] docs(operations): throwOnNotFound + dynamic status (#2327) --- core/operations.md | 127 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/core/operations.md b/core/operations.md index 6239007ca39..42b1dc677c1 100644 --- a/core/operations.md +++ b/core/operations.md @@ -117,6 +117,87 @@ resources: +## Controlling the 404 Response When Data Is Missing + +Available since API Platform 4.4. + +When the provider returns `null` (no matching entity, or a provider that has nothing to give back +for this request), API Platform's `ReadProvider` decides whether to throw a `404 Not Found` or to +let the request through with `null` data. By default, that decision depends on the HTTP method: + +- a `POST` operation never throws: creating a resource does not require one to already exist; +- a `PUT` operation with [`allowCreate`](#upsert-creating-a-resource-with-put) enabled never throws + either, since a missing item is exactly the "create it" case of the upsert behavior; +- every other operation (`GET`, `GetCollection`, `PATCH`, `DELETE`, or a `PUT` without + `allowCreate`) throws a `404 Not Found` when the provider returns `null`. + +Set the `throwOnNotFound` property to `false` to opt out of this default and let the operation +proceed with `null` data, or to `true` to force the `404` even on an operation that would not throw +by default (for instance a `PUT` with `allowCreate: true` for which you still want a strict "must +already exist" semantics). + +A common use case for `throwOnNotFound: false` is an operation whose provider legitimately returns +`null` as valid data, for example a "current user" or "current cart" endpoint that returns `null` +when none is set instead of failing: + + + +```php + + + + + + + + + +``` + + + +With `throwOnNotFound: false`, the `null` value reaches the rest of the pipeline (normalization, +custom processors, and so on) instead of interrupting the request with an exception, so the +controller and later stages must be prepared to handle a `null` resource. + ## Enabling and Disabling Operations If no operation is specified, all default CRUD operations are automatically registered. It is also @@ -428,6 +509,52 @@ resources: +## Setting the Response Status Code at Runtime + +Available since API Platform 4.4. + +The `status` option shown above is static: it is the right tool when the response code for an +operation is fixed and known when you configure it. Sometimes, though, the status code can only be +decided while the request is being handled, for example a state processor that returns +`202 Accepted` when a task is queued for later processing but `200 OK` when it completes +synchronously. + +For this case, `RespondProcessor` reads a `_api_response_status` request attribute before falling +back to the operation's static `status` (or to the framework default). Set it from a custom state +processor to override the status code for the current request only: + +```php +isQueuedForAsyncImport($data)) { + $request->attributes->set('_api_response_status', Response::HTTP_ACCEPTED); + } + + // ... persist $data, return it or a DTO + + return $data; + } +} +``` + +> [!NOTE] The `_api_response_status` attribute always wins over the operation's `status` option, so +> use it only when the code truly depends on runtime conditions. When the status is fixed per +> operation, the static `status` option documented above remains the right tool: it is visible in +> the resource metadata and in the generated OpenAPI/Hydra documentation, while a request attribute +> set at runtime is not. + ## Prefixing All Routes of All Operations Sometimes it's also useful to put a whole resource into its own "namespace" regarding the URI. Let's From 023421ed8388871d2505f8b96ec6672774192d4c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:21:34 +0200 Subject: [PATCH 09/13] docs(jsonld): document resource-level jsonldContext (#2328) --- core/extending-jsonld-context.md | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/core/extending-jsonld-context.md b/core/extending-jsonld-context.md index af54bfa9886..a832704fad7 100644 --- a/core/extending-jsonld-context.md +++ b/core/extending-jsonld-context.md @@ -63,6 +63,62 @@ The generated context will now have your custom attributes set: Note that you do not have to provide the `@id` attribute. If you do not provide an `@id` attribute, the value from `iri` will be used. +### Extending the Context of a Whole Resource + +The `jsonldContext` option is also available on `#[ApiResource]` itself. Its main use is declaring +namespace prefixes once for the whole resource, instead of repeating a full IRI on every property +that needs one: + +```php + 'http://purl.org/dc/terms/'], +)] +class Book +{ + // ... + + #[ApiProperty(types: ['https://schema.org/name'], iris: ['dct:title'])] + public $name; + + // ... +} +``` + +The `dct` prefix is merged into the top-level `@context`, so properties can then reference it +through a compact IRI such as `dct:title`: + +`GET /contexts/Book` + +```json +{ + "@context": { + "@vocab": "http://example.com/apidoc#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "dct": "http://purl.org/dc/terms/", + "name": "dct:title" + } +} +``` + +The resource-level context is merged into `@context` before the per-property entries are added, and +each property's entry is then written to `@context[]`, overwriting anything already +present at that key. In practice, the two never collide because the resource-level context is meant +for prefix declarations, while a property's own entry is keyed by the property name; a collision +only happens if a property is literally named after one of your prefixes, in which case the property +wins. + +If an operation (for example a `Get` or a `Patch`) declares its own `jsonldContext`, that value is +used as-is for that operation instead of the resource's: the two are not merged together, the +operation's `jsonldContext` simply takes precedence. + ## Hydra

Hydra screencast
Watch the Hydra screencast

@@ -122,3 +178,34 @@ resources: ``` + +### The `hydra:memberAssertion` Property + +For every resource exposing a collection operation, the generated Hydra API documentation +(`GET /docs.jsonld`) automatically adds a `hydra:memberAssertion` entry to the entrypoint's +`hydra:supportedProperty` for that collection. It asserts, as an `rdf:type` statement, that every +member returned by the collection is an instance of the resource: + +```json +{ + "@id": "#Entrypoint/books", + "@type": "hydra:Link", + "domain": "#Entrypoint", + "owl:maxCardinality": 1, + "range": "hydra:Collection", + "hydra:memberAssertion": { + "hydra:property": { "@id": "rdf:type" }, + "hydra:object": { "@id": "#Book" } + }, + "hydra:supportedOperation": ["..."] +} +``` + +This assertion is generated automatically for every collection and isn't configurable through +`jsonldContext` or `hydraContext`. + +> [!NOTE] Before API Platform 4.4, the same assertion was expressed as an `owl:equivalentClass` +> restriction (an `owl:onProperty: hydra:member` / `owl:allValuesFrom: #Book` pair nested inside the +> `range` array). `owl:equivalentClass` no longer appears anywhere in the generated Hydra +> documentation: if you parse `range` and expect that structure, read `hydra:memberAssertion` +> instead. From 5c11d729c029b37e63fe7fc7ae9655a5a72beff4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:35:43 +0200 Subject: [PATCH 10/13] docs(filters): document search filter family (#2338) --- core/doctrine-filters.md | 233 +++++++++++++++++++++++++++++++++++++-- core/filters.md | 13 +++ laravel/filters.md | 29 +++++ 3 files changed, 264 insertions(+), 11 deletions(-) diff --git a/core/doctrine-filters.md b/core/doctrine-filters.md index 64d1d9fe423..615f912aec3 100644 --- a/core/doctrine-filters.md +++ b/core/doctrine-filters.md @@ -136,6 +136,12 @@ To add some search filters, choose over this new list: notation) - [PartialSearchFilter](#partial-search-filter) (filter using a `LIKE %value%`; supports nested properties via dot notation) +- [StartSearchFilter](#start-search-filter) (filter using a `LIKE value%`; supports nested + properties via dot notation) +- [EndSearchFilter](#end-search-filter) (filter using a `LIKE %value`; supports nested properties + via dot notation) +- [WordStartSearchFilter](#word-start-search-filter) (filter on a word boundary prefix, matching + fields containing a word that starts with the value; supports nested properties via dot notation) - [ComparisonFilter](#comparison-filter) (filter with comparison operators `gt`, `gte`, `lt`, `lte`, `ne`; replaces `NumericFilter` and `RangeFilter` — it is not a replacement for `DateFilter`, which is kept) @@ -315,6 +321,164 @@ It will return all chickens where the name contains the substring _tom_. `PartialSearchFilter` supports searching on nested properties using dot notation in the `property` argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). +## Start Search Filter + +The start search filter allows filtering a resource by the beginning of a string property. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new StartSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=Ger`. + +It will return all chickens whose name starts with the substring _Ger_ (for e.g. "Gertrude"). + +> [!NOTE] The generated query and the default case sensitivity differ between Doctrine ORM and +> MongoDB ODM: +> +> - **Doctrine ORM** builds a `LOWER(field) LIKE LOWER('value%')` clause and is **case-insensitive +> by default**. Pass `new StartSearchFilter(caseSensitive: true)` for a case-sensitive +> `field LIKE 'value%'` match. +> - **MongoDB ODM** matches with a regular expression anchored at the start of the string (`^value`) +> and is **case-sensitive by default**. Pass `new StartSearchFilter(caseSensitive: false)` to add +> the case-insensitive `i` regex flag. + +`StartSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `start` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'start')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] A Laravel/Eloquent equivalent also exists: +> [`ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter`](../laravel/filters.md#text). It always +> generates a `LIKE 'value%'` clause (case sensitivity depends on your database collation) and does +> not support nested/relation properties. + +## End Search Filter + +The end search filter allows filtering a resource by the end of a string property. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new EndSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=trude`. + +It will return all chickens whose name ends with the substring _trude_ (for e.g. "Gertrude"). + +> [!NOTE] The generated query and the default case sensitivity differ between Doctrine ORM and +> MongoDB ODM: +> +> - **Doctrine ORM** builds a `LOWER(field) LIKE LOWER('%value')` clause and is **case-insensitive +> by default**. Pass `new EndSearchFilter(caseSensitive: true)` for a case-sensitive +> `field LIKE '%value'` match. +> - **MongoDB ODM** matches with a regular expression anchored at the end of the string (`value$`) +> and is **case-sensitive by default**. Pass `new EndSearchFilter(caseSensitive: false)` to add +> the case-insensitive `i` regex flag. + +`EndSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `end` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'end')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] A Laravel/Eloquent equivalent also exists: +> [`ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter`](../laravel/filters.md#text). It always +> generates a `LIKE '%value'` clause (case sensitivity depends on your database collation) and does +> not support nested/relation properties. + +## Word Start Search Filter + +The word start search filter allows filtering a resource by fields that contain a word starting with +the given value, matching either the beginning of the string or a word boundary further inside it. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new WordStartSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=Coq`. + +It matches "Coquette" (the value starts the string) and "Farm Coquette" (the value starts a word +that is not the first one), but not "Silkycoquette" (there, `coq` is inside the word +"silkycoquette", not at the start of a word). + +> [!NOTE] The generated query, and the exact word-boundary semantics, differ between Doctrine ORM +> and MongoDB ODM: +> +> - **Doctrine ORM** builds `field LIKE 'value%' OR field LIKE '% value%'` (word boundaries are +> recognized only on a literal ASCII space) and is **case-insensitive by default** (via +> `LOWER()`). Pass `new WordStartSearchFilter(caseSensitive: true)` for a case-sensitive match. +> - **MongoDB ODM** matches with the regular expression `(^value|\svalue)` — the value at the start +> of the string, or preceded by any whitespace character (space, tab, newline, not only a plain +> space) — and is **case-sensitive by default**. Pass +> `new WordStartSearchFilter(caseSensitive: false)` to add the case-insensitive `i` regex flag. + +`WordStartSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `word_start` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'word_start')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] There is no Laravel/Eloquent equivalent of `WordStartSearchFilter`: the Laravel package +> only ships `PartialSearchFilter`, `StartSearchFilter`, and `EndSearchFilter`. + ## Free Text Query Filter The free text query filter allows filtering allows you to apply a single filter across a list of @@ -357,6 +521,50 @@ This request will return all chickens where: For the `OR` option refer to the [OrFilter](#or-filter). +### Using a Different Filter per Property + +The constructor also accepts an `array` map instead of a single shared +filter, so a single free-text parameter can apply a **different** filter strategy to each property. +This is available identically for Doctrine ORM and MongoDB ODM. + +```php +// api/src/ApiResource/Book.php + +#[GetCollection( + parameters: [ + 'q' => new QueryParameter( + filter: new FreeTextQueryFilter([ + 'title' => new PartialSearchFilter(), + 'isbn' => new ExactFilter(), + ]), + ), + ], +)] +class Book +{ + //... +} +``` + +Given that the endpoint is `/books`, the query `/books?q=vin` will return all books where: + +- the `title` contains the substring "vin" +- **AND** +- the `isbn` is exactly "vin". + +As with the single-filter form, properties are combined with `AND` by default; wrap the whole +`FreeTextQueryFilter` in [`OrFilter`](#or-filter) to combine them with `OR` instead — this still +works when `filter` is a map, since `OrFilter` only changes how the outer `FreeTextQueryFilter` call +combines its results, not which per-property filter is used. + +When `filter` is a map, the `properties` option is not required: it defaults to the map's keys +(`['title', 'isbn']` above). Pass `properties` explicitly only to restrict the search to a subset of +the map's keys. + +> [!NOTE] A property listed in `properties` that has no matching entry in the `filter` map (and, +> symmetrically, a map entry not listed in `properties`) is silently skipped: no filtering criteria +> is added for it, it neither restricts nor is required by the resulting query. + ## Or Filter The or filter allows you to explicitly change the logical condition used by the filter it wraps. Its @@ -1405,17 +1613,20 @@ The following table shows how to replace each legacy filter. All modern replacem for both Doctrine ORM (`ApiPlatform\Doctrine\Orm\Filter\*`) and MongoDB ODM (`ApiPlatform\Doctrine\Odm\Filter\*`). -| Legacy filter (`AbstractFilter`) | Modern replacement | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SearchFilter` (exact strategy) | [`ExactFilter`](#exact-filter) | -| `SearchFilter` (partial, start, end, word_start strategies) | [`PartialSearchFilter`](#partial-search-filter) | -| `SearchFilter` (relations / IRI matching) | [`IriFilter`](#iri-filter) | -| `BooleanFilter` | [`ExactFilter`](#exact-filter) | -| `NumericFilter` | [`ExactFilter`](#exact-filter) (exact) or [`ComparisonFilter(new ExactFilter())`](#comparison-filter) (range) | -| `OrderFilter` | [`SortFilter`](#sort-filter) | -| `DateFilter` | Kept — declare it through a `QueryParameter`, same class, same `[before]`/`[after]` URL syntax (drop-in) | -| `RangeFilter` | Kept — declare it through a `QueryParameter`, same class, same `[between]` URL syntax (drop-in) | -| `ExistsFilter` | Kept — declare it through a `QueryParameter`, same class (drop-in) | +| Legacy filter (`AbstractFilter`) | Modern replacement | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `SearchFilter` (exact strategy) | [`ExactFilter`](#exact-filter) | +| `SearchFilter` (partial strategy) | [`PartialSearchFilter`](#partial-search-filter) | +| `SearchFilter` (start strategy) | [`StartSearchFilter`](#start-search-filter) | +| `SearchFilter` (end strategy) | [`EndSearchFilter`](#end-search-filter) | +| `SearchFilter` (word_start strategy) | [`WordStartSearchFilter`](#word-start-search-filter) | +| `SearchFilter` (relations / IRI matching) | [`IriFilter`](#iri-filter) | +| `BooleanFilter` | [`ExactFilter`](#exact-filter) | +| `NumericFilter` | [`ExactFilter`](#exact-filter) (exact) or [`ComparisonFilter(new ExactFilter())`](#comparison-filter) (range) | +| `OrderFilter` | [`SortFilter`](#sort-filter) | +| `DateFilter` | Kept — declare it through a `QueryParameter`, same class, same `[before]`/`[after]` URL syntax (drop-in) | +| `RangeFilter` | Kept — declare it through a `QueryParameter`, same class, same `[between]` URL syntax (drop-in) | +| `ExistsFilter` | Kept — declare it through a `QueryParameter`, same class (drop-in) | There are two kinds of migration: diff --git a/core/filters.md b/core/filters.md index afc3c6fc145..141122feb74 100644 --- a/core/filters.md +++ b/core/filters.md @@ -51,6 +51,19 @@ a new instance: - **`PartialSearchFilter`**: For partial string matching (SQL `LIKE %...%`). Supports dot notation for nested properties. - Usage: `new QueryParameter(filter: PartialSearchFilter::class)` +- **`StartSearchFilter`**: For prefix matching (`LIKE value%`). Supports dot notation for nested + properties. See the [Doctrine Filters documentation](doctrine-filters.md#start-search-filter) for + case-sensitivity defaults. + - Usage: `new QueryParameter(filter: StartSearchFilter::class)` +- **`EndSearchFilter`**: For suffix matching (`LIKE %value`). Supports dot notation for nested + properties. See the [Doctrine Filters documentation](doctrine-filters.md#end-search-filter) for + case-sensitivity defaults. + - Usage: `new QueryParameter(filter: EndSearchFilter::class)` +- **`WordStartSearchFilter`** (Doctrine ORM/ODM only, no Laravel/Eloquent equivalent): Matches + fields containing a word that starts with the value. Supports dot notation for nested properties. + See the [Doctrine Filters documentation](doctrine-filters.md#word-start-search-filter) for + details. + - Usage: `new QueryParameter(filter: WordStartSearchFilter::class)` - **`IriFilter`**: For filtering by IRIs (e.g., relations). Supports dot notation for nested associations. - Usage: `new QueryParameter(filter: IriFilter::class)` diff --git a/laravel/filters.md b/laravel/filters.md index 5c77192d74f..34fb541a48c 100644 --- a/laravel/filters.md +++ b/laravel/filters.md @@ -147,6 +147,35 @@ As shown above the following search filters are available: - `ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter` queries `LIKE term%` - `ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter` queries `LIKE %term` +`StartSearchFilter` and `EndSearchFilter` are used the same way as `EqualsFilter` above: + +```php +// app/Models/Book.php + +use ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter; +use ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; + +#[ApiResource] +#[QueryParameter(key: 'titleStartsWith', property: 'title', filter: StartSearchFilter::class)] +#[QueryParameter(key: 'titleEndsWith', property: 'title', filter: EndSearchFilter::class)] +class Book extends Model +{ +} +``` + +`/books?titleStartsWith=The` returns every book whose `title` starts with "The", and +`/books?titleEndsWith=Farm` returns every book whose `title` ends with "Farm". Case sensitivity +depends on your database collation; neither filter exposes a `caseSensitive` option. + +> [!NOTE] Unlike `PartialSearchFilter`, `StartSearchFilter` and `EndSearchFilter` do not support +> filtering across a relationship using dot notation (for e.g. `author.name`): they only apply to a +> property of the resource itself. + +There is no Eloquent equivalent of the Doctrine ORM/ODM `WordStartSearchFilter` (see +[Word Start Search Filter](../core/doctrine-filters.md#word-start-search-filter)). + ### Date The `DateFilter` allows to filter dates with an operator (`eq`, `lt`, `gt`, `lte`, `gte`): From 6c269449135d7a8f1b0099d414697252b65d1d66 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:36:25 +0200 Subject: [PATCH 11/13] docs(openapi): document Scalar UI and OpenAPI 3.2.0 (#2337) --- core/openapi.md | 122 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/core/openapi.md b/core/openapi.md index 13e5eda927a..4f4fd577b6c 100644 --- a/core/openapi.md +++ b/core/openapi.md @@ -6,13 +6,15 @@ API Platform natively supports the [OpenAPI](https://www.openapis.org/) API spec

OpenAPI screencast
Watch the OpenAPI screencast

-The specification of the API is available at the `/docs.jsonopenapi` path. By default, OpenAPI v3 is -used. You can also get an OpenAPI v3-compliant version thanks to the `spec_version` query parameter: -`/docs.jsonopenapi?spec_version=3` +The specification of the API is available at the `/docs.jsonopenapi` path. Generated documents +currently target OpenAPI v3.2.0 (`ApiPlatform\OpenApi\OpenApi::VERSION`). If a tool you use does not +yet support v3.2.0, you can ask for the legacy v3.0.0 format instead thanks to the `spec_version` +query parameter: `/docs.jsonopenapi?spec_version=3.0.0` -It also integrates a customized version of [Swagger UI](https://swagger.io/swagger-ui/) and -[ReDoc](https://rebilly.github.io/ReDoc/), some nice tools to display the API documentation in a -user friendly way. +It also integrates a customized version of [Swagger UI](https://swagger.io/swagger-ui/), +[ReDoc](https://rebilly.github.io/ReDoc/) and +[Scalar API Reference](https://github.com/scalar/scalar), some nice tools to display the API +documentation in a user friendly way. ## Using the OpenAPI Command @@ -36,18 +38,19 @@ Create a file containing the specification: bin/console api:openapi:export --output=swagger_docs.json ``` -If you want to use the old OpenAPI v2 (Swagger) JSON format, use: - -```console -bin/console api:swagger:export -``` - -It is also possible to use OpenAPI v3.0.0 format: +By default, `api:openapi:export` generates a document following the current OpenAPI version, +`3.2.0`. It is also possible to use the legacy OpenAPI v3.0.0 format: ```console bin/console api:openapi:export --spec-version=3.0.0 ``` +> [!NOTE] This `--spec-version` option (and the `spec_version` query parameter documented above) +> only switches between the OpenAPI v3.0.0 and v3.2.0 document formats. Do not confuse it with the +> `api_platform.swagger.versions` configuration option: that array (`[3]` by default, and emptied +> automatically when `enable_swagger` is set to `false`) tracks which major OpenAPI versions are +> exposed at all, it does not select between the v3.0.0 and v3.2.0 document formats. + ## Create several versions of a specification You can now decline a same OpenAPI specification in multiple versions using the `x-apiplatform-tags` @@ -608,9 +611,65 @@ resources: ![Impact on Swagger UI](../symfony/images/swagger-ui-2.png) -## Disabling Swagger UI or ReDoc +## Choosing Between Swagger UI, ReDoc and Scalar + +When the HTML format is served, API Platform can render three different documentation UIs: +[Swagger UI](https://swagger.io/swagger-ui/), [ReDoc](https://rebilly.github.io/ReDoc/) and +[Scalar API Reference](https://github.com/scalar/scalar). All three are backed by the same +documentation route (`/docs` by default) and the same generated OpenAPI document; which one is +rendered is chosen with the `ui` query parameter, for instance: + +`/docs?ui=scalar` -### Disabling Swagger UI or ReDoc with Symfony +Swagger UI is the default when no `ui` parameter is given. If it is disabled and ReDoc is also +disabled while Scalar remains enabled, Scalar becomes the default UI instead. Every UI's page prints +an "Other API docs" footer linking to the other enabled UIs. + +> [!NOTE] The value expected by `ui` for ReDoc differs between stacks: `ui=re_doc` on Symfony, +> `ui=redoc` on Laravel. `ui=scalar` is the same on both. + +Like Swagger UI and ReDoc, Scalar is enabled by default (when `symfony/twig-bundle` is installed, on +Symfony); see [Disabling Swagger UI, ReDoc or Scalar](#disabling-swagger-ui-redoc-or-scalar) below +to turn it off. + +### Configuring Scalar API Reference + +API Platform builds the object passed to `Scalar.createApiReference()` from the generated OpenAPI +document (`content`) and a default `theme` of `default`. Any key you pass through +`scalar_extra_configuration` is merged on top of it and forwarded as-is, so it accepts any option +supported by [Scalar API Reference](https://github.com/scalar/scalar), such as `theme` or +`darkMode`. + +With Symfony: + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + swagger: + scalar_extra_configuration: + theme: "purple" + darkMode: true +``` + +With Laravel: + +```php + [ + 'extra_configuration' => [ + 'theme' => 'purple', + 'darkMode' => true, + ], + ], +]; +``` + +## Disabling Swagger UI, ReDoc or Scalar + +### Disabling Swagger UI, ReDoc or Scalar with Symfony To disable Swagger UI (ReDoc will be shown by default): @@ -630,7 +689,16 @@ api_platform: enable_re_doc: false ``` -### Disabling Swagger UI or ReDoc with Laravel +To disable Scalar: + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + # ... + enable_scalar: false +``` + +### Disabling Swagger UI, ReDoc or Scalar with Laravel To disable Swagger UI (ReDoc will be shown by default): @@ -654,6 +722,19 @@ return [ ]; ``` +To disable Scalar: + +```php + [ + 'enabled' => false, + ], +]; +``` + ## Changing the Location of Swagger UI By default, the Swagger UI is available at the API location (when the HTML format is asked) and at @@ -693,7 +774,10 @@ Change `/api_documentation` to the URI you wish Swagger UI to be accessible on. ### Disabling Swagger UI at the API Location -To disable the Swagger UI at the API location, disable both Swagger UI and ReDoc. +To disable all HTML documentation at the API location, disable Swagger UI, ReDoc and Scalar. Note +that disabling only Swagger UI and ReDoc leaves Scalar enabled, and it will be shown instead (see +[Choosing Between Swagger UI, ReDoc and Scalar](#choosing-between-swagger-ui-redoc-and-scalar) +above). With Symfony use: @@ -703,6 +787,7 @@ api_platform: # ... enable_swagger_ui: false enable_re_doc: false + enable_scalar: false ``` Or with Laravel use: @@ -714,6 +799,9 @@ return [ // .... 'enable_swagger_ui' => false, 'enable_re_doc' => false, + 'scalar' => [ + 'enabled' => false, + ], ]; ``` From 8f3361b592f14915f9b91eaddc6c61bad6d467dd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 08:52:44 +0200 Subject: [PATCH 12/13] docs: 4.4 Content-Type charset + drop dead Symfony <=6.4 branch (#2329) * docs(content-negotiation): document 4.4 charset change Since 4.4 (core #8226), Content-Type only gets `; charset=utf-8` for text/* and application/xml; JSON-family media types no longer carry it. Verified against formatContentType() in src/State/Util/HttpResponseHeadersTrait.php at upstream/4.4. * docs(serialization): drop unreachable Symfony <=6.4 branch Symfony floor is ^7.4 || ^8.0 since core #8397 (verified in src/Symfony/composer.json at upstream/4.4), so the annotations branch is unreachable and the >=7.0 branch is unconditional. Collapse both into a single attributes-only instruction. * docs(getting-started): state PHP/Symfony version floor No page stated the supported versions. Add PHP >=8.2 and Symfony ^7.4 || ^8.0, taken from composer.json and src/Symfony/composer.json at upstream/4.4. --- core/content-negotiation.md | 34 ++++++++++++++++++++++++++++++++++ core/getting-started.md | 2 ++ core/serialization.md | 36 +++--------------------------------- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/core/content-negotiation.md b/core/content-negotiation.md index ec8f060545f..f4286b05554 100644 --- a/core/content-negotiation.md +++ b/core/content-negotiation.md @@ -420,3 +420,37 @@ API Platform automatically adds two HTTP headers to responses for resources: > See [LDP §4.2 / Primer notes on Accept-Post](https://www.w3.org/TR/ldp/#Accept-Post) and typical > exposure via OPTIONS. + +## Content-Type Charset + +> [!NOTE] Behavioral change in API Platform 4.4 (core +> [#8226](https://github.com/api-platform/core/pull/8226)). + +Since 4.4, the `; charset=utf-8` parameter is only appended to the `Content-Type` response header +for media types whose IANA registration actually defines a `charset` parameter: `text/*` types (for +example `text/html`, `text/csv`, `text/xml`) and `application/xml`. JSON-based media types — +`application/json`, `application/ld+json`, `application/hal+json`, `application/vnd.api+json`, +`application/merge-patch+json`, `application/problem+json`, and so on — do not define a `charset` +parameter (per RFC 8259, and the `+json` structured syntax suffix of RFC 6839) and are always UTF-8, +so the parameter is no longer added to them. + +Before 4.4, `; charset=utf-8` was appended unconditionally to every response, including JSON-based +formats. For example, for a JSON-LD response: + +```diff +- Content-Type: application/ld+json; charset=utf-8 ++ Content-Type: application/ld+json +``` + +An XML response is unaffected, as `application/xml` still gets the parameter: + +```http +Content-Type: application/xml; charset=utf-8 +``` + +> [!WARNING] If a client or a test asserts on the exact `Content-Type` header value for a JSON-based +> format, it will break after upgrading to 4.4: the `charset=utf-8` suffix is gone. Update those +> assertions accordingly. + +The rule is implemented in `formatContentType()` in +[`HttpResponseHeadersTrait`](https://github.com/api-platform/core/blob/4.4/src/State/Util/HttpResponseHeadersTrait.php). diff --git a/core/getting-started.md b/core/getting-started.md index 2d9e9c1342f..678b43ecd63 100644 --- a/core/getting-started.md +++ b/core/getting-started.md @@ -5,6 +5,8 @@ You can choose your preferred stack between Symfony, Laravel, or bootstrapping the API Platform core library manually. +API Platform requires PHP 8.2 or higher. The Symfony variant requires Symfony `^7.4` or `^8.0`. + > [!CAUTION] If you are migrating from an older version of API Platform, make sure you read the > [Upgrade Guide](upgrade-guide.md). diff --git a/core/serialization.md b/core/serialization.md index 0a0659ccf4f..d4b077ea094 100644 --- a/core/serialization.md +++ b/core/serialization.md @@ -62,45 +62,15 @@ Just like other Symfony and API Platform components, the Serializer component ca using attributes, XML or YAML. Since attributes are easy to understand, we will use them in the following examples. -> [!NOTE] If you are not using the API Platform Symfony variant, you need to enable annotation -> support in the serializer configuration as outlined below, depending on your Symfony version. - -#### Configuration for Symfony `<= 6.4` - -##### General Case +> [!NOTE] If you are not using the API Platform Symfony variant, you need to enable attribute +> support in the serializer configuration as outlined below. Add the following configuration to your `framework.yaml` file: ```yaml # api/config/packages/framework.yaml framework: - serializer: { enable_annotations: true } -``` - -##### Using Symfony Flex - -If you use [Symfony Flex](https://github.com/symfony/flex) and Symfony `<= 6.4`, simply run the -following command: - -```console -composer req doctrine/annotations -``` - -You're all set! - -#### Configuration for Symfony `>= 7.0` - -If you are using Symfony >= 7.0, -[annotations have been replaced by attributes](https://www.doctrine-project.org/2022/11/04/annotations-to-attributes.html). - -Update your configuration as follows: - -```diff -# api/config/packages/framework.yaml - -framework: -- serializer: { enable_annotations: true } -+ serializer: { enable_attributes: true } + serializer: { enable_attributes: true } ``` #### Additional Syntax Configuration for All Versions From c0d9d241b168fd5c8eb1d5374b84c2f0bd7f48ac Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2026 09:00:07 +0200 Subject: [PATCH 13/13] docs(security): document voter decision reasons (#2333) Core PR #8448 (cd67e0b23) adds the access_decision variable to security expressions and AccessDecision::getMessage(); document how a voter's reason reaches the client and that it is gated by kernel.debug. Also add a usage example for ApiPlatform\Metadata\Exception\AccessDeniedException (F22), the class the upgrade guide already points to. --- symfony/security.md | 141 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/symfony/security.md b/symfony/security.md index 005ff7d6b7e..36df0cd507f 100644 --- a/symfony/security.md +++ b/symfony/security.md @@ -371,6 +371,147 @@ _Note 2: You can't use Voters on the collection GET method, use [Collection Filters](https://api-platform.com/docs/core/security/#filtering-collection-according-to-the-current-user-permissions) instead._ +## Exposing Voter Reasons in the Error Response + +Since API Platform 4.4, the variables available to a `security` (and `securityPostDenormalize`, +etc.) expression also include `access_decision`, an instance of +[`Symfony\Component\Security\Core\Authorization\AccessDecision`](https://github.com/symfony/symfony/blob/7.4/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php). +It carries the `isGranted` result together with the `votes` cast by every voter consulted through +`is_granted()`, and exposes `getMessage(): string`, which concatenates `"Access Granted."` or +`"Access Denied."` with every reason attached by a voter whose vote matches the final result. + +A voter extending Symfony's `Voter` base class receives an optional `?Vote $vote` argument in +`voteOnAttribute()`. Call `$vote?->addReason(...)` to explain a denial: + +```php +owner === $token->getUser()) { + return true; + } + + $vote?->addReason(\sprintf('Only the owner (%s) can edit this book.', $subject->owner->getUserIdentifier())); + + return false; + } +} +``` + +The resource only needs to call `is_granted()` as usual — API Platform forwards `access_decision` to +the voters for you: + +```php +getMessage()` to build the 403 response's `detail`: + +```json +{ + "@context": "/contexts/Error", + "@type": "Error", + "title": "An error occurred", + "detail": "Access Denied. Only the owner (alice) can edit this book.", + "status": 403 +} +``` + +> [!WARNING] That `detail` is only populated from `$decision->getMessage()` when the kernel runs +> with `debug: true` (Symfony's `dev` and `test` environments by default). In `prod` +> (`kernel.debug: false`), the client always receives the generic `"Access Denied."` detail instead, +> no matter what your voters added — unless you also configure an explicit `securityMessage`, which +> is always returned verbatim, in every environment. Do not treat `debug: false` as your only +> safeguard: write vote reasons as if any caller could read them, and never put roles, ownership +> details, or other authorization internals in them. + +## Throwing `AccessDeniedException` Directly + +You are not limited to `security` expressions and voters: any provider, processor, or voter can deny +access explicitly by throwing `ApiPlatform\Metadata\Exception\AccessDeniedException`. Unlike a +voter's reason (see above), the `detail` you pass here is always returned to the client, in every +environment: + +```php + + */ +final class BookProcessor implements ProcessorInterface +{ + public function __construct(private readonly Security $security) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($data->archived && !$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access Denied.', detail: 'Archived books can only be edited by an administrator.'); + } + + // call your persistence layer to save $data + return $data; + } +} +``` + +This produces the same problem response shape: + +```json +{ + "@context": "/contexts/Error", + "@type": "Error", + "title": "An error occurred", + "detail": "Archived books can only be edited by an administrator.", + "status": 403 +} +``` + +> [!WARNING] Because the `detail` you pass to `AccessDeniedException` is always exposed to the +> client, apply the same rule as for `securityMessage`: keep it free of roles, ownership details, or +> any other authorization internals. +> +> [!NOTE] `ApiPlatform\Symfony\Security\Exception\AccessDeniedException` is deprecated since API +> Platform 4.4 in favor of `ApiPlatform\Metadata\Exception\AccessDeniedException` shown above. See +> the [upgrade guide](../core/upgrade-guide.md#security-accessdeniedexception). + ## Configuring the Access Control Error Message By default when API requests are denied, you will get the "Access Denied" message. You can change it