Skip to content

Extension points

phil/content-bundle

What an application implements, replaces or relies on. Every port has a Doctrine adapter registered when the kernel has DoctrineBundle; without it, the application provides the six persistence ports itself, by aliasing each interface to its own service.

Persistence ports

DimensionContentRepository

Phil\ContentBundle\Domain\Repository\DimensionContentRepository — loads and stores the rows of a root. Doctrine adapter: DoctrineDimensionContentRepository.

MethodContract
find(ContentRoot $root, DimensionKey $dimension): ?DimensionContentthe row of $root whose dimension equals $dimension, or null. An unlocalized key finds only the unlocalized row: no locale stands in for it
findAll(ContentRoot $root, Stage $stage, Version $version): list<DimensionContent>every row of the root in that stage and version: the unlocalized row first when there is one, then the localized rows ordered by locale
latestVersion(ContentRoot $root, Stage $stage): ?Versionthe highest version that has at least one row of the root in the stage, localized or not; null when none
latestLocalizedVersion(ContentRoot $root, Stage $stage, Locale $locale): ?Versionthe highest version that has a row of the locale in the stage; null when none
findVersions(ContentRoot $root, Stage $stage, Locale $locale): list<DimensionContent>every row of the locale in the stage, one per version, the latest version first: a locale's history in one lookup
add(DimensionContent $row): voidstores a new row, written when the transaction flushes
remove(DimensionContent $row): voiddeletes a stored row when the transaction flushes

Invariants an adapter keeps:

  • one row per (root, stage, version, locale or none); a second row of the same dimension is refused by the store (a unique constraint), never silently kept;
  • a row removed and a row of the same dimension added are written in that order when ContentTransaction::flush() is called between them;
  • find() and findAll() see the rows added and removed in the running transaction once it has flushed.

RouteRepository

Phil\ContentBundle\Domain\Repository\RouteRepository — Doctrine adapter: DoctrineRouteRepository.

MethodContract
findBySlug(string $resourceKey, Locale $locale, Slug $slug): ?Routethe route, current or history, that holds the slug for the kind in the locale
findCurrent(ContentReference $content, Locale $locale): ?Routethe current route of the root in the locale; never a history route
findCurrentAmong(string $resourceKey, Locale $locale, list<string> $resourceIds): list<Route>the current routes in the locale of the contents of one kind named by their identifiers, in one lookup; a content without one is missing, in no order
findAllOf(ContentReference $content): list<Route>every route of the root, current and history, in every locale
add(Route $route): void, remove(Route $route): voidstore and delete, written when the transaction flushes

Invariant: at most one route per (resource key, locale, slug), and at most one current route per root and locale (the second is kept by RouteUpdater, which retires the previous current route).

ContentRootRepository

Phil\ContentBundle\Domain\Repository\ContentRootRepository — Doctrine adapter: DoctrineContentRootRepository.

MethodContract
find(ContentReference $reference): ?ContentRootthe root the reference names, or null when none exists or the identifier cannot be one of its kind
findAll(string $resourceKey): list<ContentRoot>every root of the kind
referenceOf(ContentRoot $root): ContentReferencehow the root is named outside the storage; throws InvalidContent when its identifier has no string form (or is not set yet)
add(ContentRoot $root): voidstores a new root
remove(ContentRoot $root): voiddeletes it when the transaction flushes; its rows and routes are removed first by the caller (ContentManager::delete())

Invariant: find($this->referenceOf($root)) returns $root.

PublishedRowRepository

Phil\ContentBundle\Domain\Repository\PublishedRowRepository — reads the published rows of a kind in a few queries, whatever the number of roots: what the sitemap, the feeds and an application's lists read. Doctrine adapter: DoctrinePublishedRowRepository.

MethodContract
findPublished(PublishedRowQuery $query): list<PublishedRow>for each root of the kind with a live row of the query's locale, that row in the highest version the locale was published from, paired with the live shared row of the same version (or null); filtered, ordered and cut as the query says

A PublishedRowQuery names the resource key and the locale, then:

ArgumentDefaultMeaning
orderPublishedOrder::RootRoot, by the root's identifier; LatestPublished, the most recently published first and those never published last; Slug, by the localized row's slug. Every order ends on the root's identifier, so pages follow each other without overlap; a row class without the capability an order reads is ordered by root
rootCriteria[]fields of the root and the values they equal, ['genre' => 'podcast']; a field the root does not map is an InvalidContentMapping
resourceIdsnullthe roots kept, by identifier; null keeps all, [] none; an identifier its type refuses matches nothing
withSlugfalsekeeps the rows with a slug, the only ones a route serves; none when the row class is not RoutableCapable
offset, limit0, nullthe page cut out of the order; a negative offset or a limit below one is an InvalidContent

