51 KiB
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_302/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/{PdoDriver,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-исключение; factory(int $code), getResponse(): Response |
System\Classes\HTTP\Exception\HTTPException_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): PdoDriver, кэш по имени подключения |
Services\DataBase\Classes\PdoDriver |
Обёртка над PDO: query()/prepare()/exec(), failover по hosts, транзакции с вложенными 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() (делегируют в PdoDriver); конкретные — в 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: XMLHttpRequestdetectUri()— определяет 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(); // уничтожить сессию
Cookie (System\Classes\Cookie)
Статический хелпер.
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(): нет авторизации → редирект на /login (не throw:
неперехваченные исключения уходят в MyException::handler(), который не вызывает getResponse(),
поэтому для 302 вызывается HTTPException::factory(302, url)->getResponse() напрямую — она сама
делает Location + exit); авторизован, но роль не подходит → 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 — это имя
подключения (не тип драйвера — драйвер сейчас всегда 'pdo'), по умолчанию 'default':
use Services\Database;
$driver = Database::instance(); // Config::get('db', 'default')
$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]);
$row = $stmt->fetch();
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
Подключение — ленивое (первое обращение к pdo()/query()), с failover: connection.hosts
(массив) перебирается по порядку до первого успешного, иначе — MyException со списком ошибок
по каждому хосту. Транзакции на уровне PdoDriver — вложенные через SAVEPOINT
(beginTransaction()/commit()/rollback() считают уровень вложенности сами), либо через обёртку:
$driver->transaction(function ($driver) {
$driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
$driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
});
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
Тайминги запросов — Services\DataBase\Classes\Profiler::entries()/totalTime() (наполняется
автоматически из Statement::execute() — единой точки для любого выполнения запроса, в т.ч.
ручного $driver->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 — делегируют в PdoDriver (там же и 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 транзакции делегируются в PdoDriver, у которого счётчик один на всё подключение.
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_302/403/404 или базовый класс для неизвестных кодов.
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()отдают пункты (массив[['title','url','active'], ...]),renderMenuTop()/renderMenuSide()рендерят их черезApp/view/menu_top.html/menu_side.htmlв готовый HTML до передачи в layout (renderMenuSide()— пустая строка, если пунктов нет). Оба хука — общие для всего сайта (не переопределяются в конкретных контроллерах), активный пункт — по текущему URI, видны на любой странице любому пользователю независимо от роли/авторизации (сейчас вmenuSide()заглушка — «Главная», «О нас», «Новости»; для последних двух заведеныAboutController/NewsController). Layout —App/view/layout.html: верхнее меню всегда, боковое — колонкаaside(Bootstrap grid) появляется, только если$menu_sideне пуст; иначеcontentна всю ширину<main>. Никакой проверкиisAjax()нет — экшен сам решает, вызыватьrender()илиrenderContent(). CSS/JS — Bootstrap, Bootstrap Icons, jQuery из/vendor/. Единый layout для всего сайта, включая админку. Контроллеры приложения наследуютControllerнапрямую.
API-контроллер делается не отдельным классом, а флагом: extends Controller + $_csrf_protection = false + ответы через json() (так же, как в eoffice_v3).
Админка (App/Controller/Admin/): базовый App\Controller\Admin\AdminController extends Controller своего layout и меню не задаёт — рендерится в том же App/view/layout.html, что и весь сайт, с тем же общим Controller::menuSide() (не связан с админкой — заглушка на весь сайт). Включает $_auth_protection = true — доступ только авторизованным (см. Auth), неавторизованный редиректится на /login. Конкретные страницы (LogsController и т.п.) наследуют AdminController.
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/PdoDriverTest.php |
failover (не требует БД), query(), транзакции/вложенные SAVEPOINT, transaction(), профилирование ручного prepare()+execute() — требует живую MariaDB, иначе markTestSkipped() |
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 тестов, 386 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). Этот файл — только
общая архитектура и команды.