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
- Increase the
versioninmodule.json(1.1.0). - If the schema changed, add a new numbered migration (
002_...sql); do not change the old one. - If a data fix is needed, write
upgrade()or anUpdateHookInterface. - Release; on sites,
cp:update(or the AACP update) runs the pending migrations and theupgrade()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/orcp-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.