Add knowledge base

This commit is contained in:
2026-04-06 15:43:45 +03:00
parent df1d2a58bf
commit ab89913ebf
11 changed files with 514 additions and 112 deletions

14
.claude/INDEX.md Normal file
View File

@@ -0,0 +1,14 @@
# Pinecore Knowledge Base
Детальная документация по реализации. См. также [CLAUDE.md](../CLAUDE.md) для базового обзора.
## Разделы
- [Архитектура и жизненный цикл запроса](kb/architecture.md)
- [Ядро, Config, Environment, ContainerFactory](kb/bootstrap.md)
- [HTTP слой](kb/http.md)
- [Worker и запуск](kb/worker.md)
- [Аутентификация (JWT)](kb/auth.md)
- [Логирование](kb/logging.md)
- [ORM (MongoDB)](kb/orm.md)
- [Console](kb/console.md)

View File

@@ -0,0 +1,59 @@
# Архитектура и жизненный цикл запроса
## Общая схема
```
HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
Kernel::boot($basePath) [один раз при старте]
→ Environment::detect() // читает APP_ENV, дефолт 'dev'
→ Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php
→ ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/
loop (WorkerRunner::run()):
frankenphp_handle_request(fn() =>
HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER)
→ Request::fromGlobals() // парсит метод, путь, заголовки, JSON body
→ OPTIONS? → CORS headers + 204 // preflight, без роутинга
→ Router::match(method, path) // regex-компиляция {param} → именованные группы
→ 404 / 405+Allow / RouteMatch
→ MiddlewarePipeline::run() // каждый middleware меняет Request через withContext()
→ $controller($request): Response // DI-resolved invokable
→ $response->withHeaders(corsHeaders())->emit()
)
→ HttpApplication::terminate() // хук для закрытия ресурсов (сейчас пустой)
→ gc_collect_cycles()
→ если !$keepRunning или достигнут MAX_REQUESTS — выход из цикла
```
## Карта компонентов
| Файл | Класс | Роль |
|---|---|---|
| `src/Kernel.php` | `Kernel` | Статический bootstrap, хранит контейнер |
| `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` |
| `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` |
| `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation |
| `src/Http/WorkerRunner.php` | `WorkerRunner` | Цикл FrankenPHP, MAX_REQUESTS, gc |
| `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений |
| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` |
| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` |
| `src/Http/Router.php` | `Router` | Regex-компиляция маршрутов, возвращает `RouteMatch` |
| `src/Http/MiddlewarePipeline.php` | `MiddlewarePipeline` | Последовательно прогоняет middleware через DI |
| `src/Http/RouteDefinition.php` | `RouteDefinition` | method, path, controller, middleware[] |
| `src/Http/HttpException.php` | `HttpException` | Выбрасывается из любого места → Response::error |
| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer JWT → AuthContext в request |
| `src/Auth/JwtService.php` | `JwtService` | HS256, issue/verify, lcobucci/jwt |
| `src/Log/StdoutLogger.php` | `StdoutLogger` | JSON → stdout, PSR-3 |
| `src/Log/FileLogger.php` | `FileLogger` | JSON → файл (если LOG_FILE задан) |
| `src/Orm/AbstractMongoRepository.php` | `AbstractMongoRepository` | persist/findById/delete/findOneWhere |
| `src/Orm/MongoHydrator.php` | `MongoHydrator` | hydrate/dehydrate, поддержка Embedded/EmbeddedList |
| `src/ExceptionHandler.php` | `ExceptionHandler` | Ловит Throwable вне dispatch (critical лог) |
## Обработка исключений в dispatch
В `HttpApplication::dispatch()` ловятся:
- `AuthException` → 401
- `HttpException` → код из исключения
- `\JsonException` → 400 (невалидный JSON body)
- `\Throwable` → логируется, 500
Исключения, вылетающие **за пределы** `handleRequest()` (т.е. до/после dispatch), ловит `ExceptionHandler` в `WorkerRunner`.

48
.claude/kb/auth.md Normal file
View File

