Skip to main content

PSAPLink Cloud — Conventions

PHP Style

  • declare(strict_types=1) in every file, no exceptions.
  • PHP 8.2+ features are expected: readonly properties, constructor property promotion, enums, named arguments, first-class callable syntax.
  • Use final on service classes unless there is a concrete reason not to (most services should be final).
  • All public methods must have declared return types.
  • No @var docblocks where a type declaration suffices.
  • PSR-12 enforced via PHP CS Fixer (run on CI).
  • No static methods on services. Use constructor injection.

Doctrine ORM

  • Mapping via PHP 8 attributes (#[ORM\Entity], #[ORM\Column], etc.). No YAML or XML mapping.
  • All PKs are UUID, generated via doctrine.uuid_generator. Use Symfony\Component\Uid\Uuid.
  • All timestamps are datetimetz_immutable (maps to timestamptz in PostgreSQL). Never use datetime or timestamp. Store UTC only.
  • Lifecycle callbacks for timestamps:
#[ORM\HasLifecycleCallbacks]
class MyEntity
{
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->createdAt = $now;
$this->updatedAt = $now;
}

#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
}
}
  • All repositories extend AgencyAwareRepository. Every query that touches agency-scoped data must go through this base class — it enforces tenant isolation automatically.
  • Append-only tables (incident_communications, audit_logs) must not have updated_at, and their repositories must not expose update or delete methods.

Controllers

  • Live in src/Controller/Api/V1/. One class per resource. Sub-directories for logical groupings (e.g., Core/ for hub federation).
  • Controllers are thin. No business logic in controllers — validate DTO, call service, return response.
  • Use #[Route] attributes only. No YAML or XML routes.
  • Return JSON via ApiResponse helper:
return ApiResponse::success($data, 201);
return ApiResponse::error('Notification not found', 404);
return ApiResponse::validationError($violations);
  • Document every endpoint with OpenApi #[OA\...] attributes. The nelmio/api-doc-bundle generates the spec from these.
  • Authenticate via Symfony security — use #[IsGranted] attributes or Voters. Never check role strings directly in controller logic.

Services

  • One responsibility per service class.
  • Constructor injection only. No service locator, no static methods.
  • Live in src/Service/. Sub-directories match domain groupings (Transport/, Ack/, Cfs/, Escalation/, Communication/).

The Three Sole-Path Rules

These are non-negotiable. Violating any of them introduces bugs that are difficult to audit and may silently corrupt the communication record.

1. CommunicationLogger is the only path to incident_communications.

Every notification sent, every ACK received, every reply, every escalation — all go through CommunicationLogger::log(). Never write to incident_communications directly. Never inject EntityManagerInterface in a controller or service and persist a communication record manually.

2. AckIngestionService is the only path to changing notification.ack_status.

Any code that needs to mark a notification acknowledged must call AckIngestionService. It is the only place in the codebase that sets ack_status, acked_at, ack_source, and escalation_cancelled_at. It also dispatches the EscalationCancelMessage and calls CommunicationLogger.

3. ACK cross-stream rule.

When AckIngestionService ACKs a notification, it marks ALL NotificationDelivery records for that notification as acknowledged — not just the one that triggered the ACK. It then logs a single AckReceived communication event whose content names the triggering stream and the total stream count:

"ACK received via sms_reply — all 3 delivery streams for this notification marked acknowledged"

Partial acknowledgement does not exist. A user is either fully ACK'd or pending.


Enums

Use backed string enums for all domain values. Key enums:

enum IncidentPriority: string   { case Irod = 'irod'; case Critical = 'critical'; ... }
enum AckStatus: string { case Pending = 'pending'; case Acked = 'acked'; }
enum AckSource: string { case Web = 'web'; case SmsReply = 'sms_reply'; ... }
enum ReplyStatus: string { case None = 'none'; case Replied = 'replied'; ... }
enum SendStatus: string { case Pending = 'pending'; case Sent = 'sent'; ... }
enum DeliveryMode: string { case Parallel = 'parallel'; case Sequential = 'sequential'; }
enum AgencyType: string { case Psap = 'psap'; case Company = 'company'; }
enum CommunicationType: string { case NotificationSent = 'notification_sent'; ... }
enum AckMode: string { case Poll = 'poll'; case Callback = 'callback'; }

Migrations

  • Add a new migration for every entity or schema change. Never edit a committed migration.
  • Use timestamped versions (default Doctrine behavior): php bin/console doctrine:migrations:diff
  • Migration file names are timestamped. Do not rename them.
  • If a migration needs data back-fills, add them in a separate migration after the schema change.

Idempotency — mandatory

Every migration up() and down() must be safe to re-run. CI enforces this via grep.

