N1 Nex1on

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.

Updated 20.10.2026

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

Code
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 class HelloModule.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.

modules/hello/module.jsonJSON
{
    "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"
}
modules/hello/src/HelloModule.phpPHP
<?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();
    }
}
modules/hello/src/HelloController.phpPHP
<?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'),
        ]);
    }
}
modules/hello/routes.phpPHP
<?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');
modules/hello/views/index.phpPHP
<?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:

modules/hello/locales/en.jsonJSON
{
    "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:

modules/hello/locales/ru.jsonJSON
{
    "title": "Привет",
    "text": "Здравствуйте, посетитель! Эту страницу создал модуль, который вы написали сами."
}

Then, from the site root:

Bash
php nex1on module:check hello
php nex1on module:install hello
php nex1on module:enable hello

The 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:

Bash
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

FieldWhat it is, with an example
namerequired, matches the folder name: "hello"
versionrequired, the module version: "1.0.0"; on update the engine compares it with the installed one
titlename per language: {"en": "Hello", "ru": "Привет"}; if a language is missing, English is used
descriptionone or two sentences about what the module does, per language; without a description in every language the release check fails
authoryour name or the name of your company
enginewhich 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
requireswhich modules it depends on: {"modules": {"news": ">=1.0"}} or simply {"modules": ["news"]}
positionload 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
groupwhere it appears in the list: content, community, sales, media, marketing, service; if not set, “Utility”
sitethe 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
classthe 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"
screenshotthe picture for the card; showcase images are the files preview/1.webp … up to six
distributiona 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:

PHP
public function permissions(): array
{
    return [
        'hello.view'   => 'content',   // permission => section in "Roles"
        'hello.manage' => 'content',
    ];
}
JSON
"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):

PHP
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/ and routes.php in 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_log in finished code: the release check finds them.

From idea to archive

  1. php nex1on module:new name "Title" is the starter (or write the files by hand, as in the example).
  2. php nex1on module:install name and module:enable name install and enable it on your own stand; the page opens.
  3. php nex1on module:check name is 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.
  4. php nex1on module:pack name runs the check again, then builds the archive. The command prints the path: by default the folder nex1on-moduli/zip two levels above the site folder; you can change it with the key app.release_directory in config/local.php. The archive is named name-version.zip, with the module folder inside.
  5. To install the archive on another site, follow the article “Installing your own add-on”.

Common errors

What you seeWhat 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::titlethere is no such key in locales/en.json, or the file does not parse as JSON
Was this article helpful?
On this page