The Doctrine adapter reads a page in two queries: the localized rows with their roots (the highest version by a correlated MAX over the (root_id, stage) prefix of the unique index), then the shared rows of those roots in one IN list, the roots' identifiers bound as their own DBAL type stores them.

ContentTransaction

Phil\ContentBundle\Application\Persistence\ContentTransaction — Doctrine adapter: DoctrineContentTransaction.

MethodContract
run(callable $operation): mixedruns the operation in a transaction, flushes and commits, and returns its result; rolls back and rethrows on failure. Nested in a transaction already open, it nests (a savepoint)
flush(): voidwrites what is pending now, inside the running transaction

Invariant: all of an operation or nothing is kept.

ContentRootFactory

Phil\ContentBundle\Application\Content\ContentRootFactory — Doctrine adapter: ConstructorContentRootFactory, which calls the root's constructor without arguments.

MethodContract
create(string $resourceKey): ContentRoota new, unsaved root of the kind; ContentManager::create() adds it to the repository

An application whose roots take constructor arguments aliases this interface to its own factory:

namespace App\Content;

use Phil\ContentBundle\Application\Content\ContentRootFactory;
use Phil\ContentBundle\Domain\Content\ContentRoot;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(ContentRootFactory::class)]
final readonly class SiteContentRootFactory implements ContentRootFactory
{
    public function __construct(private CurrentSite $site)
    {
    }

    public function create(string $resourceKey): ContentRoot
    {
        return match ($resourceKey) {
            'news' => new News($this->site->id()),
            'static_page' => new StaticPage($this->site->id()),
        };
    }
}

An adapter without Doctrine

Without DoctrineBundle, alias the six ports in config/services.yaml:

services:
    Phil\ContentBundle\Domain\Repository\DimensionContentRepository: '@App\Content\Storage\Rows'
    Phil\ContentBundle\Domain\Repository\RouteRepository: '@App\Content\Storage\Routes'
    Phil\ContentBundle\Domain\Repository\ContentRootRepository: '@App\Content\Storage\Roots'
    Phil\ContentBundle\Domain\Repository\PublishedRowRepository: '@App\Content\Storage\PublishedRows'
    Phil\ContentBundle\Application\Persistence\ContentTransaction: '@App\Content\Storage\Transaction'
    Phil\ContentBundle\Application\Content\ContentRootFactory: '@App\Content\Storage\Factory'

The pipeline, the publication services and ContentManager then work over them. The controller, the Twig functions and the console commands are only registered with DoctrineBundle.

Content classes

ContentRoot and DimensionContent

Domain\Content\ContentRoot is all the pipeline needs of a root: createDimensionContent(DimensionKey): DimensionContent, a new, empty row of the application's own row class attached to the root, used as the target of merges and copies so that the result carries the same capabilities. Domain\Content\DimensionContent is a row: root(): ContentRoot and dimension(): DimensionKey.

With the Doctrine adapters, roots extend AbstractContentRoot and rows extend AbstractDimensionContent (storage.md); with adapters of the application, any class implementing the two interfaces works.

Pipeline stages

DataMapper, Merger and Normalizer (Phil\ContentBundle\Application\Pipeline) are tagged by autoconfiguration; their contracts are in pipeline.md. Rules an application stage keeps:

  • a data mapper handles one capability, ignores rows that lack it, honours "absent key changes nothing, null clears", and writes keys no other mapper writes;
  • a merger copies only what its source sets, and never reads anything but $source and $target; it runs for aggregation and for copies (publication, forks), so a capability without a merger is neither read nor published;
  • a normalizer returns an empty array for a row without its capability, and keys no other normalizer returns (ContentNormalizer refuses a repeated key with a LogicException).

An application capability

namespace App\Content\Capability;

interface EventDatesCapable
{
    public function getStartsAt(): ?\DateTimeImmutable;
    public function setStartsAt(?\DateTimeImmutable $startsAt): void;
}

trait EventDatesTrait
{
    private ?\DateTimeImmutable $startsAt = null;

    public function getStartsAt(): ?\DateTimeImmutable { return $this->startsAt; }
    public function setStartsAt(?\DateTimeImmutable $startsAt): void { $this->startsAt = $startsAt; }
}
use Phil\ContentBundle\Application\DataMapper\SubmittedData;
use Phil\ContentBundle\Application\Pipeline\{DataMapper, Merger, Normalizer};
use Phil\ContentBundle\Domain\Content\DimensionContent;
use Phil\ContentBundle\Infrastructure\Doctrine\Mapping\CapabilityMapping;

