<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Spryker Documentation</title>
        <description>Spryker documentation center.</description>
        <link>https://docs.spryker.com/</link>
        <atom:link href="https://docs.spryker.com/feed.xml" rel="self" type="application/rss+xml"/>
        <lastBuildDate>Thu, 10 Sep 2026 11:23:36 +0000</lastBuildDate>
        <generator>Jekyll v4.2.2</generator>
        
        
        <item>
            <title>Glue API: Authenticate as a merchant user</title>
            <description>This endpoint allows authenticating as a merchant user. A merchant user is a Back Office user that is assigned to a merchant; the access token it receives carries the `merchant-user` scope, which the Backend API maps to the `ROLE_MERCHANT_USER` role. Resources built for the Merchant Portal audience, like the merchant profile, check for this role.

The merchant does not have to be approved: a merchant user of a merchant that is still waiting for approval can authenticate and use the endpoints available to merchant users.

{% info_block warningBox &quot;API Platform only&quot; %}

The JSON:API request format, the roles, and the resolution of the acting user described on this page are available with the [API Platform](/docs/integrations/spryker-api/api-platform/api-platform.html) integration of the Backend API only. Before using them, [integrate API Platform](/docs/integrations/spryker-api/migrate-from-glue-to-api-platform/integrate-api-platform.html) and [integrate API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html).

On the legacy Glue infrastructure, `POST /token` with the form-encoded body still issues a token that carries the `merchant-user` scope, but no roles are derived from it and no acting user is established. Resources there are protected by scope-based authorization instead: `MerchantUserTypeOauthScopeAuthorizationCheckerPlugin` checks the request path against `OauthMerchantUserConfig::getAllowedForMerchantUserPaths()`.

{% endinfo_block %}

## Installation

