Database

A Symfony CMS that works with your Doctrine entities

Javi

12 reading minutes

When a mature Symfony project needs a content layer, the market answer is always the same: stand up a separate CMS, expose an API and sync. From then on you have two sources of truth, a sync process to maintain and a data model that no longer lives where you wrote it.

Armonic starts from the opposite position. It's a Symfony bundle, not an external application, and it builds on the Doctrine you already have. Your entities aren't copied or wrapped: they become editorial resources, or they relate to them. The business keeps its model, and the CMS contributes what it should — editing, publishing and page composition.

Let's look at it with real code from projects in production.

Content types backed by your own entities

A content type in Armonic can be backed by a specific entity that you define. That's the approach we use for technologies and projects on our own site, for example.

The content type configuration lives in a YAML file:

# cms/contents/technology/config.yaml
content:
    revision: 1
    entity_class: 'App\Entity\Cms\TechnologyContent'
    extra_fields:
        description:
            type: translation
            type_options:
                type: textarea
        logo:
            type: media

And the entity is declared like any other Symfony entity:

#[ORM\Table(name: 'cms_content_technology')]
#[ORM\Entity]
class TechnologyContent extends Content
{
}

With that, a technology inherits Armonic's editorial capabilities: translations, images, admin, layouts, publishing and SEO. You've given up nothing on the Doctrine side, and you haven't written an admin panel.

The same pattern applies to ProjectContent, where we add fields such as client, description and image.

Relating editorial content to entities you already have

An editorial entity can hold ordinary Doctrine relations to entities that are already part of your application. No adapters, no bridge tables invented by the CMS.

In Librio, a CMS product page relates to a real product from the catalogue:

#[ORM\Entity]
class ProductContent extends Content
{
    #[ORM\ManyToOne(targetEntity: Product::class)]
    #[ORM\JoinColumn(name: 'product_id', onDelete: 'CASCADE')]
    private ?Product $product = null;
    public function getProduct(): ?Product
    {
        return $this->product;
    }
    public function setProduct(?Product $product): void
    {
        $this->product = $product;
    }
}

The page keeps its editorial capabilities and the commercial data still belongs to the product. If the price changes tomorrow, it changes in one place.

The relation between articles and users works the same way:

<many-to-one
    field="author"
    target-entity="Softspring\CmsBlogPlugin\Model\AuthorInterface">
    <join-column name="author_id" on-delete="SET NULL"/>
</many-to-one>

In your application you only have to say which entity implements the concept of an author:

sfs_cms_blog:
    author:
        class: 'App\Entity\User'

You reuse the users table you already have. You don't create a second author database inside the CMS that someone will have to keep in sync for the next five years.

Admin forms are Symfony forms

This sounds obvious and almost never is. Armonic's forms are extensible Symfony forms: you can add EntityType fields, validation, filters or custom types with the same tools you use everywhere else in the project.

Librio, for instance, adds a product selector to the CMS form:

class ProductContentCreateForm extends ContentCreateForm
{
    public function buildForm(
        FormBuilderInterface $builder,
        array $options,
    ): void {
        parent::buildForm($builder, $options);
        $builder->add('product', EntityType::class, [
            'class' => Product::class,
            'choice_label' => fn (Product $product) => $product->getName(),
        ]);
    }
}

The content type declares that custom form:

content:
    entity_class: 'App\Entity\Cms\ProductContent'
    admin:
        create:
            type: 'App\Form\Type\Cms\ProductContentCreateForm'
        update:
            type: 'App\Form\Type\Cms\ProductContentUpdateForm'

In the blog plugin we apply the same idea to pick an author:

$builder->add('author', UserType::class, [
    'required' => true,
]);

And in the listing filters too:

$builder->add('author', EntityType::class, [
    'class' => AuthorInterface::class,
    'choice_label' => 'displayName',
    'required' => false,
]);

Entities inside modules and blocks

Modules can include fields that select existing entities. The technology card module uses a specific form type:
module:
    module_options:
        form_fields:
            technology:
                type: technologyCard
                type_options:
                    constraints:
                        - notBlank

