Bicycle/CLAUDE.md
Egor Isaev cff643ebc5 dev
2026-08-12 17:08:46 +03:00

54 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project overview

Bicycle — самописный PHP MVC micro-framework (PHP >= 8.2). Название отражает идиому «изобрести велосипед».

Commands

# Установка зависимостей
composer install

# Обновление автозагрузчика после добавления классов
composer dump-autoload

# Запуск всех тестов через Docker (контейнер уже должен быть запущен)
docker exec bicycle vendor/bin/phpunit

# Один файл / один тест
docker exec bicycle vendor/bin/phpunit tests/Unit/RouteTest.php
docker exec bicycle vendor/bin/phpunit --filter testControllerAndAction tests/Unit/RouteTest.php

Composer-скриптов нет — phpunit вызывается напрямую. Запускать тесты надо именно внутри контейнера: PHP-интерпретатор живёт в Docker, volume /home/isaevea/http/Bicycle → /opt/project.

Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере bicycle.

Architecture

Directory layout

index.php                          — точка входа; константы EXT, DOCROOT, APPPATH, SYSPATH
System/Classes/                    — ядро фреймворка (namespace System\Classes\)
System/Classes/HTTP/               — HTTP-инфраструктура: интерфейсы, заголовки, исключения
System/Classes/HTTP/Client/        — исходящие HTTP-запросы (Curl, Request, Response)
System/Classes/HTTP/Exception/     — HTTPException_403/404
App/Controller/                    — контроллеры приложения (namespace App\Controller\)
App/Repositories/                  — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
App/config/                        — конфиги (config.php; config.local.php — в .gitignore)
App/view/                          — шаблоны приложения (.html файлы с PHP-кодом)
App/media/                         — статические ресурсы (js, css, img)
Services/Auth/                     — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
Services/Database.php, DataBase/   — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoConnection,Statement,Profiler} (см. раздел DataBase ниже)
Services/Elasticsearch.php, Elasticsearch/  — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
Services/Mongo.php, Mongo/         — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
Services/Mail/, PDF/               — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
System/view/                       — системные шаблоны (ошибки 404/500/403, exception)
tools/sort_html_attrs.php          — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
tests/                             — PHPUnit тесты

Часть Services/* (Mail, PDF) ещё не созданы. App/Classes/ — уже не пуст: App\Classes\Currency (const LIST — код ISO 4217 => название, сейчас RUB/USD/EUR/CNY), используется и в форме счёта (App/view/Index/index.html, <select>), и в валидации App\Controller\AccountsController — единственное место, которое нужно поправить, чтобы добавить валюту.

Autoload (composer.json)

PSR-4 с относительными путями:

  • System\ → System/
  • App\ → App/
  • Services\ → Services/
  • Tests\ → tests/ (dev)

PhpStorm может показывать предупреждение «Namespace doesn't match PSR-4» — это ложное срабатывание. Отключается в Settings → Editor → Inspections → PHP → General.

Namespaces

  • System/Classes/*.php → namespace System\Classes;
  • System/Classes/HTTP/*.php → namespace System\Classes\HTTP;
  • System/Classes/HTTP/Client/*.php → namespace System\Classes\HTTP\Client;
  • System/Classes/HTTP/Exception/*.php → namespace System\Classes\HTTP\Exception;
  • App/Controller/*.php → namespace App\Controller;
  • App/Classes/*.php → namespace App\Classes;
  • Services/**/*.php → namespace Services\...;

Core classes

