Creating SDUI Screens
This guide walks through adding screens to your module. The SDUI (Server-Driven UI) system lets any module contribute screens to both the web frontend and the mobile app without requiring a frontend rebuild.
Both platforms, one definition. The same
module.jsonregistration and screen provider class serves both web and mobile. The backend JSON is identical — each platform renders it with native components (shadcn/Tailwind on web, React Native on mobile).
Prerequisites
- The MobileApp module must be enabled
- Your module must have a PSR-4 autoload entry in
backend/composer.json - Familiarity with the SDUI system overview
Quick Start
1. Create the Screen Provider
Create a class extending BaseMobileScreenProvider in your module's backend:
modules/YourModule/backend/App/MobileScreens/YourListScreen.php
<?php
namespace Modules\YourModule\App\MobileScreens;
use App\Core\Services\BaseMobileScreenProvider;
class YourListScreen extends BaseMobileScreenProvider
{
protected int $definitionCacheTtl = 600; // Cache for 10 minutes
public function getScreenId(): string
{
return 'your-module.list';
}
public function getDefinition(array $context = []): array
{
return [
'id' => 'your-module.list',
'version' => 1,
'type' => 'screen',
'title' => 'Your Module',
'layout' => 'scroll',
'header' => [
'title' => 'Your Module',
'subtitle' => 'Description here',
'show_back' => true,
],
'sections' => [
// Add sections here
],
'pull_to_refresh' => true,
];
}
public function getData(string $sectionId, array $params = [], array $context = []): array
{
// Return data for each section
return [];
}
}
2. Register in module.json
Add the mobile section to your module's module.json:
{
"name": "YourModule",
"mobile": {
"screens": [
{
"id": "your-module.list",
"title": "Your Module",
"icon": "Box",
"layout": "scroll",
"route": "/your-module",
"version": 1
}
],
"screenProviders": {
"your-module.list": "Modules\\YourModule\\App\\MobileScreens\\YourListScreen"
}
}
}
3. Sync
Run the module sync command:
php artisan module:sync
The screen is now available on both platforms and appears in navigation automatically:
- Web:
https://your-domain.com/m/your-module/list - Mobile:
/(app)/m/your-module/list(sidebar entry)
The Contract
All screen providers implement MobileScreenProviderContract:
interface MobileScreenProviderContract
{
public function getScreenId(): string;
public function getDefinition(array $context = []): array;
public function getData(string $sectionId, array $params = [], array $context = []): array;
public function handleAction(string $actionId, array $payload = [], array $context = []): array;
}
The BaseMobileScreenProvider base class provides:
- Definition caching (configurable TTL)
- Cache invalidation helpers
- Section builder methods (
section(),statsRow(),listSection(),field(),action()) - Default
handleAction()that returns not-found
Context Object
All methods receive a $context array:
| Key | Type | Description |
|---|---|---|
tenant_id |
string | Current tenant ID |
user |
User model | Authenticated user |
params |
array | Request query parameters |
Building a List Screen
A typical list screen has a stats row at top and a searchable/filterable list below.
Definition
public function getDefinition(array $context = []): array
{
return [
'id' => 'products.list',
'version' => 3,
'type' => 'screen',
'title' => 'Products',
'layout' => 'scroll',
'header' => [
'title' => 'Products',
'subtitle' => 'Manage your catalog',
'show_back' => true,
],
'sections' => [
[
'id' => 'stats',
'type' => 'stats-row',
'items' => [
['label' => 'Total', 'value_key' => 'total', 'format' => 'number'],
['label' => 'Active', 'value_key' => 'active', 'format' => 'number'],
['label' => 'Revenue', 'value_key' => 'revenue', 'format' => 'currency', 'currency' => 'INR'],
],
],
[
'id' => 'list',
'type' => 'list',
'fields' => [
['key' => 'image', 'label' => 'Image', 'type' => 'image', 'image_size' => 'sm'],
['key' => 'name', 'label' => 'Name', 'type' => 'text', 'primary' => true],
['key' => 'sku', 'label' => 'SKU', 'type' => 'text', 'secondary' => true],
['key' => 'price', 'label' => 'Price', 'type' => 'currency', 'visible_in_list' => true, 'currency' => 'INR'],
['key' => 'status', 'label' => 'Status', 'type' => 'badge', 'visible_in_list' => true,
'badge_colors' => ['active' => '#16A34A', 'draft' => '#6B7280']],
],
'searchable' => true,
'search_placeholder' => 'Search...',
'filterable' => true,
'filters' => [
[
'key' => 'status',
'label' => 'Status',
'type' => 'select',
'options' => [
['label' => 'All', 'value' => 'all'],
['label' => 'Active', 'value' => 'active'],
['label' => 'Draft', 'value' => 'draft'],
],
],
[
'key' => 'sort',
'label' => 'Sort',
'type' => 'select',
'options' => [
['label' => 'Newest', 'value' => 'newest'],
['label' => 'Price: Low', 'value' => 'price_asc'],
['label' => 'Price: High', 'value' => 'price_desc'],
],
],
],
'on_tap' => [
'type' => 'navigate',
'route' => '/(app)/m/products/detail',
'params' => ['id' => '{{id}}'],
],
'pagination' => true,
],
],
'pull_to_refresh' => true,
];
}
Data Handler
public function getData(string $sectionId, array $params = [], array $context = []): array
{
if ($sectionId === 'stats') {
return [
'total' => Product::count(),
'active' => Product::where('status', 'active')->count(),
'revenue' => (float) Order::sum('total'),
];
}
if ($sectionId === 'list') {
$query = Product::with('images');
// Status filter
if (!empty($params['status']) && $params['status'] !== 'all') {
$query->where('status', $params['status']);
}
// Search
if (!empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%");
});
}
// Sort
match ($params['sort'] ?? 'newest') {
'oldest' => $query->oldest(),
'price_asc' => $query->orderBy('price', 'asc'),
'price_desc' => $query->orderBy('price', 'desc'),
default => $query->latest(),
};
$page = max((int) ($params['page'] ?? 1), 1);
$paginated = $query->paginate(20, ['*'], 'page', $page);
return [
'items' => $paginated->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'sku' => $p->sku,
'price' => (float) $p->price,
'status' => $p->status,
'image' => $p->images->first()?->url,
])->toArray(),
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'has_more' => $paginated->hasMorePages(),
];
}
return [];
}
Building a Detail Screen
A detail screen typically has an image gallery, grouped info cards, and action buttons.
Definition
public function getDefinition(array $context = []): array
{
return [
'id' => 'products.detail',
'version' => 1,
'type' => 'screen',
'title' => 'Product',
'layout' => 'scroll',
'header' => ['title' => 'Product', 'show_back' => true],
'sections' => [
['id' => 'images', 'type' => 'image-gallery', 'image_key' => 'images'],
[
'id' => 'info',
'type' => 'detail-fields',
'title' => 'Overview',
'fields' => [
['key' => 'name', 'label' => 'Name', 'type' => 'text'],
['key' => 'price', 'label' => 'Price', 'type' => 'currency', 'currency' => 'INR'],
['key' => 'status', 'label' => 'Status', 'type' => 'badge',
'badge_colors' => ['active' => '#16A34A', 'draft' => '#6B7280']],
['key' => 'created_at', 'label' => 'Created', 'type' => 'relative-time'],
],
],
[
'id' => 'actions',
'type' => 'actions-bar',
'actions' => [
[
'id' => 'share',
'label' => 'Share',
'variant' => 'outline',
'action' => ['type' => 'share', 'text' => '{{name}} — {{price}}'],
],
],
],
],
'pull_to_refresh' => true,
];
}
Data Handler
For detail screens, the item ID comes from the query params:
public function getData(string $sectionId, array $params = [], array $context = []): array
{
$id = $params['id'] ?? $context['params']['id'] ?? null;
if (!$id) return [];
$item = Product::with(['category', 'images'])->find($id);
if (!$item) return [];
// Return all fields — each detail-fields section reads what it needs
return [
'id' => $item->id,
'name' => $item->name,
'price' => (float) $item->price,
'status' => $item->status,
'category' => $item->category?->name,
'created_at' => $item->created_at?->toISOString(),
'images' => $item->images->pluck('url')->toArray(),
];
}
Handling Actions
Override handleAction() for server-side actions:
public function handleAction(string $actionId, array $payload = [], array $context = []): array
{
if ($actionId === 'archive') {
$product = Product::find($payload['id']);
if (!$product) return ['success' => false, 'error' => 'Not found'];
$product->update(['status' => 'archived']);
return ['success' => true, 'message' => 'Product archived'];
}
return parent::handleAction($actionId, $payload, $context);
}
Screen ID Conventions
Screen IDs follow the pattern {module}.{page}:
| Screen ID | Web URL | Mobile URL | Purpose |
|---|---|---|---|
products.list |
/m/products/list |
/(app)/m/products/list |
Product listing |
products.detail |
/m/products/detail?id=123 |
/(app)/m/products/detail?id=123 |
Product detail |
orders.list |
/m/orders/list |
/(app)/m/orders/list |
Order listing |
customers.detail |
/m/customers/detail?id=456 |
/(app)/m/customers/detail?id=456 |
Customer detail |
Each platform converts URL segments to screen IDs:
- Web:
/m/products/listbecomesproducts/list(joined with/) - Mobile:
/(app)/m/products/listbecomesproducts.list(joined with.)
Versioning
Bump the version number in your screen definition whenever the structure changes (new sections, renamed fields, etc.). The mobile app uses version numbers to invalidate cached definitions.
return [
'id' => 'products.list',
'version' => 3, // Bump this when definition changes
...
];
Cross-Platform: One Definition, Two Renderers
The module.json mobile section is the single source of truth for both platforms. You do not need separate definitions for web and mobile:
{
"mobile": {
"screens": [
{
"id": "your-module.list",
"title": "Your Module",
"icon": "Box",
"layout": "scroll",
"route": "/your-module",
"version": 1
}
],
"screenProviders": {
"your-module.list": "Modules\\YourModule\\App\\MobileScreens\\YourListScreen"
}
}
}
This single registration makes the screen available at:
/m/your-module/liston the web (shadcn/Tailwind rendering)/(app)/m/your-module/liston mobile (React Native rendering)
The backend API serves identical JSON to both. Each platform's ScreenRenderer and section registry handle the platform-specific rendering.
For the web-specific implementation details, see Web SDUI Renderer.
Discovery
Screens are automatically discovered by ModuleExtensionLoader during the Laravel boot phase. The loader reads module.json, finds the mobile.screens and mobile.screenProviders arrays, and registers them in MobileScreenRegistry.
The mobile app's sidebar fetches GET /mobile/screens on first open and displays entry-point screens (IDs not containing "detail", "edit", or "create") as navigation items. On the web, the catch-all route at /m/[...slug] checks for SDUI screens dynamically on each page load.
Helper Methods
BaseMobileScreenProvider provides builder methods:
// Create a section
$this->section('stats', 'stats-row', ['items' => [...]]);
// Create a stats row with items
$this->statsRow('stats', [
$this->stat('Total', 'total_count', 'number'),
$this->stat('Revenue', 'total_revenue', 'currency', 'DollarSign'),
]);
// Create a stat item
$this->stat('Label', 'value_key', 'format', 'icon');
// Create a list section with fields
$this->listSection('list', [
$this->field('name', 'Name', 'text', ['primary' => true]),
$this->field('status', 'Status', 'badge', ['visible_in_list' => true]),
], ['searchable' => true, 'pagination' => true]);
// Create a field
$this->field('key', 'Label', 'type', ['extra' => 'options']);
// Create an action reference
$this->action('navigate', ['route' => '/somewhere']);
Docker Deployment
Since backend code is baked into the Docker image (not volume-mounted), you must rebuild the container after adding or modifying screen providers:
docker compose -f docker-compose.prod.yml build app
docker compose -f docker-compose.prod.yml up -d app
docker compose -f docker-compose.prod.yml exec app php artisan module:sync
Related Documentation
- SDUI System Overview — architecture, section types, action types, caching
- Web SDUI Renderer — web-specific implementation reference
- Mobile App Module — push notifications, voice assistant, settings
- Module Development — general module development guide