OperationRequired form
ADD COLUMNADD COLUMN IF NOT EXISTS
DROP COLUMNDROP COLUMN IF EXISTS
CREATE TABLECREATE TABLE IF NOT EXISTS
DROP TABLEDROP TABLE IF EXISTS
CREATE INDEX / CREATE UNIQUE INDEXCREATE INDEX IF NOT EXISTS
DROP INDEXDROP INDEX IF EXISTS

ADD CONSTRAINT — PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS syntax. Wrap in a DO block:

DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_my_constraint') THEN
ALTER TABLE my_table ADD CONSTRAINT fk_my_constraint FOREIGN KEY ...;
END IF;
END $$;

DROP CONSTRAINT — use:

ALTER TABLE my_table DROP CONSTRAINT IF EXISTS fk_my_constraint;

Naming

ThingConventionExample
ClassesPascalCaseNotificationDeliveryService
InterfacesPascalCase + InterfaceTransportHandlerInterface
EnumsPascalCaseIncidentPriority
MethodscamelCasedispatchDeliveries()
VariablescamelCase$agencyId, $deliveryMode
DB tablessnake_case pluralnotification_deliveries
DB columnssnake_casesender_timezone, acked_at
Routeskebab-case/api/v1/cfs-configurations
Env varsUPPER_SNAKECFS_DEFAULT_BASE_PATH

Timestamps

  • All DB timestamps: timestamptz UTC. Use datetimetz_immutable Doctrine type. No exceptions.
  • All API responses: ISO 8601 UTC strings (.format(\DateTimeInterface::ATOM)).
  • CommunicationLogger captures sender_timezone and receiver_timezone from user profiles as tz strings for display context — the stored timestamps are still UTC.
  • Frontend converts UTC to local time using users.timezone for display only. The database never stores local time.

Security

  • JWT: 15-min access token + 7-day refresh token. Issued at login; refresh rotates the token.
  • API keys: SHA-256 hashed. Raw key shown exactly once at creation.
  • transport_channels.config and user_transport_preferences.config_override: encrypted at rest using libsodium before persist(). Decrypted only at send time inside the transport handler.
  • Inbound transport webhooks (SMS, email, webhook ACK): validate the provider signature before calling any service. Never process an unsigned inbound payload.
  • Never expose transport credentials in API responses, logs, or payload_snapshot. Strip credentials from payloads before passing to CommunicationLogger.
  • All authorization decisions via Symfony Voters. Never check hasRole('ROLE_PSAP_ADMIN') directly in a controller.
  • All queries scoped via AgencyAwareRepository.

Testing

  • tests/Unit/ — service tests with mocked dependencies.
  • tests/Functional/WebTestCase against real routes and a test PostgreSQL database.
  • Every endpoint needs tests for: happy path, authentication failure (401), permission failure (403), and at least one validation error (400/422).
  • CFS tests must cover: valid XML, duplicate file hash (dedup), malformed XML, missing field mappings.
  • ACK flow test must cover: send → web ACK, send → inbound SMS ACK, send → timeout → escalate → ACK cancels escalation.
  • Foundry factories for all entities. No raw SQL inserts in tests.
  • Test database is configured separately — see documents/running-tests.md.

Code Review Checklist

  • declare(strict_types=1) present in every new file
  • Controller is thin — no business logic
  • DTO used for request validation
  • Agency scope enforced on all queries (AgencyAwareRepository)
  • Migration added for any entity change
  • Communication events go through CommunicationLogger::log() only
  • ACK changes go through AckIngestionService only
  • ACK cross-stream rule applied (all deliveries marked, log message names stream + count)
  • NotificationDelivery records created alongside every Notification
  • CFS config paths validated before use; errors written to last_poll_error
  • Transport credentials stripped from payloads before logging
  • All timestamps UTC (datetimetz_immutable)
  • Audit event emitted for destructive or sensitive actions
  • Tests added or updated (happy path + auth failure + permission failure)
  • OpenApi attributes on new controller methods
  • Encrypted fields accessed only via service layer (never raw DB queries that bypass the listener)
  • New PII fields added to SensitiveFieldEncryptionListener and EncryptExistingFieldsCommand

Encrypted Fields

All sensitive fields are encrypted transparently by SensitiveFieldEncryptionListener using EncryptionService (XChaCha20-Poly1305, per-agency subkey). Ciphertext format: v{n}:<base64>.

TableEncrypted fields
incidentstitle, description, address, city, state, latitude, longitude, location_raw, close_reason, assistance_requested
incident_communicationscontent
cfs_incidentscaller_name, caller_phone, description, address, nearest_cross_streets, dispatcher_name, raw_xml

Rules:

  • Never write encrypted fields via raw SQL in application code (only in migration back-fill commands where the Doctrine listener is deliberately bypassed)
  • Never log, serialize to JSON, or include in API responses without decryption going through the service layer
  • Never search (LIKE, full-text) on encrypted fields — use a blind index or denormalized searchable column
  • See docs/security/encryption-architecture.md for the full architecture and compliance mapping