Skip to main content

Doctrine Entity Generator

Overview​

The Doctrine Entity Generator is a CLI tool that automatically generates Doctrine entity classes from your database schema. It creates a separation between generated code (abstract entities) and custom code (concrete entities), allowing you to regenerate entities without losing custom logic.

Key Features​

  • Generates abstract entity classes with all database mappings
  • Preserves custom code in concrete entity classes
  • Supports VOL-specific field types (YesNo, YesNoNull, etc.)
  • Handles complex relationships (OneToMany, ManyToOne, ManyToMany)
  • Auto-detects and excludes join tables

Generated File Structure​

For each database table, the generator creates:

  • {Namespace}/Abstract{EntityName}.php - Auto-generated abstract class with properties and mappings
  • {Namespace}/{EntityName}.php - Concrete class for custom logic (only created if it doesn't exist)
  • Test stub in test/module/Api/src/Entity/{Namespace}/{EntityName}EntityTest.php

The concrete class is yours to edit; the abstract is not. One trap is worth calling out: do not declare table-level #[ORM\Index] or #[ORM\UniqueConstraint] attributes on the concrete for columns the abstract already covers. Doctrine unions the two sets rather than letting the concrete override, so a differently-named duplicate makes the metadata claim an index the database has never had. Sixty-four such attributes were removed across 34 entities under VOL-7070; between them they accounted for the entire CREATE INDEX section of the schema drift baseline.

They are easy to miss, because DBAL's comparator pairs indexes by shape as well as by name — a duplicate can sit invisible until an unrelated one beside it is removed. Match on columns, not names: a concrete declaration whose columns are already covered by its abstract is redundant whatever it is called.

The namespace is determined by namespace.config.php which maps entity names to subdirectories. For example:

  • Application entity → Application/AbstractApplication.php and Application/Application.php
  • BusReg entity → Bus/AbstractBusReg.php and Bus/BusReg.php
  • ContactDetails entity → ContactDetails/AbstractContactDetails.php and ContactDetails/ContactDetails.php

Configuration​

The generator uses app/api/data/db/EntityConfig.php to customize entity generation. The configuration file handles special cases like:

  • Custom class names for tables (e.g., txn → Transaction)
  • Custom property names for columns
  • VOL-specific field types
  • Relationship mappings and cascade options

VOL Field Types​

The generator supports special VOL field types that are configured in EntityConfig.php:

  • yesno - Maps Y/N database values to boolean
  • yesnonull - Nullable version of yesno
  • Custom encrypted types for sensitive data

What the generator emits​

The generated attributes are a faithful mirror of the introspected schema. These rules are pinned by test/module/Cli/src/Service/EntityGenerator/AttributeEmissionTest.php — if you change emission behaviour, update that test alongside it, and expect the schema-drift baseline (see Testing) to move.

Columns​

  • Unique keys are emitted as #[ORM\UniqueConstraint] only. They are never also emitted as #[ORM\Index]: MySQL reports unique constraints as indexes, but emitting both with the same name makes DBAL's schema tooling throw (IndexAlreadyExists).

  • unsigned and fixed (CHAR) columns carry matching entries in the Column attribute's options array. Foreign key columns don't need them: Doctrine derives a join column's type (including unsignedness) from the referenced identifier.

  • Binary columns map to Doctrine's binary type, with an explicit length. Mapping them as string would declare the column as CHAR/VARCHAR (a binary(16) came out as CHAR(16)), so the bytes would round-trip through the connection's character set and the schema-drift check would report the column as drifted. length is emitted because DBAL falls back to 255 without it, and fixed is what separates BINARY(16) from VARBINARY(16):

    // cf. Doc\DocumentAnalysis::$token
    #[ORM\Column(type: 'binary', name: 'token', length: 16, nullable: false, options: ['fixed' => true])]

    The generated property is documented as @var string|resource, not string: DBAL's BinaryType::convertToPHPValue() always returns a php://temp stream, so a concrete entity reading one has to handle both forms. Anchor the read at offset 0 (stream_get_contents($value, -1, 0)); reading a stream consumes it, so an unanchored second read returns an empty string. blob columns behave the same way.

  • MySQL ENUM columns are introspected as string. DBAL has no ENUM mapping, and a column it cannot type aborts the whole run with Unknown database type enum requested, not just the table that owns it. The introspector registers enum => string so it degrades to the way these columns are already modelled (a string property with class constants). No length is emitted for them, because MySQL reports an enum's length as 0 and length: 0 would declare VARCHAR(0).

  • Column comments are not emitted. They are owned by the database DDL in olcs-etl, and ORM 2's JoinColumn cannot carry options at all, so comments can never round-trip through entity metadata. The schema-drift comparison excludes them for the same reason. Revisit once on ORM 3, where JoinColumn supports options.

Relationships​

  • JoinColumn nullability is always explicit. JoinColumn's default is nullable: true — the opposite of Column's — so a NOT NULL foreign key that omitted the argument would misreport the real constraint.
  • onDelete mirrors the database's referential action where it differs from the default (CASCADE / SET NULL). This is DDL-level metadata only; application-level cascades are configured separately via EntityConfig cascade options.
  • inversedBy is emitted on an owning side if and only if EntityConfig declares the inverse side for that column — the same condition under which the generator creates the inverse collection — and is resolved with the same pluralization rules (PropertyNameResolver, including its PLURAL_OVERRIDES map for irregulars like appendix → appendices), so the inversedBy/mappedBy pair always matches.
  • ManyToMany associations targeting RefData are unidirectional (no inversedBy): RefData never declares inverse collections, so an inversedBy would point at a property that does not exist.

Hand-written inverse sides: generateInverse: false​

Some features (currently the Letter module) declare their inverse collections by hand in the concrete entities, because they need cascade/orphanRemoval options alongside custom logic. For those columns, EntityConfig sets 'generateInverse' => false inside the inversedBy config:

'letter_instance_choice' => array(
'letter_instance_id' => array(
'inversedBy' => array(
'entity' => 'LetterInstance',
'property' => 'letterInstanceChoice',
'generateInverse' => false
)
)
),

The owning side still emits inversedBy (pointing at the hand-written collection), but the generator does not create a duplicate collection in the abstract. Do not convert these to normal inversedBy config while the hand-written properties exist — you would generate a clashing second declaration.

A related trap: the mappingConfig key skipManyToMany suppresses the whole owning-side property, not just its inversedBy. Making the stale Dvsa\Olcs\Api\Entity\RefData entry match the real class name (...\Entity\System\RefData) would delete real, in-use collections on the next regeneration.

Verifying a regeneration​

A regeneration is only trustworthy if you can prove what changed and why. The workflow:

  1. Commit (or stash) everything first, so git diff afterwards shows only the regeneration.

  2. Regenerate (see Usage below) and review git diff --stat. A regeneration with no generator or schema change should be a no-op; after a generator fix, every changed line should match the pattern the fix predicts.

  3. Run the gates:

    vendor/bin/phpunit test/module/Api/src/Entity # includes the ORM mapping gate
    vendor/bin/phpunit test/module/Cli/src/Service/EntityGenerator
    composer test:integration # schema drift vs baseline
    composer run-script phpstan

The ORM mapping validation test has an empty baseline — any mapping error a regeneration introduces fails immediately. The schema-drift baseline holds the known, triaged entity-vs-schema disagreements; new statements mean the entities and the Liquibase schema disagree in a new way. See Testing for how both baselines work and when to regenerate them.

If the generator fails with Module (Dvsa\Olcs\Transfer) could not be initialized, the cli container has probably lost its in-tree lib mounts after a host-side composer install — see Local Setup troubleshooting.

Usage​

The command introspects the live database schema, so it must run somewhere with database access — with the local Docker stack up, run it inside the cli container. The standard workflow is to regenerate entities in place:

docker compose exec -w /var/www/html cli \
vendor/bin/laminas --container=config/container-cli.php entity:generate --replace

(From within the container, or any environment with a db host, the command is vendor/bin/laminas --container=config/container-cli.php entity:generate --replace run from app/api.)

This will:

  1. Regenerate all abstract entities from the current database schema
  2. Leave concrete entity classes untouched (preserving custom logic)
  3. Create concrete entities only if they don't exist

Note: The --replace flag only replaces abstract entities, not concrete ones, so your custom code is safe.

Command Options​

OptionDescriptionDefault
-r, --replaceReplace existing abstract entities (recommended)false
-d, --dry-runPreview what would be generated without writing filesfalse
-o, --output-pathOutput directory for generated entities/tmp/generated-entities
-c, --configPath to EntityConfig.php filedata/db/EntityConfig.php

Common Scenarios​

The examples below use entity:generate as shorthand for the full invocation shown in Basic Usage.

1. Standard Regeneration​

After database schema changes:

entity:generate --replace

2. Preview Changes​

See what would be generated without making changes:

entity:generate --dry-run

3. Generate for Review​

If you want to review changes before applying:

entity:generate -o /tmp/review-entities
# Then compare with existing:
diff -r /tmp/review-entities app/api/module/Api/src/Entity

A run with -o writes everything under that path and nothing into the repository, including the generated *EntityTest.php stubs, which land in <output-path>/tests. Only --replace writes into the tree.

Output Example​

OLCS Entity Generator
=====================

Generated 125 entities in 2.34 seconds

Important Notes​

  • Always commit your code before regenerating - while concrete entities are safe, it's good practice
  • Never hand-edit abstract entities - anything written there (including docblock documentation) is silently lost on the next regeneration. Document entity behaviour in the concrete class; column descriptions belong in the database column comments (managed in olcs-etl), which the generator copies into the generated docblocks
  • Regeneration output reflects your local database schema - make sure your local database is up to date with the latest olcs-etl changesets before regenerating, otherwise the diff will mix schema drift with your intended changes
  • The generator automatically detects join tables for ManyToMany relationships and excludes them (tables marked with _owner in EntityConfig.php); it will not generate or refresh an entity class for such tables
  • Tables with @settings['ignore'] in their database comment will be skipped
  • The generator preserves the inheritance structure: abstract entities contain generated code, concrete entities contain custom logic
  • After regenerating, run your test suite to ensure everything still works correctly (vendor/bin/phpunit test/module/Api/src/Entity plus composer run-script phpstan)