Класс Роль
System\Classes\Core Bootstrap, обработка ошибок, поиск файлов (findFile), константы среды
System\Classes\BaseController Голое ядро (abstract); жизненный цикл executeAction(): before() → экшен → after()
System\Classes\Controller Веб-контроллер (extends BaseController): render()/renderContent() + авто-CSRF в before() + json() + заголовок X-Profiler в after() для admin
System\Classes\View Рендеринг .html-шаблонов через ob_start + extract + include; setStyle()/setScript()/setManifest()+getStyles()/getScripts() — подключение CSS/JS из шаблона
System\Classes\ProfilerToolbar Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из Profiler; render() — пусто, если пользователь не admin
System\Classes\Route Автоматический роутинг: парсит URI, ищет контроллер в App/Controller/
System\Classes\Request Входящий HTTP-запрос; реализует HTTP\Request; post(), query(), body(), isAjax()
System\Classes\Response HTTP-ответ; body() — геттер/сеттер содержимого
System\Classes\MyException Кастомное исключение; зарегистрировано через set_exception_handler
System\Classes\Cookie Статический хелпер для работы с куками: get(), set(), delete()
System\Classes\Session Синглтон сессии: instance(string $name), get/set/delete/destroy/regenerate
System\Classes\Config Статический конфиг: lazy-load App/config/config.php + merge config.local.php
System\Classes\Log Файловый логгер: уровни, аудит запросов, лог ошибок; info()/error(), маскировка
System\Classes\LogReader Интерфейс чтения логов (read(), dates()); абстракция над хранилищем
System\Classes\FileLogReader Чтение/парсинг логов из файлов; реализация LogReader (позже возможен DbLogReader)
System\Classes\Validation Валидация входных данных по декларативным правилам; factory(), rule(), check(), errors()
System\Classes\CSRF Защита форм от CSRF; токен в сессии: token(), validate(), field()
System\Classes\HTTP redirect(), requestHeaders()
System\Classes\HTTP\Header Extends ArrayObject; send(), __toString() → RFC-формат
System\Classes\HTTP\Message Интерфейс: protocol(), headers(), body(), render()
System\Classes\HTTP\Request Интерфейс входящего запроса; константы методов GET/POST/PUT/...
System\Classes\HTTP\HTTPException HTTP-исключение для действительно исключительных кодов (throw + MyException::handler()); factory(int $code), getResponse(): Response. Редиректы (302) через неё не делаются — не исключение, а штатный поток, см. HTTP::redirect()
System\Classes\HTTP\Exception\HTTPException_403 403 Forbidden
System\Classes\HTTP\Exception\HTTPException_404 404 Not Found
System\Classes\HTTP\Client\Curl Исполнитель исходящих HTTP-запросов через cURL
System\Classes\HTTP\Client\Request Билдер исходящего запроса: method(), header(), json(), timeout()
System\Classes\HTTP\Client\Response Ответ внешнего запроса: status(), body(), json(), isSuccess()
Services\Auth Точка входа в авторизацию: instance(?string $driver = null), кэш по драйверу
Services\Auth\AuthDriver Интерфейс: login(), logout(), loggedIn(), getUser(), checkPassword()
Services\Auth\FileAuthDriver Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak
Services\Database Точка входа в реляционную БД (MariaDB): instance(?string $name = null): PdoConnection, кэш по имени подключения
Services\DataBase\Classes\PdoConnection Обёртка над PDO: query()/prepare()/exec(), диалект mysql|pgsql|sqlite через конфиг dialect (по умолчанию mysql), failover по hosts (кроме sqlite — там нет хостов), транзакции с вложенными SAVEPOINT (transaction())
Services\DataBase\Classes\Statement extends PDOStatement (через PDO::ATTR_STATEMENT_CLASS); showQuery()/sq() — SQL с подставленными параметрами, для дебага; execute() также профилирует в Profiler
Services\DataBase\Classes\Profiler Статический накопитель таймингов запросов: log(), entries(), totalTime(), count(), reset()
Services\DataBase\Model #[AllowDynamicProperties], лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под PDO::FETCH_CLASS; toArray()
System\Classes\Repository @template T, abstract CRUD поверх таблицы: get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere(), beginTransaction()/commit()/rollBack()/transaction() (делегируют в PdoConnection); конкретные — в App\Repositories\*
Services\Elasticsearch Точка входа в Elasticsearch: instance(?string $name = null): Client, кэш по имени подключения
Services\Elasticsearch\Client REST-обёртка на HTTP\Client\Curl (без composer-зависимости elasticsearch/elasticsearch): index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()
Services\Mongo Точка входа в MongoDB: instance(?string $name = null): MongoDriver, кэш по имени подключения
Services\Mongo\MongoDriver Обёртка над MongoDB\Client (пакет mongodb/mongodb, требует ext-mongodb): client(), database(), collection()

Request lifecycle

index.php
  → Core::init()
  → Request::factory()        # detectUri(), Route::resolve(), заполняет post/query/body/method
  → Request::execute()        # require_once контроллера, вызывает executeAction({action}Action)
  → Response->body()          # строка с HTML
  → echo

Internal Request (System\Classes\Request)

Входящий запрос от браузера. Реализует System\Classes\HTTP\Request.

  • factory() — создаёт экземпляр, заполняет $_GET, $_POST, метод, тело, X-Requested-With
  • $initial — первый запрос; $current — текущий
  • post($key?, $value?) — getter/setter POST-данных
  • query($key?, $value?) — getter/setter GET-параметров
  • body($content?) — getter/setter тела запроса
  • method($method?) — getter/setter HTTP-метода
  • isAjax() — проверяет X-Requested-With: XMLHttpRequest
  • detectUri() — определяет URI через PATH_INFO → REQUEST_URI → PHP_SELF

External HTTP Client (System\Classes\HTTP\Client*)

Исходящие запросы к внешним API.

use System\Classes\HTTP\Client\{Request as HttpRequest, Curl};

$response = (new Curl())->execute(
    HttpRequest::factory('https://api.example.com/data')
        ->method('POST')
        ->json(['key' => 'value'])
        ->timeout(10)
);

