m10c / content-elements-bundle
A Symfony Bundle that adds CMS functionality with Identity/Variant patterns, Dimensions, and Filters
Package info
github.com/m10c/M10cContentElementsBundle
Type:symfony-bundle
pkg:composer/m10c/content-elements-bundle
Requires
- php: >=8.4
- api-platform/core: ^4.0
- doctrine/orm: ^2.12 || ^3.0
- symfony/cache: ^6.4 || ^7.0
- symfony/clock: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/serializer: ^6.4 || ^7.0
- symfony/validator: ^6.4 || ^7.0
- webmozart/assert: ^1.11
Requires (Dev)
- doctrine/common: ^3.5
- doctrine/doctrine-bundle: ^3.2
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpstan/phpstan-webmozart-assert: ^2.0
- phpunit/phpunit: ^11.0
- symfony/browser-kit: ^6.4 || ^7.0
- symfony/framework-bundle: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/object-mapper: ^7.0
- symfony/security-bundle: ^6.4 || ^7.0
- symfony/yaml: ^6.4 || ^7.0
Suggests
- symfony/object-mapper: Required for Map attributes in traits
This package is auto-updated.
Last update: 2026-07-24 12:09:10 UTC
README
A Symfony Bundle, designed to integrate closely with API Platform and Doctrine, which adds small "elements" of logic to help provide Content Management System (CMS) functionality.
A core principle is for this library to remain flexible and out of your way:
- It works with your regular Doctrine entities, with only minimal code changes needed to "upgrade" an entity to support the chosen functionality.
- Any database changes that do occur are the minimal ones which would be needed to support the functionality - the bundle doesn't bloat your database with excess tables or fields.
- There are several different features available, which can be used in isolation, or combined together.
- The abstractions provided are highly flexible, with extension points for hooking in your own logic, or relatively easy routes to replace with your own bespoke implementations.
- The implementations are just automatically providing the "glue code" (e.g. query building) which you'd otherwise have to tediously write for each endpoint.
Core concepts
Dimensions
A common pattern in CMSs is to have multiple database rows for a single "thing", e.g. for content that has been translated into multiple languages, or is maintaining a version history.
We represent this by referring to the base entity as the Identity (e.g. src/Entity/Author), which can have Variants (e.g. src/Entity/AuthorVariant), which contains the fields that may be different based on Dimensions. Built in Dimensions:
- Locale - For making content available in multiple languages.
- Stage - For having staging/production versions of content in the same database (making them promoteable).
- Version - For tracking a version history.
Filters
While Dimensions can lead to multiple rows for a single entity, Filters simply hide content from users based on certain conditions. Built in Filters:
- Archivable - Allow content to be "soft-deleted" so it doesn't show up in lists by default, but can still be easily restored.
- Publishable - Let content be in a draft state, or scheduled for published, before it appears publicly to users.
Blocks
Webpage or app screen content is often an ordered list of typed Blocks (e.g. a hero, a rich-text section, a feature grid) each with its own fields. The bundle can store blocks as JSON on an entity, with each block type declaring its own validation rules and schema for rendering an admin form.
Usage
Basic Identity/Variant Setup
The #[Identity] attribute marks an entity as having variants. By default, it expects:
- A
$variantscollection on the Identity - An
$identityproperty on each Variant pointing back - A
$variantproperty on the Identity where the resolved variant gets hydrated
// src/Entity/Author.php #[Identity(variantClass: AuthorVariant::class)] class Author { public string $id; public string $email; // Shared across all locales #[OneToMany(targetEntity: AuthorVariant::class, mappedBy: 'identity')] public Collection $variants; public AuthorVariant $variant; // Hydrated by Provider } // src/Entity/AuthorVariant.php class AuthorVariant { use LocaleDimensionTrait; // Adds $locale #[ManyToOne(inversedBy: 'variants')] public Author $identity; public string $bio; // Locale-specific }
Custom Property Names
The #[Identity] attribute supports custom property names, which is essential for multi-level hierarchies or when your naming conventions differ:
#[Identity(
variantClass: AuthorVariant::class,
variantsProperty: 'translations', // Collection property name (default: 'variants')
identityProperty: 'author', // Back-reference property name (default: 'identity')
variantProperty: 'translation', // Hydrated variant property name (default: 'variant')
)]
class Author
{
#[OneToMany(targetEntity: AuthorVariant::class, mappedBy: 'author')]
public Collection $translations;
public AuthorVariant $translation; // Hydrated by Provider
}
class AuthorVariant
{
#[ManyToOne(inversedBy: 'translations')]
public Author $author; // Matches identityProperty
}
Multi-Level Hierarchies (Separate Version History)
The Identity attribute is composable - a Variant can itself be an Identity with its own Variants. This enables powerful patterns like having separate version history for shared data vs locale-specific data:
// Level 1: Stable identity (just an ID, never changes) #[Identity( variantClass: ContentVersion::class, variantsProperty: 'versions', identityProperty: 'contentIdentity', variantProperty: 'version', )] class ContentIdentity { public string $id; #[OneToMany(targetEntity: ContentVersion::class, mappedBy: 'contentIdentity')] public Collection $versions; public ContentVersion $version; // Active version (hydrated) public ContentLocalised $localised; // Shortcut to version->localised } // Level 2: Versioned shared data (thumbnail, tags, etc.) #[Identity( variantClass: ContentLocalised::class, variantsProperty: 'localisedVariants', identityProperty: 'contentVersion', variantProperty: 'localised', )] class ContentVersion { use VersionDimensionTrait; // Adds $version #[ManyToOne(inversedBy: 'versions')] public ContentIdentity $contentIdentity; public ?MediaUpload $thumbnail = null; // Shared, versioned public Collection $tags; // Shared, versioned #[OneToMany(targetEntity: ContentLocalised::class, mappedBy: 'contentVersion')] public Collection $localisedVariants; public ContentLocalised $localised; // Active locale (hydrated) } // Level 3: Locale-specific, versioned content class ContentLocalised { use LocaleDimensionTrait; // Adds $locale use VersionDimensionTrait; // Adds $version (locale-level versioning) #[ManyToOne(inversedBy: 'localisedVariants')] public ContentVersion $contentVersion; public string $title; // Locale-specific, versioned public string $body; // Locale-specific, versioned }
This structure allows:
- Identity-level versioning: When
thumbnailortagschange, create a newContentVersion - Locale-level versioning: When
titleorbodychange, create a newContentLocalised - Independent histories: English content can have 5 versions while French has 3
Building a CMS: Pages and Blocks
The primitives below are all opt-in — an entity mixes in only the traits it needs. A typical page is an Identity with a per-locale Variant that carries a slug, SEO metadata and an ordered list of typed blocks:
// src/Entity/Page.php #[ApiResource( operations: [ new GetCollection(provider: IdentityWithVariantProvider::class), new Get(provider: IdentityWithVariantProvider::class), new Get( uriTemplate: '/pages/by-slug/{slug}', provider: ByPropertyProvider::class, ), ], )] #[Identity(variantClass: PageVariant::class)] #[ORM\Entity] #[ORM\UniqueConstraint(columns: ['slug'])] class Page { use HasSlugTrait; #[ORM\Id, ORM\Column] public string $id; #[ORM\OneToMany(targetEntity: PageVariant::class, mappedBy: 'identity', cascade: ['persist'])] public Collection $variants; public ?PageVariant $variant = null; } // src/Entity/PageVariant.php #[ApiResource(operations: [new Patch()])] #[ORM\Entity, ORM\HasLifecycleCallbacks] class PageVariant { use LocaleDimensionTrait; use HasSeoMetaTrait; use HasBlocksTrait; use HasUpdatedAtTrait; #[ORM\Id, ORM\Column] public string $id; #[ORM\ManyToOne(inversedBy: 'variants')] public Page $identity; }
Each block type is a service defining its data shape, which is validated when updates are written:
// src/Cms/BlockType/HeroBlockType.php final class HeroBlockType implements BlockTypeInterface { public function getKey(): string { return 'hero'; } // Enforced on every write; a violation reports as e.g. blocks[0].data[headline]. public function getDataConstraints(): Constraint { return new Assert\Collection([ 'fields' => [ 'headline' => [new Assert\NotBlank(), new Assert\Length(max: 255)], 'subhead' => [new Assert\NotBlank(), new Assert\Length(max: 500)], ], 'allowExtraFields' => false, 'allowMissingFields' => false, ]); } public function getDefaultData(): array { return ['headline' => '', 'subhead' => '']; } public function getSchema(): array { return [ 'label' => 'Hero', 'fields' => [ 'headline' => ['kind' => 'text', 'label' => 'Headline', 'maxLength' => 255], 'subhead' => ['kind' => 'textarea', 'label' => 'Subhead', 'maxLength' => 500], ], ]; } }
A CMS frontend can call GET /block-types to list all registered blocks and their schemas, so forms can be rendered completely dynamically.