@@ -0,0 +1,48 @@
# Аутентификация (JWT)
## Компоненты
| Файл | Класс | Роль |
|---|---|---|
| `src/Auth/JwtService.php` | `JwtService` | Выдача и верификация JWT (HS256, lcobucci/jwt) |
| `src/Auth/AuthMiddleware.php` | `AuthMiddleware` | Bearer → JwtService → UserProvider → AuthContext |
| `src/Auth/AuthContext.php` | `AuthContext` | Обёртка над объектом пользователя |
| `src/Auth/UserProviderInterface.php` | `UserProviderInterface` | Контракт: `findById(string): ?object` |
| `src/Auth/AuthException.php` | `AuthException` | → 401 в HttpApplication::dispatch() |
## JwtService (`src/Auth/JwtService.php`)
Конфигурируется через Config:
- `jwt.secret` — HMAC-ключ (обязателен, иначе RuntimeException при старте)
- `jwt.access_ttl` — TTL в секундах
```php
$jwt->issue(string $userId): string // sub = $userId, iat, exp
$jwt->verify(string $tokenString): string // возвращает sub (userId), бросает AuthException
```
Использует `lcobucci/jwt`, HS256, `StrictValidAt` (проверяет exp с текущим временем).
## AuthMiddleware (`src/Auth/AuthMiddleware.php`)
1. Читает заголовок `authorization` (lowercase в Request)
2. Ожидает формат `Bearer <token>`
3. `JwtService::verify()``userId`
4. `UserProviderInterface::findById($userId)` → user object
5. `null``AuthException('User not found')`
6. Возвращает `$request->withContext('auth', new AuthContext($user))`
```php
// В контроллере:
$auth = $request->get('auth'); // AuthContext
$user = $auth->user; // объект пользователя (тип зависит от приложения)
```
## Подключение в приложении
В `config/services.php` нужно забиндить `UserProviderInterface`:
```php
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
```
`UserRepository` должен реализовывать `UserProviderInterface` (`findById(string $id): ?object`).

56
.claude/kb/bootstrap.md Normal file
View File

@@ -0,0 +1,56 @@
# Ядро, Config, Environment, ContainerFactory
## Kernel (`src/Kernel.php`)
Статический класс, хранит единственный инстанс контейнера.
```php
Kernel::boot(string $basePath): Container // ENV → Config → ContainerFactory
Kernel::container(): Container // бросает RuntimeException если не вызван boot()
```
## Environment (`src/Environment.php`)
```php
Environment::detect(): self // getenv('APP_ENV') ?: 'dev'
$env->name(): string
$env->is(string $name): bool
$env->isProd(): bool // name === 'prod'
```
## Config (`src/Config.php`)
**Загрузка** (`Config::load($configDir, $env)`):
1. Все `$configDir/*.php` — ключ = имя файла без расширения
2. Deep-merge `$configDir/env/{envName}.php` (если есть)
3. Deep-merge `$configDir/env/local.php` (если есть, всегда поверх)
**Доступ:**
```php
$config->get('section.key', $default) // dot-notation, deep lookup
$config->get('app.cors') // возвращает array
```
Merge: рекурсивный для assoc-массивов, replace для списков (array_values === array).
## ContainerFactory (`src/ContainerFactory.php`)
```php
ContainerFactory::build(Environment $env, Config $config, string $basePath): Container
```
- Autowiring включён всегда
- В prod: `$builder->enableCompilation($basePath . '/var/cache/prod/')`
- Загружает `$basePath/config/services.php` — файл должен вернуть `callable($builder, $config, $basePath)`
**Пример `config/services.php`:**
```php
return function (ContainerBuilder $builder, Config $config, string $basePath): void {
$builder->addDefinitions([
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
Router::class => fn() => new Router([
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]),
]),
]);
};
```

64
.claude/kb/console.md Normal file
View File

