Skip to content

Admin Module Refactoring Guide

Project Philosophy

Petrovo CMS admin modules follow a clean Controller → Service → Database pattern:

  • Services own all database queries and business logic
  • Controllers handle HTTP/responses, form validation flow
  • Abstract base class AdminController provides common functionality
  • Each module is self-contained with no shared state

Module Structure

app/Modules/{ModuleName}/
├── {ModuleName}Controller.php           # HTTP handler, returns Response
├── {ModuleName}Service.php              # DB queries + business logic
├── {ModuleName}ListService.php          # Listing/filtering (only if complex)
├── router.php                           # Route definitions
├── data.yaml                            # Form field schema (YAML)
├── templates/
│   ├── listing.tpl                      # Listing page
│   └── form.tpl                         # Create/edit form (auto-generated)
└── src/
    ├── js/{module}.js                   # Client-side logic (optional)
    └── scss/{module}.scss               # Styling (optional)

When to create extra classes:

  • {Module}ListService — only if list() logic exceeds ~50 lines OR has complex filters/pagination
  • {Module}FrontendService — only if module provides data to frontend template tags
  • Never create files "for consistency" — add them when you need them

Step 1: Abstract Base Class AdminController

All admin module controllers inherit from Admin\Shared\AdminController. This base class provides:

abstract class AdminController
{
    // Properties populated by initModule()
    public array  $selects       = [];  // Form select options (from child or override)
    public array  $yamlFields    = [];  // Parsed form_fields from data.yaml
    public string $sessionPrefix = '';  // Session key prefix for this module

    // Load YAML config and define PAGE_TITLE, PAGE constants
    protected function initModule(string $dir, string $page): void

    // Session helper: save to session if in $data, else retrieve or return default
    public function sessionValue(array $data, string $key, mixed $default = null): mixed

    // Reset listing pagination to page 1
    public function resetListingPage(): int

    // Override for business validation (beyond YAML schema)
    protected function validate(AdminForm $form): bool

    // Orchestrate form submission: validate → execute callback → return response
    protected function handleFormSubmit(AdminForm $form, callable $onSuccess): Response

    // Define EDIT_RECORD_TITLE constant for template rendering
    protected function setEditRecordTitle(?string $title = null): void
}

Key points:

  • Child controller calls $this->initModule(__DIR__, self::PAGE) in __construct()
  • Form validation happens in AdminForm::__construct() (YAML schema) + override validate() for business logic
  • handleFormSubmit() checks validation, calls $onSuccess callback on success, returns error JSON on failure
  • sessionValue() persists filter state between page loads (search, orderBy, pagination)

Step 2: Service Layer

{Module}Service.php

Owns all database queries and business logic. Returns clean data (objects, booleans, integers).

Requirements:

  • Type hints on all methods (inputs and return types)
  • Business validation only (e.g., isNameTaken())
  • All DB queries use parameterized statements (DB::query(..., $params))
  • Return types: object|null, bool, int, array

Example: AdminsPermissionsService

<?php
declare(strict_types=1);

namespace App\Modules\AdminsPermissions;

use Petrovo\Database\DB;

class AdminsPermissionsService
{
    public function get(int $id): object|null
    {
        return DB::row(
            "SELECT * FROM admin_permission WHERE id = ?",
            [$id]
        );
    }

    public function create(string $permission): int
    {
        DB::query(
            "INSERT IGNORE INTO admin_permission (permission) VALUES (?)",
            [$permission]
        );

        return empty(DB::$lastError) ? DB::$insertId : 0;
    }

    public function update(int $id, string $permission): bool
    {
        DB::query(
            "UPDATE admin_permission SET permission = ? WHERE id = ?",
            [$permission, $id]
        );

        return empty(DB::$lastError);
    }

    public function delete(int $id): bool
    {
        DB::query(
            "DELETE FROM admin_permission WHERE id = ?",
            [$id]
        );

        return (bool)DB::$affectedRows;
    }

    /**
     * Get all permissions (optionally excluding superadmin)
     * @return array<int, object{id: int, permission: string}>
     */
    public function selectAll(bool $superadmin = false): array
    {
        $permissions = DB::results(
            "SELECT id, permission FROM admin_permission ORDER BY permission"
        );

        if (!$superadmin) {
            $permissions = array_filter(
                $permissions,
                fn($p) => $p->permission !== 'superadmin'
            );
        }

        return array_values($permissions);
    }
}

