Skip to content

Doctrine storage

phil/content-bundle

With DoctrineBundle, the bundle stores roots, rows and routes through the ORM. The application writes two entities per kind of content, and maps neither the dimension nor the capabilities: the bundle adds that mapping when Doctrine loads the classes.

The two entities

namespace App\Content;

use Doctrine\ORM\Mapping as ORM;
use Phil\ContentBundle\Domain\Capability\{BodyCapable, BodyTrait, ExcerptCapable, ExcerptTrait,
    RoutableCapable, RoutableTrait, SeoCapable, SeoTrait, TemplateCapable, TemplateTrait,
    WorkflowCapable, WorkflowTrait};
use Phil\ContentBundle\Domain\Content\{AbstractContentRoot, AbstractDimensionContent};

#[ORM\Entity]
#[ORM\Table(name: 'news')]
class News extends AbstractContentRoot
{
    #[ORM\Id]
    #[ORM\Column(name: 'id', type: NewsIdType::NAME)]
    private NewsId $id;

    public function __construct()
    {
        $this->id = NewsId::generate();
    }

    public static function dimensionContentClass(): string
    {
        return NewsDimension::class;
    }
}

#[ORM\Entity]
#[ORM\Table(name: 'news_dimension')]
class NewsDimension extends AbstractDimensionContent implements TemplateCapable, WorkflowCapable,
    SeoCapable, ExcerptCapable, RoutableCapable, BodyCapable
{
    use BodyTrait;
    use ExcerptTrait;
    use RoutableTrait;
    use SeoTrait;
    use TemplateTrait;
    use WorkflowTrait;
}
phil_content:
    roots:
        - App\Content\News

NewsId here is an Identifier of phil/ddd-bundle with its IdentifierType; any identifier type works, see References.

AbstractContentRoot

A mapped superclass that maps nothing. The root entity:

  • declares its own identifier, in a column named id, which the row's root_id join column references. Its type is the application's: an integer generated by the database, a string, or a value object with a DBAL type;
  • implements public static function dimensionContentClass(): string, the class of its rows. One row class belongs to one root class;
  • may override public static function resourceKey(): string, the name of its kind of content in routes, links and commands. By default it is the short class name in snake case (News is news, StaticPage is static_page); override it to keep the key when the class is renamed. It must be a lowercase letter followed by at most 63 lowercase letters, digits or underscores;
  • inherits createDimensionContent(DimensionKey), which builds an empty row of its row class attached to it.

The root maps no collection of its rows, and may hold any shared field of its own.

AbstractDimensionContent

A mapped superclass. It maps:

FieldColumnType
ididphil_content_dimension_content_id: a UUIDv7 generated when the row is built; a native uuid column where the platform has one, BINARY(16) elsewhere
localelocalephil_content_locale, VARCHAR(12), NULL for the unlocalized row
localeKeylocale_keyVARCHAR(12): the locale, or * for the unlocalized row
stagestagephil_content_stage, VARCHAR(8)
versionversionphil_content_version, INT

Its constructor is final and takes the root and a DimensionKey: AbstractContentRoot::createDimensionContent() builds rows through it, with these two arguments only. A row receives its values through its capabilities. Public methods: id(), localeKey(), root(), dimension(); the constant UNLOCALIZED_KEY is '*'.

Uniqueness of the unlocalized row is portable. No database counts two NULL as a duplicate in a unique index — neither MySQL, nor PostgreSQL by default, nor SQLite — so a unique index over locale would let two unlocalized rows of one stage and version coexist. The unique constraint therefore covers locale_key, which holds the sentinel * in place of NULL; a Locale never contains *. Both columns are written once, by the constructor, from one key, so they cannot drift apart. A generated column would avoid the second column, at the cost of a raw definition that differs by platform and that schema comparison reads as a change on every run.

The mapping listener

DimensionContentMappingListener listens to loadClassMetadata. For every mapped class:

  1. every capability the class implements gets its columns, from the CapabilityMapping tagged phil_content.capability_mapping for that interface (capabilities.md);
  2. every concrete subclass of AbstractDimensionContent gets:
    1. a root many-to-one association towards the declared root that names the class, on a root_id join column that cannot be null and is deleted with its root (ON DELETE CASCADE), referencing the root's id;
    2. the unique constraint (root_id, stage, version, locale_key), unnamed so that Doctrine derives a name from the table and the columns, which keeps it unique across a PostgreSQL schema.

The column order of the constraint serves the repository's lookups: a row by its whole key, the rows of one stage and version, and the highest version of one stage each read a prefix of that one index.

Rules the listener keeps:

  • a field the class maps itself is left as it maps it, and the others are still added;
  • a class that maps root itself keeps its association, and the unique constraint names its join column instead of root_id; it must have a single join column, or InvalidContentMapping is thrown;
  • a row class that no declared root names is refused (InvalidContentMapping);
  • a capability field is mapped on the first mapped class that holds the property: a mapped superclass that uses the trait maps it for all its subclasses; a mapped superclass that implements the interface without the property is skipped; an entity that implements the interface without the property is refused.

DBAL types

Registered in the doctrine.dbal.types configuration by the bundle:

NameClassColumnPHP value
phil_content_localeLocaleTypeVARCHAR of the mapped length, 12 by defaultLocale
phil_content_stageStageTypeVARCHAR of the mapped length, 8 by defaultStage (its backing string is accepted on the way in)
phil_content_versionVersionTypeINTVersion (a numeric string from the driver is read)
phil_content_dimension_content_idDimensionContentIdTypenative uuid, or BINARY(16)DimensionContentId
phil_content_route_idRouteIdTypenative uuid, or BINARY(16)RouteId