The endpoint is provided by the `OauthBackendApi` module. Merchant user scopes are provided by the `OauthMerchantUser` module; to register its plugins, see [Install the Marketplace Merchant feature](/docs/pbc/all/merchant-management/latest/marketplace/install-and-upgrade/install-features/install-the-marketplace-merchant-feature.html#optional-enable-the-backend-api-authentication).

## Authenticate as a merchant user

---
`POST` **/token**

---

### Request

| HEADER KEY | HEADER VALUE | REQUIRED | DESCRIPTION |
| --- | --- | --- | --- |
| Content-Type | application/vnd.api+json | &amp;check; | The request body is a JSON:API document. The form-encoded body described in [Authenticate as a Back Office user](/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-back-office-user.html) is accepted as well. |

Request sample: authenticate as a merchant user

`POST https://glue-backend.mysprykershop.com/token`

```json
{
    &quot;data&quot;: {
        &quot;type&quot;: &quot;tokens&quot;,
        &quot;attributes&quot;: {
            &quot;username&quot;: &quot;michele@sony-experts.com&quot;,
            &quot;password&quot;: &quot;change123&quot;
        }
    }
}
```

| ATTRIBUTE | TYPE | REQUIRED | DESCRIPTION |
| --- | --- | --- | --- |
| username | String | &amp;check; | Username of the merchant user. You define it when [creating a merchant user](/docs/pbc/all/merchant-management/latest/marketplace/manage-in-the-back-office/manage-merchant-users/create-merchant-users.html). |
| password | String | &amp;check; | Password of the merchant user. |

### Response

&lt;details&gt;&lt;summary&gt;Response sample: authenticate as a merchant user&lt;/summary&gt;

```json
{
    &quot;data&quot;: {
        &quot;type&quot;: &quot;tokens&quot;,
        &quot;id&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...&quot;,
        &quot;attributes&quot;: {
            &quot;accessToken&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...&quot;,
            &quot;tokenType&quot;: &quot;Bearer&quot;,
            &quot;expiresIn&quot;: 28800,
            &quot;refreshToken&quot;: &quot;def50200a1b2c3d4e5f6789012345678901234567890abcdef...&quot;
        }
    }
}
```

&lt;/details&gt;

| ATTRIBUTE | TYPE | DESCRIPTION |
| --- | --- | --- |
| accessToken | String | Authentication token used to send requests to the protected resources available for this merchant user. It is also the resource `id`. |
| tokenType | String | Type of the authentication token. Set this type when sending a request with the token. |
| expiresIn | Integer | Time in seconds in which the `accessToken` token expires. |
| refreshToken | String | Authentication token used to refresh `accessToken`. See [Refresh the access token](#refresh-the-access-token). |

## Refresh the access token

To exchange a refresh token for a new access token and refresh token, send the request:

---
`POST` **/refresh-tokens**

---

Request sample: refresh the access token

`POST https://glue-backend.mysprykershop.com/refresh-tokens`

```json
{
    &quot;data&quot;: {
        &quot;type&quot;: &quot;refresh-tokens&quot;,
        &quot;attributes&quot;: {
            &quot;refreshToken&quot;: &quot;def50200a1b2c3d4e5f6789012345678901234567890abcdef...&quot;
        }
    }
}
```

| ATTRIBUTE | TYPE | REQUIRED | DESCRIPTION |
| --- | --- | --- | --- |
| refreshToken | String | &amp;check; | Refresh token returned by [Authenticate as a merchant user](#authenticate-as-a-merchant-user) or by a previous refresh. |

&lt;details&gt;&lt;summary&gt;Response sample: refresh the access token&lt;/summary&gt;

```json
{
    &quot;data&quot;: {
        &quot;type&quot;: &quot;refresh-tokens&quot;,
        &quot;id&quot;: &quot;def50200f1e2d3c4b5a6978012345678901234567890fedcba...&quot;,
        &quot;attributes&quot;: {
            &quot;refreshToken&quot;: &quot;def50200f1e2d3c4b5a6978012345678901234567890fedcba...&quot;,
            &quot;accessToken&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...&quot;,
            &quot;tokenType&quot;: &quot;Bearer&quot;,
            &quot;expiresIn&quot;: 28800
        }
    }
}
```

&lt;/details&gt;

| ATTRIBUTE | TYPE | DESCRIPTION |
| --- | --- | --- |
| refreshToken | String | Newly issued refresh token. It is also the resource `id`. The refresh token of the request is revoked. |
| accessToken | String | Newly issued authentication token. |
| tokenType | String | Type of the authentication token. |
| expiresIn | Integer | Time in seconds in which the `accessToken` token expires. |

## Roles the token grants

The scopes in the token decide which roles the Backend API grants to the request:

| USER | SCOPES | ROLES |
| --- | --- | --- |
| Merchant user | `user`, `merchant-user` | `ROLE_USER`, `ROLE_MERCHANT_USER` |
| Back Office user without a merchant | `user`, `back-office-user` | `ROLE_USER`, `ROLE_BACK_OFFICE_USER` |

`ROLE_USER` is held by every authenticated caller, so a resource that must distinguish the two audiences checks `ROLE_MERCHANT_USER` or `ROLE_BACK_OFFICE_USER`. A merchant user calling a resource that requires `ROLE_BACK_OFFICE_USER` gets `403`, and the other way round.

On every request with a valid token, the Backend API resolves the user behind the token and makes it the acting user. The user must be active; a token of a deactivated or deleted user is rejected with `401` and the error code `003`. For details, see [API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/security.html#resolving-the-user-behind-a-token).

## Possible errors

| STATUS | CODE | REASON |
| --- | --- | --- |
| 401 | invalid_grant | The provided user credentials are incorrect or invalid. |
| 401 | 001 | The user could not be authenticated. |
| 401 | 003 | The access token does not belong to an active user (on protected resources). |
| 401 | invalid_request | The refresh token sent to `/refresh-tokens` is unknown, expired, or revoked. |
| 422 | N/A | The request body is not a valid document for the resource, for example, `username` or `password` is missing on `/token`, or `refreshToken` is missing on `/refresh-tokens`. |

To view generic errors and status codes of the Backend API, see [Backend API request and response reference](/docs/integrations/spryker-api/backend-api/developing-apis/backend-api-request-and-response-reference.html).
</description>
            <pubDate>Thu, 10 Sep 2026 11:23:18 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-merchant-user.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-merchant-user.html</guid>
            
            
        </item>
        
        <item>
            <title>Glue API: Authenticate as a Back Office user</title>
            <description>This endpoint allows authenticating as a Back Office user. For a Back Office user that is assigned to a merchant, see [Authenticate as a merchant user](/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-merchant-user.html).

With the API Platform integration of the Backend API, the same endpoint also accepts a JSON:API `tokens` document, and `POST /refresh-tokens` exchanges a refresh token for a new token pair; the form-encoded request described here keeps working. For details, see [Authenticate as a merchant user](/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-merchant-user.html).

## Installation

For detailed information on the modules that provide the API functionality and related installation instructions, see [Install the Customer Account Management Glue API](/docs/pbc/all/identity-access-management/latest/install-and-upgrade/install-the-customer-account-management-glue-api.html).

## Authenticate as a Back Office user

---
`POST` **/token**

---

### Request

| HEADER KEY | HEADER VALUE | REQUIRED | DESCRIPTION |
|-|-|-|-|
| Content-Type | application/x-www-form-urlencoded | &amp;check; | `x-www-form-urlencoded` is a URL encoded form. This is the default value if the encrypted attribute is not set to anything. The keys and values are encoded in key-value tuples separated by `&amp;`, with a `=` between the key and the value. Non-alphanumeric characters in both keys and values are percent encoded. |

&lt;details&gt;&lt;summary&gt;Request sample: authenticate as a Back Office user&lt;/summary&gt;

| REQUEST BODY KEY | VALUE             |
|-|-------------------|
| grant_type | password          |
| username | admin@spryker.com |
| password | change123         |

&lt;/details&gt;

| ATTRIBUTE | TYPE | REQUIRED | DESCRIPTION |
|-|-|-|-|
| grant_type | password | &amp;check; | Method through which the application can gain Access Tokens and by which you grant limited access to the resources to another entity without exposing credentials. |
| username | String | &amp;check; | Back Office user&apos;s username. You define it when [creating](/docs/pbc/all/user-management/latest/base-shop/manage-in-the-back-office/manage-users/create-users.html) or [editing users](/docs/pbc/all/user-management/latest/base-shop/manage-in-the-back-office/manage-users/edit-users.html). |
| password | String | &amp;check; | Back Office user&apos;s password. You define it when [creating](/docs/pbc/all/user-management/latest/base-shop/manage-in-the-back-office/manage-users/create-users.html) or [editing users](/docs/pbc/all/user-management/latest/base-shop/manage-in-the-back-office/manage-users/edit-users.html). |

### Response

&lt;details&gt;&lt;summary&gt;Response sample: authenticate as a Back Office user&lt;/summary&gt;

```json
{
    &quot;token_type&quot;: &quot;Bearer&quot;,
    &quot;expires_in&quot;: 28800,
    &quot;access_token&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJmcm9udGVuZCIsImp0aSI6IjMwZWM0NDEwMDBhYzM3NmJmZTU2NGVjNjVhODdjYWY0ODUzZGMwYjZjOGM2MzRlZTk2ODlmOTZkMzZmODIzMzgzNDM1MWYzMTM3MzZiYTYyIiwiaWF0IjoxNjk5MzQ3ODM0LjQ2MTU5MDEsIm5iZiI6MTY5OTM0NzgzNC40NjE1OTI5LCJleHAiOjE2OTkzNzY2MzQuNDQyNjc2MSwic3ViIjoie1widXNlcl9yZWZlcmVuY2VcIjpudWxsLFwiaWRfdXNlclwiOjQsXCJ1dWlkXCI6XCIwZDc0M2NjMy1hNzcyLTUxNDUtOTcxZS1kNDAxOGVlN2E0ODlcIn0iLCJzY29wZXMiOlsidXNlciJdfQ.o96j8nuU8EPf674f449KZxGAQi3TGL17U45DNqiJUZQJXpABmJG-qUug4HdlFLnzIMMHLUKIdsjF4Dd2ArOJ_1o6uaxtPB_z_4Kau8bUiittTye1y0wJ3YjCy1VbQIKynIJ7E0_VCOv1Ok0gRiYJC5hfwiHOhXdIbkoG1d9CWWE542nAm1xH__QDYlrwh57RJBLAXB7HCF7EGobQkYiiQXnJ4-qPkSHGL_sXuHQgnkXD7qLpLILv0TwCe-ZrOM1RwI53AFyKrZDU2cJQdFNTF3zI6hsadZOTcvRVk8wS95G3KKrAuURHi46w13oLqFL3-V1lq_JnjCZp3Uu68xGtiA&quot;,
    &quot;refresh_token&quot;: &quot;def5020074bed6be1f1310453251c9f9cffff6ed531b7d1ea31dbc7e5ef072a7f56a54fbda6a6b2126d46b9a8b9b4ae649ca1502cc0f01fbbcfb95ab79299a9b6fe310966fdcb58b8688b424b95b123503fdc388d318fad63f1e86a184321f097b4c4e51648952448bcee315df2cac018089591c31348b5e107e8d37e8256afbd142da1011b9cf390715c11f5dfeff0b106bd5bd3df1a142c8c72fcafc5b682f2fb110e03a387c1041a21e3ee15165dff52b159fc6ef57c50b2c3b39381d604648c413d7ca6845f5ae62fb649caabd6f5f87da2535406b91c6f042fc98989289f9f0ab7b7be33597418149a394aea31194be458db2877c22b7eb48f190f351e7bbaee7563f0a5e16cc5bb1da9449c713771c47d164e105a2d27b378824fa322c5038df8e64049eb5bdbd28e8994e59e4c05da788fb064a081d78e14cb360be1fe76621f2da11d85255031f59f5859033b5029c53ac647f61c132fa5f3e04853d126d58f5bd115aadc9f3d36543167f285c115e7593212b7d85816ecf3749e3d19f17d8f86f999fca3e5a9dbea3a6026a321f52309b2068f1487cf11212f0515f5399906fbc167dda9ae3325b500f150397c7b07858568decc7668244bc59cd439fc8e7255f970c20&quot;
}
```

&lt;/details&gt;

| ATTRIBUTE | TYPE | DESCRIPTION |
|-|-|-|
| token_type | String | Type of the authentication token. Set this type when sending a request with the token. |
| access_token | String | Authentication token used to send requests to the protected resources available for this Back Office user. |
| expires_in | Integer | Time in seconds in which the `access_token` token expires. |
| refresh_token | String | Authentication token used to refresh `access_token`. |


## Possible errors

| ERROR NAME | DESCRIPTION |
|-|-|
| invalid_grant | The provided user credentials are incorrect or invalid. |
| unsupported_grant_type | The provided grant type is not supported. The grant type must be `password`. |


To view generic errors that originate from the Glue Application, see [Reference information: GlueApplication errors](/docs/integrations/spryker-api/storefront-api/api-references/reference-information-storefront-application-errors.html).
</description>
            <pubDate>Thu, 10 Sep 2026 11:23:18 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-back-office-user.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-back-office-user.html</guid>
            
            
        </item>
        
        <item>
            <title>Integrate API Platform security</title>
            <description>This document describes how to integrate Symfony&apos;s SecurityBundle with the API Platform to enable authentication and authorization for your API resources.

## Prerequisites

- API Platform is already integrated as described in [Integrate API Platform](/docs/integrations/spryker-api/migrate-from-glue-to-api-platform/integrate-api-platform.html).
- The `spryker/api-platform` module version `1.0.0` or later is installed.

## 1. Register the SecurityBundle

Add `SecurityBundle` to the `bundles.php` file for each Glue application where you want to enable security.

### For Glue application

**config/Glue/bundles.php**

```php
&lt;?php

declare(strict_types = 1);

use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Spryker\ApiPlatform\SprykerApiPlatformBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;

return [
    FrameworkBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SecurityBundle::class =&gt; [&apos;all&apos; =&gt; true],
    TwigBundle::class =&gt; [&apos;all&apos; =&gt; true],
    ApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SprykerApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
];
```

### For GlueStorefront application

**config/GlueStorefront/bundles.php**

```php
&lt;?php

declare(strict_types = 1);

use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Spryker\ApiPlatform\SprykerApiPlatformBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;

return [
    FrameworkBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SecurityBundle::class =&gt; [&apos;all&apos; =&gt; true],
    TwigBundle::class =&gt; [&apos;all&apos; =&gt; true],
    ApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SprykerApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
];
```

### For GlueBackend application

**config/GlueBackend/bundles.php**

```php
&lt;?php

declare(strict_types = 1);

use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Spryker\ApiPlatform\SprykerApiPlatformBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;

return [
    FrameworkBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SecurityBundle::class =&gt; [&apos;all&apos; =&gt; true],
    TwigBundle::class =&gt; [&apos;all&apos; =&gt; true],
    ApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
    SprykerApiPlatformBundle::class =&gt; [&apos;all&apos; =&gt; true],
];
```

{% info_block infoBox &quot;Bundle ordering&quot; %}

`SecurityBundle` must be registered before `ApiPlatformBundle` and `SprykerApiPlatformBundle` so that the security services are available when API Platform compiles its configuration.

{% endinfo_block %}

## 2. Configure the security firewall

Create a `security.php` configuration file for each Glue application. This file defines the authentication provider, firewall, and default access control.

### For Glue application

**config/Glue/packages/security.php**

```php
&lt;?php

declare(strict_types=1);

use Spryker\ApiPlatform\Security\ApiUserProvider;
use Spryker\ApiPlatform\Security\GlueAuthenticationEntryPoint;
use Spryker\ApiPlatform\Security\OauthAuthenticator;
use Symfony\Config\SecurityConfig;

return static function (SecurityConfig $security): void {
    $security-&gt;provider(&apos;api_oauth_provider&apos;)
        -&gt;id(ApiUserProvider::class);

    $security-&gt;firewall(&apos;main&apos;)
        -&gt;lazy(true)
        -&gt;stateless(true)
        -&gt;provider(&apos;api_oauth_provider&apos;)
        -&gt;customAuthenticators([OauthAuthenticator::class])
        -&gt;entryPoint(GlueAuthenticationEntryPoint::class);

    // Public by default - individual resources use security expressions for authorization
    $security-&gt;accessControl()
        -&gt;path(&apos;^/&apos;)
        -&gt;roles([&apos;PUBLIC_ACCESS&apos;]);
};
```

### For GlueStorefront application

**config/GlueStorefront/packages/security.php**

Use the same configuration as above.

### For GlueBackend application

**config/GlueBackend/packages/security.php**

Use the same configuration as above.

### Configuration explained

| Setting | Description |
|---------|-------------|
| `provider(&apos;api_oauth_provider&apos;)` | Registers the user provider that builds `ApiUser` objects from validated JWT claims. |
| `firewall(&apos;main&apos;)-&gt;lazy(true)` | The authenticator is only instantiated when a route requires authentication, reducing overhead for public endpoints. |
| `firewall(&apos;main&apos;)-&gt;stateless(true)` | Disables session-based authentication. Every request must include its own Bearer token. |
| `customAuthenticators([OauthAuthenticator::class])` | Registers the Spryker OAuth authenticator that validates Bearer tokens using the local OAuth infrastructure. |
| `entryPoint(GlueAuthenticationEntryPoint::class)` | Returns the standard `403` `Missing access token.` error when an unauthenticated request hits a protected resource. |
| `accessControl()-&gt;roles([&apos;PUBLIC_ACCESS&apos;])` | Grants public access to all paths by default. Individual resources opt in to authentication using `security` expressions. |

## 3. Add security expressions to resources

After the SecurityBundle is configured, you can protect resources using `security` expressions in your YAML resource schemas, either for an entire resource or for specific operations. For the expression syntax, available variables, and examples, see [API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/security.html#security-expressions).

### Regenerate resources

After adding security expressions, regenerate your API resources:

```bash
docker/sdk cli glue api:generate
```

## 4. Optional: Enable Persistent ACL for the Backend API

For tokens of Back Office and merchant users, the Backend API resolves the user behind the token and makes it the acting user of the request. To have Persistent ACL scope the requests of merchant users to their merchant the same way the Merchant Portal does, register the following plugins. Back Office users are not scoped, as in the Back Office. The scoping applies to API Platform resources only: legacy Glue resources run without an acting user, so Persistent ACL stays disabled for them.

| PLUGIN | SPECIFICATION | PREREQUISITES | NAMESPACE |
| --- | --- | --- | --- |
| AclEntityApplicationPlugin | Enables Persistent ACL for the Backend API application. | | Spryker\Glue\AclEntity\Plugin\Application |
| NoCurrentMerchantUserAclEntityDisablerPlugin | Disables Persistent ACL unless the acting user is a merchant user, so that Back Office users, userless requests like the token endpoint, and public endpoints are not filtered. | | Spryker\Zed\MerchantUser\Communication\Plugin\AclEntity |

**src/Pyz/Glue/GlueBackendApiApplication/GlueBackendApiApplicationDependencyProvider.php**

```php
&lt;?php

namespace Pyz\Glue\GlueBackendApiApplication;

use Spryker\Glue\GlueBackendApiApplication\GlueBackendApiApplicationDependencyProvider as SprykerGlueBackendApiApplicationDependencyProvider;
use Spryker\Glue\AclEntity\Plugin\Application\AclEntityApplicationPlugin;

class GlueBackendApiApplicationDependencyProvider extends SprykerGlueBackendApiApplicationDependencyProvider
{
    /**
     * @return array&lt;\Spryker\Shared\ApplicationExtension\Dependency\Plugin\ApplicationPluginInterface&gt;
     */
    protected function getApplicationPlugins(): array
    {
        return [
            new AclEntityApplicationPlugin(),
        ];
    }
}
```

**src/Pyz/Zed/AclEntity/AclEntityDependencyProvider.php**

```php
&lt;?php

namespace Pyz\Zed\AclEntity;

use Spryker\Zed\AclEntity\AclEntityDependencyProvider as SprykerAclEntityDependencyProvider;
use Spryker\Zed\MerchantUser\Communication\Plugin\AclEntity\NoCurrentMerchantUserAclEntityDisablerPlugin;

class AclEntityDependencyProvider extends SprykerAclEntityDependencyProvider
{
    /**
     * @return array&lt;\Spryker\Zed\AclEntityExtension\Dependency\Plugin\AclEntityDisablerPluginInterface&gt;
     */
    protected function getAclEntityDisablerPlugins(): array
    {
        return [
            new NoCurrentMerchantUserAclEntityDisablerPlugin(),
        ];
    }
}
```

{% info_block warningBox &quot;Verification&quot; %}

Authenticate as a merchant user and request a resource that is scoped by Persistent ACL, for example, `GET /merchant-profile`. Make sure the response contains only the data of the merchant the user is assigned to, that a Back Office user still reads the data of every merchant, and that `POST /token` still succeeds without an `Authorization` header.

{% endinfo_block %}

## 5. Clear caches

Clear application caches after configuration changes:

```bash
docker/sdk cli console cache:clear
```

## Verification

### Verify SecurityBundle is registered

Check that the security services are available:

```bash
docker/sdk cli glue debug:container SecurityBundle
```

### Test authentication

Send a request without a token to a protected resource — it should return `403 Forbidden` with the `Missing access token.` error:

```bash
curl -s https://glue-storefront.your-domain/customers/DE--1 | jq .
```

Send a request with a valid Bearer token:

```bash
curl -s -H &quot;Authorization: Bearer &lt;your-jwt-token&gt;&quot; \
  https://glue-storefront.your-domain/customers/DE--1 | jq .
```

### Compile-time validation

If you add security expressions to resource schemas but forget to register the SecurityBundle, the application throws an error at compile time:

```text
InvalidArgumentException: The following API resource schemas use security expressions
but SecurityBundle is not registered: customers, orders. Register SecurityBundle in
your bundles.php to enable security expression evaluation.
```

This validation is performed by the `SecurityServiceRegistrationPass` compiler pass.

## Next steps

- [Security](/docs/integrations/spryker-api/authenticating-and-authorization/security.html) - Understanding authentication and authorization
- [Resource schemas](/docs/integrations/spryker-api/api-platform/resource-schemas.html) - Security expression syntax
- [API Platform configuration](/docs/integrations/spryker-api/api-platform/configuration.html) - Configuration options
</description>
            <pubDate>Thu, 10 Sep 2026 10:54:25 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html</guid>
            
            
        </item>
        
        <item>
            <title>API Platform security</title>
            <description>This document explains how authentication and authorization work in the API Platform integration and how to secure your API resources.

## Overview

Spryker&apos;s API Platform security is built on Symfony&apos;s [SecurityBundle](https://symfony.com/doc/current/security.html) and provides the following:

- **Authentication**: Bearer token (JWT) validation using Spryker&apos;s OAuth infrastructure.
- **Authorization**: Security expressions on resources and operations using Symfony&apos;s `is_granted()` function.
- **Role mapping**: OAuth scopes from JWT tokens are automatically mapped to Symfony roles.

For setup instructions, see [Integrate API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html).

## How authentication works

When a request includes an `Authorization: Bearer &lt;token&gt;` header, the following flow is executed:

1. The `OauthAuthenticator` extracts the Bearer token from the request header.
2. The token is validated locally using Spryker&apos;s OAuth client infrastructure — no Zed call is required.
3. JWT claims (user ID, scopes, client ID) are extracted from the validated token.
4. An `ApiUser` object is created with the extracted claims and made available through Symfony&apos;s security system.

If no `Authorization` header is present, the request proceeds as unauthenticated. Resources that require authentication must enforce it using security expressions.

### Resolving the user behind a token

For tokens issued to Back Office and merchant users, the Backend API also resolves the user record behind the token and makes it the acting user of the request. Business rules that depend on the current user then apply as they do in the Back Office and the Merchant Portal, and Persistent ACL scopes merchant users to their merchant the way the Merchant Portal does; Back Office users are not scoped. This applies to API Platform resources only; the legacy Glue infrastructure does not establish an acting user and exposes the token data as `GlueRequestTransfer.requestUser` instead.

By default, the user is looked up by the `id_user` claim of the token. Only active users qualify: a token of a deactivated or deleted user, or one that resolves to no single user, is rejected with `401` and the error code `003` before the resource is reached.

Tokens issued by a third-party identity provider may not carry `id_user`. To resolve the user from other claims, implement `\Spryker\Shared\UserExtension\Dependency\Plugin\UserIdentityCriteriaExpanderPluginInterface` and register the plugin in `UserDependencyProvider::getUserIdentityCriteriaExpanderPlugins()` of the Glue layer. The plugin receives the decoded claims and maps them onto the user criteria; the default `id_user` lookup applies only when no plugin adds an identifying condition.

```php
&lt;?php

namespace Pyz\Glue\User\Plugin\User;

use Generated\Shared\Transfer\UserCriteriaTransfer;
use Spryker\Shared\UserExtension\Dependency\Plugin\UserIdentityCriteriaExpanderPluginInterface;

class SubjectClaimUserIdentityCriteriaExpanderPlugin implements UserIdentityCriteriaExpanderPluginInterface
{
    public function expand(array $identityClaims, UserCriteriaTransfer $userCriteriaTransfer): UserCriteriaTransfer
    {
        if (!isset($identityClaims[&apos;sub&apos;])) {
            return $userCriteriaTransfer;
        }

        $userCriteriaTransfer-&gt;getUserConditionsOrFail()-&gt;addUsername($identityClaims[&apos;sub&apos;]);

        return $userCriteriaTransfer;
    }
}
```

### Public by default

The default security configuration grants `PUBLIC_ACCESS` to all paths. This means all endpoints are publicly accessible unless a resource explicitly defines a `security` expression. This approach lets you selectively protect resources rather than maintaining a global allowlist.

## Security expressions

Security expressions are the primary mechanism for protecting API resources. They use [Symfony&apos;s ExpressionLanguage](https://symfony.com/doc/current/security/expressions.html) and are evaluated at different stages of request processing.

### Resource-level security

Apply security to all operations of a resource:

```yaml
resource:
  name: Customers
  shortName: customers
  security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;

  operations:
    - type: Get
    - type: Patch
    - type: Delete
```

All operations on this resource require the user to have `ROLE_USER`.

### Operation-level security

Apply security to specific operations while keeping others public:

```yaml
resource:
  name: Customers
  shortName: customers

  operations:
    - type: Post
      # No security — registration is public

    - type: Get
      security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;

    - type: Patch
      security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;

    - type: Delete
      security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;
```

Operation-level security overrides resource-level security for that specific operation.

{% info_block warningBox &quot;Declare security on the resource&quot; %}

Bearer token validation runs only for resources that declare a `security` expression at the resource level. If only the operations carry expressions, an anonymous request skips authentication and is answered with `403` instead of `401`. When the operations of a resource need different roles, declare the union at the resource level and narrow it per operation:

```yaml
resource:
  name: MerchantProfiles
  shortName: merchant-profiles
  security: &quot;is_granted(&apos;ROLE_MERCHANT_USER&apos;) or is_granted(&apos;ROLE_BACK_OFFICE_USER&apos;)&quot;

  operations:
    - type: Get
      uriTemplate: &apos;/merchant-profile&apos;
      security: &quot;is_granted(&apos;ROLE_MERCHANT_USER&apos;)&quot;
    - type: Get
      uriTemplate: &apos;/merchant-profiles/{merchantReference}&apos;
      security: &quot;is_granted(&apos;ROLE_BACK_OFFICE_USER&apos;)&quot;
```

{% endinfo_block %}

### Post-denormalize security

Evaluated after the request body has been deserialized into the resource object. This lets you check authorization based on the submitted data:

```yaml
resource:
  name: Orders
  shortName: orders
  securityPostDenormalize: &quot;is_granted(&apos;EDIT&apos;, object)&quot;
```

The `object` variable refers to the deserialized resource instance.

### Post-validation security

Evaluated after validation has passed. Use this when authorization depends on validated data:

```yaml
resource:
  name: Payments
  shortName: payments
  securityPostValidation: &quot;is_granted(&apos;PROCESS&apos;, object)&quot;
```

### Expression variables

The following variables are available in security expressions:

| Variable | Description |
|----------|-------------|
| `user` | The authenticated `ApiUser` object, or `null` if unauthenticated |
| `object` | The resource object (available in `securityPostDenormalize` and `securityPostValidation`) |
| `request` | The current Symfony `Request` object |

### Common expression patterns

```yaml
# Require any authenticated user
security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;

# Require a specific role
security: &quot;is_granted(&apos;ROLE_ADMIN&apos;)&quot;

# Allow authenticated users OR public access
security: &quot;is_granted(&apos;PUBLIC_ACCESS&apos;) or is_granted(&apos;ROLE_USER&apos;)&quot;
```

### Spryker-specific security keys

In addition to the standard `security`, `securityPostDenormalize`, and `securityPostValidation` expressions, resource schemas support the following Spryker-specific keys that control how denials are reported:

| Key | Purpose |
|-----|---------|
| `securityMessage` | Custom message returned when the `security` expression denies access. |
| `securityCode` | Glue-compatible numeric error code returned with `403 Forbidden` when an authenticated user is denied. |
| `securityGetStatusCode` | For GET requests, the status code to return instead of `403`—typically `404`. The response is rewritten to the provider&apos;s not-found error so the API does not reveal whether the resource exists. |
| `securityBearerAuthRequired` | Marks the resource as requiring Bearer authentication. Unauthenticated requests receive the standard `403` `Missing access token.` response. |
| `securityAnonymousAuthRequired` | Appends `or request.headers.has(&apos;X-Anonymous-Customer-Unique-Id&apos;)` to the security expression at generation time, letting guest customers through. |
| `securityPostDenormalizeMessage`, `securityPostValidationMessage` | Custom messages for the corresponding expressions. |

Example from the Customers resource:

```yaml
resource:
  name: Customers
  shortName: customers
  security: &quot;is_granted(&apos;ROLE_CUSTOMER&apos;)&quot;
  securityCode: &apos;411&apos;
  securityGetStatusCode: 404
  securityBearerAuthRequired: true
```

## Roles and OAuth scope mapping

When a JWT token is validated, OAuth scopes are automatically mapped to Symfony roles using the following convention:

| OAuth Scope | Symfony Role |
|-------------|-------------|
| `read` | `ROLE_READ` |
| `write` | `ROLE_WRITE` |
| `admin` | `ROLE_ADMIN` |
| `customer` | `ROLE_CUSTOMER` |
| `back-office-user` | `ROLE_BACK_OFFICE_USER` |
| `merchant-user` | `ROLE_MERCHANT_USER` |
| `{custom_scope}` | `ROLE_{CUSTOM_SCOPE}` |

The scope name is uppercased and hyphens become underscores, so the roles match the ones the Back Office and the Merchant Portal use. The `user` scope, which every Back Office and merchant user token carries, is not mapped to a role; instead, every authenticated caller holds `ROLE_USER`. Use `ROLE_BACK_OFFICE_USER` or `ROLE_MERCHANT_USER` to distinguish the two audiences. For how the tokens are obtained, see [Authenticate as a Back Office user](/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-back-office-user.html) and [Authenticate as a merchant user](/docs/pbc/all/identity-access-management/latest/manage-using-glue-api/glue-api-authenticate-as-a-merchant-user.html).

All authenticated users automatically receive `ROLE_USER` in addition to their scope-based roles.

The mapping rule is: the scope name is uppercased and prefixed with `ROLE_`.

## Accessing the authenticated user

In providers and processors, you can access the authenticated user through Symfony&apos;s `Security` service.

### In a provider

```php
use Spryker\ApiPlatform\State\Provider\AbstractStorefrontProvider;
use Symfony\Bundle\SecurityBundle\Security;

class CustomersStorefrontProvider extends AbstractStorefrontProvider
{
    public function __construct(
        protected Security $security,
    ) {
    }

    protected function provideItem(): ?object
    {
        $user = $this-&gt;security-&gt;getUser();

        if ($user === null) {
            return null;
        }

        // $user is an instance of ApiUser
        $userId = $user-&gt;getUserIdentifier();

        // Access OAuth metadata
        $oauthClientId = $user-&gt;getOauthClientId();

        // Fetch and return the customer data using the user ID
    }
}
```

### In a processor

```php
use Spryker\ApiPlatform\State\Processor\AbstractStorefrontProcessor;
use Symfony\Bundle\SecurityBundle\Security;

class CustomersStorefrontProcessor extends AbstractStorefrontProcessor
{
    public function __construct(
        protected Security $security,
    ) {
    }

    protected function processPatch(mixed $data): mixed
    {
        $user = $this-&gt;security-&gt;getUser();

        // Use the authenticated user context for business logic
    }
}
```

### ApiUser properties

The `ApiUser` object provides the following methods:

| Method | Return Type | Description |
|--------|-------------|-------------|
| `getUserIdentifier()` | `string` | The user ID extracted from the JWT token |
| `getRoles()` | `array` | All roles including `ROLE_USER` and scope-mapped roles |
| `getOauthClientId()` | `string` | The OAuth client ID from the token |
| `getOauthAccessTokenId()` | `string` | The OAuth access token ID |

## Error responses

Error responses keep the Glue-compatible JSON:API format. The exact response depends on why access was denied:

- **Missing token on a protected resource**: the `GlueAuthenticationEntryPoint` returns `403 Forbidden` with the standard Glue error:

  ```json
  {
      &quot;errors&quot;: [
          {
              &quot;code&quot;: &quot;002&quot;,
              &quot;status&quot;: 403,
              &quot;detail&quot;: &quot;Missing access token.&quot;
          }
      ]
  }
  ```

- **Authenticated user denied by a security expression**: the API returns `403 Forbidden` with the resource&apos;s configured `securityCode` and `securityMessage`.

- **GET requests on resources with `securityGetStatusCode`**: instead of `403`, the response is rewritten to the configured status—typically `404` with the provider&apos;s not-found error—so the API does not reveal whether a resource exists for someone else&apos;s account.

- **Resources that do not require Bearer tokens** (`securityBearerAuthRequired` not set, for example agent endpoints): an unauthenticated denial returns `401` with the resource&apos;s configured error code.

## Next steps

- [Integrate API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html) - Setup guide
- [Resource schemas](/docs/integrations/spryker-api/api-platform/resource-schemas.html) - Security expression syntax in schemas
- [Symfony Security documentation](https://symfony.com/doc/current/security.html) - Full Symfony Security reference
</description>
            <pubDate>Thu, 10 Sep 2026 09:47:23 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/spryker-api/authenticating-and-authorization/security.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/spryker-api/authenticating-and-authorization/security.html</guid>
            
            
        </item>
        
        <item>
            <title>Resource schemas</title>
            <description>This document explains how to define API Platform resource schemas in Spryker.

## Schema file structure

API Platform uses YAML files to define resource schemas. Resource schemas describe the structure, operations, and behavior of your API resources.

### Schema location

Resource schemas must be placed in the `resources/api/{api-type}/` directory within your module:

```MARKDOWN
src/
├── Spryker/
│   └── {Module}/
│       └── resources/
│           └── api/
│               ├── storefront/
│               │   └── resource-name.resource.yml
│               └── backend/
│                   └── resource-name.resource.yml
├── SprykerFeature/
│   └── {Feature}/
│       └── resources/
│           └── api/
│               └── backend/
│                   └── resource-name.resource.yml
└── Pyz/
    └── Glue/
        └── {Module}/
            └── resources/
                └── api/
                    └── backend/
                        └── resource-name.resource.yml
```

## CodeBucket resources

API Platform supports CodeBucket-specific resource variants that are resolved at runtime based on the `APPLICATION_CODE_BUCKET` environment constant. A variant keeps the same file name as the base schema but lives in a separate variant module directory—for example, `StoresApiEU`—and sets the `codeBucket:` property inside the schema file. The generator produces one class per variant following the `{ResourceName}{CodeBucket}{ApiType}Resource` pattern, and the base resource is used when no matching variant exists. For file naming, class naming, URL behavior, and implementation examples, see [CodeBucket support](/docs/integrations/spryker-api/api-platform/code-buckets.html).

## Resource schema syntax

### Minimal example

```yaml
resource:
  name: Products
  shortName: products
  description: &quot;Product resource&quot;

  operations:
    - type: Get
    - type: GetCollection

  properties:
    id:
      type: integer
      writable: false
      identifier: true

    name:
      type: string
```

{% info_block infoBox &quot;shortName convention&quot; %}

`shortName` is the JSON:API `type` field for the resource and is used as the public URL segment. Use **lowercase kebab-case**, plural for noun-style resources (`products`, `addresses`, `abstract-product-prices`) and singular for action-style endpoints (`catalog-search`, `cart-reorder`). Multi-word names are always hyphenated. This matches every shipped resource in the platform.

{% endinfo_block %}

### Complete example with all options

```yaml
# yaml-language-server: $schema=../../../../../vendor/spryker/api-platform/resources/schemas/api-resource-schema-v1.json

resource:
  # Resource identification
  name: Customers                    # Internal name (used for schema merging)
  shortName: customers               # URL name (becomes /customers); JSON:API type field
  description: &quot;Customer resource&quot;   # OpenAPI description

  # State providers and processors
  provider: &quot;Pyz\\Glue\\Customer\\Api\\Backend\\Provider\\CustomerBackendProvider&quot;
  processor: &quot;Pyz\\Glue\\Customer\\Api\\Backend\\Processor\\CustomerBackendProcessor&quot;

  # Pagination configuration
  paginationEnabled: true
  paginationItemsPerPage: 10
  paginationMaximumItemsPerPage: 100
  paginationClientEnabled: true
  paginationClientItemsPerPage: true

  # JSON:API `included` array ordering — see &quot;Sort priority for included resources&quot;
  includedSortPriority: 0

  # Security
  security: &quot;is_granted(&apos;ROLE_ADMIN&apos;)&quot;
  securityPostDenormalize: &quot;is_granted(&apos;EDIT&apos;, object)&quot;

  # Operations
  operations:
    - type: Post                     # Create new resource
    - type: Get                      # Get single resource
    - type: GetCollection            # Get collection with pagination
    - type: Put                      # Replace entire resource
    - type: Patch                    # Update partial resource
    - type: Delete                   # Delete resource

  # Relationships — see Relationships article for full reference
  includes:
    - relationshipName: addresses
      targetResource: CustomersAddresses
      uriVariableMappings:
        customerReference: customerReference

  # Properties
  properties:
    idCustomer:
      type: integer
      description: &quot;The unique identifier of the customer.&quot;
      writable: false                # Read-only property
      readable: true                 # Include in responses (default: true)

    email:
      type: string
      description: &quot;The email address.&quot;
      required: true                 # Required for all operations
      openapiContext:
        example: &quot;john@example.com&quot;
        format: &quot;email&quot;

    firstName:
      type: string
      description: &quot;First name.&quot;
      openapiContext:
        example: &quot;John&quot;
        minLength: 1
        maxLength: 100

    status:
      type: string
      description: &quot;Customer status.&quot;
      openapiContext:
        example: &quot;active&quot;
        schema:
          enum: [&quot;active&quot;, &quot;inactive&quot;, &quot;pending&quot;]

    customerReference:
      type: string
      description: &quot;Unique customer reference.&quot;
      writable: false
      identifier: true               # Use as URL identifier instead of @id

    dateOfBirth:
      type: string
      description: &quot;Date of birth.&quot;
      openapiContext:
        format: &quot;date&quot;
        example: &quot;1990-01-01&quot;

    isActive:
      type: boolean
      description: &quot;Active status.&quot;
      default: true

    creditLimit:
      type: number
      description: &quot;Credit limit.&quot;
      openapiContext:
        format: &quot;float&quot;
        example: 5000.00
```

## Property types

### Supported types

| Type | PHP Type | Example | Description |
|------|----------|---------|-------------|
| `string` | `string` | `&quot;John&quot;` | Text values |
| `integer` | `int` | `42` | Whole numbers |
| `number` | `float` | `3.14` | Decimal numbers |
| `boolean` | `bool` | `true` | True/false values |
| `array` | `array` | `[&quot;a&quot;, &quot;b&quot;]` | Lists of values. Add an `items` sibling to publish a typed element schema instead of an untyped array — see [Object collections](#object-collections) and [Typed collections in the published contract](/docs/integrations/spryker-api/api-platform/typed-collections.html). |
| `object` | `object` | `{&quot;key&quot;: &quot;value&quot;}` | Strictly typed nested objects — generates a typed companion class. See [Typed nested objects](#typed-nested-objects). A project can also share one shape across resources with a [canonical nested object](#project-defined-canonical-nested-objects). |
| `map` | `array` | `{&quot;key&quot;: &quot;value&quot;}` | Free-shape associative payloads documented via `openapiContext`. Stored as PHP `array` and rendered as `type: object` in the OpenAPI specification. |
| `mixed` | `mixed` | any | Use only when the payload genuinely has no fixed shape and cannot be described via `openapiContext`. |

Use `map` when the payload is a structured JSON object whose schema you want to describe via
`openapiContext` rather than a strongly typed PHP class. This is the recommended type whenever a
request or response body is a JSON object with a known shape but no dedicated class — it
keeps the property typed as a simple `array` in PHP while still producing rich OpenAPI metadata
and a working &quot;Try Out&quot; body in Swagger UI. See
[Documenting nested properties for OpenAPI and Swagger UI](#documenting-nested-properties-for-openapi-and-swagger-ui)
for the full pattern.

When you do want a strongly typed class for the payload — so PHP enforces the field set and the
OpenAPI document publishes a named component schema — use `type: object` with nested
`properties:` instead. See [Typed nested objects](#typed-nested-objects).

### Property attributes

#### writable

Controls if property can be sent in requests (POST/PUT/PATCH):

```yaml
password:
  type: string
  writable: true    # Can be sent in requests
  readable: false   # Not included in responses
```

#### readable

Controls if property is included in responses:

```yaml
idCustomer:
  type: integer
  writable: false   # Cannot be modified
  readable: true    # Included in responses
```

#### identifier

Marks property as URL identifier:

```yaml
customerReference:
  type: string
  identifier: true  # URL becomes /customers/{customerReference}
```

#### required

Makes property mandatory (use validation schemas for detailed rules):

```yaml
email:
  type: string
  required: true    # Must be present
```

#### default

Sets default value:

```yaml
isActive:
  type: boolean
  default: true     # Defaults to true if not provided
```

## Typed nested objects

A property declared as `type: object` with its own nested `properties:` block generates a
dedicated, strongly typed companion class — not an untyped array. The generator emits one PHP
class per nested object, types the parent property to that class, and publishes a full
field-by-field schema in the OpenAPI document. The serializer hydrates the nested object from the
same JSON payload, so the response on the wire is identical to the array-based form it replaces.

This is the strongly typed counterpart to the `map` pattern described in
[Documenting nested properties for OpenAPI and Swagger UI](#documenting-nested-properties-for-openapi-and-swagger-ui):
`map` documents a nested object while keeping it a plain PHP `array`; `type: object` promotes it
to a real class whose shape is enforced by PHP&apos;s type system.

### Why use it

- **Type safety in PHP.** The parent property is typed to the generated class (for example,
  `?CartsTotalsStorefrontObject`) instead of `array`, so providers and processors get IDE
  autocompletion and the language enforces the field set.
- **Precise OpenAPI schema.** Each sub-field carries its own `type`, `description`, and `example`,
  so the OpenAPI document and Swagger UI render the object as a named component schema instead of
  an opaque `object`.
- **No runtime contract change.** Because the serializer denormalizes the typed object from the
  same keys, migrating a property from `array`/`map` to `type: object` leaves the JSON response
  unchanged — only the generated PHP and the published schema improve.

### When to use which type

| Use | When |
|-----|------|
| `type: object` (with `properties`) | The payload has a **stable, known shape** you want enforced as a PHP class — for example, cart and order `totals`, or a quote-request `customer`. |
| `type: map` (with `openapiContext`) | The shape is known and worth documenting, but you do **not** want a dedicated PHP class — for example, payloads aggregated from several transfer objects, or PSP-specific responses. See [Documenting nested properties for OpenAPI and Swagger UI](#documenting-nested-properties-for-openapi-and-swagger-ui). |
| `type: mixed` | The payload genuinely has **no fixed shape** and cannot be described via `openapiContext`. |

### How to declare it

Give the property `type: object` and nest its fields under `properties:`. Sub-fields accept the
same attributes as top-level properties (`type`, `description`, `openapiContext`, `nullable`,
`serializedName`, `serializedPath`):

```yaml
totals:
    type: object
    readable: true
    writable: false
    required: false
    description: &apos;Calculated cart totals in cents.&apos;
    properties:
        subtotal:
            type: integer
            description: &apos;Items × prices before any discount/tax.&apos;
            openapiContext: { example: 16058 }
        grandTotal:
            type: integer
            description: &apos;What the customer pays.&apos;
            openapiContext: { example: 14601 }
        priceToPay:
            type: integer
            description: &apos;Grand total adjusted for any pre-paid amount (e.g. gift cards).&apos;
            openapiContext: { example: 14601 }
```

### Generated output

For a `Carts` resource with the `totals` property above, the generator:

1. Types the property on the resource class:

   ```php
   public ?CartsTotalsStorefrontObject $totals = null;
   ```

2. Writes a companion class in the `Generated\Api\{ApiType}\{ResourceName}\` namespace (a
   sub-namespace named after the owning resource, alongside the resource class in
   `Generated\Api\{ApiType}\`). The class is `final`, carries **no** `#[ApiResource]` attribute —
   it is an embedded value object, not a routed resource — and exposes the typed sub-fields plus
   their accessors:

   ```php
   namespace Generated\Api\Storefront\Carts;

   use ApiPlatform\Metadata\ApiProperty;

   final class CartsTotalsStorefrontObject
   {
       #[ApiProperty(description: &apos;Items × prices before any discount/tax.&apos;, openapiContext: [&apos;example&apos; =&gt; 16058])]
       public ?int $subtotal = null;

       #[ApiProperty(description: &apos;What the customer pays.&apos;, openapiContext: [&apos;example&apos; =&gt; 14601])]
       public ?int $grandTotal = null;

       #[ApiProperty(description: &apos;Grand total adjusted for any pre-paid amount (e.g. gift cards).&apos;, openapiContext: [&apos;example&apos; =&gt; 14601])]
       public ?int $priceToPay = null;

       // Getters, setters, toArray(), fromArray() …
   }
   ```

The companion class name is `{ResourceName}{PropertyPath}{ApiType}Object` — the resource&apos;s
normalized name, the capitalized property path, the API type, and the `Object` suffix (contrast
the routed resource class itself, which keeps the `Resource` suffix). It lives in the
`Generated\Api\{ApiType}\{ResourceName}` sub-namespace. So `Carts` + `totals` on the storefront API
becomes `Generated\Api\Storefront\Carts\CartsTotalsStorefrontObject`; a checkout `billingAddress`
becomes `Generated\Api\Storefront\Checkout\CheckoutBillingAddressStorefrontObject`.

{% info_block infoBox &quot;Imports in companion classes&quot; %}

Companion classes import only the attributes they actually use (`ApiProperty`, `SerializedName`,
`SerializedPath`). An attribute referenced without its `use` statement would resolve to a
non-existent class in the `Generated` namespace and break attribute reflection at runtime, so the
generator never emits an unused import.

{% endinfo_block %}

### Nested objects within objects

Objects can nest to any depth. Each level generates its own class, named by concatenating the
property path onto the resource name. For example:

```yaml
totals:
    type: object
    properties:
        tax:
            type: object
            properties:
                amount:
                    type: integer
                    description: &apos;Tax amount in cents.&apos;
                    openapiContext: { example: 1457 }
```

on the storefront `Carts` resource generates a `CartsTotalsStorefrontObject` class with
`public ?CartsTotalsTaxStorefrontObject $tax = null;`, plus a separate
`CartsTotalsTaxStorefrontObject` class with `public ?int $amount = null;` (both in the
`Generated\Api\Storefront\Carts` namespace). A deeper path simply keeps concatenating — an agent
quote-request resource&apos;s `shownVersion.cartTotals` object becomes
`AgentQuoteRequestsShownVersionCartTotalsStorefrontObject`.

### Object collections

A `type: array` property whose `items:` are themselves a typed object (`type: object` with nested
`properties:`) generates a value-object class for the element type. The class is named after the
**pluralized** field segment — `{ResourceName}{PluralField}{ApiType}Object` — and the parent
property stays a PHP `array` carrying a `@var array&lt;…&gt;` docblock so the serializer denormalizes
each element into the generated class:

```yaml
# carts.resource.yml — a list of typed customer objects
customer:
    type: array
    items:
        type: object
        properties:
            firstName: { type: string }
            email:     { type: string }
```

On the storefront `Carts` resource this generates `CartsCustomersStorefrontObject` (the field
`customer` pluralized to `Customers`) as the element type, and types the property as
`array&lt;\Generated\Api\Storefront\Carts\CartsCustomersStorefrontObject&gt;`.

In the published contract, the property becomes `&quot;type&quot;: &quot;array&quot;` with an `items` reference to the
generated element schema, which API Platform registers in the same document. Without an `items` block,
the property publishes as a bare array with no element description.

{% info_block warningBox &quot;Typing an existing list is a backward-compatibility decision&quot; %}

Generated value objects copy only the fields you declare, so adding an `items` block to a list that is
already part of a released response silently drops every payload key missing from `items.properties`.
Check a real payload first — see
[Typed collections in the published contract](/docs/integrations/spryker-api/api-platform/typed-collections.html).

{% endinfo_block %}

### Per-resource validation lifting

Each typed nested object gets its **own** value-object class, so validation you authored the
array-shaped way — an `Assert\Collection` on the object property in the resource&apos;s
`{resource-name}.validation.yml` — would reject the denormalized object value with a 422
(`This value should be of type array`). The generator resolves this automatically: for a writable
object property it **lifts** the `Collection.fields` constraints off the property and onto the
matching fields of that resource&apos;s value object, and emits a plain `#[Assert\Valid]` cascade
(carrying the operation groups) on the property instead of the `Collection`.

You keep authoring validation exactly as before — write the `Collection` against the object
property:

```yaml
# checkout-data.validation.yml
post:
    customer:
        - Optional:
              constraints:
                  - Collection:
                        allowExtraFields: true
                        fields:
                            email:
                                - NotBlank: { message: &apos;Email is invalid.&apos; }
                                - Email:    { message: &apos;Email is invalid.&apos; }
```

The lifted constraints are re-grouped through the resource&apos;s own operation groups (so this
`checkout-data` `customer.email` rule stays in the `checkout-data:create` group) and attached to
the value object&apos;s `email` field; the `customer` property itself carries only `#[Assert\Valid]`.
Each resource&apos;s value object is validated independently — there is **no** cross-resource union,
because every resource has its own value-object class. A property whose object is not writable, or
a plain list property that is not a typed object collection, keeps its array-shaped `Collection` —
only writable typed-object properties are lifted.

#### `allowMissingFields`

A `Collection` with `allowMissingFields: true` (for example, a checkout `billingAddress` referenced
only by id) tolerates absent keys. On a value object an absent field denormalizes to `null`, so the
generator relaxes presence constraints when lifting: each `NotBlank` gains `allowNull: true` and
each `NotNull` is dropped — an absent field passes, a present-but-empty one still fails.

### Cross-module field contribution

Because each resource owns its value-object class, a nested object&apos;s fields can still be
contributed from several modules — this is how you keep the dependency direction correct, with
each field declared in its owning module. Multiple modules ship a same-named `*.resource.yml`
fragment for the same resource, and the schema merger **deep-merges nested object `properties`**
(and `items.properties` for collections) rather than letting a later fragment&apos;s nested block
replace an earlier one.

For example, both `DiscountsRestApi` and `ProductOptionsRestApi` add fields to the cart-items
`calculations` object:

```yaml
# DiscountsRestApi — cart-items.resource.yml
resource:
    name: CartItems
    properties:
        calculations:
            type: object
            properties:
                discountTotal: { type: integer }

# ProductOptionsRestApi — cart-items.resource.yml
resource:
    name: CartItems
    properties:
        calculations:
            type: object
            properties:
                productOptionTotal: { type: integer }
```

The merged `calculations` object carries **both** `discountTotal` and `productOptionTotal`, and a
single `CartItemsCalculationsStorefrontObject` value object is generated for it. This deep merge —
not a shared class — is how identically-named objects accumulate fields across modules while each
resource keeps its own independent request/response shape.

#### Conflicting shapes fail generation

Deep merge only applies when the contributors agree on the shape. When one contributor declares a
property as a typed object (`type: object` with `properties`) or an object collection (`type: array`
with `items.properties`) and another declares the **same** property as something structurally
different — a `map`, a scalar, a plain array, or an object without `properties` — a silent
last-wins merge would drop either the typed value object or the plain field. Instead, generation
**fails with an error** that names the property and both contributing source files:

```text
Conflicting shapes for property &quot;calculations&quot;: .../DiscountsRestApi/.../cart-items.resource.yml
declares it as a typed object (`type: object` with `properties`), but
.../project/.../cart-items.resource.yml declares it as `type: map`. ...
```

This applies both within a layer and across layers (project overrides feature overrides core). The
usual cause is a project fragment that still declares a property as `type: map`/`array` while a core
module has since promoted it to a typed object — convert the project fragment to the typed form.
Same-shape overrides (object + object, collection + collection) still deep-merge, and attribute-only
overrides (an override that sets, for example, `writable: false` without re-declaring `type`) merge
as before.

If you deliberately intend to re-shape an inherited property — for example, collapse a core typed
object back into a `map`, or replace it wholesale rather than extend it — set `replace: true` on the
overriding declaration. It takes your declaration wholesale (the inherited one is discarded),
suppresses the conflict guard, and is stripped from the generated output:

```yaml
# project cart-items.resource.yml — deliberately override the core shape
calculations:
    type: map
    replace: true
```

## Project-defined canonical nested objects

[Typed nested objects](#typed-nested-objects) generate one value-object class **per resource
property**: a `billingAddress` on the checkout resource and a `shippingAddress` on the order
resource each get their own independent class, even when both describe the same real-world shape.
That keeps each resource self-contained, but it also means the same address shape is authored and
maintained in several places.

A **canonical nested object** lets a project define that shared shape **once** and have it flow
into every resource property that opts in. All the opting-in properties then collapse onto a single
generated class — `Generated\Api\{ApiType}\{Object}` (for example, `Generated\Api\Storefront\Address`) —
instead of a per-resource companion class.

This is a pure project opt-in. With no canonical object files present, generation is byte-for-byte
identical to the default per-resource behavior described above — nothing changes until a project
adds its first `*.object.yml`.

### File location and naming

Canonical objects live in a **dedicated, reserved subdirectory literally named `objects/`** inside the per-`apiType` resource directory. The directory name is always `objects` — it is never named after a resource or module. This is distinct from resource definition files, which live directly in the `apiType` directory:

```text
resources/api/storefront/
├── checkout.resource.yml          # a resource definition
├── checkout.validation.yml        # its validation
└── objects/                       # reserved dir — canonical objects only
    ├── address.object.yml
    └── address.object.validation.yml
```

Only `*.object.yml` and `*.object.validation.yml` files belong in `objects/`. Resource files (`*.resource.yml`) are placed directly in the per-`apiType` directory, never inside `objects/`.

The `&lt;dashed-name&gt;.&lt;kind&gt;.yml` naming pattern is the same for both file types — only the kind word differs. `address.object.yml` is the canonical-object analog of `checkout.resource.yml`, and `address.object.validation.yml` is the analog of `checkout.validation.yml`. The `object` versus `resource` word identifies the artifact kind, not a different naming scheme.

Full path patterns:

```text
resources/api/&lt;apiType&gt;/objects/&lt;dashed-name&gt;.object.yml
resources/api/&lt;apiType&gt;/objects/&lt;dashed-name&gt;.object.validation.yml   # optional, see Validation
```

For example, on the storefront API:

```text
src/Pyz/resources/api/storefront/objects/address.object.yml
src/Pyz/resources/api/storefront/objects/address.object.validation.yml
```

The file name uses a dashed (kebab-case) object name, while `object.name` **inside** the file is
CamelCase. The CamelCase `object.name` is the contract: it must exactly match the `objectName:`
join tag declared on the resource properties that want this shape (see [The `objectName` join
tag](#the-objectname-join-tag)).

### Central directory

A project may keep canonical object files in one central location instead of (or in addition to) the per-module `objects/` directories. Both locations are scanned simultaneously.

Configure the central directory via the Symfony bundle config node `spryker_api_platform.canonical_object_search_directories`, keyed by API type. Relative paths resolve against the project root; `%kernel.project_dir%` is also supported:

```yaml
# config/packages/spryker_api_platform.yaml
spryker_api_platform:
    canonical_object_search_directories:
        storefront:
            - &apos;%kernel.project_dir%/config/api/objects/storefront&apos;
```

The same `*.object.yml` / `*.object.validation.yml` naming rules apply. Files in a central directory are always treated as the **project** layer, so they participate in the standard `project &gt; feature &gt; core` merge precedence.

Defining the same `objectName` more than once within the same layer — for example, one module file and one central-directory file both at project layer — is a fail-loud error: generation aborts with an `ApiSchemaGenerationException` naming both source files. The same name across different layers is fine — that is the normal override.

### File format

The file contains a single top-level `object:` key:

```yaml
# address.object.yml
object:
    name: Address                                   # CamelCase; matches `objectName: Address` on resource properties
    properties:
        salutation: { type: string, description: &apos;Address salutation.&apos;, example: &apos;Mr&apos; }
        firstName:  { type: string, description: &apos;First name.&apos;, example: &apos;Jane&apos; }
        lastName:   { type: string, description: &apos;Last name.&apos;, example: &apos;Doe&apos; }
        address1:   { type: string, description: &apos;Street name.&apos;, example: &apos;Julie-Wolfthorn-Straße&apos; }
        zipCode:    { type: string, description: &apos;ZIP / postal code.&apos;, example: &apos;10115&apos; }
        city:       { type: string, description: &apos;City.&apos;, example: &apos;Berlin&apos; }
```

| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `object.name` | string | Yes | CamelCase object name. Matched against `objectName:` join tag on every resource property that references this object. |
| `object.properties` | map | Yes | Field definitions. Each field uses the **same syntax as a resource property** — `type`, `description`, `validation`, `example`, and so on. |
| `object.extends` | string | No | CamelCase name of another canonical object whose resolved fields are inherited first. See [Composition](#composition-with-extends-and-omit). |
| `object.omit` | string[] | No | Names of inherited fields to drop from the `extends` base before this object&apos;s own properties are applied. |

### Composition with `extends` and `omit`

An object can inherit another canonical object&apos;s fields with `extends`, then trim and extend them.
This avoids re-declaring a shared shape when one variant is a near-copy of another — for example, a
read-only address snapshot derived from a writable address:

```yaml
# address-snapshot.object.yml
object:
    name: AddressSnapshot
    extends: Address                                # inherit all Address fields first
    omit: [id, idCompanyBusinessUnitAddress]        # drop the write-only identifiers
    properties:
        country: { type: string, description: &apos;Country name.&apos;, example: &apos;Germany&apos; }   # add a read-only field
```

Fields resolve in this order, with later steps winning:

1. The fields inherited from `extends`.
2. Any field named in `omit` is removed.
3. This object&apos;s own `properties` are applied — a field redeclared here overrides the inherited one.

An `extends` cycle (for example, two objects that extend each other) is rejected at generation time
with an `ApiSchemaGenerationException`.

### The `objectName` join tag

A resource property opts into a canonical object by declaring `type: object` together with an
`objectName:` tag whose value equals the canonical `object.name`:

```yaml
# checkout.resource.yml
properties:
    billingAddress:
        type: object
        objectName: Address       # joins this property to the canonical Address object
        readable: false
        writable: true
        properties:
            zipCode: { type: string }
```

The `objectName` tag is dormant on its own: if no `address.object.yml` exists, the property&apos;s
inline `properties:` block is generated exactly as a normal [typed nested
object](#typed-nested-objects). When a canonical file for `Address` **is** present, the tag
activates and:

- The property&apos;s inline `properties:` are **replaced** by the canonical object&apos;s resolved shape.
- The mount attributes — `readable`, `writable`, `required`, `nullable` — stay on the referencing
  property. They describe how this property is mounted on this resource and are **not** owned by
  the canonical object, so the same canonical shape can be writable on one resource and read-only
  on another.
- A single shared `Generated\Api\{ApiType}\{Object}` class is emitted for the canonical object. No
  per-property companion class is generated for that property; every property tagged with the same
  `objectName` is typed to the one shared class.

{% info_block infoBox &quot;Shared class versus per-resource class&quot; %}

Without `objectName`, each `type: object` property generates its own per-resource value-object
class (for example, `CheckoutBillingAddressStorefrontObject`). With `objectName: Address`, all
matching properties across all resources instead share the single `Generated\Api\Storefront\Address`
class. Use a canonical object when several resources genuinely share one shape and you want them to
stay in lockstep; keep the inline form when each resource&apos;s shape is independent.

{% endinfo_block %}

### Validation

Field-level validation for a canonical object is authored in a parallel
`&lt;dashed-name&gt;.object.validation.yml` file, using the same format as a resource
[validation schema](/docs/integrations/spryker-api/api-platform/validation-schemas.html):

```yaml
# address.object.validation.yml
zipCode:
    - NotBlank: { message: &apos;ZIP code is required.&apos; }
firstName:
    - NotBlank: { message: &apos;First name is required.&apos; }
```

These constraints are lifted onto the generated canonical class. Every resource property that
references the object through `objectName` then carries an `Assert\Valid` cascade to that class, so
the canonical field rules are enforced wherever the object is used — you author the object&apos;s
validation once, in one place.

### Layer precedence

Canonical objects follow the same layer rules as resource schemas. The layer is detected from the
file path — a `/Pyz/` path is a project file, a `/SprykerFeature/` path is a feature file, and
anything else is core. Same-named objects merge by `object.name` with the precedence:

```text
project &gt; feature &gt; core
```

Because the merge is by `objectName`, a project can add a single field to a feature-layer canonical
object without redefining the whole object. Core ships no canonical object files today; the
mechanism is available to the project, feature, and core layers, and in practice projects are the
primary users.

## Documenting nested properties for OpenAPI and Swagger UI

Many endpoints accept or return structured JSON payloads — for example, a payment initialization
request that takes `payment`, `quote`, and `customer` sub-objects. Without explicit metadata,
those payloads appear as opaque `object` entries in the OpenAPI document, which means:

- The generated OpenAPI specification does not describe the child fields, their types, or which
  ones are required.
- The Swagger UI &quot;Try Out&quot; button shows an empty request body, forcing consumers to read code or
  external documentation to discover the expected shape.

The `map` property type combined with nested `openapiContext` entries closes both gaps.

### When to use this pattern

Use this pattern when the request or response body is a structured JSON object whose schema you
want to publish through OpenAPI, but you do not want to introduce a dedicated typed PHP class
for it. Typical cases are:

- Request payloads that aggregate fields from multiple transfer objects (for example, payment
  selection plus quote context).
- PSP- or provider-specific response payloads whose shape varies by configuration.

For payloads with a stable, strongly typed shape, prefer `type: object` so the generated PHP
class enforces the structure at the language level.

### Pattern

Combine `type: map` on the property with the following entries inside `openapiContext`:

| Entry | Purpose |
|-------|---------|
| `properties` | Declares each child field with its own `type`, `description`, `format`, and `example`. Used by Swagger UI to render the field-by-field schema. |
| `required` | Lists the child fields that must be present on a request. Drives the &quot;required&quot; markers in Swagger UI and the OpenAPI specification. |
| `example` | A complete sample payload. This is the value Swagger UI prefills into the &quot;Try Out&quot; body, so consumers can execute the request immediately. |

When the property is a `map`, the generator merges `&apos;type&apos; =&gt; &apos;object&apos;` into the emitted
`openapiContext`, so the property appears as an object — with the documented schema — in the
OpenAPI document while staying as a plain PHP `array` in the generated resource class.

### Worked example

The following extract is taken from
`src/Spryker/PaymentsRestApi/resources/api/storefront/payments.resource.yml`. It shows three
common shapes: a flat request object (`payment`), a request object with nested object children
(`quote`), and a response-only object whose contents vary at runtime (`preOrderPaymentData`).

```yaml
properties:
    payment:
        type: map
        writable: true
        readable: false
        required: true
        description: &apos;Payment selection for the pre-order initialization&apos;
        openapiContext:
            required: [&apos;paymentProviderName&apos;, &apos;paymentMethodName&apos;, &apos;amount&apos;]
            properties:
                paymentProviderName:
                    type: string
                    example: &apos;DummyPayment&apos;
                paymentMethodName:
                    type: string
                    example: &apos;Invoice&apos;
                amount:
                    type: integer
                    description: &apos;Amount in minor units (cents)&apos;
                    example: 9999
            example:
                paymentProviderName: &apos;DummyPayment&apos;
                paymentMethodName: &apos;Invoice&apos;
                amount: 9999

    quote:
        type: map
        writable: true
        readable: false
        required: true
        description: &apos;Quote context required to initialize the payment&apos;
        openapiContext:
            required: [&apos;customer&apos;, &apos;billingAddress&apos;, &apos;currency&apos;]
            properties:
                customer:
                    type: object
                    required: [&apos;firstName&apos;, &apos;lastName&apos;, &apos;email&apos;]
                    properties:
                        firstName: { type: string, example: &apos;Sonia&apos; }
                        lastName: { type: string, example: &apos;Wagner&apos; }
                        email: { type: string, format: email, example: &apos;sonia@acme.com&apos; }
                billingAddress:
                    type: object
                    required: [&apos;iso2Code&apos;]
                    properties:
                        iso2Code: { type: string, example: &apos;DE&apos; }
                currency:
                    type: object
                    required: [&apos;code&apos;]
                    properties:
                        code: { type: string, example: &apos;EUR&apos; }
            example:
                customer:
                    firstName: &apos;Sonia&apos;
                    lastName: &apos;Wagner&apos;
                    email: &apos;sonia@acme.com&apos;
                billingAddress:
                    iso2Code: &apos;DE&apos;
                currency:
                    code: &apos;EUR&apos;

    preOrderPaymentData:
        type: map
        writable: false
        readable: true
        required: false
        description: &apos;PSP-specific response payload returned by the payment provider&apos;
        openapiContext:
            example:
                transactionId: &apos;tx_abc123&apos;
                redirectUrl: &apos;https://psp.example.com/pay/tx_abc123&apos;
```

### Read-only versus write-only payloads

- **Write-only request payloads** (`writable: true`, `readable: false`) should declare
  `properties`, `required`, and `example`. The first two drive request validation and the
  generated OpenAPI schema; `example` makes the Swagger UI &quot;Try Out&quot; body usable without
  edits.
- **Read-only response payloads** (`writable: false`, `readable: true`) only need
  `openapiContext.example` when the response shape is dynamic. If the response shape is fixed,
  prefer declaring `properties` (and optionally `required`) so consumers see the full schema.

### Validation note

`openapiContext.required` controls only the OpenAPI documentation. If a request field must be
enforced at runtime, add the matching constraint to the resource&apos;s validation schema — see
[Validation schemas](/docs/integrations/spryker-api/api-platform/validation-schemas.html).

## Automatic JSON:API request body examples

For JSON:API endpoints (`application/vnd.api+json`), the generator automatically wraps property-level examples in the JSON:API envelope (`data.type` + `data.attributes`) when it builds the OpenAPI request body. You define examples once per property; the generator assembles the envelope for every write operation.

Given:

```yaml
resource:
  name: Customers
  shortName: customers   # becomes the JSON:API &quot;type&quot; field

  properties:
    email:
      type: string
      writable: true
      openapiContext:
        example: &quot;john@example.com&quot;
    firstName:
      type: string
      writable: true
      openapiContext:
        example: &quot;John&quot;
    idCustomer:
      type: integer
      writable: false      # excluded from request body example
      openapiContext:
        example: 42
```

…the generated OpenAPI request body for `POST`, `PATCH`, and `PUT` operations is:

```json
{
  &quot;data&quot;: {
    &quot;type&quot;: &quot;customers&quot;,
    &quot;attributes&quot;: {
      &quot;email&quot;: &quot;john@example.com&quot;,
      &quot;firstName&quot;: &quot;John&quot;
    }
  }
}
```

Rules the generator applies:

- The `shortName` value becomes the `type` field.
- Only **writable** properties are included — anything marked `writable: false` is filtered out (so identifiers and timestamps do not appear in the request example).
- Properties without an `openapiContext.example` are omitted from the example body.
- If no writable property has an example, no `requestBody` example is emitted at all — the operation appears without a prefilled &quot;Try Out&quot; body.

If you need a custom request body example that does not match this shape, override it at the operation level — see [Operations](#operations).

## Operations

Define which HTTP operations are available for the resource:

```yaml
operations:
  - type: Get                      # GET /customers/{id}
  - type: GetCollection            # GET /customers
  - type: Post                     # POST /customers
  - type: Put                      # PUT /customers/{id}
  - type: Patch                    # PATCH /customers/{id}
  - type: Delete                   # DELETE /customers/{id}
```

The operation names map to HTTP methods:
- `post` → POST (create)
- `get` → GET (single resource)
- `getCollection` → GET (collection)
- `put` → PUT (replace)
- `patch` → PATCH (update)
- `delete` → DELETE (remove)

## Pagination

API Platform provides built-in pagination for collection endpoints (`GetCollection`). You can configure pagination behavior per resource using YAML schema options.

### Pagination options

| Option | Type | Description |
|--------|------|-------------|
| `paginationEnabled` | `boolean` | Enables or disables pagination for this resource. When `false`, `GetCollection` returns all results without pagination. Default: inherits from global configuration. |
| `paginationItemsPerPage` | `integer` | Number of items returned per page. Overrides the global default. |
| `paginationMaximumItemsPerPage` | `integer` | Maximum number of items a client can request per page via `itemsPerPage` query parameter. Prevents clients from requesting excessively large pages. |
| `paginationClientEnabled` | `boolean` | Allows clients to enable or disable pagination via the `pagination` query parameter (for example, `?pagination=false`). |
| `paginationClientItemsPerPage` | `boolean` | Allows clients to set the number of items per page via the `itemsPerPage` query parameter (for example, `?itemsPerPage=50`). |

The global default for `paginationItemsPerPage` is defined in the project&apos;s `api_platform.php` configuration file. To override it for a specific resource, set `paginationItemsPerPage` in the resource schema.

### Minimal pagination example

```yaml
resource:
  name: Products
  shortName: products

  paginationEnabled: true
  paginationItemsPerPage: 10

  operations:
    - type: GetCollection
```

### Full pagination example

```yaml
resource:
  name: Products
  shortName: products

  paginationEnabled: true
  paginationItemsPerPage: 20
  paginationMaximumItemsPerPage: 100
  paginationClientEnabled: true
  paginationClientItemsPerPage: true

  operations:
    - type: GetCollection
    - type: Get
```

With this configuration, clients can use the following query parameters:

```bash
# Default pagination (20 items per page)
GET /products

# Navigate to page 3
GET /products?page=3

# Request 50 items per page (up to maximum of 100)
GET /products?itemsPerPage=50

# Disable pagination to get all results
GET /products?pagination=false
```

### Generated output

The pagination options are rendered as named parameters in the `#[ApiResource]` attribute:

```php
#[ApiResource(
    operations: [new GetCollection(), new Get()],
    shortName: &apos;products&apos;,
    provider: ProductsBackendProvider::class,
    paginationItemsPerPage: 20,
    paginationEnabled: true,
    paginationMaximumItemsPerPage: 100,
    paginationClientEnabled: true,
    paginationClientItemsPerPage: true
)]
```

### Provider requirements

For pagination to work, your Provider must return a `TraversablePaginator` instance for collection operations:

```php
use ApiPlatform\State\Pagination\TraversablePaginator;

return new TraversablePaginator(
    new \ArrayObject($resources),
    $currentPage,
    $itemsPerPage,
    $totalItems
);
```

If `paginationEnabled` is `true` but the Provider returns a plain array, API Platform wraps the result in a `PartialPaginatorInterface`, which may not include total count or page metadata.

### Global pagination defaults

Global pagination defaults can be configured in the application configuration file. Per-resource settings override the global defaults. See [API Platform configuration](/docs/integrations/spryker-api/api-platform/configuration.html) for details.

## Relationships

Define relationships between resources to enable including related resources via the `?include=` query parameter.

### includes section

Declares what relationships this resource can include. `includes` is declared once on the parent resource — the child resource does not need a reverse declaration.

```yaml
includes:
  - relationshipName: addresses
    targetResource: CustomersAddresses
    uriVariableMappings:
      customerReference: customerReference
```

**Entry fields:**

| Field | Required | Description |
|-------|----------|-------------|
| `relationshipName` | Yes | Name used in the `?include=` parameter and as the JSON:API relationship key. |
| `targetResource` | Yes | The `name` of the included resource as declared in its `resource.yml` (for example, `CustomersAddresses`). Also determines the JSON:API `type` field of the related resources. |
| `uriVariableMappings` | Conditional | Maps properties from the parent resource to the URI variables of the included resource. Required when the included resource is routed by URI variables. Format: `parentProperty: childUriVariable`. Ignored when `resolverClass` is set. |
| `uriTemplate` | Optional | Explicit URI template for the included resource when it has multiple operations and the relationship must target a specific path (for example, `/abstract-products/{abstractProductSku}/abstract-product-prices`). |
| `resolverClass` | Optional | Fully qualified class name of a relationship resolver. Use when the relationship cannot be expressed via URI variables — the resolver receives the parent resources and the request context, and returns the related resources directly. When `resolverClass` is set, `uriVariableMappings` and `uriTemplate` are not used for routing. See [Custom relationship resolvers](/docs/integrations/spryker-api/api-platform/relationships.html#custom-relationship-resolvers). |
| `autoInclude` | Optional | Resolve this relationship for every response of the parent type, even when the client did not request it via `?include=`. Use `autoIncludeMaxDepth` and `autoIncludeMinDepth` to bound where in the response graph the auto-include applies. |

#### URI-variable mapping example

For relationships routed by sub-resource URLs, map parent properties to child URI variables:

```yaml
includes:
  - relationshipName: abstract-product-prices
    targetResource: AbstractProductPrices
    uriTemplate: /abstract-products/{abstractProductSku}/abstract-product-prices
    uriVariableMappings:
      sku: abstractProductSku
```

#### Resolver-based example

For relationships whose targets cannot be derived from URI variables (for example, derived from order state or aggregated across multiple sources), reference a resolver class:

```yaml
includes:
  - relationshipName: order-shipments
    targetResource: OrderShipments
    resolverClass: Spryker\Glue\ShipmentsRestApi\Api\Storefront\Relationship\OrderShipmentsRelationshipResolver
```

**Further reading:** [Resource relationships](/docs/integrations/spryker-api/api-platform/relationships.html) — full reference for declaring, resolving, and troubleshooting relationships between API Platform resources, including provider-based and resolver-based dispatch, response shape, validation, and worked examples.

## Sort priority for included resources

The JSON:API response wraps related resources in an `included` array. By default, API Platform sorts that array alphabetically by resource `type`. Use `includedSortPriority` on a resource to override where its entries appear relative to other types.

### How it works

| Rule | Behavior |
|------|----------|
| Default | Every resource has an implicit priority of `0`. |
| Higher priority | Entries appear **later** in the `included` array. |
| Equal priority | Entries are sorted alphabetically by `type`. |

The priority is read from the resource&apos;s own `.resource.yml` and applied globally to every response that surfaces that type in `included`.

### Syntax

```yaml
resource:
  name: CartItems
  shortName: items

  includedSortPriority: 100
```

The generator passes the value through to the generated `#[ApiResource]` attribute via `extraProperties`:

```php
#[ApiResource(
    shortName: &apos;items&apos;,
    extraProperties: [&apos;includedSortPriority&apos; =&gt; 100],
    // ...
)]
```

### When to set a custom priority

Set `includedSortPriority` higher than `0` when a resource must appear after its nested children in the `included` array. The typical case is cart-item-like resources whose `?include=` chain resolves to abstract or concrete products: keeping the parent items last preserves the ordering of the legacy REST API and matches the order most clients expect when iterating the `included` array.

The following resources ship with `includedSortPriority: 100`:

- `items`
- `guest-cart-items`
- `bundle-items`
- `configurable-bundle-template-image-sets`

All other shipped resources rely on the default of `0`. Override the priority on project-level resources only when you need to enforce a specific ordering in `included`.

{% info_block infoBox &quot;Sort priority is not a guarantee of stable ordering across versions&quot; %}

`includedSortPriority` is a hint for the sort algorithm, not a JSON:API contract. Clients should still address resources by `type` and `id` rather than by index in the `included` array.

{% endinfo_block %}

## Resource generation process

### Generation workflow

The resource generation process is organized into distinct phases, each producing result objects for comprehensive error tracking and reporting:

```MARKDOWN
1. Preparation Phase
   ↓
2. Schema Parsing Phase → ParseResult
   - Load validation schemas
   - Parse validation rules
   - Load resource schemas
   - Parse resource definitions
   ↓
3. Schema Merging Phase → MergeResult
   - Merge schemas (Core → Feature → Project)
   - Track contributing source files
   ↓
4. Validation Phase → ValidationResult
   - Validate merged schemas
   - Apply validation rules
   ↓
5. Code Generation Phase
   - Generate PHP resource classes
   - Write files to output directory
   ↓
6. Cache Update
```

### Result objects

Each phase produces result objects that encapsulate both successful outcomes and failures:

- **ParseResult**: Contains grouped schemas and tracks failed validation files and schema files that could not be parsed
- **MergeResult**: Contains successfully merged schemas and tracks resources that failed to merge
- **ValidationResult**: Contains validated schemas and tracks resources that failed validation with detailed error messages

This structured approach ensures that errors in one resource do not block the generation of other valid resources, and provides clear feedback about what succeeded and what failed.

### Extending an existing resource (schema layering)

Spryker automatically merges schemas from multiple layers:

**Core layer** (lowest priority):

**vendor/spryker/customer/resources/api/backend/customer.resource.yml**

```yaml
resource:
  name: Customers
  properties:
    email:
      type: string
    firstName:
      type: string
```

**Feature layer** (medium priority):

**src/SprykerFeature/CRM/resources/api/backend/customer.resource.yml**

```yaml
resource:
  name: Customers
  properties:
    phone:
      type: string      # Added property
```

**Project layer** (highest priority):

**src/Pyz/Glue/Customer/resources/api/backend/customer.resource.yml**

```yaml
resource:
  name: Customers
  properties:
    email:
      required: true    # Override core definition
    customField:
      type: string      # Project-specific field
```

**Merged result:**

```yaml
resource:
  name: Customers
  properties:
    email:
      type: string
      required: true    # From project layer
    firstName:
      type: string      # From core layer
    phone:
      type: string      # From feature layer
    customField:
      type: string      # From project layer
```

### Generated resource class

The generator creates a complete PHP class with API Platform attributes:

```php
&lt;?php

declare(strict_types=1);
namespace Generated\Api\Backend;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;

#[ApiResource(
    operations: [new Post(), new Get(), new GetCollection(), new Patch(), new Delete()],
    shortName: &apos;customers&apos;,
    provider: CustomerBackendProvider::class,
    processor: CustomerBackendProcessor::class,
    paginationItemsPerPage: 10,
    paginationEnabled: true,
    paginationMaximumItemsPerPage: 100,
    paginationClientEnabled: true,
    paginationClientItemsPerPage: true
)]
final class CustomersBackendResource
{
    #[ApiProperty(writable: false)]
    public ?int $idCustomer = null;

    #[ApiProperty(openapiContext: [&apos;example&apos; =&gt; &apos;john@example.com&apos;])]
    #[Assert\NotBlank(groups: [&apos;customers:create&apos;])]
    #[Assert\Email(groups: [&apos;customers:create&apos;])]
    public ?string $email = null;

    #[ApiProperty(identifier: true, writable: false)]
    public ?string $customerReference = null;

    public ?bool $isActive = true;

    // Getters, setters, toArray(), fromArray() methods...
}
```

## Debugging schemas

### Debug commands

```bash
# List all resources
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:debug --list

# Show specific resource
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:debug customers --api-type=backend

# Show merged schema
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:debug customers --api-type=backend --show-merged

# Show contributing source files
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:debug customers --api-type=backend --show-sources

# Validate schemas without generating
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate --validate-only
```

### Common schema errors

The generator validates schemas and provides detailed error messages:

```bash
# Missing required fields
Error: Resource &quot;customers&quot; is missing required field &quot;name&quot;

# Invalid operation type
Error: Invalid operation type &quot;INVALID&quot;. Must be one of: Get, Post, Put, Patch, Delete, GetCollection

# Invalid property type
Error: Property &quot;age&quot; has invalid type &quot;int&quot;. Must be one of: string, integer, number, boolean, array, object

# Provider class not found
Error: Provider class &quot;Pyz\Glue\Customer\Api\Backend\Provider\MissingProvider&quot; does not exist
```

## Advanced schema features

### Custom URL paths

Operations support `uriTemplate` and `uriVariables` to define custom URL paths, including sub-resource URLs like `/customers/{customerReference}/addresses`.

#### Sub-resource with full CRUD

Define a child resource with nested URLs by adding `uriTemplate` and `uriVariables` to each operation:

**customers-addresses.resource.yml**

```yaml
resource:
  name: CustomersAddresses
  shortName: customers-addresses

  operations:
    - type: GetCollection
      uriTemplate: &apos;/customers/{customerReference}/addresses&apos;
      uriVariables:
        customerReference:
          toProperty: &apos;customer&apos;
          fromClass: CustomersStorefrontResource

    - type: Get
      uriTemplate: &apos;/customers/{customerReference}/addresses/{uuid}&apos;
      uriVariables:
        customerReference:
          toProperty: &apos;customer&apos;
          fromClass: CustomersStorefrontResource
        uuid:
          fromClass: CustomersAddressesStorefrontResource

    - type: Post
      uriTemplate: &apos;/customers/{customerReference}/addresses&apos;
      uriVariables:
        customerReference:
          toProperty: &apos;customer&apos;
          fromClass: CustomersStorefrontResource
```

**`uriVariables` properties:**
- `fromClass`: The generated resource class the variable originates from
- `toProperty`: The property on the current resource that links to the parent resource

#### Action-style sub-resource

For single-action endpoints nested under a parent resource:

**customers-confirm-registration.resource.yml**

```yaml
resource:
  name: CustomersConfirmRegistration
  shortName: customers-confirm-registration

  operations:
    - type: Post
      uriTemplate: /customers/{customerReference}/confirm-registration
```

For more details on `uriTemplate`, `uriVariables`, and sub-resource patterns, see the [API Platform sub-resources documentation](https://api-platform.com/docs/core/subresources/).

### Security expressions

Security expressions protect resources and operations using [Symfony&apos;s ExpressionLanguage](https://symfony.com/doc/current/security/expressions.html). They require the SecurityBundle to be configured. See [Integrate API Platform security](/docs/integrations/spryker-api/authenticating-and-authorization/integrate-api-platform-security.html) for setup instructions.

{% info_block infoBox &quot;Where roles come from&quot; %}

Roles like `ROLE_CUSTOMER` in security expressions come from OAuth scopes that are automatically mapped to Symfony roles. The mapping convention is as follows: a scope name is uppercased, hyphens become underscores, and the result is prefixed with `ROLE_`. For example, the `customer` scope becomes `ROLE_CUSTOMER`, and the `back-office-user` scope becomes `ROLE_BACK_OFFICE_USER`.

Scopes are provided by scope provider plugins registered in `OauthDependencyProvider::getScopeProviderPlugins()`. The following table lists the out-of-the-box scope provider plugins and the scopes they provide:

| Plugin | Scopes |
|--------|--------|
| `CustomerOauthScopeProviderPlugin` | `customer` |
| `CompanyUserOauthScopeProviderPlugin` | `company_user` |
| `AgentOauthScopeProviderPlugin` | `agent` |
| `CustomerImpersonationOauthScopeProviderPlugin` | `customer_impersonation`, `customer` |
| `UserOauthScopeProviderPlugin` | `user`, plus `back-office-user` when no user type plugin claims the user |
| `MerchantUserTypeOauthScopeProviderPlugin` | `merchant-user`, for users assigned to a merchant |
| `WarehouseOauthScopeProviderPlugin` | `warehouse` |

For details on how the mapping works, see [Security — Roles and OAuth scope mapping](/docs/integrations/spryker-api/authenticating-and-authorization/security.html). For instructions on setting up scopes, see [Integrate the authorization scopes](/docs/integrations/spryker-api/backend-api/integrate-backend-api/integrate-the-authorization-scopes.html).

{% endinfo_block %}

Three types of security expressions are supported:

| Expression | Evaluated | Use case | When to use |
|-----------|-----------|----------|-------------|
| `security` | Before the request is processed | Check user roles or authentication status | For role or authentication checks that do not depend on the request body. |
| `securityPostDenormalize` | After the request body is deserialized | Check authorization based on submitted data | When authorization depends on the deserialized resource `object`, for example, to verify the user owns the resource being modified. |
| `securityPostValidation` | After validation passes | Check authorization based on validated data | When authorization depends on validated data, for example, to verify a value is within the user&apos;s authorized limit after validation confirms the data is structurally correct. |

#### Resource-level security

Applies to all operations on the resource:

```yaml
resource:
  name: Customers
  shortName: customers
  security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;
```

#### Operation-level security

Applies to a specific operation, overriding resource-level security:

```yaml
resource:
  name: Customers
  shortName: customers

  operations:
    - type: Post
      # No security — public registration

    - type: Get
      security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;

    - type: Patch
      security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;
```

#### Post-denormalize security

Evaluated after the request body has been deserialized. The `object` variable contains the resource instance:

```yaml
resource:
  name: Orders
  shortName: orders
  security: &quot;is_granted(&apos;ROLE_USER&apos;)&quot;
  securityPostDenormalize: &quot;is_granted(&apos;EDIT&apos;, object)&quot;
```

{% info_block infoBox &quot;Custom voter attributes&quot; %}

`EDIT` in the example is a **custom voter attribute** — it is an application-defined string, not a built-in Symfony or Spryker constant. For `is_granted(&apos;EDIT&apos;, object)` to work, you must register a custom Symfony [Voter](https://symfony.com/doc/current/security/voters.html) that supports the `EDIT` attribute and implements the authorization logic, for example, checking that the authenticated user owns the resource.

Use `securityPostDenormalize` when the authorization decision depends on the **submitted request data** (the deserialized `object`), such as verifying resource ownership.

{% endinfo_block %}

#### Post-validation security

Evaluated after validation has passed:

```yaml
resource:
  name: Payments
  shortName: payments
  securityPostValidation: &quot;is_granted(&apos;PROCESS&apos;, object)&quot;
```

{% info_block infoBox &quot;Custom voter attributes&quot; %}

`PROCESS` in the example is a **custom voter attribute** — it is an application-defined string, not a built-in Symfony or Spryker constant. For `is_granted(&apos;PROCESS&apos;, object)` to work, you must register a custom Symfony [Voter](https://symfony.com/doc/current/security/voters.html) that supports the `PROCESS` attribute.

Use `securityPostValidation` when the authorization decision depends on **validated data**, for example, to verify a payment amount is within the user&apos;s authorized limit after validation confirms the data is structurally correct.

{% endinfo_block %}

For detailed information about the authentication flow, role mapping, and accessing the authenticated user in providers, see [Security](/docs/integrations/spryker-api/authenticating-and-authorization/security.html).

## Generation commands

### Basic generation

```bash
# Generate all configured API types
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate

# Generate specific API type
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate backend
docker/sdk cli GLUE_APPLICATION=GLUE_STOREFRONT glue api:generate storefront

# Generate with options
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate --dry-run           # Preview without writing
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate --validate-only     # Only validate schemas
docker/sdk cli GLUE_APPLICATION=GLUE_BACKEND glue api:generate --resource=customers  # Generate single resource
```

### Output

```bash
Generating API resources for ApiType: backend

Discovering schema files...
Validating schemas... OK
Merging schemas... OK

Generating resources:
 10/10 [============================] 100%

Generated: 10 file(s)
Cache updated

Done!
```

## Schema validation rules

The generator enforces these rules:

### Required fields

Every resource must have:
- `name` - Internal resource name
- `shortName` - URL-friendly name
- At least one `operation`
- At least one `property`

### Valid operation types

Only these operation types are allowed:
- `Get` - Retrieve single resource
- `GetCollection` - Retrieve collection
- `Post` - Create resource
- `Put` - Replace entire resource
- `Patch` - Update partial resource
- `Delete` - Delete resource

### Valid property types

Only these property types are allowed:
- `string`
- `integer`
- `number`
- `boolean`
- `array`
- `object`
- `map`
- `mixed`

### Provider/Processor validation

- Provider/Processor classes must exist
- Classes must implement correct interfaces
- Namespaces must be valid PHP namespaces

## Best practices

### 1. Use semantic naming

```yaml
# ✅ Good
resource:
  name: Customers              # PascalCase plural — used for schema merging
  shortName: customers         # lowercase kebab-case plural — JSON:API type + URL segment

# ✅ Good — multi-word
resource:
  name: AbstractProductPrices
  shortName: abstract-product-prices

# ❌ Bad — wrong shortName casing/form
resource:
  name: Customers
  shortName: Customer          # Should be lowercase plural

# ❌ Bad — abbreviated, unclear
resource:
  name: CustomerData
  shortName: cust
```

### 2. Document all properties

```yaml
# ✅ Good
email:
  type: string
  description: &quot;The customer&apos;s email address used for login and notifications&quot;

# ❌ Bad
email:
  type: string
```

### 3. Leverage schema merging

Core — define base properties:

**src/Spryker/Customer/resources/api/backend/customer.resource.yml**

```yaml
resource:
  name: Customers
  properties:
    email:
      type: string
```

Project — only override what is needed:

**src/Pyz/Glue/Customer/resources/api/backend/customer.resource.yml**

```yaml
resource:
  name: Customers
  properties:
    email:
      required: true  # ← Only the difference
```

### 4. Use readable/writable correctly

```yaml
# Read-only fields (IDs, timestamps)
idCustomer:
  type: integer
  writable: false

# Write-only fields (passwords)
password:
  type: string
  readable: false

# Read-write fields (normal data)
email:
  type: string
  writable: true
  readable: true
```

## Next steps

- [API Platform](/docs/integrations/spryker-api/api-platform/api-platform.html) - Architecture overview
- [Validation schemas](/docs/integrations/spryker-api/api-platform/validation-schemas.html) - Define validation rules
- [CodeBucket support](/docs/integrations/spryker-api/api-platform/code-buckets.html) - Code Bucket-specific resources
- [Implement an API Platform resource](/docs/integrations/spryker-api/api-platform/enablement.html) - Creating resources
- [Test API Platform resources](/docs/integrations/spryker-api/api-platform/testing.html) - Writing and running tests
- [Troubleshooting](/docs/integrations/spryker-api/api-platform/troubleshooting.html) - Common issues
- [API Platform Documentation](https://api-platform.com/docs/) - Official API Platform docs
</description>
            <pubDate>Wed, 09 Sep 2026 13:21:24 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/spryker-api/api-platform/resource-schemas.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/spryker-api/api-platform/resource-schemas.html</guid>
            
            
        </item>
        
        <item>
            <title>Prices feature overview</title>
            <description>The *Prices* feature enables Back Office users to set prices for products and manage them effectively.

## Price types

To accommodate business requirements, there can be various price types—for example, a *default price* is a product&apos;s regular price. An *original price* is typically used to show a product&apos;s price before a discount was applied. The original price is displayed in a strikethrough font next to the default price.


![Default and original prices](https://spryker.s3.eu-central-1.amazonaws.com/docs/pbc/all/price-management/prices-feature-overview/prices-feature-overview.md/default-and-original-prices.png)


## Product types and price inheritance

Back Office users can set prices for both [abstract products and product variants](/docs/pbc/all/product-information-management/latest/base-shop/feature-overviews/product-feature-overview/product-feature-overview.html#abstract-products-and-product-variants). When an abstract product has multiple product variants, you can set a price for the abstract product and different prices for each product variant.

On the Storefront, when customers browse catalog and search pages, they see abstract product prices.

![Abstract product prices in catalog](https://spryker.s3.eu-central-1.amazonaws.com/docs/pbc/all/price-management/prices-feature-overview/prices-feature-overview.md/abstract-product-prices-in-catalog.png)

When a customer opens a Product Details page, they still see an abstract product price.

![Abstract product price on the Product Details page](https://spryker.s3.eu-central-1.amazonaws.com/docs/pbc/all/price-management/prices-feature-overview/prices-feature-overview.md/abstract-product-prices-on-pdp.png)

After selecting a product variant, they see the variant&apos;s price.

&lt;iframe width=&quot;960&quot; height=&quot;720&quot; src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/pbc/all/price-management/prices-feature-overview/prices-feature-overview.md/prices-of-abstract-products-and-pruduct-variants.mp4&quot; frameborder=&quot;0&quot; allowfullscreen&gt;&lt;/iframe&gt;

In some cases, you may want to set the same price for all the product variants. Then, you set the price for the abstract product and don&apos;t set any for the variants. When the variants don&apos;t have prices, they inherit the price of their abstract product.

Similarly, when there is one product variant, it makes sense to set the price just for the abstract product.

In the last two cases described, since product variants don&apos;t have prices, customers see the abstract product price on all the Storefront pages.

## Prices in database

Prices are stored as an integer, in the smallest unit of a currency—for example, Euro prices are stored in cents.

Each price is assigned to a price type, like DEFAULT or ORIGINAL price. For a price type, there can be *one* to *n* product prices defined. Price type entity is used to differentiate between use cases. For example, you can have DEFAULT and ORIGINAL types to use for sale pricing.

The price can have a gross or net value which can be used based on a price mode selected by a customer on the Storefront. You can have a shop running in both modes and select the net mode for the business customer, for example.

{% info_block Net and Gross Prices Across Tax Regions %}

It&apos;s important to understand how Spryker calculates gross price for a product across tax regions, to ensure your store displays the intended price to customers. If a customer chooses *gross* mode and chooses to buy the product in a tax region different than the store&apos;s tax region, Spryker calculates the new region&apos;s tax based on the product&apos;s gross price defined for this product in the store, rather than the product&apos;s net price. Therefore, the tax amount will be different depending on whether *gross* or *net* is enabled on the storefront. Here&apos;s a simple example of price calculation across tax regions:

The Value Added Tax (VAT) in country A, the store&apos;s tax region, is 20%. The net price of a product is 100. In country A the gross price is 120. The customer sees a price of 100 in net mode and a price of 120 in gross mode.

However, your customer chooses to buy the same product from country B while still in the store that falls under country A tax rates.

VAT in country B is 10%. Tax for the new region is calculated based on the product&apos;s *gross* price in the store&apos;s region because the customer selected *gross* mode. In country B, the customer still sees a gross price of 120. However, the net price shown is 109,09 (VAT at 10% rate from 120 is 10.91).

{% endinfo_block %}

Price also has currency and store assigned to it.
![Price calculation](https://spryker.s3.eu-central-1.amazonaws.com/docs/Features/Price/Price+Functionality/price_calculation.png)

## Price retrieving logic

If a concrete product doesn&apos;t have a price entity stored, it inherits the values stored for its abstract product. When fetching the price of a concrete product, the price entity of the respective concrete product SKU is checked. If the entity exists, the price is returned. If not, an abstract product owning that concrete product is queried and its price entity is checked. If it exists, the abstract product&apos;s price is returned for the concrete product. If it does not exist an exception is thrown.

The following diagram summarizes the logic for retrieving the price for a product:
![Price retrieval logic](https://spryker.s3.eu-central-1.amazonaws.com/docs/Features/Price/Price+Functionality/price_retrieval_logic.png)

## Related Business User documents

| OVERVIEWS | BACK OFFICE USER GUIDES |
|---| - |
| [Volume prices](/docs/pbc/all/price-management/latest/base-shop/prices-feature-overview/volume-prices-overview.html) | [Define prices when creating abstract products and product bundles](/docs/pbc/all/product-information-management/latest/base-shop/manage-in-the-back-office/products/manage-abstract-products-and-product-bundles/create-abstract-products-and-product-bundles.html)   |
| | [Edit prices of an abstract product](/docs/pbc/all/product-information-management/latest/base-shop/manage-in-the-back-office/products/manage-abstract-products-and-product-bundles/edit-abstract-products-and-product-bundles.html#edit-prices-of-an-abstract-product-or-product-bundle)   |
| | [Define prices when creating a concrete product](/docs/pbc/all/product-information-management/latest/base-shop/manage-in-the-back-office/products/manage-product-variants/create-product-variants.html)  |
| | [Edit prices of a concrete product](/docs/pbc/all/product-information-management/latest/base-shop/manage-in-the-back-office/products/manage-product-variants/edit-product-variants.html)   |

## Related Developer documents

| INSTALLATION GUIDES  | GLUE API GUIDES | DATA IMPORT | TUTORIALS AND HOWTOS | REFERENCES |
|---|---|---|---|---|
| [Install the Prices feature](/docs/pbc/all/price-management/latest/base-shop/install-and-upgrade/install-features/install-the-prices-feature.html) | [Retrieving abstract product prices](/docs/pbc/all/price-management/latest/base-shop/manage-using-glue-api/glue-api-retrieve-abstract-product-prices.html) | [File details: product_price.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-product-price.csv.html) | [Handle price explosion and ERP-owned pricing in B2B](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-price-explosion-and-erp-owned-pricing-in-b2b.html)&lt;br&gt;[HowTo: Handle twenty five million prices in Spryker Commerce OS](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-twenty-five-million-prices-in-spryker-commerce-os.html) | [Money module: reference information](/docs/pbc/all/price-management/latest/base-shop/extend-and-customize/money-module-reference-information.html) |
| [Install the Product Price Glue API](/docs/pbc/all/price-management/latest/base-shop/install-and-upgrade/install-the-product-price-glue-api.html) | [Retrieving concrete product prices](/docs/pbc/all/price-management/latest/base-shop/manage-using-glue-api/glue-api-retrieve-concrete-product-prices.html) |  |  | [PriceProduct module details: reference information](/docs/pbc/all/price-management/latest/base-shop/extend-and-customize/priceproduct-module-details-reference-information.html) |
</description>
            <pubDate>Wed, 09 Sep 2026 12:22:48 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/prices-feature-overview/prices-feature-overview.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/prices-feature-overview/prices-feature-overview.html</guid>
            
            
        </item>
        
        <item>
            <title>Price Management</title>
            <description>The *Price Management* capability lets you define and manage all kinds of prices, set them for different time periods and entities. To learn about prices, see the following feature overviews:

- [Prices feature overview](/docs/pbc/all/price-management/latest/base-shop/prices-feature-overview/prices-feature-overview.html)
- [Merchant Custom Prices feature overview](/docs/pbc/all/price-management/latest/base-shop/merchant-custom-prices-feature-overview.html)
- [Scheduled Prices feature overview](/docs/pbc/all/price-management/latest/base-shop/scheduled-prices-feature-overview.html)

For high-volume, contract-heavy B2B pricing, see [Handle price explosion and ERP-owned pricing in B2B](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-price-explosion-and-erp-owned-pricing-in-b2b.html), which helps you choose a pricing architecture before you start technical optimization.
</description>
            <pubDate>Wed, 09 Sep 2026 12:22:48 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/price-management/latest/price-management.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/price-management/latest/price-management.html</guid>
            
            
        </item>
        
        <item>
            <title>Integrating with Middleware</title>
            <description>Middleware is an external service or third‑party application that integrates multiple data sources and converts their data into the format your target system expects. Acting as a bridge, it applies complex logic - such as normalization, filtering, and enrichment - before the data reaches your core platform.

## Integration with Spryker Data Exchange methods

As explained in the [Data Exchange overview](/docs/integrations/custom-building-integrations/data-exchange/data-exchange.html), Spryker provides several data exchange methods that middleware can connect to:

- [Data import from S3 bucket](/docs/integrations/custom-building-integrations/data-exchange/data-import-from-s3-bucket.html): Import CSV files stored in Amazon S3, ideal for ERP integrations
- [Data Export](/docs/integrations/custom-building-integrations/data-exchange/data-export/data-export.html): Export data to other systems, with extensible order export functionality  
- [Data Exchange API](/docs/integrations/spryker-api/backend-api/data-exchange-api/data-exchange-api.html): Real-time database API for dynamic data operations

Middleware can leverage any combination of these methods depending on your integration requirements - whether you need real-time API synchronization, batch file processing via S3, or scheduled data exports.

## Benefits of Middleware integration

- System decoupling: Connect many external systems without changing your core Spryker code  
- Performance optimization: Offload resource-intensive data transformations from Spryker  
- Scalability: Handle multiple integration partners and data formats efficiently  
- Maintenance simplification: Centralized integration logic as data formats and partners evolve  
- Error resilience: Built-in retry mechanisms and error handling for failed integrations  

## Trade-offs and considerations

- Architectural complexity: Middleware adds additional layers and potential points of failure  
- Infrastructure costs: Additional licensing, hosting, and monitoring expenses  
- Latency considerations: Extra network hops may impact real-time data requirements  
- Monitoring requirements: Dedicated oversight needed to maintain data consistency and reliability  
- Vendor dependency: Reliance on middleware provider for critical business operations  

## Implementation recommendations

- Assess data requirements: Determine which data needs real-time versus batch processing  
- Choose appropriate Spryker integration method: API for real-time, files for bulk operations  
- Design for resilience: Implement proper error handling, logging, and monitoring  
- Plan for scalability: Consider future growth in data volume and integration partners  
- Establish governance: Define data quality standards and integration testing procedures

For a worked example of these trade-offs in one domain, see [Handle price explosion and ERP-owned pricing in B2B](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-price-explosion-and-erp-owned-pricing-in-b2b.html), which compares synced, cached, live, and hybrid pricing architectures — including the middleware-backed cached pattern.  
</description>
            <pubDate>Wed, 09 Sep 2026 12:22:48 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/custom-building-integrations/data-exchange/integrating-with-middleware.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/custom-building-integrations/data-exchange/integrating-with-middleware.html</guid>
            
            
        </item>
        
        <item>
            <title>Import and export Price Management data</title>
            <description>To learn how data import works and about different ways of importing data, see [Data import](/docs/dg/dev/data-import/latest/data-import.html). This section describes the data import files that are used to import data related to the Price Management PBC:

- [product_price.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-product-price.csv.html): allows you to define the price-related information for each product. This data defines the price type, whether is gross or net, its value, the store and currency to which the price applies, and other price data (for example, volumes price).

- [product_price_schedule.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-product-price-schedule.csv.html): use this file to schedule a specific price for a product. For that you have to define the price type, whether it&apos;s gross or net, its value, the store and currency to which the price applies, the activation date of that price, and its validity.

- [currency.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-currency.csv.html)

If you import contract prices at high volume — millions of records from an ERP — see [Handle price explosion and ERP-owned pricing in B2B](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-price-explosion-and-erp-owned-pricing-in-b2b.html) for the architecture and write-path options before you tune the importers.


The table below provides details on Pricing data importers, their purpose, CSV files, dependencies, and other details. Each data importer contains links to CSV files used to import the corresponding data, including specifications of mandatory and unique fields, dependencies, detailed explanations, recommendations, templates, and content examples.

| DATA IMPORTER | PURPOSE | CONSOLE COMMAND | FILES | DEPENDENCIES |
| --- | --- | --- | --- |--- |
| Product Price   | Imports information relative to product prices. |`data:import:product-price` | [product_price.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-product-price.csv.html)|&lt;ul&gt;&lt;li&gt;[product_abstract.csv](/docs/pbc/all/product-information-management/latest/base-shop/import-and-export-data/products-data-import/import-file-details-product-abstract.csv.html)&lt;/li&gt;&lt;li&gt;[product_concrete.csv](/docs/pbc/all/product-information-management/latest/base-shop/import-and-export-data/products-data-import/import-file-details-product-concrete.csv.html)&lt;/li&gt;&lt;li&gt;*stores.php* configuration file of demo shop PHP project&lt;/li&gt;&lt;/ul&gt;  |
| Product Price Schedule  | Imports information about product scheduled prices.  |`data:import:product-price-schedule` |[product_price_schedule.csv](/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-file-details-product-price-schedule.csv.html) | &lt;ul&gt;&lt;li&gt;[product_abstract.csv](/docs/pbc/all/product-information-management/latest/base-shop/import-and-export-data/products-data-import/import-file-details-product-abstract.csv.html)&lt;/li&gt;&lt;li&gt;[product_concrete.csv](/docs/pbc/all/product-information-management/latest/base-shop/import-and-export-data/products-data-import/import-file-details-product-concrete.csv.html)&lt;/li&gt;&lt;li&gt;stores.php configuration file of demo shop PHP project&lt;/li&gt;&lt;/ul&gt; |
</description>
            <pubDate>Wed, 09 Sep 2026 12:22:48 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-and-export-price-management-data.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/import-and-export-data/import-and-export-price-management-data.html</guid>
            
            
        </item>
        
        <item>
            <title>Handle twenty five million prices in Spryker Commerce OS</title>
            <description>B2B business model usually challenges any software with higher requirements to amounts of data and business complexity.

Imagine you have thousands of products and customers with unique pricing terms and conditions. A product can have thousands of prices assigned—one per customer. This document shares the technical challenges of handling such a number of prices and the solutions to solve them.

Such a number of prices cannot be managed manually, but it&apos;s defined by business rules based on which the prices can be generated automatically. For example, you might agree on the special terms with your B2B partner, and they receive their own prices for the whole catalog. It might be considered as a discount, but usually, it&apos;s not a single simple rule but a set of rules and their priorities for each partner. These rules exist in an ERP system, which can export data through SOAP or CSV files.

In Spryker, each price is imported as a [price dimension](/docs/pbc/all/price-management/latest/base-shop/merchant-custom-prices-feature-overview.html) and has a unique key, which determines its relation to a customer—for example, `specificPrice-DEFAULT-EUR-NET_MODE-FOO1-BAR2`. To appear on the Storefront, the prices must appear in the key-value store (Redis or Valkey) price entries and abstract product search documents so that facet filters can be applied in search and categories.

Price import flow:

![price import flow ](https://spryker.s3.eu-central-1.amazonaws.com/docs/Tutorials/HowTos/HowTo+-+handle+25+million+prices+in+Spryker+Commerce+OS/price-import-flow.jpg)


{% info_block infoBox &quot;Choose an architecture before you optimize&quot; %}

This document is an implementation case study of one architecture. To decide which pricing architecture fits your project — synced, cached, live, or hybrid — first read [Handle price explosion and ERP-owned pricing in B2B](/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-price-explosion-and-erp-owned-pricing-in-b2b.html).

{% endinfo_block %}

## Challenges

When enabling Spryker to handle such a number of prices, the following challenges occur:

1. 25,000,000 prices are imported in two separate price dimensions.
2. A product can have about 40,000 prices. This results in overpopulated product abstract search documents: each document aggregates prices of abstract products and all related concrete products. Each price is represented as an indexed field in the search document. Increasing the number of indexed fields slows `Elasticsearch(ES)` down.
3. Overloaded product abstract search documents cause issues with memory limit and slow down [Publish and Synchronization](/docs/dg/dev/backend-development/data-manipulation/data-publishing/publish-and-synchronization.html). The average document size is bigger than 1&amp;nbsp;MB.
4. When more than 100 product abstract search documents are processed at a time, the payload gets above 100&amp;nbsp;MB, and ES rejects queries. [AWS native service](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-limits.html) does not allow changing this limit.

5. Each price having unique key results in more different index properties in the whole index. Key structure: `specificPrice-DEFAULT-EUR-NET_MODE-FOO1-BAR2`. This key structure requires millions of actual facets, which slows down ES too much.

## Problem

The following example represents a short version of the overpopulated document structure:

```json
{
	&quot;store&quot;: &quot;ABC&quot;,
	&quot;locale&quot;: &quot;en_US&quot;,
	&quot;type&quot;: &quot;product_abstract&quot;,
	&quot;is-active&quot;: true,
	&quot;integer-facet&quot;: [{
			&quot;facet-name&quot;: &quot;specificPrice-DEFAULT-EUR-NET_MODE-FOO1-BAR2&quot;,
			&quot;facet-value&quot;: [319203]
		}, {
			&quot;facet-name&quot;: &quot;specificPrice-DEFAULT-EUR-GROSS_MODE-FOO2-BAR1&quot;,
			&quot;facet-value&quot;: [379852]
		}, {
			&quot;facet-name&quot;: &quot;specificPrice-DEFAULT-EUR-NET_MODE-FOO3-BAR3&quot;,
			&quot;facet-value&quot;: [324272]
		}, {
			&quot;facet-name&quot;: &quot;specificPrice-DEFAULT-EUR-GROSS_MODE-FOO4-BAR4&quot;,
			&quot;facet-value&quot;: [385884]
		},
		{
			&quot;facet-name&quot;: &quot;merchantPrice-DEFAULT-EUR-NET_MODE-30&quot;,
			&quot;facet-value&quot;: [319200]
		}, {
			&quot;facet-name&quot;: &quot;merchantPrice-DEFAULT-EUR-GROSS_MODE-30&quot;,
			&quot;facet-value&quot;: [379848]
		}
	],
	&quot;integer-sort&quot;: {
		&quot;merchantPrice-DEFAULT-EUR-NET_MODE-30&quot;: 319200,
		&quot;merchantPrice-DEFAULT-EUR-GROSS_MODE-30&quot;: 379848,
		&quot;specificPrice-DEFAULT-EUR-NET_MODE-FOO-BAR&quot;: 122,
		&quot;specificPrice-DEFAULT-EUR-GROSS_MODE-FOO1-BAR1&quot;: 379852,
		&quot;specificPrice-DEFAULT-EUR-NET_MODE-FOO2-BAR2&quot;: 324272,
		&quot;specificPrice-DEFAULT-EUR-GROSS_MODE-FOO3-BAR3&quot;: 385884
	}
}
```

All the `specificPrice-DEFAULT-EUR-NET_MODE-FOO-BAR` properties in the document are converted into mapping properties in ES. The default limit of 1,000 properties is hit quickly and receives the following exception:

```text
\/de_search\/page\/product_abstract:de:de_de:576 caused Limit of total fields [1000] in index [de_search] has been exceeded\nindex`
```

You could increase the limit, but it slows down the reindexing process.

The events with the data for ES are processed and acknowledged in RabbitMQ but not delivered to the search service, and you don&apos;t get any related errors.

In AWS, the `http.max_content_length` ES limit defines the maximum payload size in an HTTP request. In this case, the payload is higher than the default limit of 100&amp;nbsp;MB without the infrastructural option to increase it. To learn about cloud service providers, technologies, and limits, see [Amazon Elasticsearch Service Limits](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-limits.html).

## Evaluated solutions

The evaluated solutions are as follows:

1. ES join field type.
   This ES functionality is similar to the classical joins in relational databases. This solution solves your problem faster and with less effort. To learn about the implementation of this solution, see [Elasticsearch join data type: Implementation](#elasticsearch-join-field-type-implementation). Also, have a look at the other evaluated solutions as they may be more appropriate in your particular case.
   &lt;br&gt;Documentation: [Join field type](https://www.elastic.co/guide/en/elasticsearch/reference/current/parent-join.html)
2. Multi sharding with the `_routing` field.
   The idea is to avoid indexing problems by sharing big documents between shards. Breaking a huge index into smaller ones makes it easier for the search to index data. The solution is complex and does not solve the payload issues.
   &lt;br&gt;Documentation: [`_routing` field](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-routing-field.html)
3. Use Postgres or combine ES and Postgres.
   Postgres provides search functionalities, and you can set up an additional database dedicated to running searches or helping ES with additional data. The `script_scoring` function in search lets you embed any data, though performance is decreased, as this script is evaluated for every document when a search is being performed.
   Compared to the first option, this solution is more complex.
   &lt;br&gt;Documentation:
   - [Script score query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html#script-score-query-ex-request)
   - [Chapter 12. Full-Text Search](https://www.postgresql.org/docs/9.5/textsearch.html)

## Elasticsearch Join field type: Implementation

To solve the ES indexing issue, we reduced the size of product abstract documents, which reduced dynamic mapping properties.

To implement the solution, follow these steps:

1. To use the Join field type feature and declare a join relation in `search.json`, restrict the ES index to use a single type of document. The following example represents a mapping definition with a declared join relation:

```json
{
	&quot;settings&quot;: {
		&quot;mapping.single_type&quot;: true,
	},

	&quot;mappings&quot;: {
		&quot;page&quot;: {
			&quot;properties&quot;: {
				&quot;joined_price&quot;: {
					&quot;type&quot;: &quot;join&quot;,
					&quot;relations&quot;: {
						&quot;product_price&quot;: [&quot;specific_price&quot;]
					}
				}
			}
		}
	}
}
```

2. To make the product-price relation work, extend product abstract documents with the required `joined_price` section:

```json
product_abstract:abc:en_us:876
{
    &quot;store&quot;: &quot;ABC&quot;,
    &quot;locale&quot;: &quot;en_US&quot;,
    &quot;type&quot;: &quot;product_abstract&quot;,
    &quot;is-active&quot;: true,
    .....
    &quot;joined_price&quot;: {
        &quot;name&quot;: &quot;product_price&quot;
    }
}
```

3. Introduce a new type of price document with the following parameters:
- parent document ID
- price
- currency
- unique identifier

The example of the price document:

```json
price_product_concrete_group_specific:abc:50445:foo-bar:en_us
{
    &quot;joined_price&quot;: {
        &quot;name&quot;: &quot;specific_price&quot;,
        &quot;parent&quot;: &quot;product_abstract:abc:en_us:50504&quot;
    },
    &quot;kg_ekg&quot;: &quot;FOO-BAR&quot;,
    &quot;currency&quot;: &quot;EUR&quot;,
    &quot;price&quot;: 101
}
```

These two documents can be viewed as two tables with a foreign key in terms of relational databases.

### Elasticsearch join data type feature: Side effects

The side effects of this solution are the following:

1. The [Product Reviews feature](/docs/pbc/all/ratings-reviews/latest/ratings-and-reviews.html) is disabled because it requires multiple document types per index.
2. Performance requires additional attention. You can read about performance issues related to the feature in [Parent-join and performance](https://www.elastic.co/guide/en/elasticsearch/reference/current/parent-join.html#_parent_join_and_performance).
3. Because of ES limitations, you can&apos;t build proper queries to run sorting by prices. Only facet filtering is possible.

### How to speed up the publishing process

To implement a parent-child relationship between documents, we built a standard search module that follows [Spryker architecture](/docs/dg/dev/backend-development/data-manipulation/data-publishing/publish-and-synchronization.html). The new price search module is subscribed to the publish and unpublish events of abstract products to manage related price documents in the search. The listener in the search module receives a product abstract ID and fetches all related prices to publish or unpublish them, depending on the incoming event. Because of a large number of prices, the publish process became slow. This causes the following issues.

#### Issues

The following issues related to a slow publish process have been added:

1. Memory limit and performance issues. As a product abstract can stand for about forty thousand prices, a table with 25,000,000 rows is parsed every time to find them. The default message chunk size of an event queue is 500. With this size, about two million rows of data have to be published per one bulk.
2. The following has to be done simultaneously:
    - Trigger product abstract events to update their structure in ES.
    - Trigger their child documents to be published.
3. RabbitMQ connection issues. The connection is getting closed after fetching a bunch of messages because the PHP process takes too long to be executed. After processing the messages, PHP tries to acknowledge them using the old connection, which has been closed by RabbitMQ. Being single-threaded, the PHP library cannot asynchronously send any heartbeats when the thread is busy with something else.
   For more information, see [Detecting Dead TCP Connections with Heartbeats and TCP Keepalives](https://www.rabbitmq.com/heartbeats.html).

#### Evaluated solutions

The following solutions were evaluated:
1. To handle bulk insert and update operations in the `_search` table, use [Common Table Expression (CTE)](https://www.postgresql.org/docs/10/queries-with.html) queries. We chose this solution because we had implemented it previously. To learn how this solution is used to optimize the speed of data importers, see [Data Importer Speed Optimization](/docs/dg/dev/data-import/latest/data-import-optimization-guidelines.html).
2. To fill the `search` table on the insert update operations in the `entity` table, see the [PostgreSQL trigger feature](https://www.postgresql.org/docs/9.1/sql-createtrigger.html).
3. Implement a reconnection logic that establishes a new connection after catching an exception.

#### Bulk insertion with raw SQL

[Postgresql CTE](https://www.postgresqltutorial.com/postgresql-tutorial/postgresql-cte/) allows managing bulk inserts and updates of huge data amounts, which speeds up the execution of PHP processes.

&lt;details&gt;&lt;summary&gt;SQL query example&lt;/summary&gt;

```sql
WITH records AS
(
          SELECT    input.fkproduct,
                    input.fkkg,
                    input.fkekg,
                    input.pricekey,
                    input.fkerprecid,
                    input.data,
                    input.KEY,
                    id_pyz_price_product_concrete_group_specific_search AS idpyzpriceproductconcretegroupspecificsearch
          FROM      (
                           SELECT Unnest(? :: varchar []) AS fkkg,
                                  Unnest(? :: varchar []) AS fkekg,
                                  Json_array_elements(?)  AS data,
                                  unnest(?::integer[])    AS fkproduct,
                                  unnest(?::varchar[])    AS pricekey,
                                  unnest(?::varchar[])    AS fkerprecid,
                                  unnest(?::varchar[])    AS KEY ) input
          LEFT JOIN pyz_price_product_concrete_group_specific_search
          ON        pyz_price_product_concrete_group_specific_search.KEY = input.KEY ), updated AS
(
       UPDATE pyz_price_product_concrete_group_specific_search
       SET    fk_kg = records.fkkg,
              fk_ekg = records.fkekg,
              fk_product = records.fkproduct,
              data = records.data,
              price_key = records.pricekey,
              fk_erp_rec_id = records.fkerprecid,
              KEY = records.KEY,
              updated_at = now()
       FROM   records
       WHERE  records.KEY = pyz_price_product_concrete_group_specific_search.KEY returning id_pyz_price_product_concrete_group_specific_search ), inserted AS
(
            INSERT INTO pyz_price_product_concrete_group_specific_search
                        (
                                    id_pyz_price_product_concrete_group_specific_search,
                                    fk_kg,
                                    fk_ekg,
                                    fk_product,
                                    data,
                                    price_key,
                                    fk_erp_rec_id,
                                    KEY,
                                    created_at,
                                    updated_at
                        )
                        (
                               SELECT nextval(&apos;pyz_price_product_concrete_group_specific_search_pk_seq&apos;),
                                      fkkg,
                                      fkekg,
                                      fkproduct,
                                      data,
                                      pricekey,
                                      fkerprecid,
                                      KEY,
                                      now(),
                                      now()
                               FROM   records
                               WHERE  idpyzpriceproductconcretegroupspecificsearch IS NULL ) returning id_pyz_price_product_concrete_group_specific_search )
SELECT updated.id_pyz_price_product_concrete_group_specific_search
FROM   updated
UNION ALL
SELECT inserted.id_pyz_price_product_concrete_group_specific_search
FROM   inserted;
```

&lt;/details&gt;

### Price events quick lane

Prices are published by pushing the corresponding message to the generic event queue. As this queue can hold more messages than just those related to prices, it makes sense to introduce a dedicated queue for publishing only price-related information.

You can configure it by tweaking the Event and EventBehavior modules. Allow the `EventBehavior` Propel behavior to accept additional parameters (except those related to columns). For example, allow it to accept the name of a custom queue, which is used later for pushing messages to the queue. In this case, price events are segregated from all other events and can be processed in parallel without being blocked by other heavier events. Also, this lets you configure different chunk sizes for the subscriber, resulting in a more optimized CPU usage and faster processing.

To implement this functionality:

1. Extend `EventEntityTransfer` with a new field, like `queueName`.
2. Override `\Spryker\Zed\EventBehavior\Persistence\Propel\Behavior\EventBehavior` and adjust it to accept an additional `queueName` parameter (except those related to columns) through the Propel schema files.

{% info_block errorBox %}

Ensure that `\Pyz\Zed\EventBehavior\Persistence\Propel\Behavior\ResourceAwareEventBehavior::addParameter()` is stored as a part of the `data` payload, which is saved to the`spy_event_behavior` table.

{% endinfo_block %}

3. Adjust `\Pyz\Zed\EventBehavior\Business\Model\TriggerManager::triggerEvents()` to extract the new piece of data from the payload obtained from the database and set it as the value of the newly created `EventEntityTransfer::queueName` property.

4. Configure `\Spryker\Zed\Event\Business\Queue\Producer\EventQueueProducer::enqueueListenerBulk()` to check if `queueName` is set on the `EventEntityTransfer.` If it&apos;s set, this queue name is used to push event messages to. Otherwise, it falls back to the default event queue.

Now you have a separate event queue for prices. This approach applies to any type of event. *Quick lane* ensures that critical data is replicated faster.

### Tweaking database

With millions of prices in a shop, we needed analytics tools to monitor data consistency in the database. The CSV files, which are the source of price data for analytics, are too big, so it&apos;s hard to process them. That&apos;s why we converted them into Postgres database tables.

The Postgres `COPY` command is the fastest and easiest way to do that. This command copies the data from a CSV file to a database table.

{% info_block errorBox %}

To convert data successfully, the order of the columns in the database table must reflect the order in the CSV files.

Example:

```bash
#Populate tables with data from csv files
if ! PGPASSWORD=$DB_PASSWORD psql -d $DB_NAME -h $DB_HOST -U $DB_LOGIN -p $DB_PORT -v &quot;ON_ERROR_STOP=1&quot; &lt;&lt;EOT
SET synchronous_commit TO OFF;
BEGIN;

TRUNCATE TABLE public.$DESTINATION_TABLE;
\copy public.$DESTINATION_TABLE from &apos;$CSV_FILE&apos; DELIMITER &apos;;&apos;;

COMMIT;
EOT
then
    exit 1
fi
```

{% endinfo_block %}

#### Disabling synchronous commit

We were running the analytics at night when there was no intensive activity in our shop. This lets us disable synchronous commit to reduce the processing time of the `COPY` operations.

The following line in the previous code snippet disables the synchronous commit: `SET synchronous_commit TO OFF;`

{% info_block errorBox %}

If you disable the synchronous commit, enable it back after you&apos;ve finished importing the files.

{% endinfo_block %}

### Materialized views for analytics

Materialized view is a tool that aggregates data for analysis. Applying indexes to filterable columns in the views lets you run `SELECT` queries faster than in relational tables.

Exemplary procedure:

1. Create an aggregated view of all the merchant prices that are already imported into a relational database with proper normalization.

```sql
CREATE materialized VIEW IF NOT EXISTS debug_merchant_relationship_prices_view AS
SELECT *
FROM spy_price_product_merchant_relationship;

create index IF NOT exists debug_merchant_relationship_prices_view_net_price ON debug_merchant_relationship_prices_view (net_price);
```

2. Create another view based on the table that contains the pure data copied from the original CSV source file.

```sql
CREATE materialized VIEW IF NOT EXISTS debug_merchant_relationship_prices_csv_data_view AS
SELECT *
FROM csv_data_merchant_relationship_prices;

create index IF NOT exists csv_data_merchant_relationship_prices_net_price ON csv_data_merchant_relationship_prices (net_price);
```

3. Compare the views to detect inconsistencies.

## Conclusion

With the configuration and customizations described in this document, Spryker can hold and manage millions of prices in one instance. RabbitMQ, internal APIs, data import modules, and Glue API allow building a custom data import to do the following:
- Fetch a lot of data from a third-party system.
- Successfully import it into the database.
- Denormalize and replicate it to be used by quick storages, such as (Redis or Valkey) and ES.
</description>
            <pubDate>Wed, 09 Sep 2026 12:22:48 +0000</pubDate>
            <link>https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-twenty-five-million-prices-in-spryker-commerce-os.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/pbc/all/price-management/latest/base-shop/tutorials-and-howtos/handle-twenty-five-million-prices-in-spryker-commerce-os.html</guid>
            
            
        </item>
        
    </channel>
</rss>