{Module}ListService.php (When Needed)

Handles complex listing: filtering, searching, pagination, ordering. Return value includes metadata for templates.

Example: AdminsPermissionsListService

<?php
declare(strict_types=1);

namespace App\Modules\AdminsPermissions;

use Petrovo\Database\DB;

class AdminsPermissionsListService
{
    /**
     * Get paginated permissions list with filtering
     *
     * Supports search, first-letter filter, ordering, pagination
     *
     * @param array{
     *     search?: string,
     *     filter1?: string,
     *     orderBy?: string,
     *     listingPage?: int,
     *     limit?: int
     * } $params Filter/pagination parameters
     *
     * @return array{
     *     rows: array<int, object{id: int, permission: string}>,
     *     count: int,
     *     search: string,
     *     filter1: array<int, array{value: string, text: string}>,
     *     filter1_selected: string,
     *     orderBy: array<int, array{value: string, text: string}>,
     *     orderBy_selected: string,
     *     listingPage: int,
     *     limit: int
     * }
     */
    public function list(array $params): array
    {
        $output = [];
        $output['listingPage'] = (int)($params['listingPage'] ?? 1);
        $output['limit'] = $limit = (int)($params['limit'] ?? 50);
        $offset = ($output['listingPage'] - 1) * $limit;
        $search = $params['search'] ?? '';
        $filter1 = $params['filter1'] ?? '';
        $output['search'] = $search;

        // Sort order whitelist (prevent SQL injection)
        $options = [
            ['value' => 'permission', 'text' => '{S Permission} A-Z'],
            ['value' => 'permission DESC', 'text' => '{S Permission} Z-A'],
        ];
        $allowed = array_column($options, 'value');
        $orderBy = $params['orderBy'] ?? 'permission';
        $orderBy = in_array($orderBy, $allowed) ? $orderBy : 'permission';
        $output['orderBy'] = $options;
        $output['orderBy_selected'] = $orderBy;

        // Build WHERE clause with parameterized query
        $parts = [];
        $queryParams = [];

        if ($filter1) {
            $parts[] = "permission LIKE ?";
            $queryParams[] = $filter1 . '%';
        }
        if ($search) {
            $parts[] = "permission LIKE ?";
            $queryParams[] = '%' . $search . '%';
        }

        $where = empty($parts) ? '' : 'WHERE ' . implode(' AND ', $parts);

        // Get paginated results
        $output['rows'] = DB::results(
            "SELECT * FROM admin_permission $where ORDER BY $orderBy LIMIT ? OFFSET ?",
            [...$queryParams, $limit, $offset]
        );

        // Get total count for pagination
        $output['count'] = (int)DB::var(
            "SELECT COUNT(*) FROM admin_permission $where",
            $queryParams
        );

        // Generate first-letter filter options (a-z, 0-9, dash, underscore)
        $chars = [...range(97, 122), 45, 95, ...range(48, 57)];
        $output['filter1'] = array_merge(
            [['value' => '', 'text' => '= {S first letter} =']],
            array_map(fn($c) => ['value' => chr($c), 'text' => strtoupper(chr($c)) . '...'], $chars)
        );
        $output['filter1_selected'] = $filter1;

        return $output;
    }
}

Step 3: Controller

Extends AdminController. Handles HTTP requests, form rendering, form submission orchestration.

Key points:

  • Call $this->initModule(__DIR__, self::PAGE) in constructor
  • All methods receive ServerRequestInterface|null $request and array $params
  • All methods return Response (use html(), translatedJson(), Html::wrap())
  • Use AdminForm for form creation/rendering
  • Use $this->handleFormSubmit() for create/update operations
  • Use PaginatorNew for AJAX pagination (not Paginator)
  • Detect fetch vs. full page with has_header($request, 'X-REQUESTED-WITH')

Response types:

  • Html::wrap($html) — full page (for initial navigation, non-AJAX)
  • html($html) — content fragment (for fetch updates)
  • translatedJson([...]) — API response (for create, update, delete, AJAX actions)

Example: AdminsPermissionsController

<?php
declare(strict_types=1);

namespace App\Modules\AdminsPermissions;