They are plain columns, not database enums, so a migration diff of a mapped table finds nothing to change. A stored value that is not a valid locale, stage or version is refused on the way out as a DBAL conversion error (ValueNotConvertible): it says the column holds what no code wrote, and a row read past it would be found by no lookup.

The repositories

PortDoctrine adapterLookups
Domain\Repository\DimensionContentRepositoryDoctrineDimensionContentRepositoryfind() one row by its dimension; findAll() every row of a stage and version, the unlocalized row first (the sentinel * sorts before any letter), then by locale; latestVersion() of a stage; latestLocalizedVersion() of one locale in a stage; findVersions(), the rows of one locale in a stage, latest first; add(), remove()
Domain\Repository\RouteRepositoryDoctrineRouteRepositoryfindBySlug() on the unique index (resource_key, locale, slug); findCurrent(), findCurrentAmong() (many contents in one IN list) and findAllOf() on the index (resource_key, resource_id, locale); add(), remove()
Domain\Repository\ContentRootRepositoryDoctrineContentRootRepositoryfind() by ContentReference, findAll() of a resource key, referenceOf() a root, add(), remove()
Domain\Repository\PublishedRowRepositoryDoctrinePublishedRowRepositoryfindPublished(): a page of the published rows of a kind in a locale, with their roots and shared rows, in two queries

The row adapter serves every root of the application: the row class is the one the root names. It only stores rows of an AbstractContentRoot (InvalidContentMapping otherwise). The unlocalized row is found by its sentinel in locale_key, never by locale IS NULL, so it reads the same index. The highest versions are read through the entity persister (findOneBy() ordered by version), which binds the root by the type of its own identifier.

Rows, routes and roots added or removed are written when the ContentTransaction flushes.

References and identifiers

Each declared root has a resource key, and a ContentReference names a root from outside the storage: its resource key and its identifier as a string, news:0192f3c4-… or static_page:12.

use Phil\ContentBundle\Domain\ValueObject\ContentReference;

$reference = ContentReference::fromString('news:0192f3c4-7b1e-7c4d-9a1f-2b3c4d5e6f70');
$reference = ContentReference::of('static_page', '12');
$reference->resourceKey;   // "static_page"
$reference->resourceId;    // "12"
$reference->toString();    // "static_page:12"

The resource key is a lowercase letter followed by at most 63 lowercase letters, digits or underscores; the identifier is 1 to 64 letters, digits, hyphens or underscores, which holds a UUID, a ULID or an integer. Anything else throws InvalidContent.

DoctrineContentRootRepository and DoctrinePublishedRowRepository rebuild the identifier from the string by the declared type of the root's identifier property (RootIdentifiers), so a lookup binds what the identifier's DBAL type expects on every platform:

Property typeThe string is read as
inta string of at most 18 digits, cast to an integer; anything else finds no root
string, or untypedthe string as it is
a class with a static fromString() — an Identifier of phil/ddd-bundle, a Uuid of symfony/uidbuilt through fromString(); a string it refuses with a DomainError or an InvalidArgumentException finds no root
another classthe string as it is

The other way, referenceOf() writes the identifier as its string: an integer in decimal, a string as it is, a Stringable through __toString(). A composite identifier, an identifier not yet set (a database-generated key before the flush) or one without a string form throws InvalidContent.

A reference whose resource key no declared root has throws InvalidContentMapping from the adapter: a reference to an unknown kind is a mistake of the caller's code or configuration, not a state of the content.

Working copies

Domain\Draft\WorkingCopy is an entity of the bundle, mapped by attributes (PhilContentBundleDraft) to the table phil_content_working_copy: an author's autosave or a named draft of a content in a locale (administration.md).

ColumnTypeHolds
idphil_content_working_copy_ida UUIDv7
resource_key, resource_idstring 64the content, indexed with locale
localephil_content_localethe locale of the draft it belongs beside
authorstring 255, nullablethe author of an autosave; the editor who saved a named draft last
namestring 100, nullablethe name of a named draft; null for an autosave
datajsonthe submitted data, 256 KiB of JSON at most
saved_atdatetime_immutablewhen it was saved, in UTC
raw_htmlboolean, false by defaultwhether the editor who saved it last held the raw HTML permission; applying it writes its body with that permission

DoctrineWorkingCopyRepository implements its port, Domain\Contract\WorkingCopyRepository; it writes nothing itself, inside the caller's transaction.

Building roots

ContentManager::create() builds a new root through the ContentRootFactory port. The Doctrine adapter, ConstructorContentRootFactory, calls the root's constructor without arguments: the root generates its own identifier, or leaves it to the database. A root whose constructor requires arguments, or is not public, is refused with InvalidContentMapping; alias ContentRootFactory to a factory of your own (extension-points.md).

Transactions

DoctrineContentTransaction implements ContentTransaction:

  • run(callable) begins a transaction on the entity manager's connection, runs the operation, flushes and commits. Inside a transaction already open — a Doctrine migration's, the caller's — DBAL nests it in a savepoint;
  • flush() writes what is pending inside the running transaction: a row removed and a row of the same dimension added must reach the database in that order, which a single flush does not promise.

On failure, it rolls back, clears the unit of work, and rethrows:

  • clearing reaches the caller. Nested in a transaction of the caller, a failure detaches the caller's entities too; they are loaded again before they are written;
  • the entity manager stays open only when the failure comes before a flush: a refusal of the domain, a slug taken, a transition refused. A failure of the database during a flush — a unique index refusing a concurrent publication, the same slug taken twice at once — closes it, as doctrine/orm closes it whenever its commit fails. A caller that catches such an error and writes again resets the entity manager first (ManagerRegistry::resetManager()).