Skip to content

Your first module


title: Your first module (tutorial) description: The "Notes" module from scratch: skeleton, table, entity, admin screen, menu, permissions and translations; about half an hour.

In this tutorial we will write a small module called Notes: a menu item in Studio, a screen that lists notes and adds new ones, its own database table, its own permissions and Turkish/English translations. All of the code here has been run and verified on a real CPalius installation.

This tutorial covers version 1.0 (one table, a list and a form). For the 1.1 version of the same module, which has turned into a real notebook, see the Example module: Notes 1.1 page.

Prerequisite: a working CPalius installation (see Installation), basic familiarity with PHP, Symfony and Doctrine.

What you will have at the end:

cp-content/modules/Notes/
  module.json
  NotesModule.php
  Install/ModuleInstaller.php
  Entity/Note.php
  Controller/Admin/NoteAdminController.php
  Resources/
    config/        capabilities.yaml  routes.yaml  services.yaml
    migrations/    001_notes.sql
    translations/  messages+intl-icu.tr.yaml  messages+intl-icu.en.yaml
    views/admin/   index.html.twig

Step 1: Generate the skeleton

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

The command generates the manifest, bundle class, installer, translation files and empty configuration files under cp-content/modules/Notes/. The name must be PascalCase (Notes, BookingSystem).

The generated module.json:

{
    "name": "Notes",
    "version": "1.0.0",
    "description": "Notes module.",
    "author": "CPalius",
    "bundle": "Modules\\Notes\\NotesModule",
    "core_version": "^2.0",
    "requires": {},
    "conflicts": {},
    "provides": ["notes"]
}
core_version

This field declares which core versions the module works on and it is verified at activation: if it does not match, the module does not open ("Notes" requires CPalius core ^1.0, but 2.2.52 is installed). On older cores cp:module:create wrote a fixed ^1.0; if you get such a manifest, change it to ^2.0 (or a narrower range such as >=2.2.52 <3).

If you want a description in another language, edit description; it appears in the store.

Step 2: Define the table

Resources/migrations/001_notes.sql:

/* Notes module: one table. Idempotent on purpose. */
CREATE TABLE IF NOT EXISTS cp_notes (
    id INT AUTO_INCREMENT NOT NULL,
    title VARCHAR(200) NOT NULL,
    body LONGTEXT DEFAULT NULL,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;

Rules: files run in order by name, are recorded once by name (the content is not looked at), so use IF NOT EXISTS and never change a released file; add a new numbered file (002_…sql). Details: Database and migrations.

The installer must know which tables to delete on uninstall. Install/ModuleInstaller.php:

final class ModuleInstaller extends AbstractSqlModuleInstaller
{
    protected function moduleId(): string { return 'notes'; }

    /** @return list<string> */
    protected function tables(): array { return ['cp_notes']; }
}

Step 3: Write the entity

Entity/Note.php: a Doctrine entity, mapped to your table:

<?php

declare(strict_types=1);

namespace Modules\Notes\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'cp_notes')]
class Note
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: Types::INTEGER)]
    private ?int $id = null;

    #[ORM\Column(type: Types::STRING, length: 200)]
    public string $title = '';

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    public ?string $body = null;

    #[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
    public \DateTimeImmutable $createdAt;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int { return $this->id; }
}

The module's Entity/ folder is introduced to Doctrine automatically; you do not write a separate mapping configuration.

Step 4: Define the permissions

Resources/config/capabilities.yaml:

capabilities:
    - notes.view
    - notes.manage

These two capabilities enter the registry. Since the admin role has the * wildcard, it gets both automatically; for other roles you assign the permissions from AACP → Users. Details: Permission system.

Step 5: Load the routes

Resources/config/routes.yaml:

notes_module_admin:
    resource: ../../Controller/Admin/
    type: attribute

