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#
- Create the folder
app/UI/<Section>/<Module>/Presenters/andXxxPresenter.phpinside it. - Keep the presenter file free of logic — only
final,extends BasePresenter,$translatorDomain,#[Persistent]properties, a constructor callingparent::__construct(), and the list ofusetraits. - 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
- Split
action*andrender*.action*loads data into a private property and validates ($this->error(),redirect(),setView()).render*only reads from the property and fills$this->template. - Match the signatures of the pair —
actionDetail(int $id)andrenderDetail(int $id)must take the same parameters in the same order, fully typed. - 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. - 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#
- A trio sharing one name stem: folder
XxxComponent/, classXxx(no suffix,final), factoryXxxFactory. - Templates always in
Templates/Default/— even for a component with a single template. The empty state isEmpty.latte. - Set the template through
setTemplateFile(__DIR__, $file), neversetFile()by hand. The base class triesTemplates/<theme module>/<variant>/, thenTemplates/<theme module>/default/, and only thenTemplates/Default/. - 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;
}
}
- The factory takes no
AppContextand calls noparent::__construct(). The context is injected by a DI decorator (config/Shared/decorators.neon→setAppContext()). - 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 |
- 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.
- The shared ancestor goes to
app/Core/Base/Shared/<Module>/Models/Entities/—abstract,#[ORM\MappedSuperclass]+#[TableAlias('alias')],protectedtyped columns, getters and setters. - 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. - Collections always through a per-collection trait
(
Core/Traits/Shared/Models/Entities/Collections/), initialised in the ancestor's constructor. - Identity through
IdentifiableTrait→getId(). NevergetNewsId(). - 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);
}
}
- 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();
- Writing has to be asked for. The front base manager can only read — it has
GetTrait,GetByTrait,GetAllTrait,TotalCountTraitand 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.
- 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#
- Find the boundary by role, not by line count: listing, detail, form, the signals of one topic.
- A trait bound to one class goes into a
Traits/subfolder next to that class. - A trait shared by several classes goes into
app/Core/Traits/, split by layer intoShared//Api//Admin//Front//Cron/and further by kind intoPresenters//Components//Models/. - The trait carries the same header as any other file and a namespace ending in
\Traits\…. - 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#
- Align what you are touching anyway — the header, the
useblock, types on the methods you change. - Leave alone what is unrelated to your change. Renaming template variants or a sweeping setter change deserves its own commit.
- Do not rewrite the old form pattern.
setTranslator+setTranslatorDomain $t = $form->getTranslateFnc()stays functional; only new forms are written canonically (see Forms).- Remove what does not belong: unused
use, commented-out code,bdump()/dump()/dd(). Debug calls are guarded by the pre-commit hook. - 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