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/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/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 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 +