# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands All PHP commands run inside Docker. The app container is named `app`. ```bash # Unit tests docker compose exec app php vendor/bin/phpunit tests/Unit/ # Single test file or method docker compose exec app php vendor/bin/phpunit tests/Unit/Domain/Article/ArticleTest.php docker compose exec app php vendor/bin/phpunit --filter testSomeMethod # Integration tests (loads .env.local secrets automatically) bin/test-integration bin/test-integration tests/Integration/Channel/EbayAdapterTest.php # PHPStan (level 9 — must be clean before committing) docker compose exec app php vendor/bin/phpstan analyse # CS Fixer (dry-run to check, no --dry-run to fix) docker compose exec app php vendor/bin/php-cs-fixer fix --dry-run --diff docker compose exec app php vendor/bin/php-cs-fixer fix # Migrations docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction docker compose exec app php bin/console doctrine:migrations:diff # generate from entity changes # Cache docker compose exec app php bin/console cache:clear # Create first user / API key docker compose exec app php bin/console app:users:create docker compose exec app php bin/console app:api-keys:create ``` ## Architecture Hexagonal architecture: **Domain → Application → Infrastructure**. The boundary is enforced by convention and PHPStan. - `src/Domain/` — pure PHP, zero framework imports. Entities, enums, value objects, repository *interfaces*. Doctrine attributes on entities are the pragmatic exception. - `src/Application/` — use cases, message handlers, service interfaces (ports). Orchestrates domain via interfaces only. - `src/Infrastructure/` — all framework/external-system code: Doctrine repositories, Symfony controllers, Messenger handlers, channel adapters, AI clients. ### Dependency Injection Every Domain repository interface is aliased to its Doctrine implementation in `config/services.yaml`. All Application interfaces (ports) are aliased there too. When adding a new interface+implementation pair, add the alias manually — autowiring alone won't resolve interfaces. Channel adapters are collected via `tagged_iterator app.channel_adapter` into `ChannelAdapterRegistry`. Tag new adapters in `services.yaml`. ### Routing (Symfony 8 gotcha) `routing.controllers` auto-discovers all controllers. API controllers **must** declare the `/api` prefix in their class-level `#[Route]` attribute — a yaml `prefix:` on top of auto-discovery is silently ignored. ### AI Pipeline The AI backend is `MistralClient` (vision: pixtral-12b, text: mistral-large). The interface is named `OllamaClientInterface` for historical reasons — the `services.yaml` alias points it to `MistralClient`. Web search uses `TavilyWebSearch` behind `WebSearchInterface`. Pipeline A (photo) chains Messages sequentially — each handler dispatches the next: `PhotoUpload → SpecsResearch → JsonCoding → Validation → DraftArticle → EbayText` After vision, `findCompletedByModelNumber()` checks the DB for a cache hit and skips the remaining AI steps if found. `PipelineJobFailureListener` catches `WorkerMessageFailedEvent` after all retries are exhausted and sets `AIPipelineJob.status = failed`. ### Messenger Transports Three isolated Redis streams — a failing worker never blocks the others: | Transport | Worker service | Retries | Delay | |---|---|---|---| | `ai_pipeline` | `worker-ai` | 3 | 2 s ×2 | | `orders` | `worker-orders` | 5 | 1 s ×2 | | `channel_sync` | `worker-channel` | 5 | 2 s ×2, max 60 s | Exhausted messages land in `failed` transport (persistent). Replay with `messenger:failed:retry`. ### eBay Integration `EbayAdapter` implements `ChannelAdapterInterface`. It uses: - `EbayInventoryApiClient` — inventory items, offers, publish/withdraw, stock updates, tracking - `EbayFulfillmentApiClient` — order fetching - `EbayOAuthClient` — Client-Credentials token with `cache.app` caching - `EbayTaxonomyService` — category/aspect lookup, also cached `ArticleTypePlatformConfig` holds per-ArticleType eBay settings (category ID, business policy IDs). `ArticleTypeEbayMapping` maps each eBay aspect name to either an `Article` field (`SOURCE_ARTICLE_FIELD`) or an `AttributeDefinition` (`SOURCE_ATTRIBUTE`). `EbayAdapter.buildAspects()` reads from this table. ### EasyAdmin (Admin Panel) `DashboardController` carries `#[AdminDashboard]` and `configureMenuItems()`. All CRUD controllers in `src/Infrastructure/Http/Controller/Admin/` are auto-discovered. Menu items reference controller class names directly via `MenuItem::linkTo()`. ### PostgreSQL Schemas Three schemas: `app` (all entities), `logs` (live log entries), `logs_archive` (rotated). `doctrine.yaml` sets `schema_filter` to exclude `logs_archive.*` and `app.inventory_seq` from migration diffs — never remove that filter. ### Auth Browser login: form + optional TOTP (`scheb/two-factor-bundle`). API access: `X-Api-Key` header — stored as bcrypt hash with prefix for lookup. `PermissionVoter` checks `User.permissions` and `ApiKey.permissions` (both jsonb) uniformly. Permission constants live in `PermissionVoter`. ### Docker All PHP services (`app`, `worker-*`, `cron`) run as user `1000:1000` with `HOME=/tmp`. Never run commands as root inside the container — it creates root-owned files on the host. `docker-compose.override.yml` exists only for local dev (exposes Postgres/Redis ports). Do not use it on the production server.