Документация

Как устроен Universal Engine: философия анти-MVC, ядро и классы, жизненный цикл запроса, universal table в PostgreSQL и правила, по которым живёт каждый проект экосистемы.

Один движок, бесконечные сценарии. Коротко - на чём стоит Sogerien Universal Engine:

  • Анти-MVC. Ноль контроллеров, репозиториев, сервисов и DI-обвязки - только движок и страница.
  • Минимум кода. Страница выходит в прод за 10 строк, а не за три слоя абстракций.
  • Одна universal table. Вся структура прикладных данных живёт в JSONB, а не в схеме БД и миграциях.
  • Один движок на все проекты: CRM, прокси-кабинет, веб-пульт, API поверх 1С - одно ядро.
  • Переиспользование по умолчанию. Кастом живёт в Page, повторился - переезжает в ядро.
  • Эффекты только через шаблонизатор. Ни одного инлайн <script> или <link> в разметке страницы.
  • Строгая типизация. declare(strict_types=1), типы на всех функциях, UTF-8 без BOM.
  • Скорость запуска. Тонкий index.php, точный матч роута, никакого фреймворкового прогрева.

Философия

Universal Engine - не MVC-клон и не очередной фреймворк со слоями ради слоёв. Идея одна: один движок, бесконечные сценарии. Ядро (Sogerien) держит переиспользуемое, прикладная логика живёт в Page - точке входа, где сходятся порт и логика. Между ними нет контроллеров, репозиториев, сервисов, DI-контейнеров и неймспейсов ради архитектуры.

Почему не MVC. Классический MVC под каждую сущность заставляет плодить контроллер + модель + репозиторий + миграцию + роут. На выходе - пачка кода, который существует ради поддержки самого себя. Universal Engine это режет: одна страница плюс маршрут, данные в одной таблице, структура в JSON.

Правило раскладки кода:

  • Одноразовое, специфичное для страницы - в Page.
  • Повторяется между страницами или проектами - расширяешь универсальный класс и выносишь в ядро, прямо в бою, через реальную задачу.
  • Не повторяется - остаётся кастомом, ядро не трогаешь.

Как ядро выбирает решение (decision algorithm):

  • Можно решить одной таблицей - решаем.
  • Можно убрать слой - убираем.
  • Можно сделать универсально - делаем.
  • Есть более редкое, но сильное решение - берём его.
  • Проще, чем вышло бы на Laravel или Django? Если нет - переделать.
Запрещено в ядре: ORM, сложные миграции, микросервисы без причины, нормализация ради «правильности», стандартные CRUD-панели, код без declare(strict_types=1) и без типов.

Экосистема на движке (живые проекты):

  • claude.sogerien.top - веб-пульт управления Claude-нодами из браузера.
  • crm.sogerien.top и crm.galaxyfinance.ua - CRM с аналитикой (скважины, звонки Binotel, дашборды).
  • proxymint.com - кабинет прокси-сервиса.
  • inv.sogerien.top
  • cabinet.sogerien.top

Ядро и классы

