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

Comments in code#

What a comment in a PHP file of this CMS looks like. The binding wording lives in the skills/code-standard/cs-comments/SKILL.md skill; this page shows the target form on two complete files and sums up the rules you will see in them.

Comments are written in English, as documentation of a library's public API (model: Nette Framework). Reference documentation is generated from the docblocks, which is why they have a fixed shape.

Older files still look different

The code is converted to this form in batches. A file with Czech or long comments is not a defect to fix "while you are there" — whole folders are unified at once so that a comment rewrite can be told apart from a code change.

Five principles#

  1. A comment is a contract, not a chronicle. It says what the thing is, what it guarantees and what breaks it. When, who and why it was changed is what git log says.
  2. Code says what, the comment says why. A comment you can read from the method name or from the next line does not exist.
  3. One style everywhere. The same skeleton, markers, vocabulary and length in Core, Api, Admin, Front and Cron. Only how much is commented differs: the core is a library with a public API, the UI is application code the framework calls by convention.
  4. Long explanations belong in the documentation. A docblock carries a summary and a reference; architecture, tables and plans live in the manual.
  5. What the generator cannot render is not in the docblock. Paragraphs, lists, backticks and code blocks yes; headings and tables no (they fall apart in the IDE), emoji no.

When to write a docblock#

Write a docblock when the reader would otherwise have to open the method body to understand the contract. Otherwise do not. The file skeleton (declare(strict_types=1), final, three groups of use) is set by Code conventions.

Method Docblock
public in the core always: summary + contract — the reference is generated from the core
public in the UI when the name does not say it all
protected when meant for subclasses or when it changes inherited behavior
private only when the purpose or algorithm is not obvious
interface method always: describes the expected behavior, not just the signature
overriding method no docblock when the contract is unchanged — it is inherited; otherwise only the differences. {@inheritDoc} is not written
constructor, getter, setter no; the exception is a non-trivial initialization condition
action*, render*, handle*, createComponent* no, the Nette convention defines the behavior; yes when it does something unusual: a privilege check, a redirect, a side effect

Example: a presenter#

A fictional admin presenter. It shows the file skeleton, the class docblock, comments on traits and properties, section separators and the three markers.

<?php

declare(strict_types=1);

namespace App\Examples\Comments\Presenters;

use Nette\Application\Attributes\Persistent;

use App\Core\Base\Admin\BasePresenter;
use App\Core\Traits\Admin\Presenters\AdminEditFormComponentsTrait;
use App\Core\Traits\Admin\Presenters\AdminFormHelpersTrait;
use App\Core\Utils\Filters\EqualFilter;
use App\Examples\Comments\Models\Entities\Zone;
use App\Examples\Comments\Models\Managers\ZoneManager;
use App\Examples\Comments\Presenters\Traits\Forms\ZoneFormTrait;
use App\UI\Admin\System\Components\DataGridComponent\DataGrid;
use App\UI\Admin\System\Components\DataGridComponent\DataGridFactory;

/**
 * Warehouse zone administration: list, add and edit (`/admin/store/zone`).
 *
 * A zone groups stock locations of one warehouse. Deleting a zone is a soft delete;
 * locations keep their zone reference so that stock documents stay consistent.
 */
final class ZonePresenter extends BasePresenter
{
    use ZoneFormTrait;                // createComponentEditForm(), saving
    use AdminFormHelpersTrait;        // getFormLanguages(), str()
    use AdminEditFormComponentsTrait; // createComponentAdminLangNav(), createComponentAdminFormActions()

    protected string $translatorDomain = 'admin.store.presenters.zone';

    /** Warehouse the list is filtered by; 0 means all warehouses. */
    #[Persistent]
    public int $warehouseId = 0;

    /** Edited zone (null when adding). */
    private ?Zone $editedZone = null;

    public function __construct(
        private readonly DataGridFactory $dataGridFactory,
        private readonly ZoneManager $zoneManager,
    ) {
        parent::__construct();
    }

    // =========================================================================
    // Actions
    // =========================================================================

    public function actionAdd(): void
    {
        // IMPORTANT: server-side gate. The action is reachable by a typed URL even without
        // the privilege; checkCurrentPrivilege() derives the key from the presenter and
        // action name, so a typo cannot bypass it.
        $this->checkCurrentPrivilege();
    }

    public function actionEdit(int $id): void
    {
        $this->checkCurrentPrivilege();
        $this->editedZone = $this->zoneManager->find($id)->include(['translations'])->execute();
        if ($this->editedZone === null) {
            $this->error();
        }
    }

    public function renderEdit(int $id): void
    {
        $this->template->zone = $this->editedZone;
    }

    // =========================================================================
    // Components
    // =========================================================================

    protected function createComponentGrid(): DataGrid
    {
        $grid = $this->dataGridFactory->create();
        $grid->setDataSource($this->zoneManager->findAll($this->buildListFilters()));

        // NOTE: the warehouse column is hidden on purpose when the list is filtered by one
        // warehouse; it would repeat the same value in every row.
        $grid->addColumnText('warehouse', 'columns.warehouse')
            ->setVisible($this->warehouseId === 0);

        return $grid;
    }

