Back Office Composable UI Best Practices
Edit on GitHubComposable 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
usekeyword, 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
Group related fields
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
Lazy load related data
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'
Thank you!
For submitting the form