Как устроен Universal Engine: философия анти-MVC, ядро и классы, жизненный цикл запроса, universal table в PostgreSQL и правила, по которым живёт каждый проект экосистемы.
Один движок, бесконечные сценарии. Коротко - на чём стоит Sogerien Universal Engine:
<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):
declare(strict_types=1) и без типов.Экосистема на движке (живые проекты):
claude.sogerien.top - веб-пульт управления Claude-нодами из браузера.crm.sogerien.top и crm.galaxyfinance.ua - CRM с аналитикой (скважины, звонки Binotel, дашборды).proxymint.com - кабинет прокси-сервиса.inv.sogerien.topcabinet.sogerien.topДвижок лежит в подпапке sogerien/. В корне сайта - тонкий index.php (front controller). Всё ядро доступно через статический фасад Sogerien::.
sogerien/Sogerien.php поднимает автозагрузку классов из sogerien/classes/ и страниц из sogerien/page/, читает конфиг, держит подключение к БД и отдаёт синглтоны ядра через статические методы: Sogerien::Routes(), Sogerien::Page(), Sogerien::DbController(), Sogerien::InputRequest(), Sogerien::AccessCheck(), Sogerien::Lang(). Sogerien::markDone() закрывает запрос.add_template('url', '/page/x.php') кладёт точный матч (isset($t[$url]), без вайлдкардов и параметров). template() резолвит текущий URL и подключает нужный page-файл. Динамика делается циклом: foreach по конфигу вешает N роутов на один файл-шаблон, а не плодит N файлов. Тут же живёт единый резолвер доступа user_can_access($url, $userId, $groups) - один источник правды.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]).sql_request($alias, ['sql' => ..., 'params' => ...]) - метод возвращает JSON-строку. Внутри два бэкенда (PDO-пул и pgsql-пул), диспатч в executeSql(); sql_request() работает на любом, а прямой pdo() может кинуть DB is not connected на pgsql-деплое, поэтому его не используем. Нормализатор именованных параметров, sanitizeParams() (PHP-массив сам кодируется в jsonb) и expandJsonInRows() (jsonb-колонка на чтении уже распакована в массив).Sogerien::InputRequest()->url), метод, GET/POST/тело - один источник входных данных вместо суперглобалов вразнобой. Ключ сущности страница достаёт из последнего сегмента через parse_url($url, PHP_URL_PATH) (без query).isset($groups[$key]), конфиг-драйв. Route-guard require_access_or_deny() вызывается ДО include страницы, per-page guards дублируют как defense-in-depth (скрытие кнопок в UI безопасностью не считается). Опирается на AEAD (XChaCha20-Poly1305) плюс AccessToken.{"login": {"ru": "Логин", "en": "Login"}}. Дробить переводы на файлы запрещено - берёшь значение по ключу и текущему языку.Affects::get_head_css_urls($domain) и Affects::get_head_js_urls($domain) централизованно отдают наборы внешних ассетов по домену, Template подмешивает их в <head>. Инлайнить <script> / <link> в разметку страницы запрещено - иначе ассеты расходятся, дедуп ломается, кеш не держится.index.php в корне сайта (front controller).index.php подключает sogerien/Sogerien.php - bootstrap: автозагрузка classes/ и page/, чтение конфига.DbController::connect() по конфигу поднимает нужный бэкенд PostgreSQL.$routes = Sogerien::Routes(); - сначала статические add_template(...), затем динамические роуты циклом из конфига.Sogerien::Routes()->template() - точный матч текущего URL, route-guard доступа, include нужного page/x.php.set_templates([Skin::class]), тянет данные через DbController, ставит title, вызывает header() -> mainmenu() -> HTML тела -> footer(). Template склеивает <head> (ассеты скина + Affects + Page add_head_*) и <body>.Sogerien::markDone() закрывает запрос.// 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();
sogerienОдна таблица на всё. Структура прикладных данных живёт в JSONB, а не в схеме БД - форму меняешь без миграционного ада.
| колонка | тип | назначение |
|---|---|---|
id | bigserial | суррогатный ключ |
entity | text | тип сущности |
item_key | text | ключ экземпляра |
title | text | заголовок/подпись |
data | jsonb | полезная нагрузка |
created_at | timestamptz | создано |
updated_at | timestamptz | обновлено |
table_name | text | логическая таблица |
name | text | имя записи |
table_index | jsonb | индекс/структура |
table_value | jsonb | значение/динамика |
status | text | статус записи |
Природный уникальный ключ - (entity, item_key). entity задаёт тип сущности, item_key - конкретный экземпляр.
Доступ - только через DbController, ответ приходит JSON-строкой; после json_decode jsonb-колонки уже массивы, повторный декод не нужен:
$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 по природному ключу:
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' => []],
],
]);
Правила параметров:
sanitizeParams() сам сериализует его в jsonb.:name в запросе схлопывается в один плейсхолдер, дублировать параметр не надо.SQLSTATE называет причину сразу.CAST(:d AS jsonb), никогда :d::jsonb - нормализатор параметров сканирует SQL регуляркой и во втором двоеточии видит параметр :jsonb, падает Missing param :jsonb.Целый прикладной экран: скин, экранирование, чтение из universal table, вывод.
<?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:
Sogerien::Routes()->add_template('', '/page/home.php');
declare(strict_types=1) первой инструкцией, UTF-8 без BOM (BOM перед <?php ломает strict_types), любой вывод через h().Ключевой принцип этого сайта: разметка страницы не содержит ни одного <script> или <link>. Любая сторонняя библиотека - график, слайдер, подсветка кода, анимация - подключается декларативно, одним из трёх санкционированных путей.
get_head_css_urls() / get_head_js_urls() скина (для JS - с флагом defer).Affects::get_head_css_urls($domain) и Affects::get_head_js_urls($domain) держат внешние ассеты в одном месте, Template подмешивает их в <head>.header():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, кеш держится, эффекты не дублируются между страницами. Инлайн-тег в разметке всё это ломает - поэтому он запрещён.