Skip to content

Module skeleton

A module is a self-sufficient package that lives under cp-content/modules/<Name>/: it comes with its own tables, capabilities, menus, translations and installation logic. The core does not know it by name; the module announces itself to the core.

If you are writing a module for the first time, start with Your first module (tutorial). This page is the reference for what you saw in the tutorial.

php cp-core/bin/console cp:module:create Example

Folder layout

cp-content/modules/Example/
  module.json                   manifest (required)
  ExampleModule.php             bundle class (required, usually empty)
  Install/ModuleInstaller.php   install/uninstall (required)
  Entity/  Controller/  Controller/Admin/  Service/  Settings/  Command/  Hooks/  Cron/
  Resources/
    config/
      capabilities.yaml         capabilities
      routes.yaml               route loaders
      services.yaml             service definition
      contributions.yaml        contributions to the core (below)
      importmap.php             JavaScript entry points
    migrations/                 001_example.sql, 002_...
    translations/               messages+intl-icu.tr.yaml  messages+intl-icu.en.yaml
    views/                      front/  admin/
    assets/                     only this module's JS/CSS files (local libraries under vendor/)
  screenshot.png                store image (optional)

The required trio: module.json, the bundle class and Install/ModuleInstaller.php. If one of them is missing or the bundle name does not match the manifest, package validation refuses to activate the module.

module.json

{
    "name": "Example",
    "version": "1.0.0",
    "description": "Short description (shown in the store)",
    "author": "Me",
    "bundle": "Modules\\Example\\ExampleModule",
    "core_version": ">=2.2.52 <3",
    "requires": { "Media": "^1.0" },
    "conflicts": { "OldExample": "*" },
    "provides": ["example"]
}
Field Meaning
name Module name; must be the same as the directory name
version The module's version (MAJOR.MINOR.PATCH). This triggers the upgrade (below)
description, author Shown in the store and AACP
bundle The full class name in the form Modules\<Directory>\<Directory>Module
core_version A Composer version range: the core versions the module works on
requires Other modules it depends on and version ranges
extends If this module is an add-on of another module, its directory name (e.g. "Forum" for Messages). The parent module is added to requires automatically; a "Forum add-on" badge appears in the module list and the store. Putting admin menu entries under the parent module is your job (with parent:)
conflicts Modules it cannot be active together with
provides Capability ids the module provides

All fields are read as optional (an old manifest stays valid), but activation verifies the requires, conflicts and core_version matrix before activating and reports the problem in human-readable form. Set the lower bound of core_version to the core version where the feature the module relies on arrived: the Docs module asks for >=2.2.52 <3 because the league/commonmark dependency arrived in 2.2.52.

Bundle class

namespace Modules\Example;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class ExampleModule extends Bundle
{
}

It can stay empty. Since it is a Symfony bundle you can override Bundle methods, but most modules do not need to: configuration is loaded with YAML.

Installer class

Extend AbstractSqlModuleInstaller; provide moduleId() and the tables() list to delete on uninstall:

final class ModuleInstaller extends AbstractSqlModuleInstaller
{
    protected function moduleId(): string { return 'example'; }
    protected function tables(): array { return ['cp_example_items']; }
}

The base class provides three hooks:

Hook When Default behaviour
install() At activation Applies the Resources/migrations/*.sql files in order
upgrade($ctx, $from, $to) When the module.json version increases (cp:update) Applies the newly numbered migration files
uninstall() cp:module:deactivate --purge Deletes the tables in the tables() list

For initial data (role capabilities, field definitions, default vocabulary terms) override these hooks. For field definitions use FieldDefinitionSeeder::ensure(): it only creates a missing field and does not overwrite a field an editor has fine-tuned.

The installer can also add capabilities to role files (the Portal and Showcase modules add their own capabilities to the editor and member roles this way); write this work idempotently.

Routes and services

Resources/config/routes.yaml has two blocks: front end and admin.

example_module_front:
    resource: ../../Controller/
    type: attribute
    prefix: /{_locale}
    requirements:
        _locale: '%cpalius.locales_pattern%'
    defaults:
        _locale: '%cpalius.default_locale%'
    exclude:
        - ../../Controller/Admin/

example_module_admin:
    resource: ../../Controller/Admin/
    type: attribute
  • Front-end controllers get the /{_locale} prefix (/tr/example); %cpalius.locales_pattern% is the constraint generated from the active languages.
  • Admin controllers (Controller/Admin/) do not get a prefix; they carry their own #[Route('/admin/example')] addresses.

Resources/config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    Modules\Example\Controller\:
        resource: '../../Controller/'
        tags: ['controller.service_arguments']

    Modules\Example\Service\:
        resource: '../../Service/'

With autoconfigure on, classes marked with attributes (#[CpHook], #[CpCronJob], #[CpFieldType], #[CpApi]) and search providers implementing SearchProviderInterface are collected automatically. For other extension interfaces you give a tag in the module's services.yaml file:

services:
    _instanceof:
        App\Core\Queue\AsyncJobHandlerInterface:
            tags: ['cp.async_job_handler']
        App\Core\Search\Admin\AdminSearchProviderInterface:
            tags: ['cpalius.admin_search.provider']

The full list of tag names: Extension points. Add the module-specific Settings/ and Command/ folders to services.yaml in the same way.

If a mistake is made (broken YAML, a class that does not exist) it is caught before the module is activated; see Overview.

contributions.yaml: contributions to the core

The core does not know any module by name. Your module declares which core features it will attach to with Resources/config/contributions.yaml:

# Front-end display route for each content type
node_show_routes:
    example: example_show

# schema.org type
schema_types:
    example: WebPage

# Home page mode: added to the AACP → Home page options
homepage_modes:
    example:
        route: example_index
        label: example.homepage.mode
        priority: 20

# Fields to write to the flat field index (queryable)
queryable_fields:
    example:
        is_featured: int

# Studio shortcuts and type labels
studio:
    type_labels:
        example: Example
    quick_create: []
    quick_links: []

# Notification types
notification_types: []

# Incoming webhook endpoints
inbound_webhooks: []
Key What it is for
node_show_routes Finding the front-end address of a content type (sitemap, search, links)
category_show_route Category page route
schema_types Mapping a content type to a schema.org type
homepage_modes Making the module show the / address
queryable_fields Adding a field of type string, int, decimal, datetime to the flat field index
studio Type labels in Studio, edit/view routes, quick create and shortcuts, hidden types
notification_types Introducing notification types
portal_blocks Portal home page blocks
account Account page sections
inbound_webhooks Introducing incoming webhook endpoints

The file is valid even with missing keys; write only the sections you need.

Versioning and upgrade

  1. Increase the version in module.json (1.1.0).
  2. If the schema changed, add a new numbered migration (002_...sql); do not change the old one.
  3. If a data fix is needed, write upgrade() or an UpdateHookInterface.
  4. Release; on sites, cp:update (or the AACP update) runs the pending migrations and the upgrade() hook.

The module's installed version is stored in cp_settings; if there is a version difference, AACP says "update required". Give a meaningful version number: only a fix is PATCH, a compatible feature is MINOR, a breaking change is MAJOR.

Security contract

  • The module cannot write to or reference cp-core/ or cp-includes/.
  • It cannot declare the App\ namespace.
  • It takes the libraries it uses locally under Resources/assets/vendor/; it does not call a CDN (CSP).
  • It does not start a session on anonymous requests.
  • It verifies the CSRF token on admin POSTs.

Next: Database and migrations.

Last updated: 13.10.2026