Your own module: how it is built and how to write one
The module folder, module.json fields, permissions, settings, translations, checking and packing; a complete working “Hello” example.
A module is a folder in modules/. The engine finds it by itself, lists it under “Add-ons → Modules” and loads it once you enable it. The core stays untouched: the module adds its own addresses, pages, permissions and texts, and takes everything shared from the engine. You need to be able to write PHP 8 and know a little JSON. Below is everything the folder must contain, plus an example you can copy and run.
What goes in the folder
modules/hello/
├── module.json
├── routes.php
├── src/
│ ├── HelloModule.php
│ └── HelloController.php
├── views/
│ └── index.php
├── locales/
├── database/migrations/
└── assets/module.json— module description (required).routes.php— module addresses.src/— classes: the module classHelloModule.php(required, named after the folder) and page controllers.views/— page templates.locales/— translations:en.json,ru.json,uk.json,no.json,pl.json,sv.json,lt.json.database/migrations/— module tables (if needed).assets/— styles, scripts, images (if needed).
The folder name is lowercase Latin letters, digits, hyphen and underscore, starting with a letter: hello, shop-orders. This name cannot be changed later: the engine uses it to tie together files, database records and translation keys. The names of built-in modules and reserved words such as admin, api, core, search are taken: the installer will not accept an archive with such a name.
A minimal working example: “Hello”
A module of five files (plus translations). It adds the page /hello, needs no database and passes the release check. Create the folder modules/hello and put the files in it as shown; every block has a “Copy” button.
{
"name": "hello",
"version": "1.0.0",
"title": {"en": "Hello", "ru": "Привет", "uk": "Привіт", "no": "Hei", "pl": "Cześć", "sv": "Hej", "lt": "Labas"},
"description": {
"en": "A page that greets the visitor.",
"ru": "Страница, которая здоровается с посетителем.",
"uk": "Сторінка, яка вітається з відвідувачем.",
"no": "En side som hilser på besøkende.",
"pl": "Strona, która wita odwiedzającego.",
"sv": "En sida som hälsar på besökaren.",
"lt": "Puslapis, kuris pasisveikina su lankytoju."
},
"author": "Your name",
"engine": ">=0.9.9",
"position": 100,
"group": "service",
"site": "/hello"
}<?php
declare(strict_types=1);
namespace Nex1on\Modules\Hello;
use Nex1on\Core\Modules\Module;
final class HelloModule extends Module
{
public function boot(): void
{
// load the addresses from routes.php
$this->loadRoutes();
}
}<?php
declare(strict_types=1);
namespace Nex1on\Modules\Hello;
use Nex1on\Core\Http\Controller;
use Nex1on\Core\Http\Response;
final class HelloController extends Controller
{
public function index(): Response
{
return $this->view('hello::index', [
'title' => $this->t('hello::title'),
]);
}
}<?php
declare(strict_types=1);
use Nex1on\Modules\Hello\HelloController;
/** @var \Nex1on\Core\Http\Router $router */
$router->get('/hello', [HelloController::class, 'index'])
->name('hello.index')
->option('module', 'hello');<?php
/** @var \Nex1on\Core\Themes\View $this */
$this->extend('layouts/main');
?>
<?php $this->section('title') ?><?= $this->e($this->t('hello::title')) ?><?php $this->end() ?>
<?php $this->section('content') ?>
<article class="hero">
<h1><?= $this->e($this->t('hello::title')) ?></h1>
<p><?= $this->e($this->t('hello::text')) ?></p>
</article>
<?php $this->end() ?>Translations go one file per language in modules/hello/locales/. The keys are the same in every language. English:
{
"title": "Hello",
"text": "Hello, visitor! This page comes from a module you wrote yourself."
}The other six files (ru.json, uk.json, no.json, pl.json, sv.json, lt.json) are built the same way; only the values change. For Russian:
{
"title": "Привет",
"text": "Здравствуйте, посетитель! Эту страницу создал модуль, который вы написали сами."
}Then, from the site root:
php nex1on module:check hello
php nex1on module:install hello
php nex1on module:enable helloThe check should end with the words “Module is ready for release”. After enabling, open /hello. You can also install and enable it in the panel: “Add-ons → Modules”, the “Install and enable” button on the “Hello” module.
What matters here. Translation keys are written with the module name: hello::title is the key title from modules/hello/locales/. Text from translations always goes through $this->e(), which escapes the output. The page is drawn inside the site frame (layouts/main), so it looks like part of the site in any theme.
A starter in one command
To avoid typing the files by hand:
php nex1on module:new shop-orders "Orders"The command creates a working module: manifest, class, page, admin address, permissions, a migration with a table and translations. It can already be installed and enabled. The starter provides three translations (en, ru, uk): add the other four languages yourself, otherwise the release check will fail.
module.json: fields
| Field | What it is, with an example |
|---|---|
name | required, matches the folder name: "hello" |
version | required, the module version: "1.0.0"; on update the engine compares it with the installed one |
title | name per language: {"en": "Hello", "ru": "Привет"}; if a language is missing, English is used |
description | one or two sentences about what the module does, per language; without a description in every language the release check fails |
author | your name or the name of your company |
engine | which engine versions you support: ">=0.9.9", "^0.9", ">=0.9 <2.0", "*" for any. If your engine version does not fit the constraint, the module will not install and cannot be enabled |
requires | which modules it depends on: {"modules": {"news": ">=1.0"}} or simply {"modules": ["news"]} |
position | load order, lower loads earlier; 100 by default. A module that takes an address right at the site root (/about-us) should be set to about 900: otherwise it will intercept other modules' addresses |
group | where it appears in the list: content, community, sales, media, marketing, service; if not set, “Utility” |
site | the address of the module page for visitors ("/hello"); the “open” link in the list is built from it. A path from the site root only |
class | the module class. You can leave it out: for the folder shop-orders the engine looks for Nex1on\Modules\ShopOrders\ShopOrdersModule. If you write it, the backslash is doubled in JSON: "Nex1on\\Modules\\Hello\\HelloModule" |
screenshot | the picture for the card; showcase images are the files preview/1.webp … up to six |
distribution | a service field: not needed for your own modules (without it the module is treated as internal and is not offered through the release catalogue) |
Permissions
Permissions are introduced by the module, not by the core. Add a method to the module class and give the permission names in the translations:
public function permissions(): array
{
return [
'hello.view' => 'content', // permission => section in "Roles"
'hello.manage' => 'content',
];
}"permissions": {
"view": "View",
"manage": "Manage"
}The permission name is the key permissions.<without the module name> in all translation files. Without it the release check fails: otherwise “Roles” would show hello.view instead of “View”. Which role may do what is decided by the module itself: hand out the permissions in the install() method (see module:new for a sample). The address must be protected by a permission in routes.php; hiding the menu item is not enough.
Settings
Default values are declared in the module class: name, value and type (string, int, bool):
public function settings(): array
{
return [
'greeting' => ['Hello', 'string'],
'per_page' => [20, 'int'],
'show_date' => [true, 'bool'],
];
}Inside the module they are read like this: $this->setting('greeting'). A settings page of your own in the panel is added through the settings.schema event; see the welcome module (modules/welcome/src/WelcomeModule.php) for a sample.
Blocks and tags for output
So that parts of your module can be inserted into tag-based themes (a list of latest entries, a form, a banner), the module declares them to the engine: pages through ViewTags::register in boot(), insertable elements through the theme.blocks event. A full sample is the polls module (PollsModule.php, PollsViewTags.php, the catalogue in PollsCatalog.php). How to use the ready-made tags in a theme is explained in the article “Your own theme”.
Translations
One file per language: locales/<code>.json. The engine knows seven languages: en, ru, uk, no, pl, sv, lt. On a site in one of them the needed file may be missing: then English is used, so the English file is always required. But a release needs all seven with the same set of keys: the check names what is missing. Nested keys are written with a dot: "menu": {"title": "…"} is read as hello::menu.title.
Tables
Migration files live in database/migrations/ and are named by date: 2026_10_20_000000_create_hello_table.php. Each one must have a down(), the rollback: without it the module cannot be removed cleanly, and the release check fails. Name tables with the module name (hello_items) and do not touch other modules' tables. A sample migration is created by module:new.
Rules you must not break
- Do not touch the core. Everything of your own goes in the module folder; if a hook point is missing, that is a question for the engine developers, not a reason to edit
core/. - There are no texts for people in code: labels, messages and names are only translation keys and
locales/files. - Everything that comes from a person is escaped on output (
$this->e()); database queries go only through the engine's query builder, with parameters. - Nothing in the uploads is executed: files supplied by a visitor are never run or included.
- PHP files live only in
src/,views/,database/,demo/androutes.phpin the root. Otherwise the module archive will be rejected on install. - File names use only Latin letters, digits, dot, underscore and hyphen, with no spaces or Cyrillic; files whose names start with a dot (
.gitignore) are not allowed in the archive. - Only these file types go into the archive: php, json, md, txt, html, xml, csv, css, js, png, jpg, gif, webp, ico, woff, woff2 (svg only in an archive signed by Nex1on). The installer rejects anything else.
- Do not leave
var_dump,print_r,dd,dump,error_login finished code: the release check finds them.
From idea to archive
php nex1on module:new name "Title"is the starter (or write the files by hand, as in the example).php nex1on module:install nameandmodule:enable nameinstall and enable it on your own stand; the page opens.php nex1on module:check nameis the release check: manifest, parsing of all php files, rollback in migrations, translations in seven languages, permission names, description, debug calls. Errors are marked “✗”, with the file or language name.php nex1on module:pack nameruns the check again, then builds the archive. The command prints the path: by default the foldernex1on-moduli/ziptwo levels above the site folder; you can change it with the keyapp.release_directoryinconfig/local.php. The archive is namedname-version.zip, with the module folder inside.- To install the archive on another site, follow the article “Installing your own add-on”.
Common errors
| What you see | What is wrong |
|---|---|
| “The module.json file cannot be parsed as JSON” | a comma or quote is missing; the backslash in class is not doubled (or simply remove the class field) |
| “The name in the manifest does not match the folder name” | name in module.json must be the same as the folder |
| “Core … required, … installed” | the engine field names a version newer than your engine: put the one you tested on |
| “no translations: …” or “keys are missing” | every one of the seven languages needs a file, and the keys must be identical in all of them |
| “no permission name …” | add permissions.<permission> to all translation files |
| “migration … has no down() rollback” | add a down() that removes what up() created |
The page opens, but instead of words you see keys like hello::title | there is no such key in locales/en.json, or the file does not parse as JSON |