This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
itk-dev/entity-bundle is a Symfony bundle (type: symfony-bundle) — not an application. It packages cross-cutting Doctrine entity concerns (ULID id, timestamps, blame, soft-delete, archivable, audit-log auto-wiring, GDPR anonymization) behind opt-in traits + config flags. It is consumed by host Symfony apps, typically via a Composer path repository.
Requires PHP 8.4+, Symfony 7.4 or 8.0, Doctrine ORM 3, and damienharper/auditor-bundle 6.3.
composer install # install deps (only when the bundle is being tested standalone)
vendor/bin/phpunit # run the full test suite (Unit + Integration)
vendor/bin/phpunit tests/Unit # one suite
vendor/bin/phpunit --filter SoftDeleteListenerTest # one test class
vendor/bin/phpunit tests/Integration/Privacy/AnonymizerTest.php # one filePHPUnit is configured to failOnNotice and failOnWarning — deprecations and notices fail tests; do not silence them.
There is no PHPStan / Psalm / php-cs-fixer config in the repo. Don't invent linter commands; if static analysis is needed, ask the user first.
tests/App/Kernel.phpis aMicroKernelTraittest kernel;phpunit.xml.distpointsKERNEL_CLASSat it.tests/bootstrap.phplooks forvendor/autoload.phpin the bundle first, then in../../../vendor— so the test suite also runs when the bundle is consumed as a path repo from a parent app.- Integration tests expect a database reachable at the
DATABASE_URLbaked intophpunit.xml.dist(mysql://db:db@database:3306/db, served by thedatabaseservice indocker-compose.yml, which runs MariaDB 11.4 by default). To run against PostgreSQL instead, layer the opt-in override:docker compose -f docker-compose.yml -f docker-compose.postgres.yml up— that file swaps thedatabaseservice forpostgres:16and overridesDATABASE_URLon thephpfpmservice. When iterating locally outside Docker, overrideDATABASE_URLrather than editing the file. - The
phpfpmservice runs PHP 8.4 by default. To run the suite on PHP 8.5, layer the opt-in override:docker compose -f docker-compose.yml -f docker-compose.php85.yml up— that file swaps thephpfpmimage foritkdev/php8.5-fpm:latest. The two PHP overrides compose: pass both-f docker-compose.postgres.yml -f docker-compose.php85.ymlto run PHP 8.5 against Postgres. - Test fixtures live in
tests/Fixtures/Entity/and are mapped to theTestFixturesDoctrine alias bytests/App/config/packages/doctrine.yaml.
Every feature in this bundle (audit auto-registration, anonymization rule discovery, listener targeting) keys off the #[ITKDevEntity] attribute (src/Attribute/). Two ways to apply it:
- Extend
ITKDev\EntityBundle\Entity\AbstractITKDevEntity(gives you a ULID id + the attribute in one step). - Annotate any Doctrine entity directly with
#[ITKDevEntity]— works when the entity has its own id strategy or base class.
The attribute is honoured up the inheritance chain. Discovery walks entity_paths (default %kernel.project_dir%/src/Entity) and skips abstract classes and anything without #[ITKDevEntity] on itself or an ancestor.
For every feature (timestampable, blameable, soft-delete, archivable, audit, anonymization) there are two opt-ins, and both are required:
- Per-entity: implement an interface + use a trait (e.g.
SoftDeletableInterface+SoftDeletableTrait). - Bundle-wide: flip
itk_dev_entity.<feature>.enabled: truein config.
If the per-entity opt-in is present but the bundle flag is off, the entity still carries the columns from the trait, but the listener/filter/command is never registered — so behavior silently degrades to a hard delete, no timestamps written, etc. When debugging "the trait is there but it doesn't work", check the bundle config first.
The full config reference (including user_class, entity_paths, audit.retention, retention overrides) is in README.md; don't duplicate it elsewhere.
This Extension does the heavy lifting that ties the pieces together:
- Reflection walk over
entity_pathsto find#[ITKDevEntity]classes, then per-class to find#[Auditable],#[AuditIgnore], and#[Anonymize]property attributes. prepend(): configuresdamienharper/auditor-bundlewith the discovered auditable entities + ignored columns, and merges config-drivenaudit.entities/audit.ignored_columns(the escape hatch for third-party entities you can't annotate).load(): dynamically registers Doctrine listeners (TimestampableListener,BlameableListener,SoftDeleteListener) and filters (soft_delete,archivable) based on the per-featureenabledflag. Thearchivablefilter is registered disabled — callers enable it per-request (e.g. a?showArchived=1listener).- For anonymization, the discovered
#[Anonymize]strategies are merged withitk_dev_entity.anonymization.rulesfrom config. Config wins over the attribute when the same property is named in both (config is the explicit override).
If you're adding a new opt-in feature, the pattern is: interface + trait under src/Entity/, listener/filter under src/Doctrine/, config tree node in DependencyInjection/Configuration.php, and conditional registration in ITKDevEntityExtension::load().
Attribute/— the#[ITKDevEntity]markerAudit/Attribute/—#[Auditable]and#[AuditIgnore]Privacy/—Anonymizer,BulkAnonymizer,StaleEntityFinder,StrategyApplier, and the#[Anonymize]attribute +Strategyenum (NullValue,Redact,Hash,Pseudonymize)Entity/—AbstractITKDevEntity(ULID id mapped superclass), per-feature*Interfacecontracts underEntity/Contract/, per-feature*TraitunderEntity/Trait/Doctrine/— listeners (onFlushfor timestamps/blame, interceptsremove()for soft-delete) and SQL filters (soft_delete,archivable)DependencyInjection/—Configuration(config tree) andITKDevEntityExtension(discovery + wiring; see above)Command/—privacy:anonymize <ulid>(right-to-erasure) andprivacy:anonymize-stale --older-than=PXX(retention sweep). Audit-row deletion is delegated to dh_auditor's ownaudit:clean; do not reimplement it.
The bundle types createdBy/modifiedBy as ?UserInterface. The host app's concrete user class is resolved at runtime via Doctrine resolve_target_entities, configured by itk_dev_entity.user_class. That config key is required whenever audit.enabled or blameable.enabled is true, and is also used by privacy:anonymize to find the subject row.