Doctrine storage
phil/content-bundleWith 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'sroot_idjoin 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 (Newsisnews,StaticPageisstatic_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:
| Field | Column | Type |
|---|---|---|
id | id | phil_content_dimension_content_id: a UUIDv7 generated when the row is built; a native uuid column where the platform has one, BINARY(16) elsewhere |
locale | locale | phil_content_locale, VARCHAR(12), NULL for the unlocalized row |
localeKey | locale_key | VARCHAR(12): the locale, or * for the unlocalized row |
stage | stage | phil_content_stage, VARCHAR(8) |
version | version | phil_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:
- every capability the class implements gets its columns, from the
CapabilityMappingtaggedphil_content.capability_mappingfor that interface (capabilities.md); - every concrete subclass of
AbstractDimensionContentgets:- a
rootmany-to-one association towards the declared root that names the class, on aroot_idjoin column that cannot be null and is deleted with its root (ON DELETE CASCADE), referencing the root'sid; - 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.
- a
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
rootitself keeps its association, and the unique constraint names its join column instead ofroot_id; it must have a single join column, orInvalidContentMappingis 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:
| Name | Class | Column | PHP value |
|---|---|---|---|
phil_content_locale | LocaleType | VARCHAR of the mapped length, 12 by default | Locale |
phil_content_stage | StageType | VARCHAR of the mapped length, 8 by default | Stage (its backing string is accepted on the way in) |
phil_content_version | VersionType | INT | Version (a numeric string from the driver is read) |
phil_content_dimension_content_id | DimensionContentIdType | native uuid, or BINARY(16) | DimensionContentId |
phil_content_route_id | RouteIdType | native 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
| Port | Doctrine adapter | Lookups |
|---|---|---|
Domain\Repository\DimensionContentRepository | DoctrineDimensionContentRepository | find() 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\RouteRepository | DoctrineRouteRepository | findBySlug() 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\ContentRootRepository | DoctrineContentRootRepository | find() by ContentReference, findAll() of a resource key, referenceOf() a root, add(), remove() |
Domain\Repository\PublishedRowRepository | DoctrinePublishedRowRepository | findPublished(): 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 type | The string is read as |
|---|---|
int | a string of at most 18 digits, cast to an integer; anything else finds no root |
string, or untyped | the string as it is |
a class with a static fromString() — an Identifier of phil/ddd-bundle, a Uuid of symfony/uid | built through fromString(); a string it refuses with a DomainError or an InvalidArgumentException finds no root |
| another class | the 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).
| Column | Type | Holds |
|---|---|---|
id | phil_content_working_copy_id | a UUIDv7 |
resource_key, resource_id | string 64 | the content, indexed with locale |
locale | phil_content_locale | the locale of the draft it belongs beside |
author | string 255, nullable | the author of an autosave; the editor who saved a named draft last |
name | string 100, nullable | the name of a named draft; null for an autosave |
data | json | the submitted data, 256 KiB of JSON at most |
saved_at | datetime_immutable | when it was saved, in UTC |
raw_html | boolean, false by default | whether 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/ormcloses it whenever its commit fails. A caller that catches such an error and writes again resets the entity manager first (ManagerRegistry::resetManager()).