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
SecurityContextcollaborator (throwAccessDeniedUnlessGranted()for voter-backed checks, plus…UnlessAdmin(),…UnlessCommunityAdmin(),…UnlessCommunityModOrAdmin(),…UnlessAuthenticated()) — never inlineAccessDeniedException. 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.
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 insrc/queries/, cache keys centralised inqueryKeys.ts. Components never callfetch. - 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.