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.json registration 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/list becomes products/list (joined with /)
  • Mobile: /(app)/m/products/list becomes products.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/list on the web (shadcn/Tailwind rendering)
  • /(app)/m/your-module/list on 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