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
AdminControllerprovides 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 iflist()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) + overridevalidate()for business logic handleFormSubmit()checks validation, calls$onSuccesscallback on success, returns error JSON on failuresessionValue()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 $requestandarray $params - All methods return
Response(usehtml(),translatedJson(),Html::wrap()) - Use
AdminFormfor form creation/rendering - Use
$this->handleFormSubmit()for create/update operations - Use
PaginatorNewfor AJAX pagination (notPaginator) - 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+ overridevalidate()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
PaginatorNewfor AJAX support) - Data table with edit/delete actions
- Uses
data-contentfor 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.phpor 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.phpclass if create/update needs multiple fields - [ ] Use constructor promotion (PHP 8+):
public string $property - [ ] Add
fromArray(array $data): selffactory method for $_POST conversion - [ ] Service methods accept DTO:
create(ModuleData $data),update(string $id, ModuleData $data)
Service Layer
- [ ] Create
{Module}Service.phpwith 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.phpif listing is complex (>50 lines or multiple filters) - [ ] Add business validation methods (e.g.,
isNameTaken())
Controller
- [ ] Extend
AdminController(not standalone) - [ ] Import
Jsonhelper:use Admin\Shared\Helpers\Json; - [ ] Define
const string PAGE = '{module}'andpublic string $sessionPrefix - [ ] Create Service instances in
__construct() - [ ] Call
$this->initModule(__DIR__, self::PAGE)in constructor - [ ] All methods:
list(),show(),create(),update(),delete(), optionallyfilter() - [ ] All methods receive
?ServerRequestInterface $request, array $params = [] - [ ] All methods return
Response(usehtml(),json(),Html::wrap(),translatedJson()) - [ ] Use
AdminFormfor 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 rawjson([...])) - [ ] Simplify success/error returns with ternary:
$id ? translatedJson(true, ...) : translatedJson(false, ...) - [ ] Use
PaginatorNew(notPaginator) - [ ] 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_ADMINmiddleware
Forms & Validation
- [ ] Create
data.yamlwith 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.phpto use new REST URL format:
(not old query param format:'{moduleName}|/ADMIN/' . LANG . '/{moduleName}'?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
- PATCH vs POST: We use POST for updates (not PATCH). Convention from form submissions.
- sessionPrefix: Must be set as public property. Used by
sessionValue()for filter persistence. - AdminForm validation: YAML schema validation is automatic. Override
validate()for business rules. - Response types: Don't mix
Html::wrap()andhtml()carelessly. Usehas_header()to detect fetch. - translatedJson() vs raw json(): Always use
translatedJson(bool, messageKey, data)for consistency. Translates message automatically. - DTO property names: Use camelCase in DTO (
groupId) but map from snake_case in $_POST (group_id). Handle infromArray(). - PaginatorNew vs Paginator: Use
PaginatorNewfor AJAX pagination (includesdataContentkey). - initModule() timing: Must be called in constructor so YAML is loaded before methods run.
- Database consistency: ALL queries in Service. Controllers never touch DB directly.
- Backup before refactor: Save original controller code (git, .bak file) before major refactoring.
Example Module Flow
- User requests
/ADMIN/adminsPermissions(GET list) - Route →
list()controller method list()callsListService::list()(reads DB, builds filter options)list()callsPaginatorNew::paginate()(builds pagination links withdataContent)list()renderslisting.tplwith data + pagination-
Returns
Html::wrap($html)(full page) -
User clicks filter → fetch POST to
/ADMIN/adminsPermissions/filter AdminController::sessionValue()saves filter state- Route →
filter()method filter()callsgetListingHtml(last: true)- Returns
html($html)(content fragment for update) -
Message auto-dismisses after 10s
-
User clicks edit → fetch GET to
/ADMIN/adminsPermissions/123 - Route →
show()method show()loads record fromService::get()- Creates
AdminFormwith data + YAML schema - Renders form with validation markers
-
Returns
html($html)(content fragment) -
User submits form → fetch POST to
/ADMIN/adminsPermissions/123 - Route →
update()method AdminFormvalidates YAML schemahandleFormSubmit()callsvalidate()for business logic- If valid: calls callback →
Service::update()→ returns JSON success - If invalid: returns JSON with errors
- JavaScript handles response + auto-dismiss message
-
On success, may redirect to list
-
Session expires → any fetch request
- Server returns login page HTML (contains
id="adminLoginForm") baseApi.jsdetects it globally, redirects to/ADMIN/login- 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(...)])withtranslatedJson(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
Adminsmodule is the primary reference (uses DTO + Json helper)AdminsPermissionsis 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