# CLAUDE.md — CoreCore Multi-tenant admin application built on MintyPHP. Manages tenants, users, departments, roles, permissions, address books, mail, CSV imports, scheduled jobs, and Microsoft Entra ID SSO. ## Mandatory Workflow All guard IDs (GR-\*) and gate IDs (QG-\*) from `.agents/checks/` are **binding — not advisory**. - **Full workflow trigger is risk-based** and defined in `.agents/checks/enforcement-policy.json` (`full_workflow_required_when`). Matching changes MUST follow `.agents/workflow.md` (Analyst → Planner → Executor → Reviewers → Finalizer). Read `.agents/README.md` and the relevant role prompt in `.agents/prompts/` before starting. - **Quick fixes** may skip full workflow only if they match `.agents/checks/enforcement-policy.json` (`quick_fix_allowlist`) and still pass required gates. - **New modules** MUST conform to `.agents/contracts/module-manifest.schema.json` and pass all 10 security guards (GR-SEC-001 through GR-SEC-010). Full workflow definition: `.agents/workflow.md` | Guard catalog: `.agents/checks/guard-catalog.json` | Enforcement policy: `.agents/checks/enforcement-policy.json` | Guard enforcement map: `.agents/checks/guard-enforcement-map.json` | Skills: `.agents/skills/` ## Tech Stack - **Runtime:** PHP 8.5 (FPM) on MintyPHP framework (`mintyphp/core`) - **Web server:** Nginx 1.25 (Alpine) - **Database:** MariaDB 11.4 - **Cache:** Memcached 1.6 - **Frontend:** Vanilla JS (ES2022 modules), vanilla CSS with `@layer` system — no build step - **Key PHP libs:** PHPMailer 7, Dompdf 3, endroid/qr-code 6, league/commonmark 2 - **Containerized:** Docker Compose (dev + prod variants) ## Quick Commands ```bash # Start dev environment docker compose up --build -d # App: http://localhost:8080 | phpMyAdmin: http://localhost:8081 # Run PHPUnit tests (phpunit.xml configures bootstrap + testsuite) docker compose exec php vendor/bin/phpunit # Run PHPStan (level 5) docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress # CLI commands (single entry point) docker compose exec php php bin/console list docker compose exec php php bin/console module:sync # full module runtime sync docker compose exec php php bin/console module:migrate # apply module SQL migrations docker compose exec php php bin/console module:permissions-sync # sync + deactivate orphaned docker compose exec php php bin/console module:build # build runtime page root docker compose exec php php bin/console module:assets-sync # publish module web assets docker compose exec php php bin/console module:deactivate --confirm docker compose exec php php bin/console scheduler:run docker compose exec php php bin/console doctor # Check unused Composer packages docker compose exec php vendor/bin/composer-unused ``` ## Project Structure ``` core/ # All backend PHP (namespace MintyPHP\) App/ # DI container + bootstrap (AppContainer, Container/Registrars/) Module/ # Module platform (Registry, Manifest, Autoloader, Builder) Repository/ # SQL queries, prepared statements, filters/paging Service/ # Business logic, validation, orchestration Http/ # Auth guards, API auth, request helpers Input/ # Request input handling (RequestInput, FormErrors) Console/ # CLI command framework (ConsoleApplication, Command base class) Commands/ # Command classes (Module/, Scheduler/, Tools/) Support/ # Crypto, flash, guard, search, helpers modules/ # Self-contained feature modules (namespace MintyPHP\Module\\) /module.php # Module manifest (routes, slots, providers, permissions) /lib/ # Module PHP classes (Service, Repository, Providers) /pages/ # Module actions + views (symlinked into runtime) /templates/ # Module-specific templates /web/ # Module CSS/JS (published to web/modules//) /tests/ # Module PHPUnit tests pages/ # Core actions (controllers) — file-based routing pages/**/*.php # Action files (read input, check permissions, call services) pages/**/*.phtml # View files (rendering only, no DB calls) templates/ # Core layouts and partials partials/ # Reusable UI components emails/ # Email HTML templates pdfs/ # PDF templates (Dompdf) config/ # App config (routes, assets, themes, modules) web/ # Document root (entry point, CSS, JS, static assets) js/core/ # DOM utilities, Grid.js factory, component runtime js/components/ # Reusable UI components css/ # Layered CSS (base → components → layout → pages) modules/ # Published module assets (symlinks to modules//web/) storage/ # Uploaded files + runtime state runtime/pages/ # Symlinked page tree (core + modules, built by module-build) runtime/settings.php # Hot-path settings cache file (generated by SettingCacheService) db/init/init.sql # Full schema + seed data (no migration framework) db/updates/ # Idempotent SQL update scripts for existing installs tests/ # PHPUnit tests (namespace MintyPHP\Tests\) docs/ # German-language documentation (Markdown) i18n/ # Translation files (de, en) bin/ # CLI entry points (bin/console, bin/dev) + shell tools docker/ # Dockerfiles, Nginx configs, PHP configs .agents/ # Agent workflow system (contracts, prompts, checks, skills, runs) ``` ## Architecture Rules ### Strict Layering (enforce in all changes) 1. **App** (`core/App/`): DI container bootstrap. Registers all service/repository factories. No business logic. 2. **Repository** (`core/Repository/`): All SQL lives here. No HTTP, session, or rendering. 3. **Service** (`core/Service/`): Business rules and validation. No HTML. Four internal sub-types: - **Service** (`*Service.php`): Business logic + orchestration. Coordinates repositories and gateways. - **Gateway** (`*Gateway.php`): Adapter for a single external dependency (repository, settings, HTTP API, scope). No orchestration flow. - **Factory** (`*Factory.php`): Only place inside `core/Service/` that may call `new` on services/gateways/repositories. Registers via DI container. - **Policy** (`*Policy.php` in `core/Service/Access/`): Authorization decisions per resource type. Returns `AuthorizationDecision`. No HTTP, no business logic. 4. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars. 5. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`. 6. **Template** (`templates/`): Layouts and partials. ### Modules - Modules are self-contained feature packages under `modules//` - **Namespace:** `MintyPHP\Module\\*` — never use core namespaces (`MintyPHP\Service\*`, `MintyPHP\Repository\*`) - **Manifest:** `module.php` declares routes, UI slots, providers, permissions, scheduler jobs - **UI integration:** Only via platform slots (`aside.tab_panel`, `topbar.right_item`, `layout.head_style`, `runtime.component`, etc.) — no core template hardcoding - **Session keys:** Prefixed with `module..*` - **Activation:** `config/modules.php` or `APP_ENABLED_MODULES` env override - **Runtime sync:** `php bin/console module:sync` after any module/manifest change (builds page symlinks, syncs permissions, publishes assets) - **Fingerprint guard:** `web/index.php` validates runtime state matches active modules; fails fast if stale - **API isolation:** API requests (`api/` prefix) skip session/auth/cookie flows entirely; modules using API endpoints rely on `ApiBootstrap.php` - **Cross-module coupling:** Modules may depend on other modules but MUST declare `requires: []` in their manifest. Undeclared cross-module imports are forbidden. Prefer loose coupling via events and providers over direct service injection - **Module DB changes:** Via `module:migrate`, not `db/updates/` (which is for core schema only) - **Module permissions:** Registered in manifest, synced via `module:permissions-sync` — never hardcoded - **Manifest contract:** Must conform to `.agents/contracts/module-manifest.schema.json` ### Routing - File-based: `pages/admin/users/edit($id).php` → URL parameter `id` - Module pages: `modules//pages/` symlinked into `storage/runtime/pages/` at build time - `...(none).phtml` → no template layout - `data().php` suffix → data/AJAX endpoints - Named aliases in `config/routes.php` with `public` flag for auth guard - Module routes declared in manifest, merged at boot (fail-fast on collision) ### PHP Conventions - PSR-4 autoload: `MintyPHP\` → `core/`, `MintyPHP\Module\*` → `modules/*/lib/`, `MintyPHP\Tests\` → `tests/` - All user-facing text uses `t('key')` translation helper — keys must exist in all language files under `i18n/` - Request input via `requestInput()` — never raw `$_GET`/`$_POST`/`$_SESSION` (GR-CORE-003) - Permissions + tenant scope enforced in actions/services, never just in UI - Security/integration settings stay in DB, not in `storage/runtime/settings.php` file cache (GR-CORE-011) - POST-Redirect-GET (PRG) pattern on form submissions; flash before redirect (GR-CORE-012) - Constructor injection for dependencies; no hidden global state (GR-TEST-002) - New/changed Service or Gateway must have PHPUnit tests — happy path + edge case (GR-TEST-001) ### Frontend Conventions - Vanilla ES2022 modules, no bundler - CSS classes prefixed with `app-` - Defensive DOM checks (elements may not exist on every page) - No business logic in frontend - Assets served raw with `assetVersion()` cache-busting (filemtime-based) ### UI Patterns - Edit pages: tabs → `Master data` | `Visibility` | optional `Danger zone` - **Tab panel width**: `data-tab-panel` defaults to `max-width: 70ch` (optimal for form readability). Add `data-tab-panel-wide` for panels that need full width (e.g. split layouts, side-by-side editors, preview panels, tables). Use 70ch for standard form fields (text inputs, selects, textareas); use wide for multi-column layouts or content that benefits from horizontal space. - Titlebar partial for primary actions (Save, Save & close) - Secondary actions in Aside block via `app-details-aside-actions.phtml` - Lists use Grid.js via `initStandardListPage()` (GR-UI-LIST) - Detail pages use shared partials and layout contracts (GR-UI-DETAIL) - New views must handle loading, empty, error, and success states (GR-UI-014) - All visible text wrapped in `t('...')` - Check existing JS/CSS components before adding new ones (GR-UI-REUSE) - **Buttons**: Always use core classes (`secondary outline small`, `danger outline small`) — custom CSS only for shape/position. Icon-only buttons must have `data-tooltip` + `aria-label`. - **Components inside forms**: Reset core `margin-bottom` on buttons/inputs/selects/labels at component scope. Checkboxes in flex need explicit `1.25em` sizing + `flex-shrink: 0`. - **Sticky elements**: Always use `top: calc(var(--app-topbar-height) + ...)` — never `top: 0`. Multi-column grids must have a responsive single-column `@media` fallback. - **File uploads** use `app-file-upload.phtml` partial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). See `templates/partials/app-file-upload.phtml` for the `$fileUpload` array contract. Never write file-upload markup inline; always use the partial. - **List exports**: every Grid.js list that offers a download MUST go through the core primitive — no inline `fputcsv`, no hand-rolled headers, no bespoke dropdown. (GR-UI-EXPORT) - **Server:** endpoint calls `exportRequireGetRequest()` + `exportCapLimit()` + `exportSendCsv()` from `core/Support/helpers/export.php`, builds columns as `ExportColumn[]` (`core/Service/Export/CsvExportService.php`), and reuses the SAME `filter-schema.php` via `gridParseFiltersFromSchemaFile()` as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (see `DomainListService` / `SystemAuditRowPresenter`). - **View:** `require templatePath('partials/app-list-export-dropdown.phtml');` inside `$listTitleActionsHtml`. Pass the URL via `'exportUrl' => endpointUrl('')` in the page config — **always `endpointUrl()`, never plain `lurl()`**. The helper resolves module routes to their target path so query strings survive. - **Client:** `initListExport({ gridConfig, exportUrl })` from `/js/core/app-list-export.js`. No hand-rolled click listener, no inline URL construction. - **Detail drawer**: Row-level detail views that slide in from the right use the core primitive — no per-module drawer implementation. - **Server:** Action `/-fragment($id).php` + view `/-fragment(none).phtml`. The `(none)` template renders without layout/topbar/breadcrumbs. Reuse the same partial that the full-page view uses (extract a `modules//templates/-profile.phtml` if needed). Must enforce auth + scope exactly like the full-page view. - **Route:** Declare `['path' => '/-fragment', 'target' => '/-fragment']` in the module manifest. - **Client:** `initDetailDrawer({ gridConfig, fetchUrl: (uuid) => new URL(`/-fragment/${uuid}`, appBase).toString(), fullUrl, hashPrefix, rowProvider?, onContentLoaded? })` from `/js/components/app-detail-drawer.js`. The drawer handles focus-trap, body-scroll-lock, session-expiry reload, and auto-initializes tabs/tooltips/fslightbox in the fragment via `initFragmentContent()`. Consumers do NOT need to call `initTabs`, `refreshFsLightbox`, etc. manually. For module-specific runtime (e.g. async communication feeds) use `onContentLoaded(contentEl, uuid)`. - **`fetchUrl` signature:** write it as a single-expression arrow (not a block body) so the architecture test can statically locate the fragment path. Pattern: ``(uuid) => new URL(`path/${uuid}...`, appBase).toString()``. - **Architecture test** `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer({ fetchUrl })` has matching `*-fragment($id).php` + `*-fragment(none).phtml`. - **Filename rule:** URL parameters go only into the `.php` action filename (`*-fragment($id).php`). The `(none).phtml` view filename must NOT contain `($id)` — MintyPHP's router parses paren groups and breaks silently on nested ones. - **Token-select field** (combobox + flat removable list): use `tokenSelectForm()` from `core/Support/helpers/ui.php` for any multi-select where the selected set is potentially large and a chip-header or full dropdown becomes unreadable. The primitive renders an inline typeahead input with popover suggestions and a flat alphabetical list of selected items with per-row remove. No drawer, no grouping — keep callers flat. - **Server:** `tokenSelectForm($id, $name, $label, $placeholder, $items, $selected, $disabled, $labelKeys, $formId, $labelOverrides)`. Hidden `` as source of truth, syncs it on every add/remove, and dispatches a native `change` event that callers can listen to for dependent UI. - **Remove button:** uses the shared `.app-icon-button` primitive + `` — never hand-roll a close button. - **Coexistence:** `multiSelectForm()` (vendor MultiSelect) remains available for small closed lists where the chip-header is acceptable. Pick `tokenSelectForm()` when the selected set can grow beyond ~10 items or typeahead is the expected interaction. - **Tests:** helper contract lives in `tests/Support/Helpers/TokenSelectFormHelperTest.php`; component lifecycle + entrypoint registration are enforced by `tests/Architecture/FrontendRuntime*ContractTest.php`. - **Dashboard tile** (icon + count + label link): use `appTile()` from `core/Support/helpers/ui.php` for dashboard / stat-grid tiles that link to a filtered list view. Renders the shared `templates/partials/app-tile.phtml` with `.app-tile` CSS. Options: `href`, `label`, `count`, `icon` (bi-*), `iconBg`, `iconColor`, `class`, `tooltip`, `tooltipPos`. Group tiles inside `
` for the grid layout. Modules may call `appTile()` directly — no wrapper class needed. ### Never Do This - Direct `$_GET`/`$_POST`/`$_SESSION` access — use `requestInput()` (GR-CORE-003) - SQL outside Repository classes (GR-SEC-003) - `new Service()` or `new Gateway()` outside Factory classes (GR-TEST-002) - Hardcoding module UI into core templates — use platform slots - Core schema changes without idempotent `db/updates/` script (GR-CORE-010) - Settings in config files instead of DB `settings` table (GR-CORE-011) - Skipping PRG pattern on form submissions (GR-CORE-012) - Loading assets from external CDNs (GR-SEC-004) - Undeclared cross-module imports (must use `requires` in manifest) - Inline SQL or string-concatenated queries (GR-SEC-003) - Raw `echo` in views without `// raw-html-ok` marker — use `e()` (GR-SEC-010) - OpenAPI changes without updating the spec (GR-CORE-007) - Hand-rolled list exports (inline `fputcsv`, own header setup, bespoke click-to-download JS) — always use `core/Service/Export/*` + `exportSendCsv()` + `app-list-export-dropdown.phtml` + `initListExport()` (GR-UI-EXPORT) - Building an export/data endpoint URL with `lurl()` when it will carry query params — use `endpointUrl()` so module routes where `path ≠ target` still resolve (GR-UI-EXPORT) ## Database - Schema defined in `db/init/init.sql` (applied once on container init) - No migration framework — core schema changes for existing installs are idempotent SQL scripts in `db/updates/` - Module schema changes via `module:migrate` (separate from core) - Settings stored in `settings` DB table (single source of truth); `storage/runtime/settings.php` is a partial file cache for hot-path UI reads only ## Security (non-negotiable) These rules are enforced by guards GR-SEC-001 through GR-SEC-010 in `.agents/checks/guard-catalog.json`. Any violation is a blocking finding. - CSRF token verified on every POST (GR-SEC-001) - No PII or secrets in logs or audit metadata (GR-SEC-002) - SQL only via Repository prepared statements — no inline queries (GR-SEC-003) - No external CDN or remote asset loading (GR-SEC-004) - Encryption only via `core/Support/Crypto.php` AES-256-GCM (GR-SEC-005) - File uploads: `storage/` only, authz-gated serving, MIME-validated (GR-SEC-006) - API requests must not start sessions (GR-SEC-007) - Authorization enforced server-side, not just in UI (GR-SEC-008) - Tenant scope (`tenant_id`) enforced on all queries — no unscoped data access (GR-SEC-009) - Views use `e()` for all output — raw `echo` only with `// raw-html-ok: reason` marker (GR-SEC-010) ## Quality Gates 9 gates defined in `.agents/checks/quality-gates.json`. Required/optional enforcement is defined in `.agents/checks/enforcement-policy.json`. | ID | Gate | Command | |---|---|---| | QG-001 | PHPUnit | `vendor/bin/phpunit` | | QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` | | QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` | | QG-006 | PHP Style | `vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose` | | QG-008 | Docs link + drift integrity | `bin/docs-link-check.sh` (plus `bin/docs-drift-check.sh` via `bin/qa-required.sh`) | | QG-009 | Codex skills sync | `bin/codex-skills-sync.sh --check` | Additional non-blocking gates: QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused. See gate catalog + enforcement policy for details. ## Environment - Config via `.env` (gitignored) — copy from `.env.example` for dev, `.env.prod.example` for prod - `config/config.php` is tracked; all environment-specific values come from `.env` via `$envString()` defaults — edit only when adding new bootstrap settings - `APP_CRYPTO_KEY` (64-char hex) needed for AES-256-GCM encryption of SSO secrets