Движок лежит в подпапке sogerien/. В корне сайта - тонкий index.php (front controller). Всё ядро доступно через статический фасад Sogerien::.

  • Sogerien (bootstrap + autoload). sogerien/Sogerien.php поднимает автозагрузку классов из sogerien/classes/ и страниц из sogerien/page/, читает конфиг, держит подключение к БД и отдаёт синглтоны ядра через статические методы: Sogerien::Routes(), Sogerien::Page(), Sogerien::DbController(), Sogerien::InputRequest(), Sogerien::AccessCheck(), Sogerien::Lang(). Sogerien::markDone() закрывает запрос.
  • Routes. Реестр маршрутов. add_template('url', '/page/x.php') кладёт точный матч (isset($t[$url]), без вайлдкардов и параметров). template() резолвит текущий URL и подключает нужный page-файл. Динамика делается циклом: foreach по конфигу вешает N роутов на один файл-шаблон, а не плодит N файлов. Тут же живёт единый резолвер доступа user_can_access($url, $userId, $groups) - один источник правды.
  • Template + скины. Двухслойный шаблонизатор. Класс Template генерит каркас документа (<!DOCTYPE>, <head>, <body>), собирает CSS/JS в <head> (add_head_css() / add_head_js()), рисует header() / footer() / mainmenu(). Скин - отдельный класс по duck-typed контракту, без обязательного интерфейса: get_head_css_urls(): array, get_head_js_urls(): array (элементы {src, defer}), get_body_class(): string, get_body_attributes(): array, render_body_open(): void, render_body_close(): void, mainmenu(): void. Готовые скины: TemplateSneat (админка и дашборды), TemplateBasePage, TemplateBlog (лёгкий холст под контент и лендинги). Страница выбирает скин через Page()->set_templates([Skin::class]).
  • DbController. Единая точка доступа к PostgreSQL. Ходить строго через sql_request($alias, ['sql' => ..., 'params' => ...]) - метод возвращает JSON-строку. Внутри два бэкенда (PDO-пул и pgsql-пул), диспатч в executeSql(); sql_request() работает на любом, а прямой pdo() может кинуть DB is not connected на pgsql-деплое, поэтому его не используем. Нормализатор именованных параметров, sanitizeParams() (PHP-массив сам кодируется в jsonb) и expandJsonInRows() (jsonb-колонка на чтении уже распакована в массив).
  • InputRequest. Разбор входящего запроса: URL (Sogerien::InputRequest()->url), метод, GET/POST/тело - один источник входных данных вместо суперглобалов вразнобой. Ключ сущности страница достаёт из последнего сегмента через parse_url($url, PHP_URL_PATH) (без query).
  • AccessCheck. Гейт доступа и авторизации, без ACL-фреймворка. Роль - ключ сущности, проверка тривиальна: isset($groups[$key]), конфиг-драйв. Route-guard require_access_or_deny() вызывается ДО include страницы, per-page guards дублируют как defense-in-depth (скрытие кнопок в UI безопасностью не считается). Опирается на AEAD (XChaCha20-Poly1305) плюс AccessToken.
  • Lang. Локализация. Все языки в одном JSON внутри одной структуры: {"login": {"ru": "Логин", "en": "Login"}}. Дробить переводы на файлы запрещено - берёшь значение по ключу и текущему языку.
  • Affects. Единственная санкционированная точка подключения сторонних библиотек и эффектов. Affects::get_head_css_urls($domain) и Affects::get_head_js_urls($domain) централизованно отдают наборы внешних ассетов по домену, Template подмешивает их в <head>. Инлайнить <script> / <link> в разметку страницы запрещено - иначе ассеты расходятся, дедуп ломается, кеш не держится.

Жизненный цикл запроса

  1. Браузер бьёт в тонкий index.php в корне сайта (front controller).
  2. index.php подключает sogerien/Sogerien.php - bootstrap: автозагрузка classes/ и page/, чтение конфига.
  3. DbController::connect() по конфигу поднимает нужный бэкенд PostgreSQL.
  4. $routes = Sogerien::Routes(); - сначала статические add_template(...), затем динамические роуты циклом из конфига.
  5. Sogerien::Routes()->template() - точный матч текущего URL, route-guard доступа, include нужного page/x.php.
  6. Страница: set_templates([Skin::class]), тянет данные через DbController, ставит title, вызывает header() -> mainmenu() -> HTML тела -> footer(). Template склеивает <head> (ассеты скина + Affects + Page add_head_*) и <body>.
  7. Sogerien::markDone() закрывает запрос.
index.php php
// index.php (корень сайта)
require __DIR__ . '/sogerien/Sogerien.php';

$routes = Sogerien::Routes();
$routes->add_template('', '/page/home.php');       // '' - корень домена
$routes->add_template('docs', '/page/docs.php');

Sogerien::Routes()->template();
Sogerien::markDone();

Universal table sogerien

Одна таблица на всё. Структура прикладных данных живёт в JSONB, а не в схеме БД - форму меняешь без миграционного ада.

колонкатипназначение
idbigserialсуррогатный ключ
entitytextтип сущности
item_keytextключ экземпляра
titletextзаголовок/подпись
datajsonbполезная нагрузка
created_attimestamptzсоздано
updated_attimestamptzобновлено
table_nametextлогическая таблица
nametextимя записи
table_indexjsonbиндекс/структура
table_valuejsonbзначение/динамика
statustextстатус записи

