Back Office Composable UI Best Practices

Edit on GitHub
Beta

Composable UI is currently in beta and intended for internal use. This functionality is under active development, and there is no backward compatibility guarantee at this time. We do not recommend using it in production projects until it reaches a stable release.

This document provides best practices and guidelines for developing Composable UI modules based on real-world implementation experience.

Configuration design

Use meaningful component IDs

Component IDs are used internally by the Composable UI system to:

  • Reference components with the use keyword, for example, use: field.customer.email
  • Override auto-generated components in partial override mode
  • Enable component reusability across forms and views

These IDs are configuration-level identifiers and are not rendered in HTML or exposed to JavaScript. Use a consistent naming convention:

# Good: Clear, namespaced IDs
layout.customer.page
table.customer.list
form.customer.create
form.customer.edit
field.customer.email
action.customer.create
headline.customer.create

# Avoid: Generic or unclear IDs
layout1
myTable
form
emailField

Prefer composition over duplication

Reuse components with use and overrides instead of duplicating configuration:

# Good: Reuse base field with overrides
form.customer.edit:
    component: DynamicFormComponent
    fields:
        - use: field.customer.email
          overrides:
              value: '${row.email}'

# Avoid: Duplicating field definition
form.customer.edit:
    component: DynamicFormComponent
    fields:
        - name: 'email'
          type: 'email'
          label: 'Email'
          value: '${row.email}'

Keep configurations DRY

Extract common patterns into reusable components:

# Define field once
field.customer.email:
    type: email
    label: 'Email'
    required: true
    validators: { email: true }

# Reuse in multiple forms with simplified syntax
form.customer.create:
    component: DynamicFormComponent
    fields:
        - use: field.customer.email
        - use: field.customer.firstName
    submit:
        label: 'Create'
        url: '/customers'
        success: 'Customer created successfully'
        error: 'Failed to create customer'

form.customer.edit:
    component: DynamicFormComponent
    fields:
        - use: field.customer.email
        - use: field.customer.firstName
    submit:
        label: 'Save'
        url: '/customers/${row.customerReference}'
        success: 'Customer saved successfully'
        error: 'Failed to save customer'

Organize components logically

Group related components together in the configuration:

view:
    components:
        # Layouts first
        layout.customer.page:
            # ...

        # Then fields
        field.customer.email:
            # ...
        field.customer.firstName:
            # ...

        # Then forms
        form.customer.create:
            # ...
        form.customer.edit:
            # ...

        # Then tables
        table.customer.list:
            # ...

        # Then actions
        action.customer.create:
            # ...

        # Then headlines
        headline.customer.create:
            # ...

API design

Use consistent naming

Follow REST conventions for resource naming:

Resource Endpoint Description
Customers /customers Customer collection
Customer /customers/{id} Single customer
CustomerAddresses /customer-addresses Address collection

Configure searchable fields in YAML

Mark fields as searchable in your entity YAML configuration:

fields:
    email:
        searchable: true
    firstName:
        searchable: true
    lastName:
        searchable: true
    customerReference:
        searchable: true

The AbstractBackendProvider automatically handles search functionality for all fields marked with searchable: true. No additional PHP code is required.

Form design

Organize form fields logically:

form.customer.create:
    component: DynamicFormComponent
    style:
        padding: '30px'
    fields:
        # Personal information
        - use: field.customer.salutation
        - use: field.customer.firstName
        - use: field.customer.lastName
        
        # Contact information
        - use: field.customer.email
        - use: field.customer.phone
        
        # Additional details
        - use: field.customer.dateOfBirth
        - use: field.customer.company
    submit:
        label: 'Create'
        url: '/customers'
        success: 'Customer created successfully'

Provide clear validation messages

Use descriptive validation messages:

# Simplified field definition
field.customer.email:
    type: email
    label: 'Email Address'
    required: true
    validators:
        email:
            message: 'Please enter a valid email address'

# Or in CRUD mode fields section
fields:
    email:
        type: email
        required: true
        searchable: true

Handle form submission feedback

Always provide success and error feedback. Use the simplified syntax:

submit:
    label: 'Save'
    url: '/customers/${row.customerReference}'  # method: PATCH is default for edit
    success: 'Customer saved successfully'
    error: 'Failed to save customer. Please check your input and try again.'

Table design

Choose appropriate column types

Use appropriate column type for each data type:

columns:
    - { id: 'reference', title: 'Reference' }
    - { id: 'status', title: 'Status', type: 'chip' }
    - { id: 'createdAt', title: 'Created', type: 'date', format: 'dd.MM.y' }
    - { id: 'thumbnail', title: 'Image', type: 'image' }
    - { id: 'price', title: 'Price' }  # Format in provider