if ($response->isSuccess()) {
    $data = $response->json();
}

Session (System\Classes\Session)

Синглтон, поддерживает именованные сессии.

use System\Classes\Session;

$session = Session::instance();          // сессия 'main'
$session->set('user_id', 42);
$id = $session->get('user_id');
$session->delete('user_id');
$session->regenerate();                  // новый session_id, старые данные сохраняются
$session->destroy();                     // уничтожить сессию

Статический хелпер.

use System\Classes\Cookie;

Cookie::set('token', 'abc123', time() + 3600);
$val = Cookie::get('token');
Cookie::delete('token');

Свойства по умолчанию: $expiration=0, $path='/', $domain=null, $secure=false, $httponly=true, $samesite='Lax'.

Config (System\Classes\Config)

Lazy-load конфига из App/config/config.php. Если существует App/config/config.local.php — глубоко мержится поверх базового.

use System\Classes\Config;

$db     = Config::get('db');                          // весь раздел
$params = Config::get('session', 'cookie_params');    // вложенный ключ
Config::set('foo', ['bar' => 'baz']);                 // переопределить в runtime

config.local.php исключён из git (.gitignore) — используется для переопределений на конкретном хосте.

Validation (System\Classes\Validation)

Валидация входных данных (обычно Request::post()/query()) по декларативным правилам.

use System\Classes\Validation;

$validation = Validation::factory($request->post())
    ->label('email', 'E-mail')
    ->rule('email', 'required')
    ->rule('email', 'email')
    ->rule('password', 'min_length', [8])
    ->rule('password_confirm', 'matches', ['password']);

if ($validation->check()) {
    // данные валидны
} else {
    $errors = $validation->errors();          // [поле => сообщение]
    $one    = $validation->error('email');    // сообщение одного поля или null
}

Правила: required, email, url, numeric, digit, min_length, max_length, exact_length, matches, in, regex. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет required) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами :field/:param1/:param2 (подстановка через strtr, как в MyException).

CSRF (System\Classes\CSRF)

Защита форм от CSRF. Токен хранится в сессии (Session), встраивается в форму скрытым полем и проверяется при обработке POST.

Контроллеры — наследники Controller — проверяют токен автоматически в before() на методах POST/PUT/PATCH/DELETE (отключается флагом $_csrf_protection = false, например для API/вебхуков). В шаблоне достаточно вставить поле:

use System\Classes\CSRF;

<form method="post">
    <?= CSRF::field() ?>
    ...
</form>

Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через CSRF::validate():

use System\Classes\HTTP\HTTPException;

if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
    throw HTTPException::factory(403);
}

Контроллер создаётся в Request::execute() как new $class() без передачи запроса — доступ к данным через статический Request::$current, не через $this->request. Рабочий пример формы (валидация поверх авто-CSRF): App/Controller/FeedbackController.php.

  • token() — токен текущей сессии (создаётся при первом обращении, random_bytes(32)).
  • validate($value) — сравнение с сессией через hash_equals() (устойчиво к timing-атакам).
  • field() — готовый <input type="hidden"> с токеном.
  • CSRF::$key — имя поля/ключа (по умолчанию csrf_token).

Auth (Services\Auth)

Авторизация через сменный драйвер (Services\Auth\AuthDriver), как в Kohana Auth: по умолчанию FileAuthDriver (логины/пароли из PHP-файла), позже — БД/LDAP/Keycloak как новые классы implements AuthDriver без изменения остального кода (тот же принцип, что и LogReader/FileLogReader).

  • Services\Auth::instance(?string $name = null) — драйвер по имени (кэшируется); без аргумента — драйвер из Config::get('auth', 'driver') (по умолчанию 'file').
  • AuthDriver::login($login, $password) — проверяет и, если верно, авторизует (пишет в Session).
  • AuthDriver::logout() / loggedIn() / getUser() (без пароля) / checkPassword($password) (сверка пароля с текущим авторизованным пользователем, например перед сменой настроек).

Файл пользователей — App/config/auth_users.php (в .gitignore, как config.local.php):

return [
    'admin' => [
        'password'  => '<password_hash(...)>',
        'full_name' => 'ФИО',
        'email'     => '...',
        'role'      => 'admin',   // 'admin' | 'manager' | 'user' | ... — своё для каждого проекта
    ],
];

Путь берётся из Config::get('auth', 'users_file'). Поля, кроме password, произвольные — AuthDriver::getUser() отдаёт их как есть (без password); role — единственное поле, которое понимает framework-код (Controller::$_auth_roles), остальное (full_name, email, …) — просто проброс для шаблонов/логов.