use Admin\Shared\AdminBaseController;use App\Modules\Html;use App\Modules\Paginator;use HttpSoft\Message\Response;use Petrovo\Form\AdminForm;use Petrovo\Template\Template;use Psr\Http\Message\ServerRequestInterface;use function Petrovo\Array\{array_get_int,array_get_str};use function Petrovo\Http\{has_header,html,translatedJson};use function Petrovo\I18n\translate;

/**
 * AdminPermission Controller
 *
 * REST API routes:
 * - GET    /ADMIN/adminsPermissions              → list()     Listing page
 * - GET    /ADMIN/adminsPermissions/{id}         → show()     Edit form
 * - POST   /ADMIN/adminsPermissions              → create()   New permission
 * - POST   /ADMIN/adminsPermissions/{id}         → update()   Update permission
 * - DELETE /ADMIN/adminsPermissions/{id}         → delete()   Delete permission
 * - POST   /ADMIN/adminsPermissions/filter       → filter()   Filter listing (AJAX)
 */
class AdminsPermissionsController extends AdminBaseController
{
    const string PAGE = 'adminsPermissions';

    private AdminsPermissionsService $service;
    private AdminsPermissionsListService $listService;

    public string $sessionPrefix = 'ADMIN:Permissions:';

    public function __construct()
    {
        $this->service = new AdminsPermissionsService();
        $this->listService = new AdminsPermissionsListService();
        $this->initModule(__DIR__, self::PAGE);  // Load data.yaml, define PAGE_TITLE
    }

    /**
     * GET /ADMIN/adminsPermissions → Listing page
     *
     * @return Response
     */
    public function list(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $isFetch = has_header($request, 'X-REQUESTED-WITH');
        $html = $this->getListingHtml(last: $isFetch);

        return $isFetch ? html($html) : Html::wrap($html);
    }

    /**
     * POST /ADMIN/adminsPermissions/filter → Filter listing (AJAX)
     *
     * @return Response
     */
    public function filter(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $this->resetListingPage();
        $html = $this->getListingHtml(last: true);
        return html($html);
    }

    /**
     * Generate listing HTML
     */
    protected function getListingHtml(bool $last = false): string
    {
        $data = $_POST;
        $data['search'] = $this->sessionValue($data, 'search');
        $data['listingPage'] = (int)$this->sessionValue($_GET, 'listingPage', 1);

        $listData = $this->listService->list($data);
        $href = '/ADMIN/' . LANG . '/adminsPermissions';
        $pagination = Paginator::paginate(
            $href,
            $listData['count'],
            $listData['limit'],
            $listData['listingPage']
        );

        $tpl = new Template();
        $tpl->data('data', $listData);
        $tpl->data('pagination', $pagination);

        return $tpl->draw(__DIR__ . '/templates/listing.tpl', $last);
    }

    /**
     * GET /ADMIN/adminsPermissions/{id} → Edit form
     */
    public function show(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $id = array_get_int($params, 'id', 0);
        $fields = $id ? (array)$this->service->get($id) : [];

        // AdminForm auto-generates form from data.yaml + existing data
        $form = new AdminForm(
            $fields,
            $this->selects,
            $this->yamlFields,
            $this->sessionPrefix,
            new_admin: true
        );

        $tpl = new Template();
        $html = $tpl->drawString($form->render(), true);

        $isFetch = has_header($request, 'X-REQUESTED-WITH');

        return $isFetch ? html($html) : Html::wrap($html);
    }

    /**
     * POST /ADMIN/adminsPermissions → Create new permission
     */
    public function create(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $form = new AdminForm(
            [],
            $this->selects,
            $this->yamlFields,
            $this->sessionPrefix,
            new_admin: true
        );

        // handleFormSubmit() checks YAML validation + business validation
        // If valid, executes callback; if not, returns error JSON
        return $this->handleFormSubmit($form, function () {
            $id = $this->service->create(array_get_str($_POST, 'permission', ''));

            return translatedJson([
                'status' => 'ok',
                'message' => translate('Saved', LANG, 'admin'),
                'data' => ['id' => $id]
            ]);
        });
    }