@@ -0,0 +1,64 @@
# Console
## Компоненты
| Файл | Класс | Описание |
|---|---|---|
| `src/Console/ConsoleApplication.php` | `ConsoleApplication` | Точка входа: парсит argv, матчит команду, запускает handler |
| `src/Console/ConsoleRouter.php` | `ConsoleRouter` | Аналог HTTP Router для консольных команд |
| `src/Console/ConsoleDefinition.php` | `ConsoleDefinition` | signature, handler class, description, options[] |
| `src/Console/OptionDefinition.php` | `OptionDefinition` | name, description, default (false = флаг) |
| `src/Console/ConsoleInput.php` | `ConsoleInput` | Распарсенные path params + options |
| `src/Console/ConsoleOutput.php` | `ConsoleOutput` | Вывод, exit code |
| `src/Console/ConsoleMatch.php` | `ConsoleMatch` | Результат матчинга (аналог RouteMatch) |
## ConsoleDefinition
```php
new ConsoleDefinition(
signature: 'users:create {role}', // {param} — path param из сигнатуры
handler: CreateUserCommand::class,
description: 'Create a new user',
options: [
new OptionDefinition('dry-run', 'Do not persist', false), // флаг (default=false)
new OptionDefinition('email', 'User email', null), // опция (default=null)
],
)
```
## Handler
Invokable-класс, резолвится через DI:
```php
final class CreateUserCommand
{
public function __invoke(ConsoleInput $input, ConsoleOutput $output): void
{
$role = $input->pathParams['role'];
$dryRun = $input->option('dry-run'); // bool для флагов
$email = $input->option('email'); // string|null для опций
$output->writeln('Done');
$output->setExitCode(0);
}
}
```
## Запуск
```php
// bin/console
Kernel::boot(__DIR__ . '/..');
$app = Kernel::container()->get(ConsoleApplication::class);
exit($app->run($argv));
```
Команды: `bin/console <signature> [--option=value] [--flag]`
Help: `bin/console help` или `bin/console help <signature>`
## Argv parsing
`ConsoleInput::parse($commandStr, $pathParams, $args, $options)`:
- `$args` = `array_slice($argv, 2)` (после имени команды)
- `--name=value` → опция
- `--name` без `=` → флаг (true)

70
.claude/kb/http.md Normal file
View File

@@ -0,0 +1,70 @@
# HTTP слой
## RouteDefinition (`src/Http/RouteDefinition.php`)
```php
new RouteDefinition(
method: 'GET',
path: '/users/{id}',
controller: GetUserController::class,
middleware: [AuthMiddleware::class], // опционально
)
```
## Router (`src/Http/Router.php`)
- Компилирует `{param}` → именованные regex-группы `(?P<param>[^/]+)`
- `match(method, path): RouteMatch`
- Сначала проверяет все маршруты на совпадение пути
- Если путь совпал, но метод нет → `RouteMatch::methodNotAllowed($allowedMethods)`
- Если ничего → `RouteMatch::notFound()`
## MiddlewarePipeline (`src/Http/MiddlewarePipeline.php`)
Резолвит каждый middleware через DI и вызывает `process(Request): Request` последовательно.
Middleware может изменять request через `withContext()` или бросать исключение.
**Интерфейс middleware:**
```php
interface MiddlewareInterface
{
public function process(Request $request): Request;
}
```
## Request (`src/Http/Request.php`)
Иммутабельный объект. Конструктор принимает именованные параметры.
```php
$request->method // 'GET', 'POST', ...
$request->path // '/users/123'
$request->query // $_GET
$request->pathParams // ['id' => '123'] — устанавливается роутером
$request->headers // lowercase-hyphen: 'content-type', 'authorization'
$request->cookies
$request->files
$request->body() // decoded JSON array (только если Content-Type: application/json)
$request->get('auth') // значение из context, установленного middleware
$request->withContext('key', $value): self // иммутабельное добавление в context
```
**Разбор заголовков:** `HTTP_*` из `$_SERVER` → lowercase-hyphen. `CONTENT_TYPE` тоже нормализуется.
## Response (`src/Http/Response.php`)
```php
Response::json($data, $status = 200): self // Content-Type: application/json
Response::error($message, $status): self // json(['error' => $message], $status)
$response->withHeader($name, $value): self
$response->withHeaders($headers): self
$response->emit(): void // http_response_code + headers + echo body
```
## HttpException (`src/Http/HttpException.php`)
```php
throw new HttpException('Forbidden', 403);
// → перехватывается в HttpApplication::dispatch() → Response::error(message, code)
```