That type extends EntityType and points at the relevant entity:

class TechnologyCardType extends AbstractType
{
    public function getParent(): string
    {
        return EntityType::class;
    }
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'class' => TechnologyContent::class,
            'choice_label' => 'name',
            'required' => false,
        ]);
    }
}

The pattern repeats for selecting related projects or articles:

module:
    module_options:
        form_fields:
            project:
                type: projectCard
            article:
                type: blogArticleCard

In Librio, a card can select a product directly:

module:
    module_options:
        form_fields:
            product:
                type: productCard

From there, the template receives the entity and uses its data:

<h3>{{ product.translation(app.request.locale).name }}</h3>
<p>
    {{ product.translation(app.request.locale).shortDescription }}
</p>
{% set image = product.getImagesByType('card_en').first %}

This is where the benefit shows up for whoever is editing: they can combine live business data with their own editorial fields, such as alternative headlines, calls to action or images made for that particular piece.

Extending internal components, links included

The integration isn't limited to full content types. You can also extend the CMS's internal form types.

In Librio, the link selector gains a new option to point at a product configurator:

class LinkType extends BaseLinkType
{
    public function buildForm(
        FormBuilderInterface $builder,
        array $options,
    ): void {
        parent::buildForm($builder, $options);
        $builder->add('product', EntityType::class, [
            'class' => Product::class,
            'required' => false,
        ]);
    }
}

An editorial button can point at a route, a URL, an anchor or a business entity, without the editor having to paste IDs by hand.

Querying entities from Twig

When you don't need to store an editorial relation, you can expose repositories or services through Twig functions and filters:

final class ProductExtension extends AbstractExtension
{
    public function __construct(
        private ProductRepository $products,
    ) {
    }
    public function getFunctions(): array
    {
        return [
            new TwigFunction(
                'get_product_by_id',
                [$this, 'getProductById'],
            ),
        ];
    }
    public function getProductById(int $id): ?Product
    {
        return $this->products->find($id);
    }
}

Any template or CMS block can then reach the product:

{% set product = get_product_by_id(productId) %}
{% if product %}
    <h2>{{ product.name }}</h2>
{% endif %}

This is the sensible route for data that changes on its own: prices, stock, availability, search results or calculated listings. Storing a fixed relation there would only give you stale content that looks tidier.

Stable references when importing and exporting content

If modules contain entities, you have to decide what gets written to the export file. Armonic lets you customise how those references are exported and imported.

Librio identifies products by their stable code:

public function export(mixed $product): array
{
    return [
        '_product' => $product->getCode(),
    ];
}
public function import(mixed $data): ?Product
{
    return $this->productRepository->findOneByCode(
        $data['_product'],
    );
}

That way you can move content between environments without relying on internal database IDs matching. Anyone who has ever synced a staging environment with production knows why this matters.

What you get from this approach

Native Symfony integration. Doctrine, Symfony Forms, services, repositories and Twig. The tools your team already knows.

A single source of truth. Users, products and everything else stay in their original tables.

No syncing. Nothing to copy across to an external CMS, nothing to watch over to make sure the copy is still faithful.

Editorial flexibility. Entities can show up in content types, cards, carousels, links or dynamic blocks.

Gradual adoption. You can start with a simple EntityType selector and work up to fully custom content types and forms. There's no big migration up front.

It's worth saying where it doesn't fit, too: if your application isn't Symfony, or if the content has nothing to do with your business model, a conventional headless CMS will solve the problem just as well and with less code of your own. Armonic wins when content and business touch.

Armonic is an editorial layer inside your Symfony application, not a parallel system. Your data model stays where it is.

Take a look at the code on GitHub, or drop us a line and we'll go through it on your project.

📫
Here’s today’s article. Feel free to reach out to us on social media as always, or at hola@softspring.es with any questions or suggestions!

Let’s work together!

Do you want to tell us your idea?

CONTACT US