Природный уникальный ключ - (entity, item_key). entity задаёт тип сущности, item_key - конкретный экземпляр.

Доступ - только через DbController, ответ приходит JSON-строкой; после json_decode jsonb-колонки уже массивы, повторный декод не нужен:

чтение из universal table php
$json = Sogerien::DbController()->sql_request('main', [
    'sql'    => 'SELECT title, data FROM sogerien WHERE entity = :e AND item_key = :k',
    'params' => ['e' => 'page', 'k' => 'home'],
]);
$rows = json_decode($json, true);

Запись - upsert через ON CONFLICT по природному ключу:

запись - upsert php
Sogerien::DbController()->sql_request('main', [
    'sql' => 'INSERT INTO sogerien (entity, item_key, title, data)
              VALUES (:e, :k, :t, CAST(:d AS jsonb))
              ON CONFLICT (entity, item_key)
              DO UPDATE SET title      = EXCLUDED.title,
                            data       = EXCLUDED.data,
                            updated_at = now()',
    'params' => [
        'e' => 'page',
        'k' => 'home',
        't' => 'Sogerien',
        'd' => ['lead' => 'Universal Engine', 'blocks' => []],
    ],
]);

Правила параметров:

  • Массив отдавать обычным PHP-массивом - sanitizeParams() сам сериализует его в jsonb.
  • Повтор одного :name в запросе схлопывается в один плейсхолдер, дублировать параметр не надо.
  • Ошибку драйвера наружу отдавать текстом, а не глотать - SQLSTATE называет причину сразу.
Каст писать только как CAST(:d AS jsonb), никогда :d::jsonb - нормализатор параметров сканирует SQL регуляркой и во втором двоеточии видит параметр :jsonb, падает Missing param :jsonb.

Страница за 10 строк

Целый прикладной экран: скин, экранирование, чтение из universal table, вывод.

page/home.php php
<?php
declare(strict_types=1);
Sogerien::Page()->set_templates([TemplateBlog::class]);
function h(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }

$json = Sogerien::DbController()->sql_request('main', [
    'sql'    => 'SELECT title, data FROM sogerien WHERE entity = :e AND item_key = :k',
    'params' => ['e' => 'page', 'k' => 'home'],
]);
$row = json_decode($json, true)[0] ?? ['title' => 'Sogerien', 'data' => []];

Sogerien::Page()->title = $row['title'];
Sogerien::Page()->header();
Sogerien::Page()->mainmenu();
?>
<section class="hero">
    <h1><?= h($row['title']) ?></h1>
    <p><?= h($row['data']['lead'] ?? '') ?></p>
</section>
<?php Sogerien::Page()->footer();

Маршрут регистрируется одной строкой в index.php:

index.php - маршрут php
Sogerien::Routes()->add_template('', '/page/home.php');
Обязательные требования к файлу страницы: declare(strict_types=1) первой инструкцией, UTF-8 без BOM (BOM перед <?php ломает strict_types), любой вывод через h().

Эффекты через Affects

Ключевой принцип этого сайта: разметка страницы не содержит ни одного <script> или <link>. Любая сторонняя библиотека - график, слайдер, подсветка кода, анимация - подключается декларативно, одним из трёх санкционированных путей.

  1. Через скин - библиотека нужна всем страницам скина. Добавляешь URL в get_head_css_urls() / get_head_js_urls() скина (для JS - с флагом defer).
  2. Через Affects - централизованный набор по домену. Affects::get_head_css_urls($domain) и Affects::get_head_js_urls($domain) держат внешние ассеты в одном месте, Template подмешивает их в <head>.
  3. Через Page - разово на одной странице, до вызова header():
page - подключение ассетов php
Sogerien::Page()->add_head_js('https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js', defer: true);
Sogerien::Page()->add_head_css('https://cdn.jsdelivr.net/npm/some-lib/dist/lib.css');
Sogerien::Page()->header();
// в теле - только контейнер под виджет; инициализация виджета тоже в подключённом файле, не инлайн

Почему так: единый <head>, дедуп ассетов, порядок загрузки и defer под контролем Template, кеш держится, эффекты не дублируются между страницами. Инлайн-тег в разметке всё это ломает - поэтому он запрещён.