# 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 ```bash # Установка зависимостей 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 тесты ``` > Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. ### 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. ```php 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) Синглтон, поддерживает именованные сессии. ```php 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(); // уничтожить сессию ``` ### Cookie (System\Classes\Cookie) Статический хелпер. ```php 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` — глубоко мержится поверх базового. ```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()`) по декларативным правилам. ```php 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/вебхуков). В шаблоне достаточно вставить поле: ```php use System\Classes\CSRF;
...
``` Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через `CSRF::validate()`: ```php 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()` — готовый `` с токеном. - `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`): ```php return [ 'admin' => [ 'password' => '', '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 не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте нет, поэтому и нечего выбирать конфигом: ```php 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()` считают уровень вложенности сами), либо через обёртку: ```php $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/*`. Таблица и класс строки задаются через конструктор, не через переопределение свойства: ```php namespace App\Repositories; use System\Classes\Repository; class BudgetRepository extends Repository { public function __construct(?string $connection = null) { parent::__construct('budget', connection: $connection); // + class-string вторым аргументом, по умолчанию Model::class } } ``` ```php $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). ```php 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`): ```php 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](https://github.com/Alert/profilertoolbar)) — портирована не один-в-один: 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` (`` перед ``) — рендерится для любой страницы сайта, но `render()` отдаёт пустую строку всем, кроме `role === 'admin'` (проверка через `Auth::instance()->getUser()`, как и остальные ролевые проверки в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн `onclick` + один `