Provide useful filters

Add filters for commonly searched fields:

filters:
    - id: 'status'
      title: 'Status'
      type: 'select'
      datasource:
          url: '/statuses'
    - id: 'createdAt'
      title: 'Created Date'
      type: 'date-range'
    - id: 'category'
      title: 'Category'
      type: 'select'
      datasource:
          url: '/categories'

Configure pagination sizes

Pagination is enabled by default with sizes 5,10, 25. Customize only if needed:

# Custom page sizes (only if default doesn't fit your needs)
pagination: [10, 50, 100]

Security

Always require authentication

Set security on all API resources:

resource:
    security: "is_granted('IS_AUTHENTICATED_FULLY')"

Validate input on the server

Never trust client-side validation alone:

# validation.yml
properties:
    email:
        - NotBlank: ~
        - Email: ~
    amount:
        - NotBlank: ~
        - Positive: ~
        - LessThanOrEqual: 1000000

API Provider implementation

Always use getter methods for Transfer mapping

Critical: When mapping Transfer objects to API resources in Providers, always use getter methods, never toArray():

// ✅ Correct - reliable data retrieval
protected function mapTransferToResource(AbstractTransfer $transfer): object
{
    return CustomersBackendResource::fromArray([
        'customerReference' => $transfer->getCustomerReference(),
        'email' => $transfer->getEmail(),
        'firstName' => $transfer->getFirstName(),
        'salutation' => $transfer->getSalutation(),
    ]);
}

// ❌ Wrong - returns null for unmodified fields
protected function mapTransferToResource(AbstractTransfer $transfer): object
{
    $resource = new CustomersBackendResource();
    $resource->fromArray($transfer->toArray());
    return $resource;
}

Why: The toArray() method only returns fields that were explicitly modified during the transfer’s lifecycle. Unmodified fields return null, causing data loss.

Extend AbstractBackendProvider

Use AbstractBackendProvider for standardized functionality:

use Spryker\ApiPlatform\Provider\AbstractBackendProvider;

class CustomersBackendProvider extends AbstractBackendProvider
{
    protected function provideItem(string $identifier): ?object
    {
        $transfer = $this->facade->findByReference($identifier);
        return $transfer ? $this->mapTransferToResource($transfer) : null;
    }
    
    protected function fetchAllItems(): array
    {
        return $this->facade->getAllEntities();
    }
}

Configure public endpoints correctly

For reference data endpoints (salutations, countries), omit the security property:

# Public endpoint - no authentication required
resource:
    name: Salutations
    provider: SprykerFeature\Glue\CustomerRelationManagement\Api\Backend\Provider\SalutationsBackendProvider
    operations:
        - type: GetCollection

# Protected endpoint - authentication required
resource:
    name: Customers
    security: "is_granted('IS_AUTHENTICATED_FULLY')"
    provider: SprykerFeature\Glue\CustomerRelationManagement\Api\Backend\Provider\CustomersBackendProvider

Performance

Optimize API queries

Fetch only required data:

protected function fetchAllItems(): array
{
    // Use efficient queries with proper indexes
    return $this->facade->getCustomersForList();
}

Use appropriate page sizes

Don’t load too much data at once:

paginationItemsPerPage: 10  # Good default
# Avoid: paginationItemsPerPage: 1000

Load related data only when needed:

# In table, show only essential columns
columns:
    - { id: 'reference', title: 'Reference' }
    - { id: 'name', title: 'Name' }
    - { id: 'status', title: 'Status' }

# Load full details in drawer on row click
rowClick:
    drawer:
        - use: headline.entity.edit
        - use: form.entity.edit

Testing

Test API endpoints

Write tests for your API providers and processors:

public function testGetCollectionReturnsCustomers(): void
{
    // Arrange
    $this->createTestCustomers();

    // Act
    $response = $this->get('/customers');

    // Assert
    $this->assertResponseIsSuccessful();
    $this->assertJsonContains(['totalItems' => 3]);
}

Test ACL rules

Verify access control works correctly:

public function testUnauthorizedUserCannotDelete(): void
{
    // Arrange
    $this->loginAsViewer();

    // Act
    $response = $this->delete('/customers/DE--1');

    // Assert
    $this->assertResponseStatusCodeSame(403);
}

Validate configuration

Use schema validation for YAML configurations:

vendor/bin/console feature:validate 

Documentation

Document custom components

Add comments to explain non-obvious configuration:

table.order.list:
    component: TableComponent
    id: 'order-table'
    dataSource:
        url: '/orders'
    # Custom column configuration for order status
    # Status values: pending, processing, shipped, delivered, cancelled
    columns:
        - id: 'status'
          title: 'Status'
          type: 'chip'