53
.claude/kb/logging.md Normal file
View File

@@ -0,0 +1,53 @@
# Логирование
## Классы
| Файл | Класс | Описание |
|---|---|---|
| `src/Log/StdoutLogger.php` | `StdoutLogger` | JSON → php://stdout |
| `src/Log/FileLogger.php` | `FileLogger` | JSON → файл |
| `src/Log/CompositeLogger.php` | `CompositeLogger` | Делегирует нескольким логгерам |
| `src/Log/NullLogger.php` | `NullLogger` | Отбрасывает все сообщения |
Все реализуют `Psr\Log\LoggerInterface`.
## Формат вывода (StdoutLogger)
JSON-строка на каждое сообщение:
```json
{"ts":"2026-04-06T12:00:00.000Z","level":"error","channel":"app","message":"...","context":{...}}
```
- `ts` — ISO 8601 с миллисекундами
- `channel` — из `log.channel` в config (дефолт `app`)
- `context` — сериализуется; `Throwable``{class, message, file, line, trace}`
- PSR-3 интерполяция `{key}` из context работает для строк/scalar
## Конфигурация
| Env / Config key | Описание |
|---|---|
| `LOG_LEVEL` / `log.level` | Минимальный уровень (debug/info/notice/warning/error/critical/alert/emergency), дефолт `debug` |
| `LOG_FILE` / `log.file` | Путь к файлу → `FileLogger` включается автоматически |
| `log.channel` | Имя канала в выводе, дефолт `app` |
## Добавление бэкенда
В `config/services.php` в фабрике `LoggerInterface`:
```php
LoggerInterface::class => fn($c) => new CompositeLogger(array_filter([
$c->get(StdoutLogger::class),
$config->get('log.file') ? $c->get(FileLogger::class) : null,
// добавить сюда
])),
```
## Использование
```php
// Inject LoggerInterface через DI
$this->logger->error('Something failed', [
'exception' => $e,
'path' => $request->path,
]);
```

80
.claude/kb/orm.md Normal file
View File

@@ -0,0 +1,80 @@
# ORM (MongoDB)
## Атрибуты
| Атрибут | Применяется к | Описание |
|---|---|---|
| `#[Collection(name: 'users')]` | класс entity | Имя коллекции MongoDB |
| `#[Id]` | свойство | Кастомное поле `id` (строка), НЕ MongoDB `_id` |
| `#[Field]` | свойство | Обычное поле; имя в MongoDB = имя свойства |
| `#[Field(name: 'x')]` | свойство | Поле с другим именем в MongoDB |
| `#[Embedded]` | свойство | Вложенный объект (не Entity, просто класс) |
| `#[EmbeddedList]` | свойство | Массив вложенных объектов |
| `#[ForEntity(Foo::class)]` | класс репозитория | Связывает репозиторий с entity |
## Объявление Entity
```php
#[Collection(name: 'users')]
final class User implements MongoEntity
{
public function __construct(
#[Id] public readonly string $id,
#[Field] public readonly string $email,
#[Field] public readonly ?string $name = null,
// массив string[] — BSONArray конвертируется автоматически
#[Field] public readonly array $roles = [],
) {}
}
```
- `#[Id]` хранится в документе как поле `id` (не `_id`)
- MongoDB `_id` остаётся нативным ObjectId, пинекор его игнорирует
- `array` поля (в т.ч. `string[]`) — `BSONArray` конвертируется в PHP array прозрачно
- Если `$id === ''` при `persist()`, генерируется новый ID через `IdGenerator`
## Объявление репозитория
```php
#[ForEntity(User::class)]
final class UserRepository extends AbstractMongoRepository
{
// Публичный типизированный метод — не override protect persist()
public function save(User $user): User
{
return $this->persist($user);
}
public function findByEmail(string $email): ?User
{
return $this->findOneWhere(['email' => $email]);
}
}
```
## AbstractMongoRepository (`src/Orm/AbstractMongoRepository.php`)
Инжектируется через DI: `Database`, `MongoHydrator`, `IdGenerator`.
| Метод | Описание |
|---|---|
| `persist(object $entity): object` | upsert по полю `id`; если `id === ''`, генерирует новый |
| `findById(string $id): ?object` | поиск по полю `id` |
| `findOneWhere(array $filter): ?object` | произвольный MongoDB filter |
| `delete(string $id): void` | deleteOne по полю `id` |
| `collection(string $entityClass): Collection` | MongoDB\Collection для entity |
| `entityClass(): string` | читает `#[ForEntity]`, кешируется статически |
## MongoHydrator (`src/Orm/MongoHydrator.php`)
- `hydrate(string $class, array|BSONDocument $doc): object` — BSONDocument/array → typed entity через named constructor args
- `dehydrate(object $entity): array` — entity → array для MongoDB
- Рекурсивно обрабатывает `#[Embedded]` и `#[EmbeddedList]`
- Embedded при dehydrate: сериализует все свойства ReflectionClass (без атрибутов)
## EntityMap (`src/Orm/EntityMap.php`)
Кеширует метаданные entity (reflection). `EntityMap::of(ClassName::class)` возвращает объект с:
- `collectionName` — из `#[Collection]`
- `fields` — список `FieldMetadata` (property, field name, флаги embedded)
- `idField()``FieldMetadata` поля с `#[Id]`