Контроллеры включают проверку флагом $_auth_protection = true (по умолчанию false, как $_csrf_protection, но с обратной полярностью — авторизация не обязательна по умолчанию). $_auth_driver — имя конкретного драйвера, если контроллеру нужен не дефолтный (null = дефолт из конфига). $_auth_roles — список разрешённых ролей (['admin'], ['admin', 'manager']); пустой массив (по умолчанию) — любой авторизованный, без проверки роли.

Проверка — в Controller::before(): нет авторизации → HTTP::redirect('/login') напрямую (не через HTTPException — редирект гостя на логин не исключительная ситуация, а штатное поведение; раньше это делалось через несуществующий больше HTTPException_302, но throw этого исключения ушёл бы в MyException::handler(), который не вызывает getResponse() и настоящий редирект не отправил бы — сам класс требовал прямого вызова ->getResponse() без throw, что нарушало контракт остальных подклассов HTTPException и вводило в заблуждение; убран целиком); авторизован, но роль не подходит → throw HTTPException::factory(403). App\Controller\Admin\AdminController включает $_auth_protection = true и $_auth_roles = ['admin'] для всей админки.

Вход/выход — App/Controller/LoginController.php (/login — форма и обработка, /login/logout), шаблон App/view/Login/login.html. Меню сайта (Controller::menu()) показывает «Войти» либо «Выйти (логин)» в зависимости от Auth::instance()->getUser(); пункт «Админка» виден только при role === 'admin'.

DataBase (Services\Database, System\Classes\Repository)

Реляционная БД (MariaDB) через PDO. Конфиг — Config::get('db', $name), ключ $name — это имя подключения, по умолчанию 'default'; значение — параметры подключения плоским массивом (host/hosts/port/user/password/dbname/dialect/charset), без обёртки под тип драйвера — PDO не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте нет, поэтому и нечего выбирать конфигом:

use Services\Database;

$connection = Database::instance();               // Config::get('db', 'default')
$stmt       = $connection->query('SELECT * FROM users WHERE id = ?', [42]);
$row    = $stmt->fetch();

echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())

Подключение — ленивое (первое обращение к pdo()/query()), с failover: hosts (массив) перебирается по порядку до первого успешного, иначе — MyException со списком ошибок по каждому хосту.

Диалект — dialect ('mysql' по умолчанию, либо 'pgsql'/'sqlite') переключает только сборку DSN внутри PdoConnection::buildDsn() — реестра классов-драйверов по типу СУБД нет (в отличие от Auth/LogReader), потому что весь остальной код (Statement, Profiler, SAVEPOINT-транзакции) от диалекта не зависит, это осталось бы дублированием ради дублирования. sqlite — особый случай: dbname там путь к файлу (или ':memory:'), а не имя базы на хосте, поэтому у него нет host/hosts/user/password/charset и failover-перебор для него не запускается (connect() подключается напрямую). Расширения pdo_mysql и pdo_sqlite есть в текущем Docker-образе, pdo_pgsql — нет (диалект pgsql в коде поддержан, но не проверен вживую).

Транзакции на уровне PdoConnection — вложенные через SAVEPOINT (beginTransaction()/commit()/rollback() считают уровень вложенности сами), либо через обёртку:

$connection->transaction(function ($connection) {
    $connection->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
    $connection->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
});
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw

Тайминги запросов — Services\DataBase\Classes\Profiler::entries()/totalTime() (наполняется автоматически из Statement::execute() — единой точки для любого выполнения запроса, в т.ч. ручного $connection->prepare()->execute() в обход query(), как делает большинство методов Repository).

