Skip to content
V
For developers
Architecture, conventions, the core and security
Getting started / Code conventions

Code conventions#

What a file you write in this CMS should look like. The canonical and binding wording lives in the skills/code-standard/ skills — this chapter turns them into a guide: the file skeleton, naming, and step-by-step procedures for the things you create most often.

It applies to every PHP file under App\ — presenter, component, entity, manager or trait. Layer rules (what may call what) are covered by the Five-layer model; this page is about writing the file itself.

When to follow it and when not to

Write new files canonically, always. Bring someone else's file in line only as far as you are changing it anyway — sweeping rewrites "while I'm here" make the code review unreadable and nobody can then tell a fix from cosmetics.

The file skeleton#

<?php

declare(strict_types=1);

namespace App\UI\Front\Blog\Presenters;

use Nette\Application\Attributes\Persistent;      // 1. Nette

use DateTime;                                     // 2. other vendors + PHP
use Doctrine\ORM\Mapping as ORM;

use App\Core\Forms\Form;                          // 3. App\
use App\UI\Front\Blog\Models\Managers\NewsManager;

final class NewsPresenter extends BasePresenter
{
    use NewsInitTrait;
    use NewsListTrait;

    protected ?string $translatorDomain = 'front.blog.presenters.news';

    #[Persistent]
    public int $id;