    /**
     * POST /ADMIN/adminsPermissions/{id} → Update permission
     */
    public function update(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $id = array_get_int($params, 'id', 0);
        $form = new AdminForm(
            [],
            $this->selects,
            $this->yamlFields,
            $this->sessionPrefix,
            new_admin: true
        );

        return $this->handleFormSubmit($form, function () use ($id) {
            $this->service->update($id, array_get_str($_POST, 'permission', ''));

            return translatedJson([
                'status' => 'ok',
                'message' => translate('Saved', LANG, 'admin')
            ]);
        });
    }

    /**
     * DELETE /ADMIN/adminsPermissions/{id} → Delete permission
     */
    public function delete(?ServerRequestInterface $request = null, array $params = []): Response
    {
        $id = array_get_int($params, 'id', 0);
        $result = $this->service->delete($id);

        if ($result) {
            return translatedJson([
                'status' => 'success',
                'message' => 'Deleted successfully',
                'data' => ['listUrl' => '/ADMIN/' . LANG . '/adminsPermissions'],
            ]);
        }

        return translatedJson([
            'status' => 'error',
            'message' => translate('Delete failed', LANG, 'admin')
        ]);
    }
}

Step 4: Routes (router.php)

Define REST routes. Use str_starts_with() condition to register routes only for relevant requests.

HTTP Method Convention:

  • GET — read operations (list, show/edit form)
  • POST — create + update + custom actions (not PATCH)
  • DELETE — delete operations

Example: AdminsPermissions router.php

<?php
declare(strict_types=1);

use App\Modules\AdminsPermissions\AdminsPermissionsController;
use function Petrovo\Http\{path, request};
use function Petrovo\Route\{delete, get, post};

// PIPE_ADMIN defined in app/routes/app-admin-api.php

$requestPath = path(request());

if (str_starts_with($requestPath, '/ADMIN/adminsPermissions')) {
    get('/ADMIN/adminsPermissions', [AdminsPermissionsController::class, 'list'], PIPE_ADMIN);
    post('/ADMIN/adminsPermissions/filter', [AdminsPermissionsController::class, 'filter'], PIPE_ADMIN);
    get('/ADMIN/adminsPermissions/{id}', [AdminsPermissionsController::class, 'show'], PIPE_ADMIN);
    post('/ADMIN/adminsPermissions', [AdminsPermissionsController::class, 'create'], PIPE_ADMIN);
    post('/ADMIN/adminsPermissions/{id}', [AdminsPermissionsController::class, 'update'], PIPE_ADMIN);
    delete('/ADMIN/adminsPermissions/{id}', [AdminsPermissionsController::class, 'delete'], PIPE_ADMIN);
}

URL Convention (IMPORTANT)

  • camelCase: /ADMIN/adminsPermissions, /ADMIN/adminGroups
  • Always use str_starts_with() guard to avoid registering routes for unrelated requests

There are TWO different URL formats:

1) Frontend URLs (templates, JS) ALWAYS include language: /ADMIN/{LANG}/{module} /ADMIN/{LANG}/{module}/{id} /ADMIN/{LANG}/{module}/new

2) Router URLs (PHP router.php) NEVER include language: /ADMIN/{module} /ADMIN/{module}/{id}

Rules:

  • NEVER use {LANG} in router definitions
  • ALWAYS use {LANG} in templates and JS
  • Do not mix these two formats

Example:

Template:
<a data-content="/ADMIN/{LANG}/admins/123">

Router:
get('/ADMIN/admins/{id}', ...)

Step 5: Form Configuration (data.yaml)

Defines form fields, validation rules (YAML schema), and UI metadata.

Validation Rules (YAML):

  • Format validation (regex, required, length) → data.yaml
  • Business validation (uniqueness, permissions) → Service + override validate() in Controller

Example: AdminsPermissions data.yaml

---
template: admin
title: Group permissions
header_new: New permission
header_edit: Change of permission

form_fields:
  -
    section: start
    title: Permission
  -
    name: permission
    label: Permission
    validation: "/^.+$/"
    validation_text: "The permission must be entered."
    element:
      type: text
      size: 70
      maxlength: 100
    description: Name of controller
  -
    section: end
  -
    fieldset: separator
    class: form_submits
  -
    name: close
    no_end: 1
    element:
      type: submit
      size: 15
      value: Save and Close
      title: Save and Close
      accesskey: c
  -
    name: back
    element:
      type: submit
      size: 15
      value: Back
      title: Back
      accesskey: b

