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"]
}
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: withpanel: 'studio',group: 'studio.group.content'it goes into Studio's Content group.labelis a translation key. If the user does not have thecapability, 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) requiresnotes.manageand 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?
- If you want to add fields and multilingual support to notes, use a
Node-based content type: Field system. - If it is going to be a business record, an automatic admin screen and permissions with
#[CpResource]: Content model. - To show it on the front end, add a
/{_locale}/notesroute: Module skeleton. - Menu, settings and search: Menu and permissions, Search, settings and SEO.
- To publish: Publishing to the store and Checklist.