Radek Krejci

Architecture

From the beginning, I did not want to build LydiaCMS as one tightly coupled bundle of features. I needed a system whose parts I could develop, release and potentially use in another project. I therefore divided it into separate Composer packages that connect to the application through Nette DI.

The result is a modular monolith: at runtime it is still a single application with simple operations, but its parts have their own repository, version and clearly defined dependencies.

How the architecture evolved

The first impulse came from a friend who asked me to create a website with a forum. The project did not continue, but it left behind a usable foundation with users and pages. I began building a more general CMS on top of it that I could also use for other websites.

The first significantly different use was Cluesport.com, an attempt at a metasearch engine for flights, tickets and planned accommodation for football matches abroad. I imported matches, teams, cities and stadiums from a public API, flight offers from another API, and obtained ticket data through scraping. Accommodation remained only a plan.

The biggest problem was not downloading the data itself, but connecting and normalizing it across sources. This need led to the creation of the separate lydiacms-integration package. Cluesport also introduced requirements for multilingual content and an API.

How the application is divided

LydiaCMS consists of 19 separately versioned Composer packages. The most important ones can be divided into three groups:

  • Core handles application startup, Doctrine integration, routing, permissions, localization and the administration foundation.
  • Blog, Menu, Forms and Socials add specific content-management features.
  • Data, Integration, API and Backup handle data structures, imports, programmatic interfaces, backups and recovery.

Each package has its own repository and version. I can therefore develop and release it independently; the application assembles a specific combination of versions through Composer.

Connecting modules through Nette DI

Each module is a separate Composer package with its own Nette DI extension. When the container is compiled, the extension registers the module's services and its Doctrine integration. As needed, it also adds routes, console commands and other parts of the application. Connecting a module therefore does not require manually assembling its components in the bootstrap.

Administration generated from Doctrine metadata

LydiaCMS contains more than one hundred Doctrine entities. Writing a separate form, table view and filters for each of them would create a large amount of repetitive code. I therefore generate the basic administration interface automatically from Doctrine ORM metadata.

I create custom components only where the generated interface is not sufficient — for example, for tree-based menu management or the form builder. Smaller differences, such as help text, placeholders or field rendering, are configured using attributes on the data model.

Multilingual content and stable URLs

A localized record can have a separate variant for each language. SEO information such as the title, description, image and slug is configured for a specific language variant without requiring a database-model change for each language.

The full page path is composed from its position in the tree. If the slug or position changes, the original address is retained as a permanent redirect to the new one. Links from search engines or external websites therefore do not end in an error.

SEO tab of a record with language variants
SEO properties are part of the record's language variant.

A unified permissions model

The administration and API use the same context tree and the same ACL rules. Permissions can be configured for an entire package, a specific part of it or a single record. Child contexts inherit rules and can refine them, so a new module does not need to create a separate authorization system.

The rules distinguish operations such as viewing, creating, editing, deleting, changing status or exporting, as well as the administration, frontend and API environments. The same mechanism therefore also restricts content management using AI.

Permissions matrix in the LydiaCMS administration
The matrix distinguishes directly assigned and inherited permissions in individual contexts.

What this division brings and what it costs

  • A project installs only the modules it actually uses.
  • Each module can be versioned and released at its own pace.
  • The data model, administration and permissions use the same principles across modules.
  • Dependencies between packages are explicitly described in Composer.

This architecture retains the operational simplicity of a single server application. The cost is the need to maintain version compatibility, clear boundaries between the shared core and modules, and the ongoing overhead of a custom package ecosystem.

What I would design differently today

I would separate one thing earlier today: I would design the basic entity properties so that they could be replaced and extended more easily without changes to a tightly coupled core. This problem led me to work on the lydiacms-data package and to take a greater interest in agnostic data models and Metadata-Driven Architecture.

Today I would move LydiaCMS towards a more loosely coupled, API-first and headless architecture. Individual parts should be easier to change or replace, and custom solutions should be created mainly where available components do not meet specific requirements.

← LydiaCMS

Contact

Looking to strengthen your PHP team?

If you're working on a new backend project, an integration, or need help developing an existing system, get in touch.