Back Office Composable UI Best Practices
Edit on GitHubThis 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