final class EventDatesCapabilityMapping extends CapabilityMapping
{
    public function capability(): string { return EventDatesCapable::class; }

    protected function fields(): array
    {
        return [['fieldName' => 'startsAt', 'type' => 'datetime_immutable', 'columnName' => 'starts_at', 'nullable' => true]];
    }
}

final readonly class EventDatesDataMapper implements DataMapper
{
    public function map(DimensionContent $unlocalized, DimensionContent $localized, array $data): void
    {
        if ($unlocalized instanceof EventDatesCapable && SubmittedData::has($data, 'startsAt')) {
            $value = SubmittedData::string($data, 'startsAt');
            $unlocalized->setStartsAt(null === $value ? null : new \DateTimeImmutable($value));   // shared by every locale
        }
    }
}

final readonly class EventDatesMerger implements Merger
{
    public function merge(DimensionContent $target, DimensionContent $source): void
    {
        if ($target instanceof EventDatesCapable && $source instanceof EventDatesCapable && null !== $source->getStartsAt()) {
            $target->setStartsAt($source->getStartsAt());
        }
    }
}

final readonly class EventDatesNormalizer implements Normalizer
{
    public function normalize(DimensionContent $content): array
    {
        return $content instanceof EventDatesCapable
            ? ['startsAt' => $content->getStartsAt()?->format(\DATE_RFC3339)]
            : [];
    }
}

With autoconfiguration on, the four services join the bundle's. A field listed by a CapabilityMapping is named after the trait's property; a backed enum property names its class under enumType. A field the row class maps itself is left alone.

Markup ports

MarkupCompiler and HtmlPurifier

PortMethodContractDefault adapter
Phil\Markup\MarkupCompilercompile(string $source, MarkupFormat $format): stringthe source compiled to HTML. The result is not safe to show: a compiler only translatesDefaultCompiler
Phil\Markup\HtmlPurifierpurify(string $html, Level $level): stringremoves everything that could run in a reader's browser or reach outside the page: scripts, event handlers, javascript: URLs, frames, forms, styles. Applied at Level::All before storing and again on every readSanitizerPurifier (defaultPolicies())

Replace either by aliasing the interface to your own service:

namespace App\Content;

use Phil\Markup\HtmlPurifier;
use Phil\Markup\Level;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;

#[AsAlias(HtmlPurifier::class)]
final readonly class StrictPurifier implements HtmlPurifier
{
    public function __construct(private HtmlSanitizerInterface $contentSanitizer)   // a sanitizer of framework.html_sanitizer
    {
    }

    public function purify(string $html, Level $level): string
    {
        return $this->contentSanitizer->sanitize($html);
    }
}

Keep a purifier idempotent — HTML it already purified comes back unchanged — since every body is purified on write and again on read.

BodyConverter

PortMethodContractDefault adapter
Phil\ContentBundle\Application\Publication\BodyConvertertoUjo(Body $body, DateTimeImmutable $publishedAt): Bodythe body a published row stores: the source converted into UJO, its time the moment $publishedAt in milliseconds, the format ujo, the HTML compiled from that UJO and purified; InvalidContent when the source cannot be readUjoBodyConverter

An application that reads more formats, or another level, aliases the port to its own service with #[AsAlias(BodyConverter::class)]. The body it returns is stored as it is: purify its HTML.

ConversionLosses

PortMethodContractDefault adapter
Phil\ContentBundle\Application\Contract\ConversionLossesof(string $source, MarkupFormat|string $format): list<ConversionLoss>what the conversion into UJO does not keep, in the order of the source — each ConversionLoss an element, its path and the reason phil/markup names —, read as toUjo() reads it; InvalidContent when the source cannot be readUjoBodyConverter

ContentPublisher refuses a publication whose body loses content, and phil:content:publish and the administration show the losses before publishing (publication.md). Without an implementation, nothing is refused for its losses.

CanonicalAddresses

PortMethodContractDefault adapter
Phil\ContentBundle\Application\Contract\CanonicalAddressesof(ContentReference $content, Locale $locale): ?Urlthe absolute address of the content in the locale, a Phil\Url\Url, or null when it has noneContentCanonicalAddresses, with TwigBundle: the router's absolute URL of the current route, localhost accepted in debug mode only

The page events carry it (publication.md). An application whose pages live on several hosts — a site per tenant — aliases the port to its own service with #[AsAlias(CanonicalAddresses::class)].

PublicationSchedule

PortMethodsDefault adapter
Phil\ContentBundle\Application\Contract\PublicationScheduleschedule(Publication, string $transition, DateTimeImmutable $at), cancel(Publication, ?string $transition = null): int, pending(Publication): list<ScheduledPublication>ScheduledTransitionsPublicationSchedule, with phil/scheduled-transitions-bundle