Admin controllers carry the /admin/... address themselves (no prefix is added). If you had front-end controllers, you would add a separate block with a /{_locale} prefix (the Docs module's routes.yaml file is an example).

Resources/config/services.yaml comes ready with the skeleton and defines the Controller/ folder automatically.

Step 6: Controller, menu and permission

Controller/Admin/NoteAdminController.php:

<?php

declare(strict_types=1);

namespace Modules\Notes\Controller\Admin;

use App\Core\Annotation\CpAdminMenu;
use Doctrine\ORM\EntityManagerInterface;
use Modules\Notes\Entity\Note;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/notes', name: 'admin_notes_')]
final class NoteAdminController extends AbstractController
{
    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    #[Route('', name: 'index', methods: ['GET'])]
    #[CpAdminMenu(label: 'notes.menu', icon: 'heroicons:pencil-square', panel: 'studio', priority: 50, capability: 'notes.view', group: 'studio.group.content')]
    #[IsGranted('notes.view')]
    public function index(): Response
    {
        return $this->render('@NotesModule/admin/index.html.twig', [
            'notes' => $this->em->getRepository(Note::class)->findBy([], ['id' => 'DESC']),
            'canManage' => $this->isGranted('notes.manage'),
        ]);
    }

    #[Route('/create', name: 'create', methods: ['POST'])]
    #[IsGranted('notes.manage')]
    public function create(Request $request): Response
    {
        if (!$this->isCsrfTokenValid('admin_notes', (string) $request->request->get('_token'))) {
            throw $this->createAccessDeniedException();
        }

        $title = trim($request->request->getString('title'));
        if ($title !== '') {
            $note = new Note();
            $note->title = mb_substr($title, 0, 200);
            $note->body = trim($request->request->getString('body')) ?: null;
            $this->em->persist($note);
            $this->em->flush();
        }

        return $this->redirectToRoute('admin_notes_index');
    }
}

Points to note:

  • #[CpAdminMenu] registers the menu item: with panel: 'studio', group: 'studio.group.content' it goes into Studio's Content group. label is a translation key. If the user does not have the capability, it does not appear in the menu at all.
  • #[IsGranted] protects the route. The two do not substitute for each other: hiding the menu does not prevent access.
  • The write action (POST) requires notes.manage and verifies the CSRF token itself.
  • Input is limited (mb_substr) and trimmed.

Step 7: Template

Resources/views/admin/index.html.twig. The @NotesModule namespace, derived from the bundle name (NotesModule), is created automatically:

{% extends 'admin/layout.html.twig' %}

{% block header %}{{ 'notes.menu'|trans }}{% endblock %}

{% block body %}
    {% if canManage %}
        <twig:studio:card>
            <form method="post" action="{{ path('admin_notes_create') }}" class="grid gap-3">
                <input type="hidden" name="_token" value="{{ csrf_token('admin_notes') }}">
                <div>
                    <label class="form-label" for="title">{{ 'notes.title'|trans }}</label>
                    <input class="form-control" id="title" name="title" maxlength="200" required>
                </div>
                <div>
                    <label class="form-label" for="body">{{ 'notes.body'|trans }}</label>
                    <textarea class="form-control" id="body" name="body" rows="3"></textarea>
                </div>
                <button class="btn btn-primary self-start">{{ 'notes.add'|trans }}</button>
            </form>
        </twig:studio:card>
    {% endif %}

    <twig:studio:card class="mt-sp-md">
        {% for note in notes %}
            <article class="border-b border-neutral-200 py-2 last:border-0">
                <h3 class="font-semibold">{{ note.title }}</h3>
                {% if note.body %}<p class="text-fs-sm text-neutral-500">{{ note.body }}</p>{% endif %}
            </article>
        {% else %}
            <p class="text-neutral-500">{{ 'notes.empty'|trans }}</p>
        {% endfor %}
    </twig:studio:card>
{% endblock %}

admin/layout.html.twig is Studio's shared template; <twig:studio:card> is Studio's card component. {{ note.title }} is auto-escaped in Twig; do not print user input raw.

Step 8: Translations

Resources/translations/messages+intl-icu.tr.yaml:

notes.menu: "Notlar"
notes.title: "Başlık"
notes.body: "Not"
notes.add: "Not ekle"
notes.empty: "Henüz not yok."

messages+intl-icu.en.yaml:

notes.menu: "Notes"
notes.title: "Title"
notes.body: "Note"
notes.add: "Add note"
notes.empty: "No notes yet."

Write each key in both languages; cp:doctor checks parity. A module cannot be packaged without the TR and EN files (package validation requires the English catalogue).

Step 9: Activate

php cp-core/bin/console cp:module:activate Notes

Activation does the following in order: dependency check (core_version, requires), build and YAML check in an isolated child process, adding to active_modules.php, running the installer and applying 001_notes.sql. If anything goes wrong the module does not activate and your site is not touched.

Then open Studio: Notes appears in the Content group; add a note.

Step 10: Debugging

Symptom Look at
Does not activate The error message says the file and line; core_version, YAML indentation, bundle class name
Not in the menu Does the user have the notes.view capability; is the cache clean (cache:clear)
403 Is the #[IsGranted] name exactly the same as in capabilities.yaml
No table cp:doctor reports module SQL consistency; does the migration file name start with 00N_
Template not found The namespace is @<Name>Module (bundle name): @NotesModule/...
Page blank / broken cp-core/var/log/module_quarantine.log

php cp-core/bin/console cp:debug lists what the system found in your module (capabilities, hooks, cron jobs, entity types).

What next?

Last updated: 13.10.2026