59
.claude/kb/worker.md Normal file
View File

@@ -0,0 +1,59 @@
# Worker и запуск
## Entrypoint (`worker.php` приложения)
FrankenPHP запускается как `frankenphp run --config Caddyfile` и сам стартует PHP-воркеры.
```php
use Pronchev\Pinecore\Kernel;
use Pronchev\Pinecore\Http\WorkerRunner;
require __DIR__ . '/vendor/autoload.php';
Kernel::boot(__DIR__);
Kernel::container()->get(WorkerRunner::class)->run();
```
`WorkerRunner` резолвится через DI autowiring — конфигурировать не нужно.
## WorkerRunner (`src/Http/WorkerRunner.php`)
```php
final class WorkerRunner
{
public function __construct(
private readonly HttpApplication $app,
private readonly ExceptionHandler $exceptionHandler,
) {}
public function run(): void
{
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0); // 0 = без лимита
for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) {
$keepRunning = frankenphp_handle_request(function (): void {
try {
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
} catch (\Throwable $e) {
$this->exceptionHandler->handleException($e); // critical лог
}
});
$this->app->terminate(); // хук: закрытие ресурсов (сейчас пустой)
gc_collect_cycles();
if (!$keepRunning) break; // FrankenPHP сигнализирует об остановке
}
}
}
```
## Env-переменные
| Переменная | По умолчанию | Описание |
|---|---|---|
| `APP_ENV` | `dev` | Среда исполнения |
| `MAX_REQUESTS` | `0` | Лимит запросов на воркер (0 = без лимита) |
| `LOG_LEVEL` | `debug` | Минимальный уровень логирования |
| `LOG_FILE` | — | Путь к файлу лога (активирует FileLogger) |
| `JWT_SECRET` | обязателен | HMAC-ключ для JWT |

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
.idea .idea
/vendor/ /vendor/
var/ var/
.claude/
CLAUDE.md