Step 6: DATA Endpoint Wrappers (Optional)

Keep DATA endpoint files as thin wrappers for backward compatibility. They delegate to Service.

Example: AdminsPermissions/Create.php

<?php
declare(strict_types=1);

use App\Modules\AdminsPermissions\AdminsPermissionsService;
use function Petrovo\Data\outputJson;

$_post = $_post ?? $_POST ?? [];
return outputJson((new AdminsPermissionsService())->create($_post['permission'] ?? ''));

Example: AdminsPermissions/Update.php

<?php
declare(strict_types=1);

use App\Modules\AdminsPermissions\AdminsPermissionsService;
use function Petrovo\Data\outputJson;

$_post = $_post ?? $_POST ?? [];
$id = (int)($_post['id'] ?? 0);
return outputJson((new AdminsPermissionsService())->update($id, $_post['permission'] ?? ''));

Example: AdminsPermissions/Delete.php

<?php
declare(strict_types=1);

use App\Modules\AdminsPermissions\AdminsPermissionsService;
use function Petrovo\Data\outputJson;

$_id = (int)($_get['id'] ?? $_GET['id'] ?? 0);
return outputJson((new AdminsPermissionsService())->delete($_id));

Step 7: Frontend Templates

listing.tpl

Displays list with filters, pagination, and row actions.

Features:

  • Filter form with search, ordering
  • Pagination (use PaginatorNew for AJAX support)
  • Data table with edit/delete actions
  • Uses data-content for fetch-based navigation

form.tpl

Auto-generated by AdminForm::render(). Render with:

$form = new AdminForm($fields, $selects, $yamlFields, $sessionPrefix, new_admin: true);
$html = $tpl->drawString($form->render(), true);

Frontend Integration

Message Dismissal

All messages auto-dismiss after timeout:

  • Success: 3 seconds
  • Error: 10 seconds

Configured in src/backend/scss/_messages.scss:

.message {
    animation: fadeOut 10s ease-in-out forwards;
}
.message3s {
    animation: fadeOut 3s ease-in-out forwards;
}

Pagination with PaginatorNew

Returns pagination items with data-content attribute for fetch-based navigation:

$pagination = PaginatorNew::paginate($href, $count, $limit, $page);
// Each item has: ['href', 'dataContent', 'type', 'page']

Session Expiration Detection

Global detection in src/backend/js/api/baseApi.js:

  • Detects id="adminLoginForm" in any response
  • Redirects to login with window.location.href = '/ADMIN/${lang}/login'

Frontend data-* conventions (Hotwire layer)

Use strictly these attributes:

  • data-content="url"
  • For navigation (GET HTML)
  • Listing links, edit links, pagination
  • Injects HTML into #content

  • data-json="url"

  • For actions (POST/DELETE JSON)
  • Delete buttons, toggle actions
  • Supports: data-method="DELETE|POST" data-confirm="message" data-after="callbackName"

  • data-filter (form)

  • For listing filters/search
  • Always POST
  • Replaces #content
  • Resets pagination

  • data-form="moduleName"

  • For create/update forms
  • Handles save / close / back buttons
  • Uses POST to /module or /module/{id}

Rules:

  • Never mix data-content and data-json
  • All deletes MUST use data-json + data-method="DELETE"
  • All navigation MUST use data-content (no full reload)
  • All filter forms MUST use data-filter
  • All CRUD forms MUST use data-form

Step 8: Data Transfer Objects (DTO)

When to Create a DTO

For modules with complex create/update logic where multiple fields need to be validated and passed together:

Example: AdminData DTO

<?php
declare(strict_types=1);

namespace App\Modules\Admins;

class AdminData
{
    public function __construct(
        public string $username,
        public string $password,
        public string $name,
        public string $email,
        public int $groupId,
        public string $status = 'Blocked',
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            $data['username'] ?? '',
            $data['password'] ?? '',
            $data['name'] ?? '',
            $data['email'] ?? '',
            (int)($data['group_id'] ?? 1),
            $data['status'] ?? 'Blocked'
        );
    }
}

Benefits:

  • Type safety: IDE completion + static analysis
  • Single responsibility: validates/transforms $_POST → typed object
  • Service receives clean, typed object instead of 6 parameters
  • Reusable: same DTO for create and update