    // =========================================================================
    // Helpers
    // =========================================================================

    /**
     * @return list<EqualFilter>
     */
    private function buildListFilters(): array
    {
        if ($this->warehouseId === 0) {
            return [];
        }

        // TODO: move the filter into ZoneManager::findByWarehouse() once the Front list
        // needs the same query.
        return [new EqualFilter('warehouse', $this->warehouseId)];
    }
}

Example: a core class#

A fictional service from App\Core. It shows a class docblock with a Usage: block, a label over a group of constants, @var with an array shape, method docblocks describing parameters, the return value and the exception, a deprecated method, and methods that need no docblock at all.

<?php

declare(strict_types=1);

namespace App\Examples\Comments\Core;

use Nette\Utils\DateTime;

use Doctrine\DBAL\Connection;

use App\Core\Utils\Clock;
use App\Examples\Comments\Core\Exceptions\LockException;

/**
 * Named locks for operations that must not run twice at the same time.
 *
 * A lock is a database row keyed by name. It is held by the request that created it and
 * released on `release()` or at the end of the request, whichever comes first. Locks are
 * fail-open: when the lock table does not exist, `acquire()` returns true and nothing
 * is protected.
 *
 * Usage:
 * ```php
 * if ($locks->acquire('order:' . $orderId)) {
 *     $this->processOrder($orderId);
 * }
 * ```
 *
 * @see \App\UI\Cron\System\Presenters\SchedulerPresenter
 */
final class LockRegistry
{
    /** Lock states as stored in `cms_system_locks.state`. */
    public const
        StateFree = 0,
        StateHeld = 1;

    /** @var array<string, int> acquired lock names mapped to their row ids */
    private array $held = [];

    public function __construct(
        private readonly Connection $connection,
        private readonly Clock $clock,
    ) {
    }

    /**
     * Acquires the named lock and returns whether the caller now holds it.
     *
     * The call does not wait: a lock held by another request returns false immediately.
     *
     * @param string $name lock name, unique within the application (`order:<id>`)
     * @param int $ttlSeconds how long the lock survives a crashed request, in seconds
     * @return bool true also when the lock table does not exist (fail-open)
     * @throws LockException when the lock table exists but cannot be written
     */
    public function acquire(string $name, int $ttlSeconds = 60): bool
    {
        // Expired rows go first, otherwise a crashed request would keep its lock until
        // someone deletes the row by hand.
        $this->purgeExpired();

        if (!$this->tableExists()) {
            return true;
        }

        $expiresAt = DateTime::from($this->clock->now())->modifyClone('+' . $ttlSeconds . ' seconds');
        try {
            $this->connection->insert('cms_system_locks', [
                'name' => $name,
                'state' => self::StateHeld,
                'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
            ]);
        } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
            return false;
        } catch (\Doctrine\DBAL\Exception $e) {
            throw new LockException('Cannot write lock "' . $name . '".', 0, $e);
        }

        $this->held[$name] = (int) $this->connection->lastInsertId();
        return true;
    }

    /**
     * Releases the named locks; names that are not held by this request are ignored.
     *
     * @param list<string> $names
     */
    public function release(array $names): void
    {
        foreach ($names as $name) {
            if (!isset($this->held[$name])) {
                continue;
            }
            $this->connection->delete('cms_system_locks', ['id' => $this->held[$name]]);
            unset($this->held[$name]);
        }
    }

    public function isHeld(string $name): bool
    {
        return isset($this->held[$name]);
    }

    /**
     * @deprecated use isHeld()
     */
    public function has(string $name): bool
    {
        return $this->isHeld($name);
    }

    private function purgeExpired(): void
    {
        if (!$this->tableExists()) {
            return;
        }
        $this->connection->executeStatement(
            'DELETE FROM cms_system_locks WHERE expires_at < ?',
            [$this->clock->now()->format('Y-m-d H:i:s')],
        );
    }

    private function tableExists(): bool
    {
        return $this->connection->createSchemaManager()->tablesExist(['cms_system_locks']);
    }
}

Anatomy of a method docblock#

/**
 * Acquires the named lock and returns whether the caller now holds it.
 *
 * The call does not wait: a lock held by another request returns false immediately.
 *
 * @param string $name lock name, unique within the application (`order:<id>`)
 * @param int $ttlSeconds how long the lock survives a crashed request, in seconds
 * @return bool true also when the lock table does not exist (fail-open)
 * @throws LockException when the lock table exists but cannot be written
 */
public function acquire(string $name, int $ttlSeconds = 60): bool
  1. Summary: one sentence, third-person verb, also says what the method returns.
  2. Description (optional, up to 5 lines): preconditions, side effects, behavior on failure.
  3. Tags in the order @param → @return → @throws → @see → @internal → @deprecated.

The smallest possible form is the summary alone:

/**
 * Returns the zone by its id, or null when it does not exist.
 */
public function find(int $id): ?Zone

And this is a redundant comment — the signature already carries the type and the name, the tag adds nothing:

/** @param int $id ID */           // delete
public function find(int $id): ?Zone

Parameters and return values#

The signature is the source of truth for types. A tag is written when it adds meaning or a narrower type, always as type, name, description. A tag that would carry only the native type is not written.

When to describe a parameter Example
units @param int $ttlSeconds … in seconds
the meaning of a special value @param int $limit 0 means no limit
the shape of the value @param string $name lock name (order:)
a narrower array type @param list<int> $ids, @param array<string, mixed> $options

The summary names what the method returns. @return is added when a narrower type is needed or when the meaning of the variants is not obvious:

Return type What must be clear
bool what true and what false mean
?Entity when null comes — it does not exist, or the user may not see it
array the shape: list<Zone>, array<string, int>, array{id: int, name: string}; never a bare array
int what the number is: a count, an id, a position
void, static nothing; @return void is not written
Result which codes it may carry and when (instead of exceptions)

The same applies to properties: @var only with an array shape or a generic, and then with a description (@var array<string, int> number of items grouped by category key).

Exceptions and side effects#

  • @throws Exception when <condition> for exceptions the method deliberately throws or propagates as part of its contract, in every layer and visibility. Technical exceptions of dependencies that the method does not handle are not listed.
  • When a method returns a Result with codes instead of throwing, the description names the codes.
  • The documentation matches the behavior: a method returning null does not claim to throw.
  • Side effects belong in the description when not knowing them would lead to wrong use: writing or deleting data, changing entity state, cache invalidation, network or internal API calls, privilege checks, behavior when a dependent service is down (fail-open, fail-closed).

Deprecated API and references#

  • @deprecated use Y::method() — the replacement must exist; no "since" and no date.
  • A reference to a class or method: {@see Class::method()}.
  • A long explanation is represented in the docblock only by @see https://manual.coolsoft.cz/…, at most one, and only to an existing page.

What the examples show#

Element Rule
Before namespace nothing — no license, author, file name or date
Class docblock directly above class; first line = one sentence with a period saying what the class is; then a description of usually up to 10 lines: contract, invariants, traps. It may be longer only when every line is contract; explanation (why it exists, comparisons, plans) goes to the manual and the docblock links to it
Presenter the summary also carries the URL: … (/admin/store/zone)
Usage: a code block only where the usage is not obvious from the signature (attributes, components, services) — in place of the @example tag
use XxxTrait; trailing comment = the members the trait provides, methods with ()
Property one-line /** … */; @var only with an array shape or a generic, then with a description
Constants one label over the group, not over each constant
Line // comment above the code, 1–3 lines, a full sentence with a period; explains why and with what consequence
Section separators a three-line // === bar, named by method role (Actions, Components, Helpers); only in longer files
Identifiers in backticks, methods with ()
Class tag order @template → @extends / @implements → @use → @property / @method → @see → @internal → @deprecated

Markers#

Marker When Example
IMPORTANT: breaking it = a bug, a security hole, data loss; always with the consequence // IMPORTANT: server-side gate. Without it the action is reachable by URL.
NOTE: non-obvious but harmless — a deliberate deviation, surprising behavior // NOTE: the warehouse column is hidden on purpose …
TODO: a known gap and what to do; no date, name or ticket number // TODO: move the filter into ZoneManager::findByWarehouse() …

A marker opens the comment, in capitals with a colon, at most one per comment. Other markers (FIXME, HACK, XXX), emoji and exclamation marks are not used.

What does not belong in a comment#

  • history: "previously", "originally", dates, finding and ticket numbers, names, links to internal notes — the rule that follows from the history stays,
  • repeating the code (// save entity above $manager->save()) and tags repeating the signature,
  • commented-out code,
  • headings, tables, long "why it exists" essays — those belong in the manual,
  • emphasis in capitals (NEVER, ONLY); emphasis is carried by a marker or the sentence,
  • Czech, even in quotes; UI text is cited by its translation key.

What is checked#

Check Tool
only comments changed — the code tokens are unchanged tests/Manual/Comments/comment_guard.php
Czech diacritics and emoji in comments comment_guard.php --strict
tag type matches the signature, generics syntax, unknown classes in tags PHPStan
forbidden markers, headings and tables in docblocks, @example, @author, a comment before namespace, emphasis in capitals comment_guard.php --strict (extension in preparation)
tags repeating the native type Rector, in bulk per area
unresolved {@see} and tags the generator cannot handle phpdoc --validate per area

The absence of a docblock on getters, constructors and action* is not checked: it is not required and not forbidden. Whether the text matches the behavior is verified only by review.

Where to look#

I want Where
the binding wording and the glossary skills/code-standard/cs-comments/SKILL.md
the example files from this page skills/code-standard/cs-comments/examples/
the file skeleton and method naming Code conventions

Related chapters: Code conventions · Five-layer model · Tests and tools

Cookie settings