Repository — System\Classes\Repository (@template T of object, abstract CRUD), конкретные — в App/Repositories/*. Таблица и класс строки задаются через конструктор, не через переопределение свойства:

namespace App\Repositories;
use System\Classes\Repository;

class BudgetRepository extends Repository
{
    public function __construct(?string $connection = null)
    {
        parent::__construct('budget', connection: $connection); // + class-string<T> вторым аргументом, по умолчанию Model::class
    }
}
$repo = new BudgetRepository();                       // подключение — Database::instance()
$repo->get(1);                                         // T|false, через fetchObject()
$repo->getItemWhere("status = 'open'");                // T|false|null, произвольное условие (без плейсхолдеров)
$repo->getList('*', ['status' => 'open']);             // T[]|false; $where — 'col' => 'value' (или 'IS NULL'/'IS NOT NULL')
$repo->create(['title' => 'Q3', 'amount' => 1000]);     // lastInsertId
$repo->update(['id' => 1, 'amount' => 1200]);           // primary_col обязателен в $data
$repo->delete(1);
$repo->deleteWhere('status = ?', ['closed']);
$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoConnection (там же и SAVEPOINT-логика)

public string $primary_col = 'id' и public bool $is_auto_increment = true — переопределяются в наследнике при необходимости. processData() (protected) — общая сборка bind-параметров для create()/update() с автоопределением PDO::PARAM_* по типу значения (массив кодируется в JSON).

getList() по умолчанию — $fetch = PDO::FETCH_CLASS, и в этом случае в fetchAll() явно передаётся $this->obj_class (не generic stdClass) — так же, как в get()/getItemWhere(). Это сознательное отличие от паттерна, с которого портировали этот класс (eoffice_v3/System/Classes/Repository.php): там по умолчанию PDO::FETCH_KEY_PAIR (падает, если $cols — не ровно 2 колонки — плохой дефолт для общего метода) и FETCH_CLASS не прокидывает obj_class в fetchAll() вовсе (реальная нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы один-в-один: там свой счётчик вложенности в каждом Repository, что ломается при двух репозиториях на одном соединении (оба думают, что они «внешние», оба зовут PDO::beginTransaction() — исключение); в Bicycle транзакции делегируются в PdoConnection, у которого счётчик один на всё подключение.

Model (Services\DataBase\Model, #[AllowDynamicProperties]) — лёгкий пассивный носитель данных, это класс по умолчанию для T в Repository. Свойства настоящие (не спрятаны за внутренним массивом) — это принципиально: PDO::FETCH_CLASS/fetchObject() выставляет их напрямую, минуя __set() (внутренний механизм PDO, до вызова конструктора), поэтому попытка перехватить запись через __set() в скрытый массив не сработает — toArray() вернул бы пусто. Не ActiveRecord — чтение/запись в БД делает Repository.

Elasticsearch (Services\Elasticsearch)

Тонкая REST-обёртка на System\Classes\HTTP\Client\Curl — без тяжёлой composer-зависимости elasticsearch/elasticsearch. Конфиг — Config::get('elasticsearch', $name) (base_url, timeout, опционально username/password для Basic Auth).

use Services\Elasticsearch;

$client = Elasticsearch::instance();
$client->index('logs', ['level' => 'error', 'message' => 'boom'], 'log-1');
$client->get('logs', 'log-1');
$client->search('logs', ['query' => ['match' => ['message' => 'boom']]]);
$client->delete('logs', 'log-1');
$client->ping(); // false вместо исключения, если кластер недоступен — удобно для graceful skip

Контейнер elasticsearch в текущей dev-инфраструктуре не поднят (см. docker-dev/docker-compose.yml) — ElasticsearchClientTest пропускает тесты через markTestSkipped(), если ping() вернул false; оживает сам, без правки кода, как только контейнер появится.

Mongo (Services\Mongo)

Обёртка над MongoDB\Client (пакет mongodb/mongodb, уже в composer.json). Требует PHP-расширение ext-mongodb, которого пока нет в docker/php-apache/8.2.8/Dockerfile (общий для нескольких проектов — трогать его нельзя, см. раздел Apache; расширение добавляет пользователь сам). Код безопасен без расширения — падает только при реальном new MongoDB\Client() внутри MongoDriver::__construct(), не при простом подключении файла/автозагрузке, поэтому Mongo::instance() не вызывать эагерно из bootstrap. Конфиг — Config::get('mongo', $name) (uri, database):

use Services\Mongo;

$driver = Mongo::instance();
$driver->collection('logs')->insertOne(['level' => 'error']);
$driver->database()->listCollections();

MongoDriverTest пропускает тесты через markTestSkipped(), если !extension_loaded('mongodb') — оживает сам, без правки кода, как только пользователь добавит расширение и поднимет контейнер mongo.

ProfilerToolbar (System\Classes\ProfilerToolbar)

Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana) — портирована не один-в-один: FireBug-вывод (firebug()/FirePHP) и подсветка исходников (debugSource()/ GeSHi) из оригинала не переносились — устаревшие технологии, не нужны здесь. Вкладки:

  • SQL — из Services\DataBase\Classes\Profiler (см. раздел DataBase выше): время, текст запроса, параметры, EXPLAIN для SELECT-запросов (прогоняется заново через Database::instance() в момент рендера панели, не при исполнении самого запроса; ошибка EXPLAIN → —, не ломает панель). Сам EXPLAIN выполняется с Statement::$skip_profiling = true — иначе попал бы в тот же Profiler и засорял бы свой же счётчик.
  • Vars — GET/POST/COOKIE/SESSION/SERVER; чувствительные ключи (Log::$mask_keys: password, pass, csrf_token, token) маскируются *** — та же маска, что и в Log.
  • Files — все подключённые к запросу файлы (get_included_files()) с размером и общим итогом.
  • Route — текущие uri/method/controller/action/params из Request::$current.
  • Custom — появляется, только если код вызвал ProfilerToolbar::addData($data, $tab = 'custom') (аналог addData() в оригинале) — свободная вкладка для точечной отладки из любого места кода.

Подключена прямо в App/view/layout.html (<?= \System\Classes\ProfilerToolbar::render() ?> перед </body>) — рендерится для любой страницы сайта, но render() отдаёт пустую строку всем, кроме role === 'admin' (проверка через Auth::instance()->getUser(), как и остальные ролевые проверки в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн onclick + один <script>/<style> внутри самого render(), без внешних файлов — панель самодостаточна.

Ajax/API-ответы панель не видят — renderContent()/json() не проходят через layout.html, где она подключена. Вместо HTML-панели Controller::after() (см. таблицу Core classes) для admin ставит заголовок X-Profiler — JSON с полной картиной запроса (time_ms, memory_mb, sql_count, sql_time_ms, список sql с временем каждого) — виден в devtools → Network → заголовки ответа для любого запроса, включая ajax; тело ответа не трогается. EXPLAIN панели (см. выше) не участвует — skip_profiling.

На странице ошибки панель тоже есть — System/view/exception/error.html (см. MyException::handler(), показывается только в DEVELOPMENT) заканчивается тем же <?= \System\Classes\ProfilerToolbar::render() ?>, что и layout.html — видно, какие SQL-запросы успели выполниться до падения. Аналог ProfilerToolbar::render(true) в конце views/kohana/error.php у оригинала; в отличие от оригинала — без своей подсветки исходников через GeSHi и без разбора стека вручную, это уже даёт $xdebug_message (готовая таблица от Xdebug: файл/строка/ память/время по каждому кадру), если расширение включено.

Панель полностью самодостаточна по стилям — общий сброс #profiler-toolbar, #profiler-toolbar * {...} (font-family/font-size/color/margin/padding и т.д. явно на каждом потомке), не полагаясь на наследование. Понадобилось из-за двух независимых багов, которые проявлялись по-разному на разных страницах:

  • на странице ошибки нет <!DOCTYPE html> (error.html рендерится как голый фрагмент, без layout) — браузер в quirks mode, где <table> в некоторых движках не наследует color от предков → текст в таблицах панели становился чёрным на тёмном фоне именно там;
  • на обычных страницах (layout.html) подключён Bootstrap, который стилизует голые теги без класса — code {color:#d63384} (розовый), h4 {font-size:1.5rem} (крупный) — эти правила побеждали наследование от #profiler-toolbar, потому что явное правило на самом элементе всегда сильнее унаследованного значения, независимо от специфичности правила у предка.

Розовый акцент для <code> (SQL/пути к файлам/ключи) — теперь свой, явный (#profiler-toolbar code {color:#ff79c6}), не случайно унаследованный от Bootstrap — поэтому одинаково выглядит что на обычной странице, что на странице ошибки.

Log (System\Classes\Log)

Файловый логгер в стиле Config/Cookie. Пишет в два файла, именованных датой: App/logs/action-{Y-m-d}.log (действия пользователя) и App/logs/error-{Y-m-d}.log (ошибки и предупреждения). Канал выбирается по уровню.

use System\Classes\Log;

Log::info('Пользователь :id вошёл', [':id' => 42]);   // подстановка :key через strtr
Log::error('Сбой оплаты');
Log::debug(...); Log::warning(...);
  • Вкл/выкл целиком — Log::$enabled → Config::get('log','enabled') → true по умолчанию; false — write() ничего не пишет ни в один файл, независимо от уровня/порога.
  • Уровни: debug(100) < info(200) < warning(300) < error(400). Пишутся только уровни не ниже порога Log::$threshold (по умолчанию из Config::get('log','threshold') → debug).
  • Канал по уровню: info/debug → файл action-…, warning/error → файл error-….
  • Каталог — Log::$directory → Config::get('log','path') → APPPATH/logs. Создаётся на лету.
  • Log::requestInfo() — строка контекста текущего запроса (METHOD /uri Controller::action user=login params=… query=… post=…) из Request::$current; user — логин из Auth::provider()->user() ('guest', если не авторизован); чувствительные ключи (Log::$mask_keys: password, pass, csrf_token, token) маскируются ***.

Интеграция (автоматически):

  • Request::execute() пишет INFO на каждый входящий запрос (аудит — кто что делал).
  • MyException::handler() пишет ERROR на каждое неперехваченное исключение (текст + контекст запроса), и в DEVELOPMENT, и в PRODUCTION.

Каталог логов в .gitignore (/App/logs/).

Просмотр логов: страница /admin/logs (App\Controller\Admin\LogsController) с фильтрами (дата, канал, уровень, текст). Источник — через интерфейс LogReader (реализация FileLogReader парсит файлы; позже подменяется на DbLogReader). Каталог берётся из Log::directory(). Вывод сообщений в шаблоне App/view/Admin/Logs/logs.html экранируется (htmlspecialchars) — в логах есть пользовательский ввод (XSS-вектор).

HTTPException (System\Classes\HTTP\HTTPException)

use System\Classes\HTTP\HTTPException;

throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]);
// или через getResponse() без throw:
$response = HTTPException::factory(403)->getResponse();

factory(int $code) создаёт HTTPException_403/404 или базовый класс для неизвестных кодов. Только для действительно исключительных ситуаций (throw); для редиректов — System\Classes\HTTP::redirect() напрямую, не через эту иерархию (см. раздел Auth выше — почему).

Routing (Route::resolve)

Автоматический роутинг без конфигурации, поиск контроллера по файловой системе в App/Controller/:

URL Контроллер Метод
/ IndexController indexAction()
/users UsersController indexAction()
/users/list UsersController listAction()
/users/list/42 UsersController listAction(), param(0)=42
/admin/users/edit Admin/UsersController editAction() (subdir)

Поиск файлов и имён контроллеров — без учёта регистра. Если контроллер не найден, Route возвращает flat-роутинг (не теряет action).

View path convention

  • Шаблон контента: App/view/{Controller}/{template}.html
  • Layout: App/view/{layout}.html — единый шаблон на весь сайт (app-специфичный — не в System, там только нейтральные fallback-страницы вроде errors/, exception/)
  • Core::findFile($dir, $file, 'html') ищет в APPPATH/{dir}/{file}.html, затем в SYSPATH/{dir}/{file}.html
  • $_paths инициализируется лениво при первом вызове findFile()
  • Controller::render() автоматически определяет $dir из имени класса (App\Controller\FooController → view/Foo)

Собственные CSS/JS во view (System\Classes\View)

Файлы — в App/media/css/ и App/media/js/. Content-шаблон подключает свои прямо в себе:

<?php
$this->setStyle('about.css');
$this->setScript('about.js');
?>

layout.html выводит накопленное — <?php $this->getStyles(); ?> в <head>, <?php $this->getScripts(); ?> перед </body> (есть также setManifest() для PWA-манифеста, выводится в getStyles()). $this внутри шаблона — это объект View, который его рендерит (шаблон — include внутри View::render()), поэтому методы вызываются как $this->..., как и CSRF/View-хелперы в других шаблонах.

Хранилище ($_styles/$_scripts/$_manifest) — статическое, общее на весь запрос: content и layout — разные объекты View (content рендерится первым внутри Controller::render(), до создания layout-View), но регистрация в content должна быть видна при выводе в layout. Файл ищется через Core::findFile() (APPPATH → SYSPATH) — если не найден, в месте вызова setStyle()/setScript()/setManifest() выводится <div class="alert alert-danger">.

Controller hierarchy

Расстановка имён — как в проекте eoffice_v3:

  • System\Classes\BaseController (abstract) — голое ядро: жизненный цикл executeAction() (before() → экшен → after()), хуки по умолчанию пустые. Нейтрально к вебу/API.
  • System\Classes\Controller extends BaseController — веб: render() (layout + content) и renderContent() (только content, без layout — для ajax-фрагментов), авто-CSRF в before(), json($data, $status) для JSON-ответов. Меню — не забота движка: раньше menuTop()/menuSide() строили пункты меню PHP-массивом прямо в System\Classes\Controller — это было ошибкой (движок не должен знать структуру навигации конкретного проекта, см. .claude/memory/feedback_engine_app_independence.md в репозитории — тот же принцип, только для меню, а не для БД). Убрано; навигация — обычная разметка прямо в App/view/layout.html, без промежуточного PHP-слоя. Никакой проверки isAjax() нет — экшен сам решает, вызывать render() или renderContent(). CSS/JS — Bootstrap, Bootstrap Icons, jQuery из /vendor/. Контроллеры приложения наследуют Controller напрямую.

API-контроллер делается не отдельным классом, а флагом: extends Controller + $_csrf_protection = false + ответы через json() (так же, как в eoffice_v3).

Текущие контроллеры приложения (пересобираются заново после сброса, см. .claude/memory/бюджет_текущий_план.md): LoginController (вход/выход, см. раздел Auth), IndexController (дашборд/«Обзор», защищён $_auth_protection), AccountsController (CRUD счетов — createAction()/editAction()/deleteAction(), JSON-ответы, без своего indexAction()/ страницы — вызывается ajax'ом с модалки прямо на дашборде, см. App/view/Index/index.html + App/media/js/accounts.js; шаблон для будущих CategoriesController/TransactionsController/ GoalsController — тот же паттерн: currentUser()/владение записью через user_id → 404, а не 403, если чужой id; архивация (is_delete=1) вместо физического delete(), где это в схеме).

Request::execute() вызывает executeAction({action}Action), поэтому before()/after() работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом $_auth_protection (см. раздел Auth), а не переопределением before() вручную.

Environment

Среда задаётся через SetEnv APP_ENV в .htaccess (PRODUCTION / STAGING / TESTING / DEVELOPMENT). По умолчанию — DEVELOPMENT. Значение читается в index.php через getenv('APP_ENV').

Apache

ServerName localhost добавлен в /home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf. Никогда не трогать /home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile.

Tests

PHPUnit 11 в Docker-контейнере bicycle. Bootstrap: tests/bootstrap.php. Константы определяются в bootstrap. Core::init() не вызывается — findFile() инициализирует $_paths лениво.

PHPStorm: интерпретатор PHP — Docker (bicycle), volume маппинг /home/isaevea/http/Bicycle → /opt/project, путь к autoload: /opt/project/vendor/autoload.php, конфиг: /opt/project/phpunit.xml.

Файл Покрытие
tests/Unit/RouteTest.php Route::resolve(), case-insensitive, params, fallback
tests/Unit/ResponseTest.php body(), status(), fluent chain
tests/Unit/MyExceptionTest.php strtr замена, code, previous
tests/Unit/CoreTest.php findFile(), getConst(), environment
tests/Unit/RequestTest.php factory(), post(), query(), body(), isAjax(), method()
tests/Unit/ViewTest.php render(), setFilename(), data extract, setStyle()/setScript()/setManifest() + getStyles()/getScripts()
tests/Unit/HttpClientRequestTest.php HTTP\Client\Request builder, json, fluent
tests/Unit/CookieTest.php get(), set(), delete(), defaults
tests/Unit/SessionTest.php singleton, get/set/delete/destroy/regenerate/bind
tests/Unit/ConfigTest.php get(), set(), merge(), lazy-load
tests/Unit/HTTPExceptionTest.php factory(), subclasses, getResponse(), codes
tests/Unit/ValidationTest.php правила, matches, пропуск пустых, first-error, label/плейсхолдеры, fluent
tests/Unit/CSRFTest.php token() стабильность/формат, validate(), field(), нет токена в сессии
tests/Unit/ControllerTest.php executeAction(), порядок before/action/after, no-op хуки
tests/Unit/ControllerAfterTest.php after() веб-Controller: заголовок X-Profiler только для admin, содержит SQL-сводку — через xdebug_get_headers(), иначе markTestSkipped()
tests/Unit/LogTest.php уровни/порог, enabled (выкл — ничего не пишет), каналы (action/error), формат, append, strtr, маскировка, requestInfo()
tests/Unit/FileLogReaderTest.php парсинг, фильтры (level/q/channel), newest-first, missing file, dates()
tests/Unit/FileAuthDriverTest.php login() верно/неверно/неизвестный логин, loggedIn(), getUser() без пароля, logout(), checkPassword(), отсутствие файла
tests/Unit/AuthTest.php instance() драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение
tests/Unit/DatabaseTest.php instance() кэш по имени подключения, неизвестное подключение/драйвер → исключение
tests/Unit/PdoConnectionTest.php failover (не требует БД), query(), транзакции/вложенные SAVEPOINT, transaction(), профилирование ручного prepare()+execute() — требует живую MariaDB, иначе markTestSkipped(); отдельно диалект sqlite (:memory:, не требует внешней БД)
tests/Unit/StatementTest.php showQuery()/sq() — подстановка позиционных/именованных параметров — требует живую MariaDB
tests/Unit/ProfilerTest.php log()/entries()/totalTime()/count()/reset()
tests/Unit/RepositoryTest.php get()/getItemWhere()/getList() (в т.ч. дефолтный FETCH_CLASS + obj_class)/create()/update()/delete()/deleteWhere(), transaction() с rollback — требует живую MariaDB
tests/Unit/ModelTest.php __get/__set/__isset/toArray(), PDO::FETCH_CLASS/fetchObject() выставляет настоящие свойства (не через __set())
tests/Unit/ElasticsearchTest.php instance() кэш по имени подключения, неизвестное подключение → исключение
tests/Unit/ElasticsearchClientTest.php index()/get()/delete()/exists() — требует живой Elasticsearch, иначе markTestSkipped() через ping()
tests/Unit/MongoTest.php неизвестное подключение → исключение (без ext-mongodb — остальное см. MongoDriverTest)
tests/Unit/MongoDriverTest.php instance(), collection(), database() — требует ext-mongodb, иначе markTestSkipped()
tests/Unit/ProfilerToolbarTest.php render() пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при addData(), EXPLAIN для реального SELECT / пропуск для не-SELECT

248 тестов, 387 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).

Frontend dependencies (через Composer)

Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из vendor/.

Дополнительно

Узкие темы (конвенции именования, правила работы с Claude Code, roadmap и т.п.) — не здесь, а в .claude/memory/ (по одному файлу на тему, индекс — .claude/memory/MEMORY.md). Этот файл — только общая архитектура и команды.