Usage in Controller:

public function create(?ServerRequestInterface $request = null, array $params = []): Response
{
    $form = new AdminForm([], $this->selects, $this->yamlFields, $this->sessionPrefix, new_admin: true);

    return $this->handleFormSubmit($form, function () {
        $dto = AdminData::fromArray($_POST);
        $id = $this->service->create($dto);

        return $id
            ? translatedJson(true, 'User created', ['id' => $id])
            : translatedJson(false, 'Create failed');
    });
}

public function update(?ServerRequestInterface $request = null, array $params = []): Response
{
    $id = array_get_str($params, 'id', '');

    $form = new AdminForm([], $this->selects, $this->yamlFields, $this->sessionPrefix, new_admin: true);

    return $this->handleFormSubmit($form, function () use ($id) {
        $dto = AdminData::fromArray($_POST);
        $ok = $this->service->update($id, $dto);

        return $ok
            ? translatedJson(true, 'User updated')
            : translatedJson(false, 'Update failed');
    });
}

Service accepts DTO:

public function create(AdminData $data): string
{
    $set = [
        'username' => $data->username,
        'password' => $this->hashPassword($data->password),
        'name'     => $data->name,
        'email'    => $data->email,
        'group_id' => $data->groupId,
        'status'   => $data->status,
    ];

    DB::query("INSERT INTO admin SET id = UUID(), " . DB::set($set), $set);

    if (empty(DB::$lastError)) {
        return (string)(DB::var("SELECT id FROM admin WHERE username = ?", [$data->username]) ?? '');
    }

    return '';
}

public function update(string $id, AdminData $data): bool
{
    $set = [
        'username' => $data->username,
        'name'     => $data->name,
        'email'    => $data->email,
        'group_id' => $data->groupId,
        'status'   => $data->status,
    ];

    if ($data->password) {
        $set['password'] = $this->hashPassword($data->password);
    }

    DB::query(
        "UPDATE admin SET " . DB::set($set) . " WHERE id = :id",
        [...$set, 'id' => $id]
    );

    return empty(DB::$lastError);
}

Step 9: JSON Response Helper

translatedJson() Helper

Unified response formatting for all JSON endpoints. Located in app/admin/shared/Helpers/Json.php:

<?php
namespace Petrovo\Http;

function translatedJson(bool $status, string $messageKey, array $data = [], string $lang = '', int $httpStatus = 200): Response
{
    $lang ??= defined('LANG') ? LANG : 'en';

    $response = [
        'status'  => $status ? 'ok' : 'error',
        'message' => translateBackend($messageKey, $lang),
    ];

    if ($data) {
        $response['data'] = $data;
    }

    return json($response, $httpStatus);
}

Benefits:

  • Automatic message translation (messageKey → backend locale)
  • Converts bool to string status (true'ok', false'error')
  • Optional data payload
  • Single point of change for response format

Usage:

use function Petrovo\Http\{translatedJson};

// Success with data
translatedJson(true, 'User created', ['id' => $id])
// Returns: {"status":"ok","message":"User vytvoř...","data":{"id":"uuid"}}

// Success without data
translatedJson(true, 'User updated')
// Returns: {"status":"ok","message":"User aktualizován"}

// Error
translatedJson(false, 'Delete failed')
// Returns: {"status":"error","message":"Smazání selhalo"}

// Ternary pattern
return $id
    ? translatedJson(true, 'Permission created', ['id' => $id])
    : translatedJson(false, 'Create failed');

Refactoring Checklist

Before Starting

  • [ ] Backup original controller: Copy to {Module}Controller.old.php or git stash
  • [ ] Identify all methods in old DATA files (Create, Update, Delete, Edit, List, etc.)
  • [ ] Check if listing has complex filters or pagination (decide on ListService)
  • [ ] Check if module provides frontend data (decide on FrontendService)
  • [ ] Check if create/update have multiple fields → decide on DTO

DTO Layer (if needed)

  • [ ] Create {Module}Data.php class if create/update needs multiple fields
  • [ ] Use constructor promotion (PHP 8+): public string $property
  • [ ] Add fromArray(array $data): self factory method for $_POST conversion
  • [ ] Service methods accept DTO: create(ModuleData $data), update(string $id, ModuleData $data)

