Existing systems used to have no automatic mechanism for the db/updates/*.sql idempotent updates after `git pull` — devs had to remember which files were already applied and run new ones manually with `mariadb < ...`. Mirror module:migrate to fix this: - New `bin/console db:migrate` walks db/updates/*.sql in alphabetical order, skipping anything already recorded in the new core_migrations tracking table. Each file runs in its own transaction; failure rolls back so the file is retried on the next run. - New `db:migrate --status` lists applied vs. pending files without running anything. Useful for ops debugging "schema seems stale" symptoms. - module:sync now runs db:migrate as its first step, so the standard post-pull command stays a single invocation. README's 3-step setup is unchanged — module:sync now also covers core schema updates. CoreMigrationRepository owns its own mysqli connection (using the same DB credentials as MintyPHP\DB) and runs raw `$mysqli->query()` instead of going through DB::query's prepared statement path — MariaDB rejects some DDL (e.g. ALTER TABLE … ADD CONSTRAINT CHECK) in the prepared protocol, which the existing 18 db/updates files trip on. ModuleMigrationRepository is left untouched (no module migration uses such DDL today and changing the vendor pattern is out of scope). End-to-end verified: against an existing DB the first run applies all 19 idempotent files as no-ops and records them, second run reports "all updates already applied". Against a freshly init'd DB the same thing happens — no double work, no surprises. All encrypted seed secrets keep decrypting because APP_CRYPTO_KEY is unchanged. Tests: tests/Console/CoreCommandsTest covers success/failure/status output shapes against a FakeModuleRunner (mirror of the existing ModuleCommandsTest pattern). 5 stale baseline entries in phpstan-baseline removed (covered by the new @api annotation on the test fixture class). Docs: CLAUDE.md and docs/reference-cli-commands.md document the new command + --status flag; docs/howto-fehlerbehebung.md gains a "schema seems stale after git pull" section pointing at db:migrate --status. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
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.mdand 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.jsonand 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
@layersystem — 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
# 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 sync: db:migrate → module:migrate → permissions → build → assets
docker compose exec php php bin/console db:migrate # apply core db/updates/*.sql idempotently
docker compose exec php php bin/console db:migrate --status # list applied vs. pending core migrations
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 <id> --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\<Name>\)
<id>/module.php # Module manifest (routes, slots, providers, permissions)
<id>/lib/ # Module PHP classes (Service, Repository, Providers)
<id>/pages/ # Module actions + views (symlinked into runtime)
<id>/templates/ # Module-specific templates
<id>/web/ # Module CSS/JS (published to web/modules/<id>/)
<id>/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/<id>/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)
- App (
core/App/): DI container bootstrap. Registers all service/repository factories. No business logic. - Repository (
core/Repository/): All SQL lives here. No HTTP, session, or rendering. - 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 insidecore/Service/that may callnewon services/gateways/repositories. Registers via DI container. - Policy (
*Policy.phpincore/Service/Access/): Authorization decisions per resource type. ReturnsAuthorizationDecision. No HTTP, no business logic.
- Service (
- Action (
pages/**/*.php): Reads input, checks permissions + tenant scope, calls services, sets view vars. - View (
pages/**/*.phtml): Pure rendering. No DB calls. HTML escaping viae(...). - Template (
templates/): Layouts and partials.
Modules
- Modules are self-contained feature packages under
modules/<id>/ - Namespace:
MintyPHP\Module\<Name>\*— never use core namespaces (MintyPHP\Service\*,MintyPHP\Repository\*) - Manifest:
module.phpdeclares 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.<id>.* - Activation:
config/modules.phporAPP_ENABLED_MODULESenv override - Runtime sync:
php bin/console module:syncafter any module/manifest change (builds page symlinks, syncs permissions, publishes assets) - Fingerprint guard:
web/index.phpvalidates 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 onApiBootstrap.php - Cross-module coupling: Modules may depend on other modules but MUST declare
requires: [<module-id>]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, notdb/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 parameterid - Module pages:
modules/<id>/pages/symlinked intostorage/runtime/pages/at build time ...(none).phtml→ no template layoutdata().phpsuffix → data/AJAX endpoints- Named aliases in
config/routes.phpwithpublicflag 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 underi18n/ - 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.phpfile 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| optionalDanger zone - Tab panel width:
data-tab-paneldefaults tomax-width: 70ch(optimal for form readability). Adddata-tab-panel-widefor 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 havedata-tooltip+aria-label. - Components inside forms: Reset core
margin-bottomon buttons/inputs/selects/labels at component scope. Checkboxes in flex need explicit1.25emsizing +flex-shrink: 0. - Sticky elements: Always use
top: calc(var(--app-topbar-height) + ...)— nevertop: 0. Multi-column grids must have a responsive single-column@mediafallback. - File uploads use
app-file-upload.phtmlpartial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). Seetemplates/partials/app-file-upload.phtmlfor the$fileUploadarray 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()fromcore/Support/helpers/export.php, builds columns asExportColumn[](core/Service/Export/CsvExportService.php), and reuses the SAMEfilter-schema.phpviagridParseFiltersFromSchemaFile()as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (seeDomainListService/SystemAuditRowPresenter). - View:
require templatePath('partials/app-list-export-dropdown.phtml');inside$listTitleActionsHtml. Pass the URL via'exportUrl' => endpointUrl('<route-path>')in the page config — alwaysendpointUrl(), never plainlurl(). 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.
- Server: endpoint calls
- Detail drawer: Row-level detail views that slide in from the right use the core primitive — no per-module drawer implementation.
- Server: Action
<route>/<name>-fragment($id).php+ view<route>/<name>-fragment(none).phtml. The(none)template renders without layout/topbar/breadcrumbs. Reuse the same partial that the full-page view uses (extract amodules/<id>/templates/<name>-profile.phtmlif needed). Must enforce auth + scope exactly like the full-page view. - Route: Declare
['path' => '<route>/<name>-fragment', 'target' => '<route>/<name>-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 viainitFragmentContent(). Consumers do NOT need to callinitTabs,refreshFsLightbox, etc. manually. For module-specific runtime (e.g. async communication feeds) useonContentLoaded(contentEl, uuid). fetchUrlsignature: 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
DetailDrawerFragmentContractTestenforces that everyinitDetailDrawer({ fetchUrl })has matching*-fragment($id).php+*-fragment(none).phtml. - Filename rule: URL parameters go only into the
.phpaction filename (*-fragment($id).php). The(none).phtmlview filename must NOT contain($id)— MintyPHP's router parses paren groups and breaks silently on nested ones.
- Server: Action
- Token-select field (combobox + flat removable list): use
tokenSelectForm()fromcore/Support/helpers/ui.phpfor 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<select multiple name="<name>[]">is the form submission contract — the consuming action reads it verbatim via$request->body('<name>', []), no parsing required. - Data contract: each item is
['id' => int]plus ONE of the keys listed in$labelKeys(default:['description', 'label', 'name']). Optional'key' => stringis a hidden search hint that participates in fuzzy matching but is NOT displayed to the user. - Label overrides: pass
$labelOverrides(10th arg) with any ofemptyState,noMatches,removeTooltip,clearAll,countSuffix,errorMessageto replace the generic defaults with domain-specific text. Values are already-translated strings (callt(...)at the call-site). - Readonly: pass
$disabled = true— combobox, clear-all and remove buttons are suppressed; the list renders as pure presentation. - Client:
initTokenSelectfrom/js/components/app-token-select.jsis auto-registered astoken-selectinapp-init.js. The component reads the hidden<select>as source of truth, syncs it on every add/remove, and dispatches a nativechangeevent that callers can listen to for dependent UI. - Remove button: uses the shared
.app-icon-buttonprimitive +<i class="bi bi-x-lg">— never hand-roll a close button. - Coexistence:
multiSelectForm()(vendor MultiSelect) remains available for small closed lists where the chip-header is acceptable. PicktokenSelectForm()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 bytests/Architecture/FrontendRuntime*ContractTest.php.
- Server:
- Dashboard tile (icon + count + label link): use
appTile()fromcore/Support/helpers/ui.phpfor dashboard / stat-grid tiles that link to a filtered list view. Renders the sharedtemplates/partials/app-tile.phtmlwith.app-tileCSS. Options:href,label,count,icon(bi-*),iconBg,iconColor,class,tooltip,tooltipPos. Group tiles inside<div class="app-tiles">for the grid layout. Modules may callappTile()directly — no wrapper class needed.
Never Do This
- Direct
$_GET/$_POST/$_SESSIONaccess — userequestInput()(GR-CORE-003) - SQL outside Repository classes (GR-SEC-003)
new Service()ornew 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
settingstable (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
requiresin manifest) - Inline SQL or string-concatenated queries (GR-SEC-003)
- Raw
echoin views without// raw-html-okmarker — usee()(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 usecore/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 — useendpointUrl()so module routes wherepath ≠ targetstill 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
settingsDB table (single source of truth);storage/runtime/settings.phpis 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.phpAES-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 — rawechoonly with// raw-html-ok: reasonmarker (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.examplefor dev,.env.prod.examplefor prod config/config.phpis tracked; all environment-specific values come from.envvia$envString()defaults — edit only when adding new bootstrap settingsAPP_CRYPTO_KEY(64-char hex) needed for AES-256-GCM encryption of SSO secrets