Developers

Code guidelines

The principles behind the codebase — thin API Platform glue, a service layer that owns the rules, and no EntityManager outside services.

These are the rules the codebase is written by. PRs are reviewed against them — following the pattern next to the code you’re touching is almost always the right call.

Principles

  • One obvious place for everything. Business rules live in services, wire formats in DTOs, authorization in voters and services — never sprinkled across controllers or listeners.
  • Boring and explicit beats clever. Clear names over comments, small focused classes, framework magic only where the framework expects it.
  • No comments unless a genuine footgun needs one. Code should read without narration; a comment is reserved for a constraint the code cannot express, kept to a line.
  • Every behavior change ships with a test. See Testing for the suites and how to run them.

API Platform stays thin

Endpoints are API Platform resources. Each operation points to a custom state class — src/State/<Domain>/Provider/ for reads, src/State/<Domain>/Processor/ for writes — and those classes are glue only: resolve arguments, call a service, shape the response. If a provider or processor grows an if about business rules, the rule is in the wrong place.

Input DTOs carry #[Assert\…] constraints for format validation (types, lengths, enums, regexes) — API Platform runs them at the boundary automatically. Anything that needs a lookup, compares fields, or depends on the moment of the action is an invariant and belongs in the service, thrown as a domain exception (implements DomainExceptionInterface) so the exception listener maps the HTTP status and translated message.

The service layer owns the rules

Services in App\Service\<Domain>\ are the single source of truth. Every mutator follows the same shape, in order:

public function addMember(UserGroup $group, User $user): UserGroup
{
    // 1. authorization — one greppable call
    $this->security->throwAccessDeniedUnlessGranted(
        UserGroupVoter::MANAGE,
        $group,
        "You don't have permission to add members to this group.",
    );

    // 2. invariants — domain exceptions, never framework ones
    if ($group->hasMember($user)) {
        throw new UserAlreadyGroupMemberException();
    }

    // 3. persist
    $group->addMember($user);

    return $this->save($group);
}
  • Authorization first, one greppable call, via the injected SecurityContext collaborator (throwAccessDeniedUnlessGranted() for voter-backed checks, plus …UnlessAdmin(), …UnlessCommunityAdmin(), …UnlessCommunityModOrAdmin(), …UnlessAuthenticated()) — never inline AccessDeniedException. Denials on hidden resources map to a not-found exception so private content doesn’t leak its existence.
  • Invariants second, throwing domain exceptions.
  • Persistence last.

The current actor is read from SecurityContext inside the service — don’t pass a User $actor through signatures. Bot and system flows wrap the call in UserContextService::runAs($bot, fn () => …).

No EntityManager outside services

Only the service layer touches persistence. Services extend AbstractDoctrineService, which provides save() (optionally applying a DTO), removeAndFlush(), and transactional() — that is the whole EntityManager surface most code ever needs. Controllers, processors, listeners, voters and entities never see the EntityManager.

Repositories follow the same discipline: a service may inject repositories from its own domain; data from another domain comes through that domain’s *ServiceInterface, never by reaching into a foreign repository.

Why so strict?

Every rule about a domain — who may act, what state is legal, what gets flushed — has exactly one home. Reviews, audits and refactors only ever need to look in one place, and nothing bypasses the checks.

Authorization

  • One voter per primary entity, attributes uppercase and entity-prefixed (CHANNEL_VIEW, USER_GROUP_MANAGE). Global admin short-circuits early.
  • Voters don’t call other voters — cross-entity checks go through AccessDecisionManagerInterface::decide().
  • Reused checks get a voter; a single-use, single-condition check may be inlined in the service.

Domain stays framework-free

Entities and DTOs never read Security, the request, or the container. Enums live under App\Enum\<Domain>\. Value objects are new-ed, not injected, and live in App\Dto\<Domain>\; static helpers in App\Utils\. The public surface of an integration uses the generic domain name (VoiceServiceInterface, RealtimePublisherInterface); only the implementation carries the vendor (LiveKitVoiceService, MercurePublisher) — swapping a vendor never ripples through consumers.

Operator-tunable knobs go in the database-backed settings registry (src/Settings/Settings.php, one method per setting) — not in env variables. Env is reserved for infrastructure wiring; see Advanced configuration.

Client-side conventions

The SPA follows the same separation instinct:

  • Raw HTTP lives in src/api/ (one module per domain), query hooks and mutations in src/queries/, cache keys centralised in queryKeys.ts. Components never call fetch.
  • Server state belongs to TanStack Query; component state stays local. Realtime events mutate the query cache — no polling.
  • Components are presentational where possible: parents own query state, children receive data and callbacks.
  • Every user-facing string goes through i18next — no hardcoded copy.
  • Same comment policy as the backend: none, unless a footgun demands it.