Service Layer

  • [ ] Create {Module}Service.php with all CRUD + business methods
  • [ ] Service methods accept DTO if one exists, otherwise accept individual params
  • [ ] Add PHPDoc type hints only if IDE won't infer from signature
  • [ ] All DB queries use parameterized statements
  • [ ] Extract repeated logic (e.g., hashPassword()) as private helpers
  • [ ] Create {Module}ListService.php if listing is complex (>50 lines or multiple filters)
  • [ ] Add business validation methods (e.g., isNameTaken())

Controller

  • [ ] Extend AdminController (not standalone)
  • [ ] Import Json helper: use Admin\Shared\Helpers\Json;
  • [ ] Define const string PAGE = '{module}' and public string $sessionPrefix
  • [ ] Create Service instances in __construct()
  • [ ] Call $this->initModule(__DIR__, self::PAGE) in constructor
  • [ ] All methods: list(), show(), create(), update(), delete(), optionally filter()
  • [ ] All methods receive ?ServerRequestInterface $request, array $params = []
  • [ ] All methods return Response (use html(), json(), Html::wrap(), translatedJson())
  • [ ] Use AdminForm for forms (not manual form building)
  • [ ] Use $this->handleFormSubmit() for create/update with DTO conversion
  • [ ] In callbacks: create DTO with {Module}Data::fromArray($_POST) if DTO exists
  • [ ] Use translatedJson(bool, string, array) for all JSON responses (instead of raw json([...]))
  • [ ] Simplify success/error returns with ternary: $id ? translatedJson(true, ...) : translatedJson(false, ...)
  • [ ] Use PaginatorNew (not Paginator)
  • [ ] Detect fetch with has_header($request, 'X-REQUESTED-WITH')

Router

  • [ ] File: app/Modules/{Module}/router.php
  • [ ] Use str_starts_with() guard
  • [ ] URL: camelCase /ADMIN/{moduleName}
  • [ ] HTTP methods: GET (list, show), POST (create, update, filter), DELETE (delete)
  • [ ] Use PIPE_ADMIN middleware

Forms & Validation

  • [ ] Create data.yaml with form_fields array
  • [ ] YAML contains format validation (regex, required, maxlength)
  • [ ] Override validate() in Controller for business logic
  • [ ] AdminForm auto-generates HTML from data.yaml

DATA Wrappers (Optional)

  • [ ] Create thin wrappers in app/DATA/{Module}/
  • [ ] Each wrapper instantiates Service and calls appropriate method
  • [ ] No DB logic in DATA files

Testing

  • [ ] List page loads with pagination
  • [ ] Create form opens and submits
  • [ ] Update form pre-fills data and submits
  • [ ] Delete removes record and returns to list
  • [ ] Filter/search works
  • [ ] Session filter state persists
  • [ ] Messages auto-dismiss
  • [ ] Session expiration redirects to login

Final Integration Checks

  • [ ] Menu URL: Update app/DATA/Menus/admin.php to use new REST URL format:

    '{moduleName}|/ADMIN/' . LANG . '/{moduleName}'
    (not old query param format: ?page=...&action=listing)

  • [ ] Template object access: In templates/listing.tpl, use -> for object properties (not . for array keys):

    {$id=$value->id}           {* Object property access *}
    {$value->group}            {* Not: $value.group *}
    {$value->level}            {* DB::results() returns objects, not arrays *}


Common Gotchas

  1. PATCH vs POST: We use POST for updates (not PATCH). Convention from form submissions.
  2. sessionPrefix: Must be set as public property. Used by sessionValue() for filter persistence.
  3. AdminForm validation: YAML schema validation is automatic. Override validate() for business rules.
  4. Response types: Don't mix Html::wrap() and html() carelessly. Use has_header() to detect fetch.
  5. translatedJson() vs raw json(): Always use translatedJson(bool, messageKey, data) for consistency. Translates message automatically.
  6. DTO property names: Use camelCase in DTO (groupId) but map from snake_case in $_POST (group_id). Handle in fromArray().
  7. PaginatorNew vs Paginator: Use PaginatorNew for AJAX pagination (includes dataContent key).
  8. initModule() timing: Must be called in constructor so YAML is loaded before methods run.
  9. Database consistency: ALL queries in Service. Controllers never touch DB directly.
  10. Backup before refactor: Save original controller code (git, .bak file) before major refactoring.

