<?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>Mon, 17 Aug 2026 09:46:33 +0000</lastBuildDate>
        <generator>Jekyll v4.2.2</generator>
        
        
        <item>
            <title>Test API Platform resources</title>
            <description>This document describes how to write and run tests for your API Platform resources in your project.

## Overview

API Platform provides a comprehensive testing infrastructure built on top of:

- **Codeception**: Test framework for PHP
- **API Platform Test Client**: Specialized HTTP client for API testing
- **PHPUnit Assertions**: Rich set of assertion methods
- **Test Helpers**: Custom helpers for test data management

The testing infrastructure supports both Backend and Storefront API types with dedicated base classes and configuration.

## Test tiers

Tests split into two tiers by what they cover and what they cost. Put a test in the cheapest tier that can carry it.

| Tier | Covers | Kernel | Cost per test |
|---|---|---|---|
| Logic | Provider and processor mapping, and the mapping from an error to a status. | Shared, built once per process. | About 0.4 ms, after a first boot of about 250 ms. |
| Integration | Full-stack CRUD and the real error surface: authentication to `401` and `403`, validation to `422`, serialization and the response envelope, and `?include=` compound documents. | The real stack. | About 2–3 s, after a one-time database template build. |

Both tiers resolve the system under test from the container, so both exercise the real service wiring. The difference is how far a request travels.

The logic tier stubs the collaborators a test names and calls `provide()` or `process()` directly. Nothing else is stubbed, and there is no automatic doubling—a collaborator you want to control must be registered explicitly.

The integration tier runs without Docker. The booted Glue kernel drives the real client and facade, and each client-to-Zed remote call is dispatched in-process to the gateway controller against a SQLite database, preserving the JSON round trip. Only OAuth token introspection is stubbed. Everything on the data path is real.

Authentication and input-validation negatives belong in the integration tier even though they persist nothing, because the firewall and the framework validator answer before the data layer is reached.

Do not assert on validation constraint messages or JSON structure in the logic tier. Those belong in the integration tier.

## Test architecture

### Test class hierarchy

```bash
AbstractApiTestCase (base class from core)
├── BackendApiTestCase (for Backend API tests)
└── StorefrontApiTestCase (for Storefront API tests)
```

### Key components

| Component | Purpose |
|-----------|---------|
| `AbstractApiTestCase` | Base class providing API Platform integration |
| `BackendApiTestCase` | Pre-configured for Backend API testing |
| `StorefrontApiTestCase` | Pre-configured for Storefront API testing |
| `ApiTestKernel` | Lightweight Symfony kernel for testing |
| `ApiTestAssertionsTrait` | API-specific assertions (from API Platform) |

### Test helper classes

The testing infrastructure provides specialized Codeception helpers to streamline test development:

| Helper Class | Purpose |
|--------------|---------|
| `BootstrapHelper` | Configures application plugin providers for test environments via codeception.yml. Allows different test suites to use different factory implementations without hardcoding dependencies in test infrastructure. |
| `ApiPlatformHelper` | Configures resource generation and cache lifecycle for the test kernel. Has two modes — `project` (default, preserves the compiled container for speed) and `core` (generates fresh resources per suite and cleans the container cache afterwards, used when testing the API Platform module itself). See [ApiPlatformHelper modes](#apiplatformhelper-modes). |
| `ApiPlatformConfigBuilder` | Provides a fluent interface for building test-specific API Platform configurations. Useful for creating isolated test scenarios with custom settings. |
| `ApiResourceGeneratorHelper` | Assists with testing resource generation functionality. Provides methods to generate test resources, validate generation output, and clean up generated files. |

These helpers are automatically available in your test cases through the Codeception actor and provide essential functionality for testing API Platform resources effectively.

## Setting up your test environment

### 1. Configure autoloading for generated test resources

Update your project-level `composer.json` to include the test API namespace:

`composer.json` (project root)

```json
{
    &quot;autoload-dev&quot;: {
        &quot;psr-4&quot;: {
            &quot;PyzTest\\&quot;: &quot;tests/PyzTest/&quot;,
            &quot;Generated\\TestApi\\&quot;: &quot;tests/_data/Api/&quot;
        }
    }
}
```

### 2. Optional: Configure application plugin providers

If your tests require application plugins to be registered (for example, service providers or middleware), configure the `BootstrapHelper` in your suite&apos;s `codeception.yml`:

`tests/PyzTest/Glue/Customer/BackendApi/codeception.yml`

```yaml
modules:
    enabled:
        - \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
            applicationPluginProvider:
                class: Spryker\Glue\GlueBackendApiApplication\GlueBackendApiApplicationFactory
                method: getApplicationPlugins
```

For Storefront API tests, use the appropriate factory:

`tests/PyzTest/Glue/Customer/StorefrontApi/codeception.yml`

```yaml
modules:
    enabled:
        - \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
            applicationPluginProvider:
                class: Spryker\Glue\GlueStorefrontApiApplication\GlueStorefrontApiApplicationFactory
                method: getApplicationPlugins
```

**Configuration options:**

- `class`: The fully qualified class name of the factory that provides application plugins
- `method`: The method name to call on the factory (typically `getApplicationPlugins`)

If no `applicationPluginProvider` is configured, the helper returns an empty array, and tests run without additional application plugins.

### 3. Create test directory structure

```bash
tests/
├── PyzTest/
│   └── Glue/
│       └── Customer/
│           ├── BackendApi/
│           │   ├── codeception.yml
│           │   └── CustomersBackendApiTest.php
│           └── StorefrontApi/
│               ├── codeception.yml
│               └── CustomersStorefrontApiTest.php
└── _data/
    └── Api/
        ├── Backend/
        │   └── CustomersBackendResource.php (generated)
        └── Storefront/
            └── CustomersStorefrontResource.php (generated)
```

### 4. Generate API resources for testing

The resources and the container are automatically generated right before the test suite runs.

#### Automatic resource generation and cleanup

The test infrastructure handles resource lifecycle automatically:

- **Generation** (core mode only): Test-specific API resources are generated into `tests/_data/Api/{ApiType}/` before each suite executes. In project mode, the helper instead validates that the project-generated resources already exist on disk.
- **Cleanup** (core mode only): The `ApiPlatformHelper` clears the compiled Symfony test kernel cache and the generated resources after the suite completes. Project mode deliberately skips this step so the compiled container can be reused across runs.
- **Mode selection**: Choose the mode in `codeception.yml` — see [ApiPlatformHelper modes](#apiplatformhelper-modes) below for the trade-offs.

This automation ensures that:
- Tests always run against the latest schema definitions
- No manual cache clearing is required between test runs
- Test failures related to stale cache are eliminated

## Writing Backend API tests

### Basic test structure

Backend API tests extend `BackendApiTestCase` and use the `BackendApiTester` tester which gets automatically injected into your tests by Codeception.

`tests/PyzTest/Glue/Customer/BackendApi/CustomersBackendApiTest.php`

```php
&lt;?php

namespace PyzTest\Glue\Customer\BackendApi;

use PyzTest\Glue\Customer\BackendApiTester;
use SprykerTest\Shared\ApiPlatform\Test\BackendApiTestCase;

/**
 * @group PyzTest
 * @group Glue
 * @group Customer
 * @group BackendApi
 * @group CustomersBackendApiTest
 */
class CustomersBackendApiTest extends BackendApiTestCase
{
    protected BackendApiTester $tester;

    public function testGivenValidDataWhenCreatingCustomerViaPostThenCustomerIsCreatedSuccessfully(): void
    {
        // Arrange
        $customerData = [
            &apos;email&apos; =&gt; &apos;john.doe@example.com&apos;,
            &apos;firstName&apos; =&gt; &apos;John&apos;,
            &apos;lastName&apos; =&gt; &apos;Doe&apos;,
        ];

        // Act
        static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [&apos;json&apos; =&gt; $customerData]);

        // Assert
        $this-&gt;assertResponseIsSuccessful();
        $this-&gt;assertResponseStatusCodeSame(201);
        $this-&gt;assertJsonContains([&apos;email&apos; =&gt; &apos;john.doe@example.com&apos;]);
        $this-&gt;assertJsonContains([&apos;firstName&apos; =&gt; &apos;John&apos;]);
        $this-&gt;assertJsonContains([&apos;lastName&apos; =&gt; &apos;Doe&apos;]);
    }
}
```

### Testing GET operations

#### Single resource

```php
public function testGivenExistingCustomerWhenRetrievingViaGetThenCustomerDataIsReturned(): void
{
    // Arrange
    $customerTransfer = $this-&gt;tester-&gt;haveCustomer([
        &apos;email&apos; =&gt; &apos;existing@example.com&apos;,
        &apos;firstName&apos; =&gt; &apos;Jane&apos;,
        &apos;lastName&apos; =&gt; &apos;Smith&apos;,
    ]);

    // Act
    static::createClient()-&gt;request(
        &apos;GET&apos;,
        sprintf(&apos;/customers/%s&apos;, $customerTransfer-&gt;getCustomerReference())
    );

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertJsonContains([&apos;email&apos; =&gt; &apos;existing@example.com&apos;]);
    $this-&gt;assertJsonContains([&apos;firstName&apos; =&gt; &apos;Jane&apos;]);
}
```

#### Collection with pagination

```php
public function testGivenMultipleCustomersWhenRetrievingCollectionViaGetThenAllCustomersAreReturned(): void
{
    // Arrange
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;customer1@example.com&apos;]);
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;customer2@example.com&apos;]);
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;customer3@example.com&apos;]);

    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers&apos;);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Collection&apos;]);
    $this-&gt;assertJsonContains([&apos;totalItems&apos; =&gt; 3]);
}

public function testGivenPaginationParamsWhenRetrievingCollectionThenPaginatedResultsAreReturned(): void
{
    // Arrange
    for ($i = 1; $i &lt;= 15; $i++) {
        $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; sprintf(&apos;customer%d@example.com&apos;, $i)]);
    }

    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers?page=2&amp;itemsPerPage=5&apos;);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Collection&apos;]);
    $this-&gt;assertJsonContains([&apos;view&apos; =&gt; [&apos;@id&apos; =&gt; &apos;/customers?page=2&amp;itemsPerPage=5&apos;]]);
}
```

### Testing POST operations

#### Successful creation

```php
public function testGivenValidDataWhenCreatingCustomerViaPostThenCustomerIsCreatedSuccessfully(): void
{
    // Arrange
    $customerData = [
        &apos;email&apos; =&gt; &apos;new.customer@example.com&apos;,
        &apos;firstName&apos; =&gt; &apos;New&apos;,
        &apos;lastName&apos; =&gt; &apos;Customer&apos;,
    ];

    // Act
    $response = static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [
        &apos;json&apos; =&gt; $customerData,
    ]);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertResponseStatusCodeSame(201);
    $this-&gt;assertJsonContains($customerData);
    $this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/ld+json; charset=utf-8&apos;);

    // Verify the resource was created and has an ID
    $responseData = $response-&gt;toArray();
    $this-&gt;assertArrayHasKey(&apos;customerReference&apos;, $responseData);
    $this-&gt;assertNotEmpty($responseData[&apos;customerReference&apos;]);
}
```

#### Validation errors

```php
public function testGivenInvalidDataWhenCreatingCustomerViaPostThenValidationErrorIsReturned(): void
{
    // Arrange
    $invalidCustomerData = [
        &apos;email&apos; =&gt; &apos;invalid-email&apos;,  // Invalid email format
        &apos;firstName&apos; =&gt; &apos;&apos;,            // Empty first name
    ];

    // Act
    static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [
        &apos;json&apos; =&gt; $invalidCustomerData,
    ]);

    // Assert
    $this-&gt;assertResponseStatusCodeSame(422);
    $this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/ld+json; charset=utf-8&apos;);
    $this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;ConstraintViolationList&apos;]);
    $this-&gt;assertJsonContains([
        &apos;violations&apos; =&gt; [
            [&apos;propertyPath&apos; =&gt; &apos;email&apos;],
            [&apos;propertyPath&apos; =&gt; &apos;firstName&apos;],
            [&apos;propertyPath&apos; =&gt; &apos;lastName&apos;],
        ],
    ]);
}
```

#### Business rule violations

```php
public function testGivenDuplicateEmailWhenCreatingCustomerViaPostThenErrorIsReturned(): void
{
    // Arrange
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;duplicate@example.com&apos;]);

    $duplicateData = [
        &apos;email&apos; =&gt; &apos;duplicate@example.com&apos;,
        &apos;firstName&apos; =&gt; &apos;Duplicate&apos;,
        &apos;lastName&apos; =&gt; &apos;Customer&apos;,
    ];

    // Act
    static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [
        &apos;json&apos; =&gt; $duplicateData,
    ]);

    // Assert
    $this-&gt;assertResponseStatusCodeSame(422);
    $this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Error&apos;]);
    $this-&gt;assertJsonContains([&apos;detail&apos; =&gt; &apos;Customer with this email already exists&apos;]);
}
```

### Testing PATCH operations

```php
public function testGivenExistingCustomerWhenUpdatingViaPatchThenCustomerIsUpdatedSuccessfully(): void
{
    // Arrange
    $customerTransfer = $this-&gt;tester-&gt;haveCustomer([
        &apos;email&apos; =&gt; &apos;update@example.com&apos;,
        &apos;firstName&apos; =&gt; &apos;Original&apos;,
        &apos;lastName&apos; =&gt; &apos;Name&apos;,
    ]);

    $updateData = [
        &apos;firstName&apos; =&gt; &apos;Updated&apos;,
        &apos;lastName&apos; =&gt; &apos;Name&apos;,
    ];

    // Act
    static::createClient()-&gt;request(
        &apos;PATCH&apos;,
        sprintf(&apos;/customers/%s&apos;, $customerTransfer-&gt;getCustomerReference()),
        [
            &apos;json&apos; =&gt; $updateData,
            &apos;headers&apos; =&gt; [
                &apos;Content-Type&apos; =&gt; &apos;application/merge-patch+json&apos;,
            ],
        ]
    );

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertJsonContains([&apos;firstName&apos; =&gt; &apos;Updated&apos;]);
    $this-&gt;assertJsonContains([&apos;email&apos; =&gt; &apos;update@example.com&apos;]); // Unchanged
}
```

### Testing DELETE operations

```php
public function testGivenExistingCustomerWhenDeletingViaDeleteThenCustomerIsDeletedSuccessfully(): void
{
    // Arrange
    $customerTransfer = $this-&gt;tester-&gt;haveCustomer([
        &apos;email&apos; =&gt; &apos;delete@example.com&apos;,
    ]);

    // Act
    static::createClient()-&gt;request(
        &apos;DELETE&apos;,
        sprintf(&apos;/customers/%s&apos;, $customerTransfer-&gt;getCustomerReference())
    );

    // Assert
    $this-&gt;assertResponseStatusCodeSame(204);
    $this-&gt;assertResponseHasNoContent();
}

public function testGivenNonExistentCustomerWhenDeletingViaDeleteThen404IsReturned(): void
{
    // Act
    static::createClient()-&gt;request(&apos;DELETE&apos;, &apos;/customers/NON-EXISTENT-REFERENCE&apos;);

    // Assert
    $this-&gt;assertResponseStatusCodeSame(404);
}
```

### Testing relationships

The relationships feature enables resources to include related resources via the `?include=` query parameter. For details on configuring relationships, see [Resource relationships](/docs/integrations/spryker-api/api-platform/relationships.html).

#### Testing include parameter

```php
public function testGivenCustomerWithAddressesWhenRequestingWithIncludeThenAddressesAreIncluded(): void
{
    // Arrange
    $customerTransfer = $this-&gt;tester-&gt;haveCustomer();
    $this-&gt;tester-&gt;haveAddress([&apos;customerReference&apos; =&gt; $customerTransfer-&gt;getCustomerReference()]);
    $this-&gt;tester-&gt;haveAddress([&apos;customerReference&apos; =&gt; $customerTransfer-&gt;getCustomerReference()]);

    // Act
    $response = static::createClient()-&gt;request(
        &apos;GET&apos;,
        sprintf(&apos;/customers/%s?include=addresses&apos;, $customerTransfer-&gt;getCustomerReference())
    );

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $data = $response-&gt;toArray();

    // Assert relationships section exists
    $this-&gt;assertArrayHasKey(&apos;relationships&apos;, $data[&apos;data&apos;]);
    $this-&gt;assertArrayHasKey(&apos;addresses&apos;, $data[&apos;data&apos;][&apos;relationships&apos;]);

    // Assert included section contains addresses
    $this-&gt;assertArrayHasKey(&apos;included&apos;, $data);
    $this-&gt;assertCount(2, $data[&apos;included&apos;]);
}
```

#### Testing JSON:API structure

```php
public function testGivenIncludedResourcesWhenRetrievingThenJsonApiStructureIsValid(): void
{
    // Arrange
    $customerTransfer = $this-&gt;tester-&gt;haveCustomer();
    $this-&gt;tester-&gt;haveAddress([&apos;customerReference&apos; =&gt; $customerTransfer-&gt;getCustomerReference()]);

    // Act
    $response = static::createClient()-&gt;request(
        &apos;GET&apos;,
        sprintf(&apos;/customers/%s?include=addresses&apos;, $customerTransfer-&gt;getCustomerReference())
    );

    // Assert
    $data = $response-&gt;toArray();

    // Verify main resource structure
    $this-&gt;assertArrayHasKey(&apos;data&apos;, $data);
    $this-&gt;assertArrayHasKey(&apos;type&apos;, $data[&apos;data&apos;]);
    $this-&gt;assertArrayHasKey(&apos;id&apos;, $data[&apos;data&apos;]);
    $this-&gt;assertArrayHasKey(&apos;attributes&apos;, $data[&apos;data&apos;]);
    $this-&gt;assertArrayHasKey(&apos;relationships&apos;, $data[&apos;data&apos;]);

    // Verify included resources structure
    foreach ($data[&apos;included&apos;] as $includedResource) {
        $this-&gt;assertArrayHasKey(&apos;type&apos;, $includedResource);
        $this-&gt;assertArrayHasKey(&apos;id&apos;, $includedResource);
        $this-&gt;assertArrayHasKey(&apos;attributes&apos;, $includedResource);
    }

    // Verify relationship linkage
    $relationshipData = $data[&apos;data&apos;][&apos;relationships&apos;][&apos;addresses&apos;][&apos;data&apos;];
    foreach ($relationshipData as $linkage) {
        $this-&gt;assertArrayHasKey(&apos;type&apos;, $linkage);
        $this-&gt;assertArrayHasKey(&apos;id&apos;, $linkage);
    }
}
```

## Writing Storefront API tests

### Basic test structure

Storefront API tests extend `StorefrontApiTestCase` and typically use mocks for read-only operations.

`tests/PyzTest/Glue/Customer/StorefrontApi/CustomersStorefrontApiTest.php`

```php
&lt;?php

namespace PyzTest\Glue\Customer\StorefrontApi;

use Codeception\Stub;
use Pyz\Client\Customer\CustomerClientInterface;
use PyzTest\Glue\Customer\StorefrontApiTester;
use SprykerTest\Shared\ApiPlatform\Test\StorefrontApiTestCase;

/**
 * @group PyzTest
 * @group Glue
 * @group Customer
 * @group StorefrontApi
 * @group CustomersStorefrontApiTest
 */
class CustomersStorefrontApiTest extends StorefrontApiTestCase
{
    protected StorefrontApiTester $tester;

    public function testGivenAuthenticatedCustomerWhenRetrievingProfileViaGetThenCustomerDataIsReturned(): void
    {
        // Arrange
        $customerClientStub = Stub::makeEmpty(CustomerClientInterface::class, [
            &apos;getCustomer&apos; =&gt; (new CustomerTransfer())
                -&gt;setEmail(&apos;customer@example.com&apos;)
                -&gt;setFirstName(&apos;John&apos;)
                -&gt;setLastName(&apos;Doe&apos;),
        ]);

        static::getContainer()-&gt;set(CustomerClientInterface::class, $customerClientStub);

        // Act
        static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers/me&apos;);

        // Assert
        $this-&gt;assertResponseIsSuccessful();
        $this-&gt;assertJsonContains([&apos;email&apos; =&gt; &apos;customer@example.com&apos;]);
    }
}
```

### Testing with service mocks

```php
public function testGivenMultipleCustomersWhenRetrievingCollectionViaGetThenAllCustomersAreReturned(): void
{
    // Arrange
    $customerClientStub = Stub::makeEmpty(CustomerClientInterface::class, [
        &apos;getCustomerCollection&apos; =&gt; [
            (new CustomerTransfer())-&gt;setEmail(&apos;customer1@example.com&apos;),
            (new CustomerTransfer())-&gt;setEmail(&apos;customer2@example.com&apos;),
        ],
    ]);

    static::getContainer()-&gt;set(CustomerClientInterface::class, $customerClientStub);

    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers&apos;);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Collection&apos;]);
}
```

## Available assertions

### HTTP response assertions

```php
// Status codes
$this-&gt;assertResponseIsSuccessful();        // 2xx status code
$this-&gt;assertResponseStatusCodeSame(200);   // Exact status code
$this-&gt;assertResponseStatusCodeSame(201);   // Created
$this-&gt;assertResponseStatusCodeSame(204);   // No content
$this-&gt;assertResponseStatusCodeSame(400);   // Bad request
$this-&gt;assertResponseStatusCodeSame(401);   // Unauthorized
$this-&gt;assertResponseStatusCodeSame(403);   // Forbidden
$this-&gt;assertResponseStatusCodeSame(404);   // Not found
$this-&gt;assertResponseStatusCodeSame(422);   // Validation error

// Headers
$this-&gt;assertResponseHasHeader(&apos;Content-Type&apos;);
$this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/ld+json; charset=utf-8&apos;);
$this-&gt;assertResponseHeaderNotSame(&apos;X-Custom-Header&apos;, &apos;value&apos;);

// Content
$this-&gt;assertResponseHasNoContent();        // Empty response body
```

### JSON assertions

```php
// Content matching
$this-&gt;assertJsonContains([&apos;email&apos; =&gt; &apos;test@example.com&apos;]);
$this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Customer&apos;]);
$this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Collection&apos;]);

// Array keys
$responseData = $response-&gt;toArray();
$this-&gt;assertArrayHasKey(&apos;customerReference&apos;, $responseData);
$this-&gt;assertArrayNotHasKey(&apos;password&apos;, $responseData);

// Validation violations
$this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;ConstraintViolationList&apos;]);
$this-&gt;assertJsonContains([
    &apos;violations&apos; =&gt; [
        [&apos;propertyPath&apos; =&gt; &apos;email&apos;],
    ],
]);

// Collection metadata
$this-&gt;assertJsonContains([&apos;totalItems&apos; =&gt; 10]);
$this-&gt;assertJsonContains([&apos;view&apos; =&gt; [&apos;@id&apos; =&gt; &apos;/customers?page=1&apos;]]);
```

### Custom API Platform assertions

```php
// JSON-LD context
$this-&gt;assertJsonContains([&apos;@context&apos; =&gt; &apos;/contexts/Customer&apos;]);

// Hydra collections
$this-&gt;assertJsonContains([&apos;hydra:totalItems&apos; =&gt; 5]);
$this-&gt;assertJsonContains([&apos;hydra:member&apos; =&gt; []]);

// IRI matching
$iri = $this-&gt;getIriFromResource($resource);
$this-&gt;assertMatchesRegularExpression(&apos;~^/customers/[A-Z0-9\-]+$~&apos;, $iri);
```

## Test data management

### Using Codeception helpers

Create test data using your project&apos;s tester helpers:

```php
// Create a customer
$customerTransfer = $this-&gt;tester-&gt;haveCustomer([
    &apos;email&apos; =&gt; &apos;test@example.com&apos;,
    &apos;firstName&apos; =&gt; &apos;John&apos;,
    &apos;lastName&apos; =&gt; &apos;Doe&apos;,
]);

// Create multiple customers
for ($i = 1; $i &lt;= 10; $i++) {
    $this-&gt;tester-&gt;haveCustomer([
        &apos;email&apos; =&gt; sprintf(&apos;customer%d@example.com&apos;, $i),
    ]);
}
```

### Cleanup strategies

#### Automatic cleanup (default)

The test kernel automatically cleans up after each test. No manual cleanup needed.

#### Manual cleanup (when needed)

```php
protected function tearDown(): void
{
    // Custom cleanup logic
    $this-&gt;tester-&gt;cleanupCustomers();

    parent::tearDown();
}
```

## Testing different media types

### JSON-LD (default)

```php
public function testJsonLdFormat(): void
{
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers&apos;, [
        &apos;headers&apos; =&gt; [
            &apos;Accept&apos; =&gt; &apos;application/ld+json&apos;,
        ],
    ]);

    $this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/ld+json; charset=utf-8&apos;);
    $this-&gt;assertJsonContains([&apos;@context&apos; =&gt; &apos;/contexts/Customer&apos;]);
}
```

### JSON:API

```php
public function testJsonApiFormat(): void
{
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers&apos;, [
        &apos;headers&apos; =&gt; [
            &apos;Accept&apos; =&gt; &apos;application/vnd.api+json&apos;,
        ],
    ]);

    $this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/vnd.api+json; charset=utf-8&apos;);
    $this-&gt;assertJsonContains([&apos;data&apos; =&gt; [&apos;type&apos; =&gt; &apos;Customer&apos;]]);
}
```

### HAL+JSON

```php
public function testHalJsonFormat(): void
{
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers&apos;, [
        &apos;headers&apos; =&gt; [
            &apos;Accept&apos; =&gt; &apos;application/hal+json&apos;,
        ],
    ]);

    $this-&gt;assertResponseHeaderSame(&apos;Content-Type&apos;, &apos;application/hal+json; charset=utf-8&apos;);
    $this-&gt;assertJsonContains([&apos;_links&apos; =&gt; [&apos;self&apos; =&gt; [&apos;href&apos; =&gt; &apos;/customers&apos;]]]);
}
```

## Advanced testing patterns

### Testing with filters

```php
public function testGivenFilterParamsWhenRetrievingCollectionThenFilteredResultsAreReturned(): void
{
    // Arrange
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;active@example.com&apos;, &apos;status&apos; =&gt; &apos;active&apos;]);
    $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;inactive@example.com&apos;, &apos;status&apos; =&gt; &apos;inactive&apos;]);

    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers?status=active&apos;);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $responseData = static::createClient()-&gt;getResponse()-&gt;toArray();
    $this-&gt;assertCount(1, $responseData[&apos;hydra:member&apos;]);
}
```

### Testing sorting

```php
public function testGivenSortParamsWhenRetrievingCollectionThenSortedResultsAreReturned(): void
{
    // Arrange
    $this-&gt;tester-&gt;haveCustomer([&apos;lastName&apos; =&gt; &apos;Zulu&apos;]);
    $this-&gt;tester-&gt;haveCustomer([&apos;lastName&apos; =&gt; &apos;Alpha&apos;]);
    $this-&gt;tester-&gt;haveCustomer([&apos;lastName&apos; =&gt; &apos;Bravo&apos;]);

    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers?order[lastName]=asc&apos;);

    // Assert
    $this-&gt;assertResponseIsSuccessful();
    $responseData = static::createClient()-&gt;getResponse()-&gt;toArray();
    $members = $responseData[&apos;hydra:member&apos;];

    $this-&gt;assertEquals(&apos;Alpha&apos;, $members[0][&apos;lastName&apos;]);
    $this-&gt;assertEquals(&apos;Bravo&apos;, $members[1][&apos;lastName&apos;]);
    $this-&gt;assertEquals(&apos;Zulu&apos;, $members[2][&apos;lastName&apos;]);
}
```

### Testing error scenarios

```php
public function testGivenMalformedJsonWhenCreatingCustomerViaPostThenBadRequestIsReturned(): void
{
    // Act
    static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [
        &apos;body&apos; =&gt; &apos;{invalid-json}&apos;,
        &apos;headers&apos; =&gt; [
            &apos;Content-Type&apos; =&gt; &apos;application/json&apos;,
        ],
    ]);

    // Assert
    $this-&gt;assertResponseStatusCodeSame(400);
}

public function testGivenUnauthorizedRequestWhenAccessingProtectedResourceThen401IsReturned(): void
{
    // Act
    static::createClient()-&gt;request(&apos;GET&apos;, &apos;/customers/me&apos;);

    // Assert
    $this-&gt;assertResponseStatusCodeSame(401);
}
```

## Running tests

Both tiers run on your host without Docker and without any running service.

### Generate the code the suites need

The suites depend on generated code that is not in version control. Run this once per checkout, and again after any schema change:

```bash
vendor/bin/console transfer:generate
vendor/bin/console transfer:databuilder:generate
vendor/bin/console propel:schema:copy
vendor/bin/console propel:model:build
vendor/bin/console transfer:entity:generate
vendor/bin/console search:setup:source-map
GLUE_APPLICATION=GLUE_STOREFRONT vendor/bin/glue api:generate
vendor/bin/console testify:build:sqlite-template
```

The order matters. Entity transfers derive from the merged Propel schema, so the schema copy and the model build come first.

`transfer:databuilder:generate` and `testify:build:sqlite-template` are registered only when development console commands are enabled:

```bash
DEVELOPMENT_CONSOLE_COMMANDS=1
```

`search:setup:source-map` writes the `Generated\Shared\Search\*IndexMap` classes. Only search-backed resources need them, but the catalog query plugins reference them while the query is being built, so a missing map is a fatal error rather than an empty result.

`api:generate` is a Glue console command, so use `vendor/bin/glue` and not `vendor/bin/console`. It removes `src/Generated/Api/{ApiType}` before it parses anything, and `--dry-run` does not suppress that. An interrupted run therefore leaves no resources behind, and every test fails with `Class &quot;Generated\Api\Storefront\…Resource&quot; not found`. Re-run the command to recover, and pass `--keep-existing` when you only want to inspect the output.

`testify:build:sqlite-template` leaves an existing template alone. Pass `--force` to drop and rebuild it after a Zed schema change, or `--path` to build it somewhere other than the configured default. A suite run rebuilds a missing template on its own, so calling it explicitly only front-loads the cost or forces a rebuild.

### Run a suite

```bash
APPLICATION_ENV=devtest vendor/bin/codecept run -c tests/PyzTest/Glue/Wishlists/codeception.yml
```

### Enable the test container

Both tiers resolve services from the container, which needs Symfony&apos;s test container. Turn `framework.test` on for the environment the suites run in—it is configured per application in `config/&lt;App&gt;/packages/framework.php`. Without it, the suites fail with `Could not find service &quot;test.service_container&quot;`.

The container is compiled on first use and then cached, which takes roughly 45 seconds. That is a one-time cost per checkout and after any change to configuration or generated resources. Run the suites once after regenerating and the compile is behind you.

### Rebuild the class-resolver cache after adding a project override

`src/Generated/Shared/Kernel/Pyz/resolvableClassCache*.php` maps every resolvable class to the winning namespace, and it is read in preference to live resolution. It never expires, so a `Pyz` class added after the cache was written is silently ignored and the module resolves to the core class. There is no error—just core behavior where your project&apos;s should be. After adding an override, run:

```bash
vendor/bin/console cache:class-resolver:build
```

CI runs from a bare checkout where the file is absent and resolution is live, so this affects local runs only.

### In CI

The suites are found by convention. Every `tests/PyzTest/Glue/*/codeception.yml` that declares a `StorefrontApiLogic` or a `StorefrontApiIntegration` suite is picked up, one process per module configuration.

Use exactly those two suite names. A new module then needs no workflow change. Use different names and the suites either run nowhere or land in the Docker lane, which they cannot survive.

One process per module configuration is a requirement, not a preference. These suites boot the Glue kernel in-process and need `APPLICATION=GLUE_STOREFRONT`. The constant is process-global and first-wins, so a suite sharing a process with the Docker Glue lane inherits whatever that lane set.

### Running inside Docker

Test suites that predate the host lane still run through the Docker SDK:

```bash
# Run Backend API tests only
docker/sdk cli vendor/bin/codecept run -c path/to/codeception.yml -g BackendApi

# Run Storefront API tests only
docker/sdk cli vendor/bin/codecept run -c path/to/codeception.yml -g StorefrontApi
```

## Codeception configuration

### Suite configuration

Configure your test suite&apos;s `codeception.yml` to enable the necessary helpers:

`tests/PyzTest/Glue/Customer/BackendApi/codeception.yml`

```yaml
suite_namespace: PyzTest\Glue\Customer\BackendApi

actor: BackendApiTester

modules:
    enabled:
        - \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
            applicationPluginProvider:
                class: Spryker\Glue\GlueBackendApiApplication\GlueBackendApiApplicationFactory
                method: getApplicationPlugins

paths:
    tests: .
    data: ../../../../../_data
    support: _support
    output: ../../../../../_output

settings:
    bootstrap: _bootstrap.php
    colors: true
    memory_limit: 1024M
```

**Key configuration points:**

- **BootstrapHelper**: Provides application plugins for the test kernel. This is optional and can be omitted if your tests do not require application-level dependencies.
- **suite_namespace**: Must match your test suite&apos;s PHP namespace
- **actor**: The tester class name (for example, `BackendApiTester`, `StorefrontApiTester`)

### Wire a suite with an umbrella helper

Each tier needs a stack of helpers in a specific order. Rather than repeating that stack in every suite, enable one umbrella helper that registers it:

```yaml
modules:
    enabled:
        - \SprykerTest\ApiPlatform\Helper\StorefrontApiIntegrationHelper:
              environmentModule: &apos;\PyzTest\Shared\Testify\Helper\Environment&apos;
              projectNamespaces: [&apos;Pyz&apos;]
              publish: true
        - \PyzTest\Glue\Wishlists\Helper\WishlistsApiHelper
        - \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
              bootOnce: true
              reuseApplicationContainer: true
```

`StorefrontApiIntegrationHelper` registers the integration stack: the database lane, bootstrap, locator, configuration, dependency and data cleanup, transactions, processor resolution, and the in-process Zed transport. Setting `publish: true` adds the in-process publish leg. `StorefrontApiLogicHelper` does the same for the logic tier—container resolution only, with no database and no HTTP.

Two keys are yours to supply, because the core helper cannot know them:

- `environmentModule` is required. It points at the project helper that defines the `APPLICATION` constants. The umbrella creates it in the one position it must occupy—after the bootstrap and before the locator freezes the configuration—which a plain entry in your `enabled` list could not guarantee.
- `projectNamespaces` tells the class resolver about your project namespace. Without it, a module your project overrides resolves to the Spryker base class and silently loses every plugin you registered.

Three rules govern the list:

- Enable the umbrella helper **first**.
- Keep `ApiPlatformHelper` **last**. Codeception runs `_afterSuite` in reverse order, and the kernel reset has to happen before the database lane deletes its work database.
- Never re-list one of the umbrella&apos;s own child helpers after it. Codeception creates the child a second time and silently discards the configuration forwarded to the replaced instance.

Per-child configuration overrides go under `modules: config:`. The exceptions are `application`, `projectNamespaces`, `applicationPluginProvider`, and `environmentModule`, which are umbrella configuration keys—setting them on a child has no effect, because the umbrella overwrites them.

Do not enable `ContainerHelper` in an integration suite. Its `_after()` nulls the shared container delegator whenever its container was touched, and the database-fixture path touches it. That discards the compiled container between methods, so every method after the first fails with a null-container `TypeError`. The logic tier keeps `ContainerHelper`, because its container stays untouched.

### ApiPlatformHelper modes

`ApiPlatformHelper` runs in one of two modes, selected in the suite&apos;s `codeception.yml`:

```yaml
modules:
    enabled:
        - \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
            mode: &apos;project&apos;      # default; or &apos;core&apos; for module-level tests
```

| Mode | Use this when | Before suite | After suite |
|---|---|---|---|
| `project` (default) | Testing your own project&apos;s API resources end-to-end. | Validates that the project-generated resources in `src/Generated/Api/` exist. Skips generation. | Does nothing — the compiled container is preserved across runs for fast subsequent invocations. |
| `core` | Testing the `ApiPlatform` module itself (or any module that ships its own schemas in isolation from a project). | Generates fresh resources into `tests/_data/Api/{ApiType}/`. Requires `apiType` to be set on the helper. | Removes the generated resources and clears the compiled test kernel cache so the next suite starts from a clean slate. |

Use `project` mode for almost all real-world test suites — it is significantly faster because the compiled Symfony container is reused. Reach for `core` mode only when you intentionally want each suite to regenerate resources from scratch (typical when testing schema generation or a single module without a project around it).

When using `core` mode, declare which API type the suite exercises so the helper knows what to generate:

```yaml
modules:
    enabled:
        - \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
            mode: &apos;core&apos;
            apiType: &apos;Storefront&apos;   # or &apos;Backend&apos;
```

### Fast-path configuration keys

These keys are all opt-in. Omitting one keeps the slower per-method boot with debug on.

```yaml
- \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
      mode: &apos;project&apos;                  # or &apos;core&apos;
      apiType: &apos;Storefront&apos;            # or &apos;Backend&apos;
      debug: false                     # Symfony debug off on warm resources; default true
      bootOnce: true                   # one kernel per suite, reset between methods; default false
      reuseApplicationContainer: true  # keep the container delegator singleton; default false
```

With `bootOnce`, the kernel is built once per process rather than once per test method. The container is still reset between methods, which is what lets each method bind its own stubs—a service already bound in the container cannot be replaced.

#### Register stubs before you resolve the system under test

Call `setService($id, $stub)` **before** the first `createClient()`, `getTestKernel()`, `getProcessor()`, or `getProvider()` in a test method. Stubs are bound when the kernel is taken for that method, so registering an ID after that point has no effect, and the same ID cannot be re-bound within one method once the container holds it.

The container knows nothing about stubs at compile time. They are bound afterwards through Symfony&apos;s test container.

### Infrastructure stand-in helpers

The host lane has no Redis, no Elasticsearch, and nothing draining the queue. These helpers put a real substitute behind each one, so the code above them runs unchanged. None of them stubs the resource under test.

| Helper | Stands in for | Provides |
|---|---|---|
| `\SprykerTest\Client\StorageDatabase\Helper\SqliteStorageHelper` | Redis, read side | Points the Storage client at the storage-database plugin, so reads hit the `spy_*_storage` tables of the lane&apos;s SQLite database. Configuration only, no methods. |
| `\SprykerTest\Zed\Publisher\Helper\PublishHelper` | The queue | `publishPendingEvents()` drains what the test just created. `publishEntities($eventName, $ids)` publishes rows that never raised an event. The synchronization leg stays off, because it only pushes into Redis. |
| `\SprykerTest\Shared\Testify\Helper\StorageCacheHelper` | — | `resetStorageCaches()`, plus a reset before every test. Storage clients memoize in statics that survive a container reset, so a read taken before the arrange step otherwise pins the empty result for the whole process. |
| `\SprykerTest\Client\Search\Helper\SearchResponseStubHelper` | Elasticsearch | `stubSearchResult(array $formattedSearchResult)` takes over the search-adapter plugin list. Everything above the adapter runs for real, so the array is the post-formatting result and not a raw Elasticsearch body. |
| `\SprykerTest\Client\Queue\Helper\QueueHelper` | — | Backs the publish leg&apos;s in-memory queue. Set `application: Zed` for a `Glue`-namespaced suite: without it, the configuration resolver guesses the application from the suite namespace, guesses `Glue`, and finds no queue configuration. The umbrella helper forces this when `publish: true`. |

A storage resource whose name does not derive its table as `spy_&lt;resource&gt;_storage` needs an entry in `SprykerTest\Client\StorageDatabase\Sqlite\SqliteStorageDatabaseConfig`. Two exist by default: `translation` maps to `spy_glossary_storage`, and `product_search_config_extension` maps to `spy_product_search_config_storage`.

Miss the `translation` entry and *every* error response becomes `PDOException: no such table`, because the error provider translates its message before rendering. The reported exception then has nothing to do with the actual failure.

### Helper classes

Create helper classes to manage test data:

`tests/PyzTest/Glue/Customer/Helper/CustomerHelper.php`

```php
&lt;?php

namespace PyzTest\Glue\Customer\Helper;

use Codeception\Module;
use Generated\Shared\Transfer\CustomerTransfer;
use Pyz\Zed\Customer\Business\CustomerFacadeInterface;

class CustomerHelper extends Module
{
    public function haveCustomer(array $seed = []): CustomerTransfer
    {
        $customerTransfer = (new CustomerTransfer())
            -&gt;fromArray($seed, true)
            -&gt;setEmail($seed[&apos;email&apos;] ?? sprintf(&apos;customer-%s@example.com&apos;, uniqid()))
            -&gt;setFirstName($seed[&apos;firstName&apos;] ?? &apos;Test&apos;)
            -&gt;setLastName($seed[&apos;lastName&apos;] ?? &apos;Customer&apos;);

        return $this-&gt;getCustomerFacade()-&gt;createCustomer($customerTransfer);
    }

    protected function getCustomerFacade(): CustomerFacadeInterface
    {
        return $this-&gt;getModule(&apos;\\PyzTest\\Shared\\Testify\\Helper\\Environment&apos;)
            -&gt;getFacade(&apos;Customer&apos;);
    }
}
```

## Best practices

### 1. Use descriptive test method names

```php
// ✅ Good
public function testGivenInvalidEmailWhenCreatingCustomerViaPostThenValidationErrorIsReturned(): void

// ❌ Bad
public function testCreate(): void
```

### 2. Follow Arrange-Act-Assert pattern

```php
public function testExample(): void
{
    // Arrange - Set up test data and preconditions
    $data = [&apos;email&apos; =&gt; &apos;test@example.com&apos;];

    // Act - Execute the operation being tested
    static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [&apos;json&apos; =&gt; $data]);

    // Assert - Verify the results
    $this-&gt;assertResponseIsSuccessful();
}
```

### 3. Test one thing per test

```php
// ✅ Good - Tests one specific validation rule
public function testGivenMissingEmailWhenCreatingCustomerThenValidationErrorIsReturned(): void
{
    static::createClient()-&gt;request(&apos;POST&apos;, &apos;/customers&apos;, [&apos;json&apos; =&gt; []]);
    $this-&gt;assertJsonContains([&apos;violations&apos; =&gt; [[&apos;propertyPath&apos; =&gt; &apos;email&apos;]]]);
}

// ❌ Bad - Tests multiple unrelated things
public function testCustomerCreation(): void
{
    // Tests validation, creation, retrieval, update all in one test
}
```

### 4. Use meaningful test data

```php
// ✅ Good
$customerData = [
    &apos;email&apos; =&gt; &apos;john.doe@example.com&apos;,  // Realistic email
    &apos;firstName&apos; =&gt; &apos;John&apos;,               // Realistic name
    &apos;lastName&apos; =&gt; &apos;Doe&apos;,
];

// ❌ Bad
$customerData = [
    &apos;email&apos; =&gt; &apos;a@b.c&apos;,    // Not realistic
    &apos;firstName&apos; =&gt; &apos;x&apos;,     // Not meaningful
    &apos;lastName&apos; =&gt; &apos;y&apos;,
];
```

### 5. Clean up test data appropriately

```php
// For Backend API tests - use tester helpers for setup
$customer = $this-&gt;tester-&gt;haveCustomer([&apos;email&apos; =&gt; &apos;test@example.com&apos;]);

// Cleanup happens automatically via test kernel shutdown
```

### 6. Test error cases

```php
// Always test both success and failure scenarios
public function testSuccessfulCreation(): void { /* ... */ }
public function testValidationErrors(): void { /* ... */ }
public function testDuplicateEmail(): void { /* ... */ }
public function testNotFound(): void { /* ... */ }
```

### 7. Use constants for repeated values

```php
class CustomersBackendApiTest extends BackendApiTestCase
{
    private const TEST_EMAIL = &apos;test@example.com&apos;;
    private const TEST_FIRST_NAME = &apos;John&apos;;

    public function testExample(): void
    {
        $data = [
            &apos;email&apos; =&gt; self::TEST_EMAIL,
            &apos;firstName&apos; =&gt; self::TEST_FIRST_NAME,
        ];
        // ...
    }
}
```

### 8. Group related tests

```php
/**
 * @group PyzTest
 * @group Glue
 * @group Customer
 * @group BackendApi
 * @group CustomersBackendApiTest
 * @group ValidationTests
 */
class CustomersBackendApiTest extends BackendApiTestCase
{
    // Run only validation tests:
    // vendor/bin/codecept run -g ValidationTests
}
```

### 9. Provision fixtures through data helpers

Build transfers through data builders, reached through a module&apos;s own helper. Never hand-roll a transfer in a test, and never let a test carry its own UUIDs, names, or counts.

A test owns the data it asserts against. In the integration tier, that means creating the products, customers, and carts the scenario needs rather than relying on data another test or an installer left behind.

Module-owned data and assertions belong in that module&apos;s helper, shipped from the core module so that projects can enable it. For example, a wishlist helper builds the transfers a stubbed client returns and asserts a resource against them:

```php
$wishlistTransfer = $this-&gt;tester-&gt;haveWishlistTransfer();   // non-persisting, logic tier
$wishlistTransfer = $this-&gt;tester-&gt;haveWishlist();           // DB-backed, integration tier
```

The authenticated customer transfer comes from the core customer helper: `haveCustomerTransfer()` for the logic tier and `haveCustomer()` for the integration tier. The API test lanes carry no customer code of their own.

## Troubleshooting

### Generated resources not found

**Problem:** Test fails with &quot;Class not found&quot; for generated resource.

**Solution:**

1. Verify autoload configuration in `composer.json`:

```json
{
    &quot;autoload-dev&quot;: {
        &quot;psr-4&quot;: {
            &quot;PyzTest\\&quot;: &quot;tests/PyzTest/&quot;,
            &quot;Generated\\TestApi\\&quot;: &quot;tests/_data/Api/&quot;
        }
    }
}
```

2. Run composer dump-autoload:

```bash
docker/sdk cli composer dump-autoload
```

### Test kernel boot failures

**Problem:** Tests fail with kernel boot errors.

**Solution:**

Ensure your test case extends the correct base class:

```php
// For Backend API
use PyzTest\Shared\ApiPlatform\Test\BackendApiTestCase;

class CustomersBackendApiTest extends BackendApiTestCase
{
    // ...
}

// For Storefront API
use PyzTest\Shared\ApiPlatform\Test\StorefrontApiTestCase;

class CustomersStorefrontApiTest extends StorefrontApiTestCase
{
    // ...
}
```

### Assertion failures with JSON-LD

**Problem:** JSON assertions fail with `@context` or `@type` fields.

**Solution:**

Use JSON-LD specific assertions:

```php
// ✅ Correct
$this-&gt;assertJsonContains([&apos;@type&apos; =&gt; &apos;Customer&apos;]);
$this-&gt;assertJsonContains([&apos;@context&apos; =&gt; &apos;/contexts/Customer&apos;]);

// ❌ Wrong
$this-&gt;assertJsonContains([&apos;type&apos; =&gt; &apos;Customer&apos;]);
```

### Tester helper not found

**Problem:** `$this-&gt;tester` property shows as undefined.

**Solution:**

1. Verify your tester class exists in the correct location
2. Check that the tester is properly type-hinted in your test:

```php
class CustomersBackendApiTest extends BackendApiTestCase
{
    protected BackendApiTester $tester;  // Must be declared
}
```

3. Rebuild Codeception actors:

```bash
docker/sdk cli vendor/bin/codecept build
```

## Next steps

- [Prove API Platform contract coverage](/docs/integrations/spryker-api/api-platform/contract-coverage.html) - Declare what your tests cover and gate it in CI
- [Implement an API Platform resource](/docs/integrations/spryker-api/api-platform/enablement.html) - Creating API resources
- [Resource schemas](/docs/integrations/spryker-api/api-platform/resource-schemas.html) - Resource schema reference
- [Validation schemas](/docs/integrations/spryker-api/api-platform/validation-schemas.html) - Validation schema reference
- [Troubleshooting](/docs/integrations/spryker-api/api-platform/troubleshooting.html) - Common issues and solutions
- [Codeception Documentation](https://codeception.com/docs/Introduction) - Codeception framework docs
- [Test API Platform resources](https://api-platform.com/docs/symfony/testing/) - Official API Platform testing guide
</description>
            <pubDate>Mon, 17 Aug 2026 09:46:16 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/spryker-api/api-platform/testing.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/spryker-api/api-platform/testing.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}
```

In a JSON:API response, the identifier is what fills `data.id`. It is therefore not an attribute, and combining `identifier: true` with `readable: false` keeps it out of the `attributes` block:

```yaml
uuid:
  type: string
  identifier: true
  readable: false
  writable: false
```

That combination is what reproduces the legacy Glue response shape, where the UUID appears as `data.id` and never inside `attributes`. Leave `readable` at its default and the same value is serialized twice—once as `data.id` and once as an attribute—which is a breaking change for clients that were written against the legacy shape.

#### 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)

### Write-only resources need `gen_id: false`

Serializing a created representation normally includes an IRI pointing at the new resource, which the serializer builds from the resource&apos;s item operation. A resource that declares only a `Post` and no `Get` has no item operation, so there is no IRI to build and the success response fails to serialize.

Turn the ID generation off for that operation:

```yaml
operations:
  - type: Post
    description: &apos;Initialize a pre-order payment&apos;
    normalizationContext:
        gen_id: false
```

This applies to action-shaped resources—payments, cancellations, and similar endpoints that accept a command and return a result rather than exposing a retrievable entity.

## 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 and prefixed with `ROLE_`. For example, the `customer` scope becomes `ROLE_CUSTOMER`.

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 UserType sub-plugins |
| `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>Mon, 17 Aug 2026 08:52:04 +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>Prove API Platform contract coverage</title>
            <description>&lt;p&gt;Contract coverage turns “the tests pass” into “every operation and validation rule this resource declares has a test that asserts on it”. You declare what a test covers with two attributes, and a CI gate diffs those declarations against the generated resource classes.&lt;/p&gt;
&lt;p&gt;This gives you two things a green test run alone does not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A reviewer reading a test method sees the verb, the URL, and the validation rule it exercises, without reading the body.&lt;/li&gt;
&lt;li&gt;The build fails when a resource gains an operation or a validation rule that no test covers.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;annotate-a-test&quot;&gt;Annotate a test&lt;/h2&gt;
&lt;p&gt;Declare the one operation the test asserts on. Do not declare the requests the arrange step makes. Import the attributes and write them as short names:&lt;/p&gt;
&lt;div class=&quot;language-php highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;#[CoversApiOperation(&apos;POST&apos;, &apos;/wishlists&apos;)]&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#[CoversApiValidation(&apos;wishlists&apos;, &apos;name&apos;, Rule::NOT_BLANK)]&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;testGivenABlankNameWhenPostWishlistThenItRespondsUnprocessableEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;CoversApiOperation(verb, uriTemplate)&lt;/code&gt; takes the OpenAPI &lt;code&gt;uriTemplate&lt;/code&gt;, such as &lt;code&gt;/wishlists/{wishlistUuid}/wishlist-items&lt;/code&gt;. Without a &lt;code&gt;status&lt;/code&gt;, it covers the operation’s success response. With one, it declares the error response the test asserts on:&lt;/p&gt;
&lt;div class=&quot;language-php highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;#[CoversApiOperation(&apos;GET&apos;, &apos;/wishlists/{uuid}&apos;, status: Response::HTTP_NOT_FOUND)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;CoversApiValidation(resource, attribute, rule)&lt;/code&gt; takes the resource short name, the guarded attribute, and a &lt;code&gt;Rule&lt;/code&gt; identifier such as &lt;code&gt;Rule::NOT_BLANK&lt;/code&gt; or &lt;code&gt;Rule::LENGTH_MAX&lt;/code&gt;. It binds to the &lt;code&gt;CoversApiOperation&lt;/code&gt; on the same method, which names the operation the rule was exercised against. Never put it on a method that has no &lt;code&gt;CoversApiOperation&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Both attributes are repeatable, but keep one asserted operation per test method.&lt;/p&gt;
&lt;h2 id=&quot;validation-coverage-is-per-operation&quot;&gt;Validation coverage is per operation&lt;/h2&gt;
&lt;p&gt;Whether a constraint fires depends on the operation’s validation groups. The truth set holds one entry per rule per input operation—&lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;—whose groups intersect the constraint’s groups. Both default to &lt;code&gt;Default&lt;/code&gt;, mirroring Symfony.&lt;/p&gt;
&lt;p&gt;For example, a wishlist name’s &lt;code&gt;NotBlank&lt;/code&gt; carries both the create and the update group, so &lt;code&gt;POST /wishlists&lt;/code&gt; and &lt;code&gt;PATCH /wishlists/{uuid}&lt;/code&gt; each need their own annotated test. A wishlist item’s &lt;code&gt;sku&lt;/code&gt; &lt;code&gt;NotBlank&lt;/code&gt; carries only the create group, so it needs a &lt;code&gt;POST&lt;/code&gt; test and no &lt;code&gt;PATCH&lt;/code&gt; test.&lt;/p&gt;
&lt;h2 id=&quot;responses-come-from-the-resource-schema&quot;&gt;Responses come from the resource schema&lt;/h2&gt;
&lt;p&gt;The gate never invents an error response. Every status a resource schema declares under &lt;code&gt;openapiContext.responses&lt;/code&gt; becomes one coverage item that a test must claim:&lt;/p&gt;
&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;GetCollection&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;uriTemplate&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/abstract-products/{abstractProductSku}/abstract-product-image-sets&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Retrieve&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;sets&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;an&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;abstract&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;product&apos;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;openapiContext&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;responses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;sets&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;the&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;abstract&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;product&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;returned.&apos;&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;404&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;No&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;abstract&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;product&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;exists&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;the&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;given&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;SKU.&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;An enforced operation that declares no responses at all is reported as a schema defect, naming the &lt;code&gt;.resource.yml&lt;/code&gt; to fix. So declaring your responses is the first step of adopting a resource, not an afterthought.&lt;/p&gt;
&lt;p&gt;Validation coverage stays separate from this. A declared &lt;code&gt;422&lt;/code&gt; demands one test for the status, and every constraint active on the operation additionally demands its own test.&lt;/p&gt;
&lt;h3 id=&quot;do-not-declare-a-response-the-router-cannot-reach&quot;&gt;Do not declare a response the router cannot reach&lt;/h3&gt;
&lt;p&gt;A provider that answers &lt;code&gt;400&lt;/code&gt; for a missing path segment is unreachable on a nested &lt;code&gt;uriTemplate&lt;/code&gt;. The router never matches such a template with an empty segment, so a segment-less call falls through to the collection operation instead. Declaring that &lt;code&gt;400&lt;/code&gt; creates a coverage item no test can ever satisfy.&lt;/p&gt;
&lt;p&gt;Given a provider that guards against a missing &lt;code&gt;{attributeKey}&lt;/code&gt;, the operation at &lt;code&gt;/product-management-attributes/{attributeKey}&lt;/code&gt; must not declare &lt;code&gt;400&lt;/code&gt;—a key-less call lands on &lt;code&gt;/product-management-attributes&lt;/code&gt; instead. The same holds for every operation nested under a parent identifier, such as &lt;code&gt;{abstractProductSku}&lt;/code&gt; or &lt;code&gt;{concreteProductSku}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Declare only the statuses a client can actually observe.&lt;/p&gt;
&lt;h3 id=&quot;operations-that-cannot-be-asserted-on&quot;&gt;Operations that cannot be asserted on&lt;/h3&gt;
&lt;p&gt;An item &lt;code&gt;GET&lt;/code&gt; with no provider—neither on the operation nor on the resource—is classified as non-servable. With nothing to read, it only mints IRIs and answers &lt;code&gt;200 null&lt;/code&gt;, so no integration test can assert on it. The gate reports these separately and does not count them as gaps.&lt;/p&gt;
&lt;h2 id=&quot;two-guarantees&quot;&gt;Two guarantees&lt;/h2&gt;
&lt;p&gt;Declarations are verified, not claimed. The base test case records every operation a booted request actually matches, and fails the test when a declared operation was never dispatched. You cannot annotate an operation your test does not exercise.&lt;/p&gt;
&lt;p&gt;In-scope resources have no gaps. The report diffs the generated &lt;code&gt;#[ApiResource]&lt;/code&gt; classes against the collected annotations and fails the build on any uncovered operation, any uncovered validation rule, and any stale claim that points at something the schema no longer defines.&lt;/p&gt;
&lt;h2 id=&quot;run-the-report&quot;&gt;Run the report&lt;/h2&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;GLUE_APPLICATION&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;GLUE_STOREFRONT vendor/bin/glue api:contract:coverage
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The command prints covered, uncovered, and stale entries for operations and validation rules, lists non-servable operations, and exits non-zero when the gate fails. Add &lt;code&gt;-v&lt;/code&gt; to list every covered entry.&lt;/p&gt;
&lt;p&gt;Narrow it to what you are working on with &lt;code&gt;--module&lt;/code&gt; or &lt;code&gt;-m&lt;/code&gt;. Casing, separators, and a trailing plural are all ignored, so the module name and the resource short name both work:&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;vendor/bin/glue api:contract:coverage &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; Wishlist                      &lt;span class=&quot;c&quot;&gt;# the wishlists resource&lt;/span&gt;
vendor/bin/glue api:contract:coverage &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; WishlistItems                 &lt;span class=&quot;c&quot;&gt;# the wishlist-items resource&lt;/span&gt;
vendor/bin/glue api:contract:coverage &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; wishlists &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; wishlist-items   &lt;span class=&quot;c&quot;&gt;# both&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Matching is exact once normalized, so &lt;code&gt;-m Wishlist&lt;/code&gt; selects &lt;code&gt;wishlists&lt;/code&gt; only and does not pull in &lt;code&gt;wishlist-items&lt;/code&gt;. A filter naming no known resource fails the command and lists what is selectable, so a typo cannot quietly report on nothing.&lt;/p&gt;
&lt;p&gt;Narrowing changes only what the run enforces. Scope drift is a property of the whole repository and is always checked against the full list.&lt;/p&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;The command is development-only, because it reads the coverage annotations off your test suites. It is registered only when development console commands are enabled:&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;DEVELOPMENT_CONSOLE_COMMANDS&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;It also needs the resources generated first:&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;GLUE_APPLICATION&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;GLUE_STOREFRONT vendor/bin/glue api:generate
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;api:generate&lt;/code&gt; is a Glue console command, so use &lt;code&gt;vendor/bin/glue&lt;/code&gt; and not &lt;code&gt;vendor/bin/console&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The check itself is boot-free—it reflects generated classes and test attributes, and reads no database.&lt;/p&gt;
&lt;h2 id=&quot;in-ci&quot;&gt;In CI&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;Standard Validation&lt;/code&gt; job runs the gate and writes the gaps to the run summary, so a failure is readable without expanding the step:&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;vendor/bin/glue api:contract:coverage &lt;span class=&quot;nt&quot;&gt;--summary-out&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$GITHUB_STEP_SUMMARY&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The suites themselves run in a separate job. For that side, see &lt;a href=&quot;/docs/integrations/spryker-api/api-platform/testing.html&quot;&gt;Test API Platform resources&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;adopt-a-resource&quot;&gt;Adopt a resource&lt;/h2&gt;
&lt;p&gt;Coverage is enforced for the resources the gate’s scope names. Adopting one means:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Declare the resource’s real responses under &lt;code&gt;openapiContext.responses&lt;/code&gt; in its &lt;code&gt;.resource.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the annotated tests that cover its servable operations and validation rules.&lt;/li&gt;
&lt;li&gt;Add the resource to the enforced scope.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A drift gate protects step 3. When an annotated test references a resource outside the enforced scope, the build fails with an unscoped-resource error, so you cannot annotate a resource’s tests and forget to enforce it.&lt;/p&gt;
&lt;p&gt;Both resources and tests are discovered automatically, so adding a test never requires editing the tool.&lt;/p&gt;
</description>
            <pubDate>Mon, 17 Aug 2026 08:45:02 +0000</pubDate>
            <link>https://docs.spryker.com/docs/integrations/spryker-api/api-platform/contract-coverage.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/integrations/spryker-api/api-platform/contract-coverage.html</guid>
            
            
        </item>
        
        <item>
            <title>Upgrade Workflow</title>
            <description>&lt;section class=&apos;info-block info-block--warning&apos;&gt;&lt;i class=&apos;info-block__icon icon-warning&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Experimental module&lt;/div&gt;
&lt;p&gt;The AiDev module is experimental and not stable. There is no backward compatibility promise for this module. We welcome your feedback and contributions as we continue to develop and improve this module.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;availability&quot;&gt;Availability&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;spryker-upgrade&lt;/code&gt; skill is available from &lt;code&gt;spryker-sdk/ai-dev&lt;/code&gt; version 0.6.4, which ships version 0.4.0 of the &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; Claude Code plugin.&lt;/p&gt;
&lt;p&gt;To update the Claude Code plugin, run &lt;code&gt;/plugin&lt;/code&gt; in Claude Code and update &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; from the &lt;code&gt;spryker-plugins-official&lt;/code&gt; marketplace to version 0.4.0 or later. For installation instructions, see &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-skill-does&quot;&gt;What the skill does&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/upgrade.png&quot; alt=&quot;upgrade&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spryker-upgrade&lt;/code&gt; upgrades your project’s modules and features to a newer Spryker release. It is built for projects with heavy &lt;code&gt;src/Pyz&lt;/code&gt; customization, where the dangerous part of an upgrade is not the code that stops compiling but the code that keeps running while doing nothing.&lt;/p&gt;
&lt;p&gt;The skill combines an orchestrated workflow with a set of deterministic detector scripts. Each script is a standalone CLI that runs on host PHP using reflection or static parsing only, with no Spryker bootstrap — so they also work in CI.&lt;/p&gt;
&lt;p&gt;Invoke it with &lt;em&gt;“upgrade the project”&lt;/em&gt;, &lt;em&gt;“update to the latest release”&lt;/em&gt;, or any request to bump &lt;code&gt;spryker-feature/*&lt;/code&gt; or &lt;code&gt;spryker/*&lt;/code&gt; packages.&lt;/p&gt;
&lt;h2 id=&quot;why-silent-damage-is-the-real-problem&quot;&gt;Why silent damage is the real problem&lt;/h2&gt;
&lt;p&gt;Everything a Spryker project overrides fails quietly when core moves. A dead override still loads, a replaced plugin stack still boots, and a stale template still renders. Nothing errors — the customization simply stops taking effect.&lt;/p&gt;
&lt;p&gt;The workflow therefore starts with a different question than “which modules changed”. It asks which of your customizations would notice if they stopped working, and whether the upgrade can be verified at all.&lt;/p&gt;
&lt;h2 id=&quot;the-phases&quot;&gt;The phases&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Coverage check&lt;/strong&gt; — measures your override surface against the tests that cover it, and offers to write characterization tests for the gaps. These must be written before the upgrade; written afterwards they pin the upgraded behavior and can no longer detect that it changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Preflight and baselines&lt;/strong&gt; — records snapshots of overrides and shadowed frontend files, checks for constraint styles that block resolution, and captures pre-existing damage so it is not misattributed to the upgrade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Constraint resolution&lt;/strong&gt; — relaxes patch-locked constraints, then resolves conflicts iteratively. Conflicts arrive in waves, each root bump revealing the next transitive layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detection&lt;/strong&gt; — after Composer completes, runs the detectors that find the silent damage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolution&lt;/strong&gt; — merges shadowed files, rewires replaced plugin stacks, fixes broken configuration references, and requires a migration guide for every major module bump.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-the-detectors-find&quot;&gt;What the detectors find&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Detector&lt;/th&gt;
&lt;th&gt;Damage it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-test-coverage.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Customizations with no test over them, ranked by risk, with the test type that would catch each gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-vendor-class-replacement.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Project files that declare a vendor namespace and replace a core class outright, so the vendor implementation never loads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-constraint-style.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Patch-locked or exactly pinned constraints that make a feature bump unresolvable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-typed-members.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Untyped overrides of constants and properties that core has since typed — a fatal on class load that aborts the console itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-dead-overrides.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Project methods overriding a vendor method that the new version deleted, so the project logic stops being called&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;twig-shadow-map.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shadowed Twig, SCSS, and TypeScript files whose vendor counterparts changed upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;merge-shadowed-files.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs a three-way merge for every shadowed file and sorts the outcomes into clean, identical, conflicted, and removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-plugin-usage.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vendor plugins that are missing or deprecated after the upgrade, and project plugins implementing a removed interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-config-constants.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configuration referencing a vendor constants interface or constant that no longer exists, which breaks bootstrap of every application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list-major-bumps.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every major module bump, with a documentation search URL, changelog URL, and compare URL per package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolve-constraints.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs Composer, parses root conflicts, raises the constraints the tree demands, and repeats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unpin-feature-driven-modules.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Breaks a cohort deadlock, where a group of modules sharing a dependency must move together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The detectors keep their snapshots and reports in &lt;code&gt;.spryker-upgrade/state/&lt;/code&gt; inside the project, created self-gitignoring on first use.&lt;/p&gt;
&lt;section class=&apos;info-block &apos;&gt;&lt;i class=&apos;info-block__icon icon-info&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Order matters&lt;/div&gt;
&lt;p&gt;The dependency between the detectors is real. The skill runs them in the correct order — coverage and replacement checks first, then baselines and constraint preflight, then resolution, then post-Composer detection. Running them out of order wastes time on phantom findings.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;run-the-detectors-in-ci&quot;&gt;Run the detectors in CI&lt;/h2&gt;
&lt;p&gt;Two detectors are cheap enough to gate every pull request, and both catch damage that is otherwise invisible until runtime:&lt;/p&gt;
&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;php &lt;span class=&quot;nv&quot;&gt;$UP&lt;/span&gt;/check-typed-members.php     &lt;span class=&quot;c&quot;&gt;# fatals on class load&lt;/span&gt;
php &lt;span class=&quot;nv&quot;&gt;$UP&lt;/span&gt;/check-plugin-usage.php      &lt;span class=&quot;c&quot;&gt;# missing plugins&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Neither needs a snapshot, a database, or a search backend — only an installed &lt;code&gt;vendor/&lt;/code&gt; directory. Failing the build on exit code 1 turns “the Back Office returns 500 after deploy” into a red pipeline.&lt;/p&gt;
&lt;p&gt;For dependency-bumping pull requests, also wrap the change in a snapshot and verify pair for &lt;code&gt;check-dead-overrides.php&lt;/code&gt; and &lt;code&gt;twig-shadow-map.php&lt;/code&gt;, because those compare against pre-upgrade state and cannot work from a single point in time.&lt;/p&gt;
&lt;p&gt;You can also gate &lt;code&gt;check-test-coverage.php&lt;/code&gt; in CI, but gate it on a ratchet rather than on zero: fail when a pull request increases the number of untested overrides. New customization then arrives with a test, instead of the project needing a coverage project before anyone can merge.&lt;/p&gt;
&lt;h2 id=&quot;known-limits&quot;&gt;Known limits&lt;/h2&gt;
&lt;p&gt;No detector covers Propel schema merges, glossary keys, ACL and navigation for new Back Office routes, or pure behavioral change. Those remain process gates and tests, and the skill marks them as such.&lt;/p&gt;
&lt;p&gt;Published migration guides are also sometimes stale or absent, so the skill treats the tag diff, not the guide, as the source of truth.&lt;/p&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Spryker project with Composer installed, and host PHP available to run the detector scripts&lt;/li&gt;
&lt;li&gt;An AI tool with the SDK’s skills loaded — either through the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code plugin&lt;/a&gt; or via &lt;code&gt;ai-dev:setup&lt;/code&gt; for another supported tool&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;related&quot;&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spryker-sdk/ai-dev/blob/project-setup-wizard/plugins/spryker-ai-dev-sdk/skills/spryker-upgrade/README.md&quot;&gt;&lt;code&gt;spryker-upgrade&lt;/code&gt; README&lt;/a&gt; — the skill’s own reference in the plugin repository&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html&quot;&gt;Skills and Agents&lt;/a&gt; — the full reference of every skill and agent&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/upgrade-and-migrate/upgrade-and-migrate.html&quot;&gt;Upgrade and migrate&lt;/a&gt; — the manual upgrade and migration guides&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;Overview&lt;/a&gt; — module and &lt;code&gt;ai-dev:setup&lt;/code&gt; command&lt;/li&gt;
&lt;/ul&gt;
</description>
            <pubDate>Fri, 14 Aug 2026 07:23:47 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-upgrade-workflow.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-upgrade-workflow.html</guid>
            
            
        </item>
        
        <item>
            <title>Project Starter Wizard</title>
            <description>&lt;section class=&apos;info-block info-block--warning&apos;&gt;&lt;i class=&apos;info-block__icon icon-warning&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Experimental module&lt;/div&gt;
&lt;p&gt;The AiDev module is experimental and not stable. There is no backward compatibility promise for this module. We welcome your feedback and contributions as we continue to develop and improve this module.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;availability&quot;&gt;Availability&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;project-starter-wizard&lt;/code&gt; skill and the nine setup skills it orchestrates are available from &lt;code&gt;spryker-sdk/ai-dev&lt;/code&gt; version 0.6.4, which ships version 0.4.0 of the &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; Claude Code plugin.&lt;/p&gt;
&lt;p&gt;To update the Claude Code plugin, run &lt;code&gt;/plugin&lt;/code&gt; in Claude Code and update &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; from the &lt;code&gt;spryker-plugins-official&lt;/code&gt; marketplace to version 0.4.0 or later. For installation instructions, see &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-skill-does&quot;&gt;What the skill does&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/setup.png&quot; alt=&quot;starter&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;project-starter-wizard&lt;/code&gt; turns a fresh, un-booted clone of a Spryker B2B or B2B Marketplace demoshop into your customer project. It asks everything once in a developer interview, records your answers in a resumable state file, and then drives nine specialist skills in the one order that works.&lt;/p&gt;
&lt;p&gt;The wizard owns the conversation and the flow — it writes almost nothing itself. Each step delegates to a sibling skill that does the transformation work.&lt;/p&gt;
&lt;p&gt;Invoke it at the very start of a new project: &lt;em&gt;“turn this demoshop into our project”&lt;/em&gt;, &lt;em&gt;“start the project setup”&lt;/em&gt;. It is also the resume entry point — if a previous run left a state file, invoking the skill again skips the interview and continues from the first unfinished step.&lt;/p&gt;
&lt;h2 id=&quot;the-interview&quot;&gt;The interview&lt;/h2&gt;
&lt;p&gt;The interview collects nine sections up front so the run needs no further configuration decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Project identity — project name, development domain, and Docker namespace&lt;/li&gt;
&lt;li&gt;Namespace — whether to keep &lt;code&gt;Pyz&lt;/code&gt; or register a custom namespace&lt;/li&gt;
&lt;li&gt;Services — database, search, broker, key-value store, and optional development services&lt;/li&gt;
&lt;li&gt;Stores and region&lt;/li&gt;
&lt;li&gt;Data mode — adapt, clean, generate, or leave the demo data&lt;/li&gt;
&lt;li&gt;Catalog scope&lt;/li&gt;
&lt;li&gt;Localization&lt;/li&gt;
&lt;li&gt;CI setup&lt;/li&gt;
&lt;li&gt;Run mode&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-nine-steps&quot;&gt;The nine steps&lt;/h2&gt;
&lt;p&gt;Steps 1 to 7 run before the first boot, because each one changes files the boot reads.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;project-ci-generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rebuilds the inherited product CI into a single lean project pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;configure-codebase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Registers the custom namespace and wires autoload, frontend build, and Codeception. Skipped when the project keeps &lt;code&gt;Pyz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;brand-project&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies the project identity — name, domain, Docker namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;configure-services&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes the chosen engines, development services, and applications into &lt;code&gt;deploy.dev.yml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;define-stores&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates the region and store definitions. Skipped when the data mode is &lt;code&gt;leave&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;project-data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adapts, cleans, generates, or leaves the import data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cypress-migration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaces the demoshop test suites with a project-owned Cypress baseline. Last pre-boot step, because it reads the output of steps 1, 3, 4, 5, and 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;boot-and-verify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First boot and per-store verification, plus the theming half of step 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;translate-content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Translates storefront content. Runs only when you selected locales to localize&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;run-modes&quot;&gt;Run modes&lt;/h2&gt;
&lt;p&gt;You choose the run mode in the interview, and the wizard honors it again on resume:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Autonomous&lt;/strong&gt; — steps 1 to 9 run as one continuous pass. At a reversible decision point the wizard picks the best option and records it in a decision log.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supervised&lt;/strong&gt; — the wizard reports a one-line result and asks &lt;em&gt;“Continue to the next step?”&lt;/em&gt; at each step boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither mode relaxes the hard stops. A required manual action, any deletion or data wipe, and any step failure all return control to you in both modes.&lt;/p&gt;
&lt;h2 id=&quot;run-artifacts&quot;&gt;Run artifacts&lt;/h2&gt;
&lt;p&gt;The run writes four files into the clone’s own tree under &lt;code&gt;.ai-dev/&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;project-setup.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The state — interview answers and a step table with per-step status. Resume reads this file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The timeline — step boundaries, conditional skips with their reason, hard stops, and every resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decision-log.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The rationale — each autonomous decision with its evidence and how to reverse it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;what-you-get-at-the-end&quot;&gt;What you get at the end&lt;/h2&gt;
&lt;p&gt;A transformed clone with the project identity and branding, a registered namespace, the chosen services and stores, project-shaped import data, a lean CI pipeline, and a vendored Cypress suite — booted and verified per store by an independent verifier agent, with all changes staged but never committed.&lt;/p&gt;
&lt;p&gt;The closing summary flags what still needs a human before go-live, such as a git remote that still points at the demoshop upstream, a still-shipped Spryker logo, or translation debt from locales left as English copies.&lt;/p&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A fresh, un-booted clone of a Spryker B2B or B2B Marketplace demoshop&lt;/li&gt;
&lt;li&gt;An AI tool with the SDK’s skills loaded — either through the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code plugin&lt;/a&gt; or via &lt;code&gt;ai-dev:setup&lt;/code&gt; for another supported tool&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;related&quot;&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spryker-sdk/ai-dev/blob/project-setup-wizard/plugins/spryker-ai-dev-sdk/skills/project-starter-wizard/README.md&quot;&gt;&lt;code&gt;project-starter-wizard&lt;/code&gt; README&lt;/a&gt; — the skill’s own reference in the plugin repository&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html&quot;&gt;Skills and Agents&lt;/a&gt; — the full reference of every skill and agent this wizard composes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-customization-workflow.html&quot;&gt;Customization Workflow&lt;/a&gt; — build features on the project once it runs&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;Overview&lt;/a&gt; — module and &lt;code&gt;ai-dev:setup&lt;/code&gt; command&lt;/li&gt;
&lt;/ul&gt;
</description>
            <pubDate>Fri, 14 Aug 2026 07:23:47 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-project-starter-wizard.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-project-starter-wizard.html</guid>
            
            
        </item>
        
        <item>
            <title>Profiler Workflow</title>
            <description>&lt;section class=&apos;info-block info-block--warning&apos;&gt;&lt;i class=&apos;info-block__icon icon-warning&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Experimental module&lt;/div&gt;
&lt;p&gt;The AiDev module is experimental and not stable. There is no backward compatibility promise for this module. We welcome your feedback and contributions as we continue to develop and improve this module.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;availability&quot;&gt;Availability&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;spryker-profiler&lt;/code&gt; skill is available from &lt;code&gt;spryker-sdk/ai-dev&lt;/code&gt; version 0.6.4, which ships version 0.4.0 of the &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; Claude Code plugin.&lt;/p&gt;
&lt;p&gt;To update the Claude Code plugin, run &lt;code&gt;/plugin&lt;/code&gt; in Claude Code and update &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; from the &lt;code&gt;spryker-plugins-official&lt;/code&gt; marketplace to version 0.4.0 or later. For installation instructions, see &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-skill-does&quot;&gt;What the skill does&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/profiler.png&quot; alt=&quot;profiler&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spryker-profiler&lt;/code&gt; turns the Spryker and Symfony WebProfiler into performance numbers you can act on, and explains how to switch profiling on when there is nothing to read.&lt;/p&gt;
&lt;p&gt;Spryker records every web request automatically. That history is the cheapest source of truth for performance work: instead of guessing which code is slow, the skill reads what actually ran. It reduces a stored profile to the metrics that matter and prints JSON, so nobody has to scrape the profiler’s HTML or open a browser.&lt;/p&gt;
&lt;p&gt;Invoke it when you ask why a page, endpoint, or Back Office screen is slow, want the heaviest request, suspect an N+1 query, need before-and-after evidence that a performance fix worked, or want to know what a request logged, threw, or which controller handled it. It also covers profiler setup — a missing toolbar, a collector showing no data, or enabling profiling for a specific application.&lt;/p&gt;
&lt;h2 id=&quot;what-it-can-tell-you&quot;&gt;What it can tell you&lt;/h2&gt;
&lt;p&gt;The I/O counters say how much work a request did:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queries&lt;/code&gt; / &lt;code&gt;unique&lt;/code&gt; / &lt;code&gt;duplicates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many SQL statements a request ran, and how many were repeats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key-value operations per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;elasticsearch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Search calls per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zed_requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yves-to-Zed calls — the architecture boundary check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;external_http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Third-party calls blocking the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duration_ms&lt;/code&gt; / &lt;code&gt;memory_mb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wall time and peak memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;segments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queries attributed to a named code path you wrapped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These say why, and whether the numbers are comparable between runs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Errors, warnings, and deprecations logged during the request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit_log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Security and compliance events by channel — a separate stream from &lt;code&gt;logs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exception&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What was thrown, with message and status code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;twig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Template count and render time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listeners called synchronously, plus Spryker application events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which controller and route handled the request, and where a 3xx redirected to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session payload size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;runtime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Debug mode and Xdebug flags — check these before comparing any timing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A Spryker request records 20 collectors. The skill reads every one that carries request data and maps it onto the fields above. The three it does not read hold no per-request measurements — they are toolbar plumbing and a dump of resolved configuration.&lt;/p&gt;
&lt;h2 id=&quot;one-user-action-is-many-profiles&quot;&gt;One user action is many profiles&lt;/h2&gt;
&lt;p&gt;The mistake that most often produces a wrong answer is treating a profile as a user action. A profile is one HTTP request. Clicking &lt;strong&gt;Login&lt;/strong&gt; on the storefront creates six profiles across two applications and two storage directories — and the storefront profile itself reports zero queries, because Yves has no SQL collector by design.&lt;/p&gt;
&lt;p&gt;The skill reconstructs the full tree instead. Yves-to-Zed links are exact, because the callee returns its own debug token, which the caller stores. Browser-issued AJAX and ESI sub-requests carry no such link, so those are grouped by time window and flagged as the heuristic they are.&lt;/p&gt;
&lt;h2 id=&quot;the-workflow&quot;&gt;The workflow&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;If you do not know which page is slow, rank the recorded profiles by a metric to find the outlier.&lt;/li&gt;
&lt;li&gt;Reproduce the request, then read the profile and check that its age is seconds — profiles accumulate for days, and without checking you may analyze last week’s request.&lt;/li&gt;
&lt;li&gt;If a collector is absent or there are no profiles at all, the skill walks the setup layers that must be enabled.&lt;/li&gt;
&lt;li&gt;For a page or user action, read the full trace rather than the entry request alone.&lt;/li&gt;
&lt;li&gt;Read the counts, not the milliseconds. Local wall time swings widely for identical work, while I/O counts are stable and are what scales badly in production.&lt;/li&gt;
&lt;li&gt;If the counts are high, profile a small and a large entity to see whether the count scales with the data.&lt;/li&gt;
&lt;/ol&gt;
&lt;section class=&apos;info-block &apos;&gt;&lt;i class=&apos;info-block__icon icon-info&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;An absent collector is not zero&lt;/div&gt;
&lt;p&gt;&lt;code&gt;collector: &amp;quot;absent&amp;quot;&lt;/code&gt; means the metric was never measured for that application — Yves has no SQL collector, and the Back Office has no Redis collector. Reporting zero queries from an absent collector is a false conclusion. &lt;code&gt;collector: &amp;quot;incompatible&amp;quot;&lt;/code&gt; means the collector is recording but an upgrade renamed part of its API, so the data is still available in the browser panel.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;going-deeper-than-counts&quot;&gt;Going deeper than counts&lt;/h2&gt;
&lt;p&gt;Segmented SQL attributes queries to a named code path, turning “this page runs 261 queries” into “the calculator stack runs 180 of them”. Wrap the suspect code and the reader reports it under &lt;code&gt;database.segments&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;language-php highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Spryker\Shared\Propel\Logger\PropelInMemoryLogger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;nc&quot;&gt;PropelInMemoryLogger&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;startSegment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;order-validation&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;validateOrder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$orderTransfer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;PropelInMemoryLogger&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;endSegment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Always use &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt;. The logger is static, so an unclosed segment swallows every later query in the request. Remove segments once you have the answer — they are debugging scaffolding.&lt;/p&gt;
&lt;p&gt;A stored profile holds far more than the reader prints, including full SQL text, headers, routing, events, and logger entries. When a question needs something the reader does not expose, the skill extends the script rather than stopping at the available options.&lt;/p&gt;
&lt;h2 id=&quot;things-that-will-confuse-you&quot;&gt;Things that will confuse you&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Timings are only comparable at equal runtime.&lt;/strong&gt; Xdebug inflates wall time several-fold and debug mode disables caches, so a “regression” can be nothing more than a differently configured container.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each application writes to its own directory.&lt;/strong&gt; Yves and Glue write to one directory; Zed, the Back Office, the Backend Gateway, and the Merchant Portal write to another. The reader picks the most recently written one, which is often not the one you want.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The index outlives the data.&lt;/strong&gt; Stored profiles are deleted after two days, but the index is never trimmed, so it can list thousands of requests when only a few dozen files survive. The skill reports how many profiles a ranking actually covered.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;external_http: 0&lt;/code&gt; may mean “not instrumented”.&lt;/strong&gt; External calls appear only if the calling code uses the external HTTP logger trait, so a zero is never proof that a request makes no outbound calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Login walls profile the redirect.&lt;/strong&gt; An unauthenticated request to the Back Office or Merchant Portal records the redirect to the login form, which says nothing about the page you wanted. Reproduce with an authenticated session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Back Office tables are two requests.&lt;/strong&gt; The page renders an empty grid and loads its rows through a separate table route, which is usually where the real cost sits. Profile both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Console commands, queue workers, and cron jobs are never profiled.&lt;/strong&gt; The WebProfiler is request-scoped. Use Xdebug profiling or explicit timing for those.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;when-there-is-no-data&quot;&gt;When there is no data&lt;/h2&gt;
&lt;p&gt;Three independent layers must all be in place, and the skill checks each one:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The web profiler is enabled in the project configuration.&lt;/li&gt;
&lt;li&gt;The web profiler application plugin is registered for the application you are profiling. Zed alone has four separate stacks — Zed, Back Office, Backend Gateway, and Backend API.&lt;/li&gt;
&lt;li&gt;A collector plugin for the metric you want is registered in that application’s &lt;code&gt;WebProfilerDependencyProvider&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After changing any of these, empty the cache and reproduce the request.&lt;/p&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A running Spryker project (Docker SDK up) with the web profiler enabled&lt;/li&gt;
&lt;li&gt;An AI tool with the SDK’s skills loaded — either through the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code plugin&lt;/a&gt; or via &lt;code&gt;ai-dev:setup&lt;/code&gt; for another supported tool&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;related&quot;&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/integrate-and-configure/integrate-development-tools/web-profiler.html&quot;&gt;WebProfiler&lt;/a&gt; — why the profiler matters and how to integrate it per application&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spryker-sdk/ai-dev/blob/project-setup-wizard/plugins/spryker-ai-dev-sdk/skills/spryker-profiler/README.md&quot;&gt;&lt;code&gt;spryker-profiler&lt;/code&gt; README&lt;/a&gt; — the skill’s own reference in the plugin repository&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html&quot;&gt;Skills and Agents&lt;/a&gt; — the full reference of every skill and agent&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;Overview&lt;/a&gt; — module and &lt;code&gt;ai-dev:setup&lt;/code&gt; command&lt;/li&gt;
&lt;/ul&gt;
</description>
            <pubDate>Fri, 14 Aug 2026 07:23:47 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-profiler-workflow.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-profiler-workflow.html</guid>
            
            
        </item>
        
        <item>
            <title>Customization Workflow</title>
            <description>&lt;section class=&apos;info-block info-block--warning&apos;&gt;&lt;i class=&apos;info-block__icon icon-warning&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Experimental module&lt;/div&gt;
&lt;p&gt;The AiDev module is experimental and not stable. There is no backward compatibility promise for this module. We welcome your feedback and contributions as we continue to develop and improve this module.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;availability&quot;&gt;Availability&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;spryker-customization&lt;/code&gt; skill ships with the &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; Claude Code plugin. Version 0.6.4 of &lt;code&gt;spryker-sdk/ai-dev&lt;/code&gt;, which ships plugin version 0.4.0, adds the conditional Cypress end-to-end phase described on this page.&lt;/p&gt;
&lt;p&gt;To update the Claude Code plugin, run &lt;code&gt;/plugin&lt;/code&gt; in Claude Code and update &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; from the &lt;code&gt;spryker-plugins-official&lt;/code&gt; marketplace to version 0.4.0 or later. For installation instructions, see &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-skill-does&quot;&gt;What the skill does&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;spryker-customization&lt;/code&gt; is the AI Dev SDK’s orchestrator skill. It takes a feature idea and walks it to a working, committed branch in your Spryker project by delegating focused work to the SDK’s other skills and agents.&lt;/p&gt;
&lt;p&gt;You describe what you want in one sentence. From there, &lt;code&gt;spryker-customization&lt;/code&gt; invokes the right specialist at each step — for example, &lt;code&gt;spryker-feature-expert&lt;/code&gt; to research which Spryker primitives apply, &lt;code&gt;product-requirement-document&lt;/code&gt; to write the spec, &lt;code&gt;spryker-refresher&lt;/code&gt; to run the post-change commands, &lt;code&gt;spryker-verifier&lt;/code&gt; to check the feature against the running app. It stops at a commit gate where you approve the diff before it is saved to git.&lt;/p&gt;
&lt;p&gt;You do not write code during the run. You make three decisions: what quality bar you want, whether the plan looks right, and whether the final diff goes in.&lt;/p&gt;
&lt;h2 id=&quot;workflow-at-a-glance&quot;&gt;Workflow at a glance&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/customization.png&quot; alt=&quot;AI Dev SDK customization workflow&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Each phase delegates to specific skills (pale yellow) and agents (deep amber) — for example, the &lt;code&gt;spryker-feature-expert&lt;/code&gt; agent researches the relevant Spryker domain during planning, and the &lt;code&gt;spryker-verifier&lt;/code&gt; agent drives the running storefront and back office to confirm the feature actually works.&lt;/p&gt;
&lt;h2 id=&quot;what-you-get-at-the-end&quot;&gt;What you get at the end&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A new branch (&lt;code&gt;ai-customize/&amp;lt;slug&amp;gt;&lt;/code&gt;) with the feature implemented&lt;/li&gt;
&lt;li&gt;Code in your project layer only — your vendor directory is never edited&lt;/li&gt;
&lt;li&gt;A clean refresh — caches, transfers, and frontend builds are all up to date&lt;/li&gt;
&lt;li&gt;Per-criterion verification results with real evidence from the running app — what passed, what did not, why&lt;/li&gt;
&lt;li&gt;A code-review report against the staged diff&lt;/li&gt;
&lt;li&gt;Tests (when you pick the MVP bar)&lt;/li&gt;
&lt;li&gt;Cypress end-to-end coverage for the feature (when you pick the MVP bar)&lt;/li&gt;
&lt;li&gt;The commit waits for your approval; nothing is pushed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you wanted a product requirement document too, the skill can produce one — by delegating to &lt;code&gt;product-requirement-document&lt;/code&gt; — as a reusable document under &lt;code&gt;resources/plan/PRD/&lt;/code&gt; before the build starts.&lt;/p&gt;
&lt;h2 id=&quot;choose-your-output-poc-or-production&quot;&gt;Choose your output: PoC or production&lt;/h2&gt;
&lt;p&gt;You pick one of two bars at the start. The skill asks once and shapes the rest of the run accordingly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PoC&lt;/strong&gt; — the fastest path to a feature that works. The orchestrator collapses the implementation into the minimum number of classes, lets values be hardcoded where convenient, sticks to a single locale, and skips test generation. Pick this for demos, sales conversations, and quick experiments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MVP&lt;/strong&gt; — production-grade output. The orchestrator uses Spryker’s canonical extension chain (plugin stacks, factory expanders, dependency injection, project-layer transfer and schema XML), covers every configured locale, adds ACL where the feature touches the back office, and writes tests for non-trivial logic. Pick this for code you intend to ship.&lt;/p&gt;
&lt;p&gt;Both bars produce a visually integrated feature — new UI elements reuse the project’s atomic design components, never raw HTML pasted onto a styled page.&lt;/p&gt;
&lt;h2 id=&quot;cypress-end-to-end-coverage&quot;&gt;Cypress end-to-end coverage&lt;/h2&gt;
&lt;p&gt;Once every acceptance criterion is verified green and any visual sign-offs are done, the workflow runs a Cypress phase. It delegates to the &lt;code&gt;cypress-tests&lt;/code&gt; skill, which decides what the feature needs against your project’s existing suite:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fix&lt;/strong&gt; a spec the feature broke&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improve&lt;/strong&gt; assertions that would have missed the change&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt; a new spec for the feature&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The phase then runs the affected specs and the project’s quality gate rather than the whole suite.&lt;/p&gt;
&lt;p&gt;The phase is on by default for the MVP bar and off for PoC, and you can switch it either way at the start. It runs only when the feature is visible on an end-to-end surface and your project has a Cypress suite. A skip is always reported with its reason, never silent.&lt;/p&gt;
&lt;p&gt;The phase deliberately runs last, after the self-correction loop has converged, so no spec is written against an implementation still in flux. If a spec goes red because the feature is wrong rather than the test, that is treated as a missed acceptance criterion and feeds back into the self-correction loop.&lt;/p&gt;
&lt;h2 id=&quot;where-you-decide&quot;&gt;Where you decide&lt;/h2&gt;
&lt;p&gt;The skill runs autonomously between three decision points where it pauses for you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Before planning&lt;/strong&gt; — you pick PoC or MVP, and which optional phases run (tests, demo screenshots, etc.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before any code is written&lt;/strong&gt; — the orchestrator presents the acceptance criteria, the list of files it intends to edit, and one consolidated round of clarifying questions. Nothing touches the disk until you confirm.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before the commit&lt;/strong&gt; — the final diff is staged for you to review. You approve, refuse, or adjust. Branches stay local; pushing is your call.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If verification fails on an acceptance criterion and the skill cannot fix it after a few tries, it surfaces what was attempted and asks you how to proceed rather than silently giving up.&lt;/p&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A running Spryker project (Docker SDK up) with the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;AI Dev SDK&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;An AI tool with the SDK’s skills loaded — either through the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code plugin&lt;/a&gt; or via &lt;code&gt;ai-dev:setup&lt;/code&gt; for another supported tool&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;related&quot;&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spryker-sdk/ai-dev/blob/project-setup-wizard/plugins/spryker-ai-dev-sdk/skills/spryker-customization/README.md&quot;&gt;&lt;code&gt;spryker-customization&lt;/code&gt; README&lt;/a&gt; — the skill’s own reference in the plugin repository&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html&quot;&gt;Skills and Agents&lt;/a&gt; — the full reference of every skill and agent this orchestrator composes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;Overview&lt;/a&gt; — module and &lt;code&gt;ai-dev:setup&lt;/code&gt; command&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt; — how to install the SDK for Claude Code&lt;/li&gt;
&lt;/ul&gt;
</description>
            <pubDate>Fri, 14 Aug 2026 07:23:47 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-customization-workflow.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-customization-workflow.html</guid>
            
            
        </item>
        
        <item>
            <title>Bugfix Workflow</title>
            <description>&lt;section class=&apos;info-block info-block--warning&apos;&gt;&lt;i class=&apos;info-block__icon icon-warning&apos;&gt;&lt;/i&gt;&lt;div class=&apos;info-block__content&apos;&gt;&lt;div class=&quot;info-block__title&quot;&gt;Experimental module&lt;/div&gt;
&lt;p&gt;The AiDev module is experimental and not stable. There is no backward compatibility promise for this module. We welcome your feedback and contributions as we continue to develop and improve this module.&lt;/p&gt;
&lt;/div&gt;&lt;/section&gt;
&lt;h2 id=&quot;availability&quot;&gt;Availability&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;spryker-bugfix&lt;/code&gt; skill is available from &lt;code&gt;spryker-sdk/ai-dev&lt;/code&gt; version 0.6.4, which ships version 0.4.0 of the &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; Claude Code plugin. Version 0.6.4 also adds the conditional Cypress end-to-end phase described on this page.&lt;/p&gt;
&lt;p&gt;To update the Claude Code plugin, run &lt;code&gt;/plugin&lt;/code&gt; in Claude Code and update &lt;code&gt;spryker-ai-dev-sdk&lt;/code&gt; from the &lt;code&gt;spryker-plugins-official&lt;/code&gt; marketplace to version 0.4.0 or later. For installation instructions, see &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-skill-does&quot;&gt;What the skill does&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/bugfix.png&quot; alt=&quot;bugfix&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spryker-bugfix&lt;/code&gt; takes a bug and drives it to a committed, validated, QA-accepted fix on a &lt;code&gt;bugfix/*&lt;/code&gt; branch. In Autonomous mode it goes all the way to a pushed draft pull request with a remote CI watch loop.&lt;/p&gt;
&lt;p&gt;The skill is an orchestrator. It runs a fixed sequence of stages and delegates the work of each stage to another SDK skill or agent — it writes no product code itself.&lt;/p&gt;
&lt;p&gt;Invoke it whenever you have a bug symptom and expect a delivered fix: &lt;em&gt;“fix this bug”&lt;/em&gt;, &lt;em&gt;“fix ticket XY-1122”&lt;/em&gt;, &lt;em&gt;“this is broken, reproduce and fix it”&lt;/em&gt;. It is not the right skill for a single isolated step, a new feature, a refactor without a symptom, or an investigation-only request.&lt;/p&gt;
&lt;h2 id=&quot;two-ways-in--the-ticket-is-optional&quot;&gt;Two ways in — the ticket is optional&lt;/h2&gt;
&lt;p&gt;Bug context can come from either or both of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A ticket from any tracker — a Jira key, a GitHub issue URL or number, or any other service&lt;/li&gt;
&lt;li&gt;A free-text description of the symptom with technical hints&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A ticket is never required. When a ticket exists and its integration is reachable, the skill pulls it for extra context; otherwise it works entirely from your description, and the branch name, commit message, and pull request title fall back to &lt;code&gt;no-ticket&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-stages&quot;&gt;The stages&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Delegates to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intake&lt;/td&gt;
&lt;td&gt;Mode, context, and pull request preference; sets up the run directory&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch&lt;/td&gt;
&lt;td&gt;Safety gate — refuses to continue on a dirty tree or a stale base&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduce&lt;/td&gt;
&lt;td&gt;Confirms the symptom in the running application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spryker-runtime&lt;/code&gt;, &lt;code&gt;spryker-docs-research&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;Finds why it happens. Loop re-entry point&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ai-runtime-debugging&lt;/code&gt;, &lt;code&gt;spryker-runtime&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Applies the minimal fix and re-runs the reproduction&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Functional tests&lt;/td&gt;
&lt;td&gt;Adds or updates Codeception tests&lt;/td&gt;
&lt;td&gt;&lt;code&gt;codecept-functional&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static validation&lt;/td&gt;
&lt;td&gt;Runs static analysis over the changed code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;static-validation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code review&lt;/td&gt;
&lt;td&gt;Gate — blocks on a blocker or major finding&lt;/td&gt;
&lt;td&gt;&lt;code&gt;code-review&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA&lt;/td&gt;
&lt;td&gt;Gate — four-bucket coverage against the running application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spryker-qa-coverage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cypress E2E&lt;/td&gt;
&lt;td&gt;Conditional — fixes, improves, or adds an end-to-end spec for the bug&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cypress-tests&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final verification&lt;/td&gt;
&lt;td&gt;Confirms the symptom is gone in the running application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spryker-verifier&lt;/code&gt;, &lt;code&gt;spryker-runtime&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit&lt;/td&gt;
&lt;td&gt;Always commits; push and pull request depend on your mode and channel&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report&lt;/td&gt;
&lt;td&gt;Outcome, root cause, decisions, gate results, and log paths&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;the-verification-loop&quot;&gt;The verification loop&lt;/h2&gt;
&lt;p&gt;Code review, QA, final verification, and remote CI are gates. A failure in any of them draws from one shared attempt counter and loops back to the root cause stage to re-investigate, then forward through the full chain again.&lt;/p&gt;
&lt;p&gt;The hard stop is three attempts. Beyond that the run stops and reports — nothing is pushed, and no pull request is marked ready with a known-broken state.&lt;/p&gt;
&lt;p&gt;A Cypress failure that indicts the fix rather than the test joins the same loop.&lt;/p&gt;
&lt;h2 id=&quot;modes&quot;&gt;Modes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Collaborative&lt;/strong&gt; — the skill stops at the important decision points and before any push, so you can review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Autonomous&lt;/strong&gt; — after the single intake step the skill runs unattended to a pushed draft pull request. Every fork is a logged decision rather than a question. The only hard stops are a dirty or stale base branch and the attempt budget.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pull-request-delivery&quot;&gt;Pull request delivery&lt;/h2&gt;
&lt;p&gt;At intake the skill asks whether to open a pull request and through which channel, then probes what is actually available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gh&lt;/code&gt; — the GitHub CLI&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mcp&lt;/code&gt; — a connected forge MCP server for GitHub, GitLab, Bitbucket, or another host&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git-only&lt;/code&gt; — pushes a branch without opening a pull request&lt;/li&gt;
&lt;li&gt;&lt;code&gt;none&lt;/code&gt; — local only&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The GitHub CLI is never assumed. If the chosen channel cannot open a pull request, the skill degrades to a clean terminal state — it commits, pushes where possible, and hands over the command to create the pull request yourself.&lt;/p&gt;
&lt;h2 id=&quot;run-artifacts&quot;&gt;Run artifacts&lt;/h2&gt;
&lt;p&gt;Every file the run produces lives in one folder per bug under &lt;code&gt;.ai-dev/spryker-bugfix/&amp;lt;bugfix-id&amp;gt;/&lt;/code&gt;, which survives loopbacks and scheduled wake-ups:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The timeline — one line per stage boundary, every loopback, and every gate verdict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decisions.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The rationale — critical decisions, open questions, and risks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repro-notes.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reproduction scenario in full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;stage&amp;gt;-attempt&amp;lt;N&amp;gt;.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk output per gate — tests, static analysis, review, verification, and CI logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;watch-state.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The handoff the remote CI watch loop re-reads on each wake-up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A running Spryker project (Docker SDK up) with the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;AI Dev SDK&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;An AI tool with the SDK’s skills loaded — either through the &lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html&quot;&gt;Claude Code plugin&lt;/a&gt; or via &lt;code&gt;ai-dev:setup&lt;/code&gt; for another supported tool&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;related&quot;&gt;Related&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spryker-sdk/ai-dev/blob/project-setup-wizard/plugins/spryker-ai-dev-sdk/skills/spryker-bugfix/README.md&quot;&gt;&lt;code&gt;spryker-bugfix&lt;/code&gt; README&lt;/a&gt; — the skill’s own reference in the plugin repository&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html&quot;&gt;Skills and Agents&lt;/a&gt; — the full reference of every skill and agent this orchestrator composes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-customization-workflow.html&quot;&gt;Customization Workflow&lt;/a&gt; — the same orchestration shape for building new features&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/docs/dg/dev/ai/ai-dev/ai-dev-overview.html&quot;&gt;Overview&lt;/a&gt; — module and &lt;code&gt;ai-dev:setup&lt;/code&gt; command&lt;/li&gt;
&lt;/ul&gt;
</description>
            <pubDate>Fri, 14 Aug 2026 07:23:47 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-bugfix-workflow.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/ai/ai-dev/ai-dev-bugfix-workflow.html</guid>
            
            
        </item>
        
        <item>
            <title>Implement Publish and Synchronization</title>
            <description>Usually in order to publish some data to the frontend from your database, you need to create a module that will handle those actions. Usual recommendation is to create a module that will handle the *Publish* and *Synchronize* processes. The module will contain the code that will handle the events, prepare data, and synchronize it to the frontend.
All required steps are described in this article.

## 1. Create a base module

We recommend putting a `Publish &amp; Synchronization` related code in a separate module. A module usually represents one database or domain entity. For example `StoreStorage` module will populate storage with `Store` data and also with data from related tables like `StoreContext`.

You can create a module with a few options:
- Create a module manually. For details, see [Create a module](/docs/dg/dev/backend-development/extend-spryker/create-modules).
- Use Spryks for creating a module. For details, see [Spryks](/docs/dg/dev/sdks/sdk/spryks/spryks).
- Use an [AI coding assistant](/docs/dg/dev/ai/ai-dev/ai-dev-overview.html) to create a module. You can ask the AI agent to use this document as a reference.

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

In the Spryker packages, you can find modules that sync data to the *Storage* or *Search* database. The module name is usually suffixed with `Storage` or `Search`. For example, `GlossaryStorage` or `CmsPageSearch`.
This is not a hard requirement, but we recommend following this convention to make it easier to understand the purpose of the module.

{% endinfo_block %}

At least a basic files structure of the module should be ready after this step.

## 2. Enable required behaviors

To automatically trigger events for the *Publish* and *Synchronize* processes, you need to enable the *Event* behavior in your Propel model. This behavior is used to trigger events when an entity is created, updated, or deleted.
Without this behavior, you will have to trigger events manually.

Let&apos;s say you want to have stores data available in Storage. You need to enable the *Event* behavior for `spy_store` table in your Propel model XML schema file.

```xml
&lt;table name=&quot;spy_store&quot;&gt;
    &lt;behavior name=&quot;event&quot;&gt;
        &lt;parameter name=&quot;spy_stores_all&quot; column=&quot;*&quot;/&gt;
    &lt;/behavior&gt;
&lt;/table&gt;
```

The `parameter` element specifies when events should be triggered. It has four attributes:

- `name`: the parameter name. It must be unique in your Propel model.
- `column`: the column that needs to be updated to trigger an event. To track all columns, use the *** (asterisk) symbol.
- `value`: a value to compare.
- `operator`: the comparison operator. You can use any PHP comparison operators (===, ==, !=, !==, &lt;, &gt;, &lt;=, &gt;=, &lt;&gt;) for this purpose.

The *value* and *operator* attributes are optional. You can use them to filter changes based on certain criteria. The following example triggers an event only if the value of the `quantity` column is `0`:

```xml
&lt;parameter name=&quot;spy_mymodule_quantity&quot; column=&quot;quantity&quot; value=&quot;0&quot; operator=&quot;===&quot;/&gt;
```

The following example triggers an event when the value of any column is less than or equals 10:

```php
&lt;parameter name=&quot;spy_mymodule_all&quot; column=&quot;*&quot; value=&quot;10&quot; operator=&quot;&lt;=&quot;/&gt;
```

Initializes the Propel ORM database layer:

```bash
console propel:install
```

Now `create`, `update` and `delete` events will be triggered automatically when you do those actions with Propel entities.

{% info_block infoBox &quot;Info&quot;%}

```php
$storeEntity = SpyStoreQuery::create()-&gt;findOne();
$storeEntity-&gt;setName(&apos;Silpo&apos;);
$storeEntity-&gt;save();
```

{% endinfo_block %}

This actions will trigger the `Entity.spy_store.update` event, which can be used to publish data.

`Entity.{table_name}.{action}` is a naming convention for the Publish events. The `{table_name}` part is the name of the Propel table that triggers the event, and `{action}` is one of the following actions: create, update, or delete.

In the Spryker packages you can find that such events are defined in the shared `Config` class of the module. For example, the `\Spryker\Shared\StoreStorage\StoreStorageConfig` class can be created to define the events for the `spy_store` table.

```php

    /**
     * Specification:
     * - Represents spy_store entity creation event.
     *
     * @api
     *
     * @var string
     */
    public const ENTITY_SPY_STORE_CREATE = &apos;Entity.spy_store.create&apos;;

    /**
     * Specification:
     * - Represents spy_store entity change event.
     *
     * @api
     *
     * @var string
     */
    public const ENTITY_SPY_STORE_UPDATE = &apos;Entity.spy_store.update&apos;;

    /**
     * Specification:
     * - Represents spy_store entity deletion event.
     *
     * @api
     *
     * @var string
     */
    public const ENTITY_SPY_STORE_DELETE = &apos;Entity.spy_store.delete&apos;;

```

This is not a mandatory step, but it is a good idea to define event names in the code once and use it later.

## 3. Create a new storage or search table

The next step is to create a database table, that is used as a mirror for the corresponding *Storage* or *Search* data.
This table is used to store records that will be later synced into Storage or Search database.

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

As a naming convention, it&apos;s recommended to append `_storage` to the end of the table name if it&apos;s synchronized with storage database (for example Redis), and `_search` if it&apos;s synchronized with search database (for example Elasticsearch).

{% endinfo_block %}

All mirror tables must implement the *Synchronization* behavior, that is used to synchronize data to *Storage* or *Search*. Also, the table must populate foreign keys necessary to backtrack the Propel entities.

This is how a storage table for store entity can look. The *Synchronization* behavior will add a `data` and `key` columns automatically, so you don&apos;t need to define them in the table definition, but you still can do it if you want to specify the column type or other parameters. Also any fields that might be required for the synchronization process can be added to the table definition.

```xml

    &lt;table name=&quot;spy_store_storage&quot; identifierQuoting=&quot;true&quot;&gt;
         &lt;column name=&quot;id_spy_store_storage&quot; type=&quot;INTEGER&quot; autoIncrement=&quot;true&quot; primaryKey=&quot;true&quot;/&gt;
         &lt;column name=&quot;fk_store&quot; type=&quot;INTEGER&quot; required=&quot;true&quot;/&gt;
         &lt;column name=&quot;data&quot; type=&quot;CLOB&quot; required=&quot;false&quot;/&gt;
         &lt;column name=&quot;store_name&quot; type=&quot;VARCHAR&quot; size=&quot;255&quot; required=&quot;true&quot;/&gt;
         &lt;index name=&quot;spy_store_storage-fk_store&quot;&gt;
            &lt;index-column name=&quot;fk_store&quot;/&gt;
         &lt;/index&gt;
         &lt;id-method-parameter value=&quot;id_spy_store_storage_pk_seq&quot;/&gt;
         &lt;behavior name=&quot;synchronization&quot;&gt;
            &lt;parameter name=&quot;resource&quot; value=&quot;store&quot;/&gt;
            &lt;parameter name=&quot;key_suffix_column&quot; value=&quot;store_name&quot;/&gt;
            &lt;parameter name=&quot;queue_group&quot; value=&quot;sync.storage.store&quot;/&gt;
            &lt;parameter name=&quot;queue_pool&quot; value=&quot;synchronizationPool&quot;/&gt;
         &lt;/behavior&gt;
         &lt;behavior name=&quot;timestampable&quot;/&gt;
      &lt;/table&gt;

```

Let&apos;s also take a look at the example of a search table:

```xml

    &lt;table name=&quot;spy_cms_page_search&quot; identifierQuoting=&quot;true&quot;&gt;
        &lt;column name=&quot;id_cms_page_search&quot; type=&quot;INTEGER&quot; autoIncrement=&quot;true&quot; primaryKey=&quot;true&quot;/&gt;
        &lt;column name=&quot;fk_cms_page&quot; type=&quot;INTEGER&quot; required=&quot;true&quot;/&gt;
        &lt;!-- &quot;structured_data&quot; column contains the result from database query while &quot;data&quot; column contains mapped data for the search engine --&gt;
        &lt;column name=&quot;structured_data&quot; type=&quot;LONGVARCHAR&quot; required=&quot;true&quot;/&gt;
        &lt;id-method-parameter value=&quot;spy_cms_page_search_pk_seq&quot;/&gt;
        &lt;behavior name=&quot;synchronization&quot;&gt;
            &lt;parameter name=&quot;resource&quot; value=&quot;cms_page&quot;/&gt;
            &lt;parameter name=&quot;store&quot; required=&quot;true&quot;/&gt;
            &lt;parameter name=&quot;locale&quot; required=&quot;true&quot;/&gt;
            &lt;parameter name=&quot;key_suffix_column&quot; value=&quot;fk_cms_page&quot;/&gt;
            &lt;parameter name=&quot;queue_group&quot; value=&quot;sync.search.cms&quot;/&gt;
            &lt;parameter name=&quot;params&quot; value=&quot;{&quot;type&quot;:&quot;page&quot;}&quot;/&gt;
        &lt;/behavior&gt;
        &lt;behavior name=&quot;timestampable&quot;/&gt;
    &lt;/table&gt;

```

The *Synchronization* behavior added by the above schema files adds a column that stores the actual data to synchronize to Storage or Search (in JSON format). The column name is *data*.

Synchronization behavior parameters:
- `resource`— specifies the Storage or Search namespace to synchronize with.
- `store`— specifies whether it&apos;s necessary to specify a store for an entity.
- `locale`— specifies whether it&apos;s necessary to specify a locale for an entity.
- `key_suffix_column`— specifies the name of the column that will be appended to the Redis or Elasticsearch key to make the key unique. If this parameter is omitted, then all entities will be stored under the same key.
- `queue_group`— specifies the queue group for synchronization.
- `params`— specifies search parameters (Search only).
- `queue_pool`— specifies the queue pool name for synchronization. If store is not required for the entity, then this parameter must be set to a string with the pool name. If store is required, then this parameter must be omitted.

Make sure that after required tables are created, you run the `propel:install` command to create the tables in the database.

## 4. Create required plugins

After all required tables are in place, we need to add some code that will move data from the main table to the `_storage` or `_search` one. And the code that will send data from those table to actual Storage or Search databases.
To implement the *Publish* and *Synchronize* process, you need to create a few plugins. The plugins are used to handle events, prepare data, and synchronize it to the frontend.
You need at least two plugins: one for handling the *Publish* events and another one for synchronizing data to the frontend.

### 4.1 Create a Publisher plugin

A Publisher plugin is used to handle the *Publish* events. It is a plugin that implements the `Spryker\Zed\PublisherExtension\Dependency\Plugin\PublisherPluginInterface` interface and contains the `handleBulk` method. The method accepts an array of event transfers and an event name.
The `handleBulk` method is meant to be called in order to prepare the data for the storage or search tables and save it in the *Storage* or *Search* database tables.
The `getSubscribedEvents` defines the events that the plugin listens to.

```php

class StoreWritePublisherPlugin extends AbstractPlugin implements PublisherPluginInterface
{
    /**
     * {@inheritDoc}
     * - Gets Store ids from event transfers.
     * - Publishes store data to storage table.
     *
     * @api
     *
     * @param array&lt;\Generated\Shared\Transfer\EventEntityTransfer&gt; $transfers
     * @param string $eventName
     *
     * @return void
     */
    public function handleBulk(array $transfers, $eventName): void
    {
        $this-&gt;getFacade()-&gt;writeCollectionByStoreEvents($transfers);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     *
     * @return array&lt;string&gt;
     */
    public function getSubscribedEvents(): array
    {
        return [
            StoreStorageConfig::ENTITY_SPY_STORE_UPDATE,
            StoreStorageConfig::ENTITY_SPY_STORE_CREATE,
        ];
    }
}

```

This plugin must be registered in the `\Pyz\Zed\Publisher\PublisherDependencyProvider::getPublisherPlugins()` method. The plugins are listening to the default publish queue, which is defined in the `\Pyz\Zed\Publisher\PublisherConfig::getPublishQueueName()` method. Custom queue names can be set by providing a key as a queue name in the `\Pyz\Zed\Publisher\PublisherDependencyProvider::getPublisherPlugins()` method.

For example

```php

protected function getPublisherPlugins(): array
    {
        return [
            ...
            PublishAndSynchronizeHealthCheckConfig::PUBLISH_PUBLISH_AND_SYNCHRONIZE_HEALTH_CHECK =&gt; [
                new PublishAndSynchronizeHealthCheckStorageWritePublisherPlugin(),
                new PublishAndSynchronizeHealthCheckSearchWritePublisherPlugin(),
            ],
            ...
        ];
    }

```

In addition `\Spryker\Zed\PublisherExtension\Dependency\Plugin\PublisherTriggerPluginInterface` can be implemented. It will be used by `publish:trigger-events` command in order to go through all entities in entity table and trigger events for them. This is useful when you need to re-publish all data, for example, after changing the mapping or if some data was lost in the Storage or Search database.

```php

class StorePublisherTriggerPlugin extends AbstractPlugin implements PublisherTriggerPluginInterface
{
    /**
     * @uses \Orm\Zed\Store\Persistence\Map\SpyStoreTableMap::COL_ID_STORE
     *
     * @var string
     */
    protected const COL_ID_STORE = &apos;spy_store.id_store&apos;;

    /**
     * {@inheritDoc}
     *
     * @api
     *
     * @param int $offset
     * @param int $limit
     *
     * @return array&lt;\Spryker\Shared\Kernel\Transfer\AbstractTransfer&gt;
     */
    public function getData(int $offset, int $limit): array
    {
        $storeCriteriaTransfer = (new StoreCriteriaTransfer())
            -&gt;setPagination(
                (new PaginationTransfer())
                    -&gt;setLimit($limit)
                    -&gt;setOffset($offset),
            );

        return $this-&gt;getFactory()
            -&gt;getStoreFacade()
            -&gt;getStoreCollection($storeCriteriaTransfer)
            -&gt;getStores()
            -&gt;getArrayCopy();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     *
     * @return string
     */
    public function getResourceName(): string
    {
        return StoreStorageConfig::STORE_RESOURCE_NAME;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     *
     * @return string
     */
    public function getEventName(): string
    {
        return StoreStorageConfig::STORE_PUBLISH_WRITE;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     *
     * @return string|null
     */
    public function getIdColumnName(): ?string
    {
        return static::COL_ID_STORE;
    }
}

```

### 4.2 Create a Synchronization plugin

A Synchronization plugin is used to synchronize data to the frontend. 
It is a plugin that implements the `\Spryker\Zed\SynchronizationExtension\Dependency\Plugin\SynchronizationDataBulkRepositoryPluginInterface` interface and contains the `getData` method. The method accepts an offset, limit, and an array of IDs and returns an array of `SynchronizationDataTransfer` objects. Those objects will be used to synchronize data to the frontend as they contain data from _storage or _search tables. 

```php

class StoreSynchronizationDataPlugin extends AbstractPlugin implements SynchronizationDataBulkRepositoryPluginInterface
{
    /**
     * Specification:
     *  - Returns the resource name of the storage or search module
     *
     * @api
     *
     * @return string
     */
    public function getResourceName(): string
    {
        return StoreStorageConfig::STORE_RESOURCE_NAME;
    }

    /**
     * Specification:
     *  - Returns true if this entity has multi-store concept
     *
     * @api
     *
     * @return bool
     */
    public function hasStore(): bool
    {
        return false;
    }

    /**
     * Specification:
     *  - Returns SynchronizationDataTransfer[] according to provided offset, limit and ids.
     *
     * @api
     *
     * @param int $offset
     * @param int $limit
     * @param array&lt;int&gt; $ids
     *
     * @return array&lt;\Generated\Shared\Transfer\SynchronizationDataTransfer&gt;
     */
    public function getData(int $offset, int $limit, array $ids = []): array
    {
        return $this-&gt;getFacade()-&gt;getStoreStorageSynchronizationDataTransfers(
            $this-&gt;createStoreStorageCriteriaTransfer($offset, $limit, $ids),
        );
    }

    /**
     * Specification:
     *  - Returns array of configuration parameter which needed for Redis or Elasticsearch
     *
     * @api
     *
     * @return array&lt;mixed&gt;
     */
    public function getParams(): array
    {
        return [];
    }

    /**
     * Specification:
     *  - Returns synchronization queue name
     *
     * @api
     *
     * @return string
     */
    public function getQueueName(): string
    {
        return StoreStorageConfig::STORE_SYNC_STORAGE_QUEUE;
    }

    /**
     * Specification:
     *  - Returns synchronization queue pool name for broadcasting messages
     *
     * @api
     *
     * @return string|null
     */
    public function getSynchronizationQueuePoolName(): ?string
    {
        return $this-&gt;getFactory()
            -&gt;getConfig()
            -&gt;getStoreSynchronizationPoolName();
    }
}

```

If the entity is store related - `hasStore()` method must return `true`, otherwise `getSynchronizationQueuePoolName()` need to return a string with the pool name. It should be the same as is provided in table definition [queue_pool](#create-a-new storage-or-search-table) of Synchronization behavior.

Below you can see an example of the code that provides data for the `getData()` method. Data taken from the Storage table and mapped to the `SynchronizationDataTransfer` object.:

```php

public function getStoreStorageSynchronizationDataTransfers(StoreStorageCriteriaTransfer $storeStorageCriteriaTransfer): array
    {
        $storeStorageQuery = $this-&gt;getFactory()-&gt;createStoreStorageQuery();

        $storeStorageConditionsTransfer = $storeStorageCriteriaTransfer-&gt;getStoreStorageConditions();
        if ($storeStorageConditionsTransfer &amp;&amp; $storeStorageConditionsTransfer-&gt;getStoreIds()) {
            $storeStorageQuery-&gt;filterByFkStore_In($storeStorageConditionsTransfer-&gt;getStoreIds());
        }

        $paginationTransfer = $storeStorageCriteriaTransfer-&gt;getPagination();
        if ($paginationTransfer) {
            $storeStorageQuery = $this-&gt;preparePagination($storeStorageQuery, $paginationTransfer);
        }

        $storeStorageEntities = $storeStorageQuery-&gt;find();

        $synchronizationDataTransfers = [];
        foreach ($storeStorageEntities as $storeStorageEntity) {
            /** @var string $data */
            $data = $storeStorageEntity-&gt;getData();

            $synchronizationDataTransfers[] = (new SynchronizationDataTransfer())
                -&gt;setData($data)
                -&gt;setKey($storeStorageEntity-&gt;getKey());
        }

        return $synchronizationDataTransfers;
    }

```

## 5. Configure Publish &amp; Synchronization queues

If you want to use separate queues for your entity you need to configure it by providing a key as a queue name in `Pyz\Zed\Publisher\PublisherDependencyProvider::getPublisherPlugins()` as mentioned before and also in the `Pyz\Zed\Queue\QueueDependencyProvider::getProcessorMessagePlugins()` by setting message and sync processors.

To deliver the prepared data to the frontend, you need to configure synchronization queues in order. This can be also done in `Pyz\Zed\Queue\QueueDependencyProvider::getProcessorMessagePlugins()`.
Spryker implemented two generic synchronization message processor plugins for synchronizing data to the frontend:

- `Spryker\Zed\Synchronization\Communication\Plugin\Queue\SynchronizationStorageQueueMessageProcessorPlugin` for synchronizing data to Redis, and
- `Spryker\Zed\Synchronization\Communication\Plugin\Queue\SynchronizationSearchQueueMessageProcessorPlugin` for synchronizing data to Elasticsearch.

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

As a naming convention, names of queues that synchronize data to Redis start with *sync.storage.*, and names of queues that synchronize data to Elasticsearch start with *sync.search.*.

{% endinfo_block %}

Error queue is created automatically by adding error suffix to the queue name. For example, if the queue name is `sync.storage.product`, then the error queue name is `sync.storage.product.error`.

```php

class QueueDependencyProvider extends SprykerDependencyProvider
{
    /**
     * @param \Spryker\Zed\Kernel\Container $container
     *
     * @return array&lt;\Spryker\Zed\Queue\Dependency\Plugin\QueueMessageProcessorPluginInterface&gt;
     */
    protected function getProcessorMessagePlugins(Container $container): array // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter
    {
        return [
            EventConstants::EVENT_QUEUE =&gt; new EventQueueMessageProcessorPlugin(),
            EventConstants::EVENT_QUEUE_RETRY =&gt; new EventRetryQueueMessageProcessorPlugin(),
            PublisherConfig::PUBLISH_QUEUE =&gt; new EventQueueMessageProcessorPlugin(),
            PublisherConfig::PUBLISH_RETRY_QUEUE =&gt; new EventRetryQueueMessageProcessorPlugin(),
            StoreStorageConfig::STORE_SYNC_STORAGE_QUEUE =&gt; new SynchronizationStorageQueueMessageProcessorPlugin(),
        ];
    }
}

```

As you can see, for the Store entity, the `Spryker\Shared\StoreStorage\StoreStorageConfig::STORE_SYNC_STORAGE_QUEUE` queue is used for synchronizing data to Redis. The queue name is defined in the `Spryker\Shared\StoreStorage\StoreStorageConfig` class.
For the publishing we are using the default publish queue, which is defined in the `Spryker\Shared\Publisher\PublisherConfig::PUBLISH_QUEUE` constant. But it also can use a separate queue for publishing data, if you provide a key as a queue name in the `Pyz\Zed\Publisher\PublisherDependencyProvider::getPublisherPlugins()` method.

## 6. Validate the implementation

To validate the implementation, you can update a Propel entity in the Back Office and check if the data is published to the *Storage* or *Search* databases. If any errors occur during the process, you can check the error queue in the RabbitMQ management UI. The error queue is created automatically by adding an `error` suffix to the queue name.

For the Search you can check this [document](/docs/pbc/all/search/latest/base-shop/tutorials-and-howtos/configure-a-search-query.html) to get more info how you can work with data that you just sync.
For the Storage on the other hand, you can check this [document](/docs/dg/dev/backend-development/client/use-and-configure-redis-as-a-key-value-storage) to learn more.</description>
            <pubDate>Fri, 14 Aug 2026 05:58:44 +0000</pubDate>
            <link>https://docs.spryker.com/docs/dg/dev/backend-development/data-manipulation/data-publishing/implement-publish-and-synchronization.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/dg/dev/backend-development/data-manipulation/data-publishing/implement-publish-and-synchronization.html</guid>
            
            
        </item>
        
        <item>
            <title>Release notes 202606.0</title>
            <description>Spryker Cloud Commerce OS is an end-to-end solution for digital commerce. This document contains a business-level description of new features and improvements.

For information about installing Spryker, see [Getting started guide](/docs/dg/dev/development-getting-started-guide).

## B2B Business-Ready Commerce Experiences

### Recurring Orders {% include badge.html type=&quot;early-access,feature&quot; %}

Recurring Orders let buyers create cadence-based repeat purchases during checkout and execute them automatically on a defined schedule. The feature includes controls for confirmations and approvals, as well as review and recovery flows for basket changes, price changes, or ERP-related issues.

**Key capabilities:**
- Create recurring order schedules such as weekly, bi-weekly, or monthly
- Support buyer-controlled confirmations, skips, modifications
- Detect basket drift, price drift, and ERP errors with clear recovery flows

**Business benefits:**
- Reduces manual effort for repeat procurement
- Helps prevent missed or delayed replenishment orders
- Increases trust through controlled automation, governance, and auditability

**Documentation:**
- [Recurring Orders Feature Overview](/docs/pbc/all/order-experience-management/latest/base-shop/feature-overviews/recurring-orders-feature-overview.html)
- [Install Recurring Orders](/docs/pbc/all/order-experience-management/latest/base-shop/install-and-upgrade/install-features/install-the-recurring-orders-feature)

### Budget &amp; Cost Centers {% include badge.html type=&quot;feature&quot; %}

Budget &amp; Cost Centers introduces native purchasing controls for departmental or project-based spending in B2B commerce. Companies can manage budgets and cost centers directly in Spryker to support compliant, auditable, and policy-driven purchasing workflows.

**Key capabilities:**
- Define and manage cost centers for purchasing activities
- Enforce budgets directly within procurement workflows
- Align spend controls with existing approval processes

**Business benefits:**
- Reduces overspending risk through built-in budget enforcement
- Improves financial transparency across teams and projects
- Streamlines procurement with consistent spend governance in one system

**Documentation:**
- [Purchasing Control Feature Overview](/docs/pbc/all/cart-and-checkout/latest/base-shop/feature-overviews/purchasing-control-feature-overview)
- [Install Purchasing Control](/docs/pbc/all/cart-and-checkout/latest/base-shop/install-and-upgrade/install-features/install-the-purchasing-control-feature)

### New Spryker Design System Storefront

We extended the Spryker design system across key storefront pages, including product listing, product detail, and cart pages. This creates a more consistent and modern buyer experience while giving teams reusable components for faster delivery.

**Key capabilities:**
- Design system applied across the product details page
- New reusable components such as cart entries, pagination, and quantity selectors
- Storybook documentation for reference and reuse

**Business benefits:**
- Improves buyer trust with a more polished storefront experience
- Reduces future implementation and QA effort through reusable patterns
- Strengthens Spryker&apos;s out-of-the-box storefront for demos and evaluations

&lt;figure class=&quot;video_container&quot;&gt;
    &lt;video width=&quot;100%&quot; height=&quot;auto&quot; controls&gt;
      &lt;source src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/spryker+b2b+design+system-no+voice.mp4&quot; type=&quot;video/mp4&quot;&gt;
  &lt;/video&gt;
&lt;/figure&gt;


### Search Statistics &amp; Google Analytics {% include badge.html type=&quot;feature&quot; %}

Search Statistics brings native search analytics to the Back Office for customers using ElasticSearch. Business users can review frequent searches and zero-result searches to better understand buyer behavior and optimize product discovery.

**Key capabilities:**
- View top frequent searches and top zero-result searches in the Back Office
- Filter analytics by time period and review detailed search data
- Export search statistics to Google Analytics for further analysis and sharing

**Business benefits:**
- Reduces the need for custom project-specific search analytics implementations
- Helps teams improve search relevance, synonyms, and catalog quality based on real data
- Identifies search gaps that can negatively affect discoverability and conversion

**Documentation:**
- [Search Statistics Feature Overview](/docs/pbc/all/miscellaneous/latest/third-party-integrations/marketing-and-conversion/analytics/google-analytics/search-statistics)
- [Install Search Statistics](/docs/pbc/all/miscellaneous/latest/third-party-integrations/marketing-and-conversion/analytics/google-analytics/install-search-statistics)

### Back Office usability improvements {% include badge.html type=&quot;improvement&quot; %}

We improved several Back Office interactions to make administration tasks clearer and less error-prone. This covers block and slot assignment validation.

**Key capabilities:**
- Restores validation feedback when saving block and slot assignments
- Fixes missing menu highlight on the View Merchant page in the Back Office

**Business benefits:**
- Improves clarity during Back Office administration
- Helps business users navigate merchant management more easily

**Releases:**
- [No validation errors when saving block + slot assignment](https://api.release.spryker.com/release-group/6509)


### PunchOut support in the Back Office {% include badge.html type=&quot;improvement&quot; %}

PunchOut support in the Back Office makes PunchOut integrations easier to configure and manage with a more low-code approach. Building on Spryker&apos;s native support for common PunchOut flows and cXML/OCI compatibility, this update adds a dedicated PunchOut section with relevant fields and configuration support. This helps solution teams deliver integrations with less custom development effort.

**Key capabilities:**
- Adds a dedicated PunchOut section in the Back Office
- Supports configuration of relevant PunchOut integration fields in a more low-code way
- Complements native cXML, OCI, and core PunchOut message handling capabilities

**Business benefits:**
- Reduces bespoke development effort for PunchOut integrations
- Makes implementations more repeatable and faster across projects
- Simplifies setup and maintenance for customers and implementation partners

&lt;figure class=&quot;video_container&quot;&gt;
    &lt;video width=&quot;100%&quot; height=&quot;auto&quot; controls&gt;
      &lt;source src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/Spryker+b2b+Punchout+Backoffice.mp4&quot; type=&quot;video/mp4&quot;&gt;
  &lt;/video&gt;
&lt;/figure&gt;

**Documentation:**
- [PunchOut Gateway](/docs/pbc/all/punchout-gateway/punchout-gateway.html)

### PunchCommerce Punchout Connector {% include badge.html type=&quot;feature&quot; %}

The PunchCommerce Punchout Connector extends Spryker&apos;s support for complex PunchOut scenarios through a partner integration. It is designed for use cases that require multiple eProcurement connectors and document handling beyond Spryker&apos;s native capabilities. This helps customers address more advanced procurement integration requirements.

**Key capabilities:**
- Connects Spryker with PunchCommerce for advanced PunchOut scenarios
- Supports use cases with multiple eProcurement connectors
- Adds support for document-handling requirements not covered by native capabilities

**Business benefits:**
- Broadens PunchOut coverage for more complex B2B procurement scenarios
- Reduces the need for bespoke implementations in advanced projects
- Expands ecosystem support through a specialized partner solution

**Documentation:**
- [https://gitlab.netzdirektion.de/packages/punchcommerce-spryker-module](https://gitlab.netzdirektion.de/packages/punchcommerce-spryker-module)

## Connected, and AI-Enabled Platform

### AI Dev SDK: AI-Assisted Customization for Spryker Projects {% include badge.html type=&quot;early-access&quot; %}

The AI Dev SDK helps teams customize Spryker projects faster and with less manual effort. It supports developers in generating quick proofs of concept and MVP customizations that follow Spryker&apos;s patterns and project conventions, with developers staying in control at each approval point. This helps teams validate ideas faster, keep customization quality more consistent, and lower the manual effort each one takes.

&lt;figure class=&quot;video_container&quot;&gt;
    &lt;video width=&quot;100%&quot; height=&quot;auto&quot; controls&gt;
      &lt;source src=&quot;https://spryker.s3.eu-central-1.amazonaws.com/docs/dg/dev/ai-dev/ai-dev-sdk-workflow.mp4&quot; type=&quot;video/mp4&quot;&gt;
  &lt;/video&gt;
&lt;/figure&gt;

**Key capabilities:**
- Orchestrates the full Spryker customization workflow, from research and planning through code generation, testing, and verification, with self-correction when issues are detected and developer approval at key checkpoints
- Automates the technical work needed after every code change, including database migrations, cache rebuilds, and frontend builds
- Offers two quality levels: quick PoC for validating ideas fast, and MVP for delivering code that follows Spryker&apos;s canonical patterns and project conventions

**Business benefits:**
- Speeds up delivery of Spryker customizations, whether a quick proof of concept or an MVP-grade build.
- Reduces development effort and catches issues against Spryker and project standards earlier.
- Lowers the cost of experimentation — quick PoCs make it cheap to validate an idea before investing in a full implementation.

**Documentation:**
- [AI Dev SDK Skills and Agents](/docs/dg/dev/ai/ai-dev/ai-dev-skills-and-agents.html)
- [AI Dev SDK Customization Workflow](/docs/dg/dev/ai/ai-dev/ai-dev-customization-workflow.html)
- [Claude Code](/docs/dg/dev/ai/ai-dev/ai-dev-claude-code.html)

### AI Commerce: Smart CMS for AI-assisted content creation {% include badge.html type=&quot;early-access&quot; %}

Smart CMS brings AI assistance directly into the CMS page and block creation in the Back Office. Content editors and business users can generate structured text content in-flow instead of writing or copying content manually. This reduces the effort required to create and update commerce content experiences at scale.

{% include carousel.html
images=&quot;
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_smart_cms_1.png||::
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_smart_cms_2.png||::
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_smart_cms_3.png||&quot;
%}

**Key capabilities:**
- Generates CMS page and block content directly in the Back Office authoring flow
- Accepts source attachments, such as documents with customer questions, to generate structured content like FAQ pages
- Understands Spryker-specific content items so generated content fits existing content structures

**Business benefits:**
- Reduces manual effort for routine content creation and updates
- Improves productivity for content editors and business admins
- Helps scale content operations more efficiently as content needs grow

**Documentation:**
- [Smart CMS Content Assistant](/docs/pbc/all/ai-commerce/latest/smart-cms-content-assistant)
- [Install Smart CMS Content Assistant](/docs/dg/dev/ai/ai-commerce/content-assistant/install-smart-cms-content-assistant#prerequisites)

### AI Foundation: Back Office AI Configuration {% include badge.html type=&quot;early-access&quot; %}

AI Foundation now includes a unified configuration experience in the Back Office for managing AI providers, models, prompts, and feature-specific behavior. This gives business and operational users a clearer, more consistent way to activate and control AI-powered capabilities, including switching AI vendors or models on the fly, without developer involvement or redeployment.

{% include carousel.html
images=&quot;
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_ai_configuration_1.png||::
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_ai_configuration_2.png||&quot;
%}

**Key capabilities:**
- Centralizes AI provider and credential management in one configuration area
- Supports feature-level enablement and control of AI behavior, including AI vendor, model, and prompt configuration, with no code changes or deployments required
- Provides an extensible foundation designed to support additional AI-powered features over time

**Business benefits:**
- Simplifies setup and onboarding for AI-powered features
- Gives merchants more control over AI behavior, output quality, and governance
- Reduces configuration errors and repetitive administrative work

**Documentation:**
- [Configure multiple AI providers for AI Commerce](/docs/dg/dev/ai/ai-commerce/configure-multiple-ai-providers)

### AI Foundation: Back Office AI Cost Estimator {% include badge.html type=&quot;early-access&quot; %}

The AI Cost Estimator adds estimated AI cost visibility to the AI Audit Logs in the Back Office. Admins can configure model pricing once and then see estimated costs per interaction, total estimated spend, and breakdowns by provider and model. This makes it easier to understand the cost of individual AI-powered commerce features and make informed decisions about their value and continued use.

{% include carousel.html
images=&quot;
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_ai_cost_estimator_1.png||::
https://spryker.s3.eu-central-1.amazonaws.com/docs/About/Releases/release-notes-202606/bo_ai_cost_estimator_2.png||&quot;
%}

**Key capabilities:**
- Shows estimated cost per AI interaction in the Audit Logs page
- Provides total estimated cost and provider and model-level cost breakdowns based on active filters
- Uses customer-configured provider and model prices, including support for multiple currencies

**Business benefits:**
- Improves visibility into AI operating costs across commerce features
- Supports better governance and value assessment of AI capabilities
- Reduces manual work previously needed to estimate AI spend from token usage

**Documentation:**
- [AI Audit Logs &amp; Cost Estimations](/docs/dg/dev/ai/ai-foundation/ai-foundation-audit-logs)

### GLUE REST API migration to API Platform {% include badge.html type=&quot;feature&quot; %}

Spryker has modernized its internal GLUE REST API infrastructure by migrating it to API Platform. This change is backward compatible for existing API clients, while making APIs easier to maintain, extend, and evolve internally. It also improves the foundation for delivering new endpoints and capabilities faster.

**Key capabilities:**
- Keeps existing external API contracts unchanged for current clients and consumers
- Replaces custom internal API infrastructure with API Platform
- Enables more standardized API generation, validation, and documentation

**Business benefits:**
- Accelerates API delivery and reduces development effort for new endpoints
- Improves maintainability and extensibility of the API layer
- Supports more consistent and predictable machine-to-machine integrations

**Documentation:**
- [API Strategy](/docs/integrations/spryker-api/getting-started-with-apis/api-strategy.html)
- [Spryker API roadmap and adoption](/docs/integrations/spryker-api/getting-started-with-apis/api-roadmap-and-adoption#whats-on-the-roadmap.html)
- [Storefront API B2B Demo Shop reference](/docs/integrations/spryker-api/storefront-api/api-references/storefront-api-b2b-demo-shop-reference.html)

### Backend API added to the Spryker API Strategy {% include badge.html type=&quot;feature&quot; %}

Spryker extended its API strategy to cover the Backend API, the application for administrative and system-to-system integrations. The strategy now documents the API types available under the Backend API—starting with the Back Office API you can build on today—and the types planned on the roadmap, all built on API Platform integration. As with the rest of the strategy, existing Glue Backend APIs remain fully supported with no End-of-Life and no forced migration.

**Key capabilities:**
- Documents the Backend API application and its API types, and clarifies that the Backend API application is distinct from the Back Office API type
- Back Office API is available today for administrator-level and trusted internal integrations
- Roadmap types documented for planning: Merchant API, Merchant Data Exchange API, Data Exchange API, and Async Event API
- Guidance for choosing a Backend API type by caller and data-flow shape—record-by-record, bulk, or event-driven

**Business benefits:**
- Gives a clear, forward-looking view of backend integration options across administration, merchant, data exchange, and events
- Protects existing Glue Backend API investments with a no-End-of-Life, no-forced-migration commitment
- Speeds up planning for ERP, PIM, OMS, and marketplace integrations with consistent guidance

**Documentation:**
- [Spryker API Strategy](/docs/integrations/spryker-api/getting-started-with-apis/api-strategy.html)
- [Spryker API roadmap and adoption](/docs/integrations/spryker-api/getting-started-with-apis/api-roadmap-and-adoption.html)

## Efficient and Flexible Cloud Foundation

### Propel 2.0 LTS  {% include badge.html type=&quot;improvement&quot; %}

This work includes advancing Propel toward a stable release and increasing active maintenance.

**Documentation:**
- [PropelORM](https://github.com/propelorm/Propel2/releases/tag/2.0.0)

### Maintenance and service updates {% include badge.html type=&quot;improvement&quot; %}

We rolled out regular maintenance and service updates across cloud components to keep environments secure, stable, and maintainable. This includes updates to core services and supporting infrastructure components. These changes help reduce operational risk and technical debt over time.

**Key capabilities:**
- Updates core services and infrastructure components such as Nginx 1.30, and Docker images
- Improves long-term maintainability of cloud services
- Updated multiple dependencies

**Business benefits:**
- Improves system reliability and operational resilience
- Updates `shell-quote` to a patched version addressing a critical shell injection vulnerability.
- Upgrades `symfony/runtime` to address unsafe request argument handling.
- Upgrades `symfony/monolog-bridge` to address unauthenticated log listener exposure.

**Documentation:**
- [Docker Images](/docs/about/all/releases/image-releases/spryker-php/release-notes-spryker-php-20260608.html)

### Key-Value Storage Optimization for Dynamic Store Mode {% include badge.html type=&quot;improvement&quot; %}

We optimized key-value storage usage for Dynamic Store Mode to reduce duplicated data across store and locale combinations. This improvement lowers Valkey/Redis memory consumption and key volume while keeping the solution backward compatible and opt-in. It is especially valuable for deployments with multiple stores and locales.

**Key capabilities:**
- Reduces duplicated product abstract and URL storage data in Dynamic Store Mode
- Keeps compatibility with existing storage formats and supports gradual adoption
- Provides an opt-in configuration and migration path for controlled rollout

**Business benefits:**
- Improves scalability for multi-store and multi-locale setups
- Helps delay or avoid costly cache instance size increases

**Documentation:**
- [KV Storage Deduplication](/docs/dg/dev/guidelines/performance-guidelines/kv-storage-deduplication.html)

### Queue processing resilience and worker behavior {% include badge.html type=&quot;improvement&quot; %}

We improved queue processing behavior to reduce the risk of blocked or stalled message handling. The update addresses long-running worker processes, retry queue handling, and premature worker exits while messages are still waiting.

**Key capabilities:**
- Improves handling of long-running or stuck queue worker child processes
- Prevents failed messages from being lost when no retry queue exists
- Ensures `queue:worker:start --stop-when-empty` behaves more reliably

**Business benefits:**
- Reduces the risk of blocked publish and processing pipelines
- Improves operational stability for queue-based workloads
- Helps teams recover from processing failures more predictably

**Documentation:**
- [Publish and synchronization (queue and event performance)](/docs/dg/dev/guidelines/performance-guidelines/keeping-dependencies-updated#publish-and-synchronization-queue-and-event-performance)
- [Enable queue worker wait limit](/docs/dg/dev/guidelines/performance-guidelines/general-performance-guidelines#enable-queue-worker-wait-limit)

### Cloud security improvements {% include badge.html type=&quot;improvement&quot; %}

We delivered a set of cloud security improvements to strengthen infrastructure protection and reduce unnecessary access paths. These updates address container-to-instance access risks, improve IAM controls, and support more secure cloud operations. They also include compatibility adjustments for newer managed service defaults.

**Key capabilities:**
- Tightens IAM policy controls to prevent overly broad administrative permissions in customer PaaS accounts
- Adds compatibility handling for MariaDB 11.8 secure transport defaults in infrastructure templates

**Business benefits:**
- Reduces the risk of unauthorized access to cloud resources and secrets
- Improves security posture across customer environments
- Helps maintain secure operations as infrastructure components evolve

### RDS Auditability Extensions {% include badge.html type=&quot;feature&quot; %}

We have introduced advanced activity tracking for database operations to enhance security governance and accountability across your environments. This update provides clear visibility into data modifications and structural changes within your live production systems.
**Key capabilities:**
- Monitors and records database operations performed in production environments
- Securely retains activity logs for a rolling two-year period to satisfy standard corporate compliance and security policies.

**Business benefits:**
- Provides complete transparency into exactly who executed which database operation, ensuring robust compliance and clear user accountability
- Enhances Security Auditing: Simplifies the detection of potential application exploits—such as SQL injections—by tracking critical service account behaviors

### Persistent Jenkins Configuration {% include badge.html type=&quot;improvement&quot; %}

We have updated our cloud automation storage model to significantly improve platform stability. This enhancement ensures that your custom adjustments are safely preserved during routine platform updates and security maintenance.
**Key capabilities:**
- Permanently safeguards all manually created configurations and fine-tunings across system upgrades and server restarts
- Unlocks continuous, automated operating system upgrades and critical infrastructure patching to keep your system completely protected

**Business benefits:**
- Eliminates the risk of losing bespoke operational settings, removing the need for costly and repetitive manual reconfiguration work
- Enhances your overall security posture by allowing the seamless deployment of critical patches without disrupting business continuity

### Customer authentication and session handling {% include badge.html type=&quot;improvement&quot; %}

We fixed issues affecting customer authentication and session behavior in edge cases. These changes improve reliability for API authentication and login flows.

**Key capabilities:**
- Prevents unintended API authentication behavior related to `spy_customer.registered = NULL`
- Improves error handling for invalid JWT tokens in warehouse token requests
- Avoids false-positive WAF blocking caused by specific patterns that are not malicious.

**Business benefits:**
- Improves trust in authentication flows
- Reduces login-related support cases
- Helps secure customer access scenarios more consistently

**Documentation:**
- [spy_customer.registered=NULL allows API auth](https://api.release.spryker.com/release-group/6618)
- [Adjusted Quote data and UI fixes](https://api.release.spryker.com/release-group/6621)
- [Added optional base64 encoding for gateway](https://api.release.spryker.com/release-group/6633)

### Voucher code tracking and order display accuracy in the Back Office {% include badge.html type=&quot;improvement&quot; %}

We fixed issues with orders that use multiple voucher-code discounts. The Back Office now shows the correct voucher code for each applied discount, and usage counters are updated correctly for all voucher codes used in an order.

**Key capabilities:**
- Displays the actual voucher code used for each discount in the Back Office order overview.
- Correctly increments usage counters for all voucher codes applied to an order.
- Supports orders with multiple non-exclusive voucher-based discounts more accurately.

**Business benefits:**
- Improves trust in discount reporting and order auditability.
- Reduces manual verification effort for merchandising and support teams.
- Ensures voucher campaign performance is tracked correctly.

**Release:**
- [Multiple voucher codes on order](https://api.release.spryker.com/release-group/6640)

### Product and catalog experience improvements {% include badge.html type=&quot;improvement&quot; %}

We improved several product-related experiences in the Back Office and storefront to make catalog management and shopping journeys more reliable. The update covers product images in Zed, product readiness visibility, replacement product rendering, and marketplace product offer handling in carts.

**Key capabilities:**
- Restores product concrete image visibility in the Back Office

**Business benefits:**
- Gives business users clearer product status information
- Reduces confusion during catalog maintenance in the Back Office
- Improves cart accuracy for marketplace shopping scenarios

**Documentation:**
- [Show Product Concrete images in Zed](https://api.release.spryker.com/release-group/6501)

### Storefront performance, search, and content management improvements {% include badge.html type=&quot;improvement&quot; %}

We improved storefront performance, search reliability, content management efficiency, and product data consistency to deliver faster customer experiences, more accurate search results, and better scalability across high-volume environments.

**Key capabilities:**
- Improves checkout and customer-facing page performance by reducing unnecessary backend requests and eliminating Redis bottlenecks for large datasets.
- Enhances product data publishing and search indexing to keep Storage and Search synchronized with catalog changes across multi-store and multi-locale environments.
- Generates search index mappings correctly for advanced analyzer and multi-field configurations.
- Optimizes CMS page version history loading for better Back Office performance when managing long-lived content.
- Preserves locale selection across storefront, configurator, agent emulation, and authentication flows for a more consistent user experience.

**Business benefits:**
- Delivers faster storefront and checkout experiences, even at scale.
- Improves search accuracy and product data consistency across channels.
- Enhances Back Office productivity for content managers.
- Reduces backend load and improves platform scalability.
- Provides a more consistent localized shopping experience for customers and support agents.

**Documentation and Releases:**
- [Cart page and checkout for large carts](/docs/dg/dev/guidelines/performance-guidelines/keeping-dependencies-updated#cart-page-and-checkout-for-large-carts-100-items)
- [Adjusted reading shipment type uuids on Checkout](https://api.release.spryker.com/release-group/5736)
- [Added child fields to index map](https://api.release.spryker.com/release-group/6616)
- [CMS page versions query for the form loads too much data](https://api.release.spryker.com/release-group/6630)
- [Remediate error in spryker/category &gt;= 5.23.0](https://api.release.spryker.com/release-group/6452)
- [Remove Concrete Product on deactivation](https://api.release.spryker.com/release-group/6500)
- [Remove product concrete from the search index](https://api.release.spryker.com/release-group/6574)
- [Locale selection preserved when navigating in the Storefront](https://api.release.spryker.com/release-group/6521)
- [Performance improvements to the checkout](https://api.release.spryker.com/release-group/6479)
</description>
            <pubDate>Fri, 14 Aug 2026 05:50:58 +0000</pubDate>
            <link>https://docs.spryker.com/docs/about/all/releases/release-notes-202606.0.html</link>
            <guid isPermaLink="true">https://docs.spryker.com/docs/about/all/releases/release-notes-202606.0.html</guid>
            
            
        </item>
        
    </channel>
</rss>