121
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code when working with this repository.
## Package ## Package
@@ -10,117 +10,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
No lint or test commands configured yet. No lint or test commands configured yet.
## Architecture ## Knowledge base
**Request lifecycle:** Detailed implementation docs: [.claude/INDEX.md](.claude/INDEX.md)
``` - [Architecture & request lifecycle](.claude/kb/architecture.md)
HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop - [Bootstrap: Kernel, Config, Environment, ContainerFactory](.claude/kb/bootstrap.md)
Kernel::boot() [once on startup] - [HTTP layer: Request, Response, Router, Middleware](.claude/kb/http.md)
→ Environment::detect() → Config::load() → ContainerFactory::build() - [Worker entrypoint & WorkerRunner](.claude/kb/worker.md)
loop: - [Auth: JWT, AuthMiddleware](.claude/kb/auth.md)
→ HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER) - [Logging](.claude/kb/logging.md)
→ Request::fromGlobals() - [ORM: MongoDB, entities, repositories](.claude/kb/orm.md)
→ OPTIONS? → CORS headers + 204 (no routing) - [Console](.claude/kb/console.md)
→ Router::match() → 404 / 405 / RouteMatch
→ MiddlewarePipeline::run()
→ Controller::__invoke(Request): Response
→ Response + CORS headers → emit()
→ Application::terminate()
→ gc_collect_cycles()
```
**Key components:**
| Path | Purpose |
|---|---|
| `src/Kernel.php` | One-time bootstrap |
| `src/Http/` | `HttpApplication`, `WorkerRunner`, `Request`, `Response`, `Router`, `RouteDefinition`, `MiddlewarePipeline`, `HttpException` |
| `src/Console/` | `ConsoleApplication`, `ConsoleRouter`, `ConsoleInput`, `ConsoleOutput`, `ConsoleDefinition` |
| `src/Auth/` | `JwtService`, `AuthMiddleware`, `AuthContext`, `AuthException`, `UserProviderInterface` |
| `src/Log/` | `StdoutLogger`, `FileLogger`, `CompositeLogger`, `NullLogger` |
| `src/Orm/` | `AbstractMongoRepository`, `MongoHydrator`, `EntityMap`, `IdGenerator`, attributes |
| `src/Model/` | Marker interfaces: `Entity`, `MongoEntity`, `SqlEntity`, `Dto` |
| `src/ExceptionHandler.php` | Catches `Throwable` escaping the worker loop |
| `src/Config.php` | Static config loader: `Config::get('section.key')` |
| `src/ContainerFactory.php` | Builds PHP-DI container from `config/services.php` |
## Worker entrypoint
FrankenPHP запускается бинарником (`frankenphp run --config Caddyfile`), который сам стартует PHP-воркеры. `worker.php` приложения должен:
```php
use Pronchev\Pinecore\Kernel;
use Pronchev\Pinecore\Http\WorkerRunner;
require __DIR__ . '/vendor/autoload.php';
Kernel::boot(__DIR__);
Kernel::container()->get(WorkerRunner::class)->run();
```
`WorkerRunner` резолвится через DI autowiring, конфигурировать не нужно.
`MAX_REQUESTS` env var ограничивает число запросов на воркер (0 = без лимита).
## HTTP
```php
// RouteDefinition: method, path, controller class, middleware array
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class])
// Controller — invokable, resolved via DI
final class GetUserController {
public function __invoke(Request $request): Response {
$id = $request->pathParams['id']; // path param
$user = $request->get('auth')->user; // from AuthMiddleware
return Response::json([...]);
}
}
// Throw from anywhere — caught centrally
throw new HttpException('Forbidden', 403);
```
## Auth
`AuthMiddleware` requires `UserProviderInterface` to be bound in DI:
```php
// UserProviderInterface: findById(string $id): object
// In app's config/services.php:
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
```
`JwtService::issue(string $userId): string` — issues a JWT. Accepts only a string ID, no dependency on app models.
## Logging
```php
// Inject LoggerInterface via DI constructor
$this->logger->error('Something failed', ['exception' => $e, 'path' => $request->path]);
```
Output: JSON to stdout — `ts`, `level`, `channel`, `message`, `context`.
Level: `LOG_LEVEL` env var (default `debug`).
File logging: set `LOG_FILE=/path/to/file.log``FileLogger` activates automatically.
To add a backend: extend the `array_filter([...])` in the `LoggerInterface` factory in `config/services.php`.
## ORM
Entities implement `MongoEntity`, use PHP 8 attributes:
```php
#[Collection(name: 'users')]
final class User implements MongoEntity {
public function __construct(
#[Id] public readonly string $id,
#[Field] public readonly string $email,
) {}
}
```
Repository extends `AbstractMongoRepository`, declare `#[ForEntity(Foo::class)]` on the class.
Implement typed `save(Foo $e): Foo { return $this->persist($e); }` — not an override of the protected `persist()`.
`#[Id]` maps to a custom field (`id`), not MongoDB's `_id` (which remains a native ObjectId).
`array` fields (e.g. `string[]`) are hydrated automatically — BSONArray is converted transparently.
## Code Style ## Code Style