An application with its own scheduler implements it, applying publish or unpublish through the phil_content_publication workflow when due (publication.md).

Working copies

WorkingCopyRepository

PortMethodsDefault adapter
Phil\ContentBundle\Domain\Contract\WorkingCopyRepositoryfindAutosave(ContentReference, Locale, string $author), findNamed(ContentReference, Locale, string $name), findAllNamed(ContentReference, Locale) by name, findAllOf(ContentReference), add(), remove()DoctrineWorkingCopyRepository, with DoctrineBundle

It writes nothing itself: WorkingCopies and ContentManager::delete() run it inside a ContentTransaction.

ContentDocumentProvider is a DocumentProvider of phil/search-bundle, registered with that bundle. An application that wants other fields decorates it, or writes its own provider for the same live rows and leaves search_index to an index of its own (search.md).

Page events

ContentManager dispatches Phil\Publishing\PagePublished and Phil\Publishing\PageRetired of phil/publishing on the container's event_dispatcher (publication.md). A listener of either follows what readers can see.

RawHtmlPermission

PortMethodContractDefault adapter
Phil\ContentBundle\Application\Contract\RawHtmlPermissionisGranted(): boolwhether the user at work writes raw HTML: BodyDataMapper records it on the body they write and compiles it for Level::All, and UjoBodyConverter publishes its raw blocks, whoever publishes itRawHtmlRole, with raw_html_role

An application that decides by something else than a role — a permission of its own, a voter on the content — aliases the port to its own service with #[AsAlias(RawHtmlPermission::class)]. It answers for the request at work, and holds no state between two requests. RawHtmlAuthorPass wraps whichever service the port names in AuthorRawHtml, so the permission is checked on the author of each revision, a working copy's included, and never on the publisher.

Workflow events

When the container has an event_dispatcher, the workflow named phil_content dispatches the events of symfony/workflow (workflow.phil_content.guard.<transition>, workflow.phil_content.completed.<transition>, …) with the localized draft row as subject. A guard that blocks makes the operation throw TransitionRefused (publication.md).

Service ids, tags and constants

WhatValue
Configuration aliasphil_content
Tag of data mappersphil_content.data_mapper (PhilContentBundle::DATA_MAPPER_TAG)
Tag of mergersphil_content.merger (PhilContentBundle::MERGER_TAG)
Tag of normalizersphil_content.normalizer (PhilContentBundle::NORMALIZER_TAG)
Tag of capability mappingsphil_content.capability_mapping (PhilContentBundle::CAPABILITY_MAPPING_TAG), with DoctrineBundle
Workflow namesphil_content (ContentWorkflow::NAME), the draft row's; phil_content_publication (PublicationWorkflow::NAME), a content in one locale's, service phil_content.publication_workflow, added to workflow.registry when there is one
Servicesevery service under its class name (configuration.md); the ports MarkupCompiler, HtmlPurifier, BodyConverter, ConversionLosses, CanonicalAddresses, PublicationSchedule, DimensionContentRepository, RouteRepository, ContentRootRepository, PublishedRowRepository, WorkingCopyRepository, ContentTransaction and ContentRootFactory are aliases
Tags of other bundlesphil_sitemap.url_provider on ContentUrlProvider; phil_scheduled_transitions.subject_resolver on PublicationSubjects; phil_search.provider on ContentDocumentProvider — named by their strings, without depending on those bundles
Container services usedclock; when present, event_dispatcher; with TwigBundle, twig, router, request_stack; with the administration, security.token_storage, security.authorization_checker, security.csrf.token_manager
DBAL typesphil_content_locale, phil_content_stage, phil_content_version, phil_content_dimension_content_id, phil_content_route_id, phil_content_working_copy_id
Tablesphil_content_route, phil_content_working_copy
Doctrine listenerDimensionContentMappingListener on loadClassMetadata
Console commandsphil:content:create, phil:content:edit, phil:content:publish, phil:content:unpublish, phil:content:list, phil:content:preview-link
Twig functionscontent, content_single, content_archive, content_url, content_link_url, content_seo, content_schema
Template variablescontent, root, reference, locale, resolved_locale, left_language; on an archive page, archived and archive
Routes of the administrationphil_content_admin_list, phil_content_admin_create, phil_content_admin_edit, phil_content_admin_autosave (config/routes_admin.php)
Translation domainphil_content, in English and French
Stimulus controllerphil--content-bundle--autosave
CSRF token idphil_content_admin

The bundle declares no PHP attribute; the events it dispatches are those of phil/publishing.