Example Module Flow

  1. User requests /ADMIN/adminsPermissions (GET list)
  2. Route → list() controller method
  3. list() calls ListService::list() (reads DB, builds filter options)
  4. list() calls PaginatorNew::paginate() (builds pagination links with dataContent)
  5. list() renders listing.tpl with data + pagination
  6. Returns Html::wrap($html) (full page)

  7. User clicks filter → fetch POST to /ADMIN/adminsPermissions/filter

  8. AdminController::sessionValue() saves filter state
  9. Route → filter() method
  10. filter() calls getListingHtml(last: true)
  11. Returns html($html) (content fragment for update)
  12. Message auto-dismisses after 10s

  13. User clicks edit → fetch GET to /ADMIN/adminsPermissions/123

  14. Route → show() method
  15. show() loads record from Service::get()
  16. Creates AdminForm with data + YAML schema
  17. Renders form with validation markers
  18. Returns html($html) (content fragment)

  19. User submits form → fetch POST to /ADMIN/adminsPermissions/123

  20. Route → update() method
  21. AdminForm validates YAML schema
  22. handleFormSubmit() calls validate() for business logic
  23. If valid: calls callback → Service::update() → returns JSON success
  24. If invalid: returns JSON with errors
  25. JavaScript handles response + auto-dismiss message
  26. On success, may redirect to list

  27. Session expires → any fetch request

  28. Server returns login page HTML (contains id="adminLoginForm")
  29. baseApi.js detects it globally, redirects to /ADMIN/login
  30. User sees login form

Refactoring Best Practices (as of 2026-03-25)

DTO Pattern

  • When: Complex create/update (3+ fields) that need type safety
  • Reference: app/Modules/Admins/AdminData.php
  • Pattern: Constructor promotion + fromArray() factory
  • Benefit: Type-safe, IDE-friendly, reusable across create/update

Json Helper Pattern

  • File: app/admin/shared/Helpers/Json.php
  • Usage: translatedJson(bool $status, string $messageKey, array $data = [])
  • Benefit: Unified response format, automatic translation, consistency
  • Migration: Replace all json(['status' => 'ok'|'error', 'message' => translate(...)]) with translatedJson(true|false, 'Key')

Service Layer Refactoring

  • Extract repeated logic into private helpers (e.g., hashPassword())
  • Remove unnecessary PHPDoc (IDE infers from signature in PHP 8+)
  • Service methods accept DTO if one exists: create(AdminData $data): string
  • Always return typed values: string, int, bool, object|null, array

Notes for Claude Code (Next Session)

  • This is the canonical module structure as of 2026-03-25
  • Admins module is the primary reference (uses DTO + Json helper)
  • AdminsPermissions is secondary reference (simpler, lighter module)
  • All new modules should follow Admins pattern (with DTO if 3+ fields)
  • Deviations require discussion and potential updates to this guide
  • Key files:
  • app/admin/shared/AdminController.php (base class)
  • app/admin/shared/Helpers/Json.php (response helper)
  • app/Modules/Admins/ (reference: DTO + complex service)
  • app/Modules/AdminsPermissions/ (reference: simple service)

Data Convention in Templates

Template data access:

  • Use $value->id in RainTPL templates for objects (object property access)
  • Use $value.id only for arrays (array access)
  • No automatic translation between . and ->
  • $value.id = $value['id'] (PHP array access)
  • $value->id = $value->id (PHP object access)
  • Never mix array and object access in templates
  • Never use ARRAY_A flag in ListService - always return objects

Example data flow:

// PHP Service: DB::results() returns objects (no ARRAY_A flag)
$output['rows'] = DB::results("SELECT * FROM admin ...", $params);
// Returns: array of object{id, username, name, ...}

// RainTPL Template: uses dot notation for object properties
// RainTPL Template: uses object access
{loop="$data.rows"}
    {$id=$value->id}
    {$value->username}
    {$value->status}
{/loop}

Why:

  • Consistent data representation: objects in PHP → object access in templates
  • No ambiguity between array and object access
  • Avoids mixing $value.id and $value->id
  • Eliminates ARRAY_A and unnecessary conversions
  • Ensures predictable behavior across all services