    public function __construct(
        private readonly NewsManager $newsManager,
    ) {
        parent::__construct();
    }
}
Element Rule
Header three lines<?php, blank, declare, blank, namespace. Never the one-line <?php declare(...)
use three groups separated by a blank line, alphabetical inside, none unused
Class a leaf class is final; abstract only in App\Core\Base
Properties fully typed, named concretely ($regionManager, not $manager)
Constructor promoted private readonly, one per line, trailing comma included
Indentation tabs; { on its own line for a class and a method, on the same line for if/foreach
Comments in English, short, explaining why — not what

How much of this already holds in the code

Measured over 4,833 files in app/: 3,917 have declare on its own line, 861 still use the old one-line form. abstract appears outside Core/Base in 15 files. The numbers can be recomputed: grep -rl "^declare(strict_types=1);" app --include="*.php" | wc -l.

The method prefix states what the method does#

This is not cosmetics — the prefix tells you whether a method returns data or touches state. A mismatch is the most common reason someone cannot find their way around another developer's presenter.

Prefix What it does Returns
init<X> fills the object's state ($this->…); called from startup() / setup() / action* void
prepare<X> prepares data for the template ($this->template->…); called from render* void / data
find<X> a query into storage via a manager entity / collection
load<X> loads and stores into state (lazy cache) what was loaded
resolve<X> picks the right value out of several sources or the context value
get<X> / set<X> accessor over an already existing value value / void|self
is<X> / has<X> / can<X> a pure predicate with no side effects bool
build<X> / create<X> assembles a new structure — DTO, entity, array object / array
check<X> a compound guard — evaluates predicates and steers the flow (redirect, error, setView) void / bool
handle<X> a signal only, never an ordinary helper void
save / delete / update persistence via a manager Result

Methods are ordered by role and flow, not alphabetically: lifecycle → the action*+render* pair → createComponent*handle*on* → private helpers at the bottom.

handle* used for a helper turns the method into a public signal

Nette maps handle* onto the URL ?do=…. A helper named handleFoo() therefore becomes callable from outside by anyone holding a link — and nobody notices, because inside the application it is called as an ordinary method.

Procedure: creating a new presenter#

  1. Create the folder app/UI/<Section>/<Module>/Presenters/ and XxxPresenter.php inside it.
  2. Keep the presenter file free of logic — only final, extends BasePresenter, $translatorDomain, #[Persistent] properties, a constructor calling parent::__construct(), and the list of use traits.
  3. Split the logic into traits by role, not by size:
Presenters/Traits/
  Inits/XxxInitTrait.php     — startup, shared preparation, signals of that topic
  Lists/XxxListTrait.php     — actionList / renderList
  Details/XxxDetailTrait.php — actionDetail / renderDetail
  Forms/XxxFormTrait.php     — createComponentXxxForm
  1. Split action* and render*. action* loads data into a private property and validates ($this->error(), redirect(), setView()). render* only reads from the property and fills $this->template.
  2. Match the signatures of the pairactionDetail(int $id) and renderDetail(int $id) must take the same parameters in the same order, fully typed.
  3. Dependencies: the presenter's go into the constructor. A trait cannot reach the constructor, so it uses an #[Inject] public property in the trait — the only permitted exception from constructor injection.
  4. Meta and OG data are filled by the presenter into $this->metaData / $this->ogData, never in the template.

A database query in render* repeats on every AJAX redraw

render* also runs on redrawControl(). A query that ran once in action* is sent again from here on every signal — and because the page works, you find out from the database load, not from an error.

Procedure: creating a new component#

  1. A trio sharing one name stem: folder XxxComponent/, class Xxx (no suffix, final), factory XxxFactory.
  2. Templates always in Templates/Default/ — even for a component with a single template. The empty state is Empty.latte.
  3. Set the template through setTemplateFile(__DIR__, $file), never setFile() by hand. The base class tries Templates/<theme module>/<variant>/, then Templates/<theme module>/default/, and only then Templates/Default/.
  4. The factory only assembles and calls initControl():
final class RegionListFactory extends BaseComponentFactory
{
    public function __construct(
        private readonly RegionManager $regionManager,
    ) {}

    public function create(): RegionList
    {
        $this->control = new RegionList($this->regionManager);
        $this->initControl();
        return $this->control;
    }
}
  1. The factory takes no AppContext and calls no parent::__construct(). The context is injected by a DI decorator (config/Shared/decorators.neonsetAppContext()).
  2. Split the initialisation by what it needs:
Method When it runs What for
__construct assembly in the factory DI
onCreate() from initControl() after assembly init independent of the presenter
setup() when attached to the presenter the analogue of startup() — needs the request
prepare*() from render* at render time data depending on the render* arguments
  1. Name the visual variants meaningfully (Box, Row, Grid, Inline) rather than by number whenever the variant carries meaning. They are called as {control frontBazaarGallery:box}.

A custom setup() without parent::setup($presenter) breaks translations and the template

The base setup() wires up the translator, $this->template, the router and the DI container. When an override forgets to call it, the component still renders — just without translated texts and with an empty $this->template->lang. It looks like a missing translation key, not like a PHP bug.

You do not write renderStyle1()

BaseComponent::__call turns renderXxx() into render('Xxx') on its own. A hand-written renderStyle1() works too, but drifts away from every other component — and __call additionally inserts an underscore between a letter and a digit (renderStyle1 → the file Style_1.latte), so the manual variant looks for a different file than the automatic one.

Procedure: creating an entity and a manager#

An entity exists in four shapes in this system. Verifiably: News lives in app/Core/Base/Shared/Blog/… (the abstract ancestor) plus Api/, Admin/ and Front/ variants over the same cms_mod_blog_news table.

  1. The shared ancestor goes to app/Core/Base/Shared/<Module>/Models/Entities/abstract, #[ORM\MappedSuperclass] + #[TableAlias('alias')], protected typed columns, getters and setters.
  2. The concrete variants go to app/UI/<Section>/<Module>/Models/Entities/#[ORM\Entity] + #[ORM\Table(name: '…')], inheriting from the shared ancestor and adding only the associations that section really needs.
  3. Collections always through a per-collection trait (Core/Traits/Shared/Models/Entities/Collections/), initialised in the ancestor's constructor.
  4. Identity through IdentifiableTraitgetId(). Never getNewsId().
  5. Keep the manager thin — it only passes the service on and carries the generic in its PHPDoc:
/**
 * @extends BaseManager<NewsService>
 */
class NewsManager extends BaseManager
{
    public function __construct(NewsService $service)
    {
        parent::__construct($service);
    }
}
  1. Reading is fluent and ends with ->execute():
$entity = $this->newsManager->find($id)->include(['translations', 'image'])->execute();
$items  = $this->newsManager->findAll($filters, $orderBy, $offset, $limit)->execute();
  1. Writing has to be asked for. The front base manager can only read — it has GetTrait, GetByTrait, GetAllTrait, TotalCountTrait and nothing else. A manager that needs to store data pulls the traits in explicitly:
use App\Core\Traits\Shared\Models\Managers\DeleteTrait;
use App\Core\Traits\Shared\Models\Managers\SaveTrait;

class AdvertFollowerManager extends BaseManager
{
    use SaveTrait;
    use DeleteTrait;
}

25 out of 111 front managers do so (grep -rl "SaveTrait\|DeleteTrait" app/UI/Front --include="*Manager.php" | wc -l). The Admin and Api base managers have writing built in.

  1. Side effects of writing (denormalisation, recounts, notifications) belong in the manager's lifecycle hooks, not in the presenter — see Manager lifecycle.

A Doctrine entity must not be final

Doctrine builds a proxy class over the entity; final prevents that and fails at runtime during a lazy load, not at build time. The code holds to it without exception — of the 848 classes carrying #[ORM\Entity], not one is final.

A collection accessor must be named after the property

protected Collection $translations needs getTranslations(). On a mismatch nothing crashes — the collection is silently not loaded, the API does not serialise it, and the admin form then saves it empty. It shows up as "the translations disappeared", not as an error.

Procedure: splitting a large file into traits#

  1. Find the boundary by role, not by line count: listing, detail, form, the signals of one topic.
  2. A trait bound to one class goes into a Traits/ subfolder next to that class.
  3. A trait shared by several classes goes into app/Core/Traits/, split by layer into Shared/ / Api/ / Admin/ / Front/ / Cron/ and further by kind into Presenters/ / Components/ / Models/.
  4. The trait carries the same header as any other file and a namespace ending in \Traits\….
  5. Leave in the original class only properties, the constructor and entry points.

A general trait inside one class's folder gets found only as a copy

When a reusable trait hides in one presenter's Presenters/Traits/, the next developer will not find it and will write their own. The difference between the copies surfaces the moment one of them gets fixed.

Procedure: editing someone else's file#

  1. Align what you are touching anyway — the header, the use block, types on the methods you change.
  2. Leave alone what is unrelated to your change. Renaming template variants or a sweeping setter change deserves its own commit.
  3. Do not rewrite the old form pattern. setTranslator + setTranslatorDomain
  4. $t = $form->getTranslateFnc() stays functional; only new forms are written canonically (see Forms).
  5. Remove what does not belong: unused use, commented-out code, bdump() / dump() / dd(). Debug calls are guarded by the pre-commit hook.
  6. Run PHPStan over it — see Tests and tooling.

Anti-patterns you will still find in the code#

What you will see The target shape
<?php declare(strict_types=1); on one line three lines
OgDataDTO the acronym as a word — OgDataDto
protected $apiEndpoint with no type a fully typed property
InitializationTrait, RenderInitTrait InitTrait
Style_1.latte Style1.latte, better still a named variant Box.latte
parts/ as the partials folder Sections/ / Elements/ / Items/ by content
{define metaTitle} in a template $metaData / $ogData in the presenter

Renaming template variants is one atomic step, not incremental tidying

The underscore form Style_1.latte appears in 136 files (find app -name "Style_*.latte" | wc -l), the underscore-free one in a single file. Renaming one file without touching BaseComponent::__call and every call site means the template is not found and the component renders nothing — with no error message.

Where to look#

I want Where
the binding wording of a convention skills/code-standard/cs-conv-*/SKILL.md
the universal PHP base skills/code-standard/cs-conv-php/SKILL.md
the base classes everything inherits from app/Core/Base/
shared traits app/Core/Traits/{Shared,Api,Admin,Front,Cron}/
the forms core app/Core/Forms/
the sweep through the system and its status skills/code-standard/cs-sweep/SKILL.md

Follow-up chapters: Five-layer model · Project structure · Components · Forms · Latte templates