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
finalon service classes unless there is a concrete reason not to (most services should be final). - All public methods must have declared return types.
- No
@vardocblocks 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. UseSymfony\Component\Uid\Uuid. - All timestamps are
datetimetz_immutable(maps totimestamptzin PostgreSQL). Never usedatetimeortimestamp. 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 haveupdated_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
ApiResponsehelper:
return ApiResponse::success($data, 201);
return ApiResponse::error('Notification not found', 404);
return ApiResponse::validationError($violations);
- Document every endpoint with OpenApi
#[OA\...]attributes. Thenelmio/api-doc-bundlegenerates 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.
| Operation | Required form |
|---|---|
ADD COLUMN | ADD COLUMN IF NOT EXISTS |
DROP COLUMN | DROP COLUMN IF EXISTS |
CREATE TABLE | CREATE TABLE IF NOT EXISTS |
DROP TABLE | DROP TABLE IF EXISTS |
CREATE INDEX / CREATE UNIQUE INDEX | CREATE INDEX IF NOT EXISTS |
DROP INDEX | DROP 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
| Thing | Convention | Example |
|---|---|---|
| Classes | PascalCase | NotificationDeliveryService |
| Interfaces | PascalCase + Interface | TransportHandlerInterface |
| Enums | PascalCase | IncidentPriority |
| Methods | camelCase | dispatchDeliveries() |
| Variables | camelCase | $agencyId, $deliveryMode |
| DB tables | snake_case plural | notification_deliveries |
| DB columns | snake_case | sender_timezone, acked_at |
| Routes | kebab-case | /api/v1/cfs-configurations |
| Env vars | UPPER_SNAKE | CFS_DEFAULT_BASE_PATH |
Timestamps
- All DB timestamps:
timestamptzUTC. Usedatetimetz_immutableDoctrine type. No exceptions. - All API responses: ISO 8601 UTC strings (
.format(\DateTimeInterface::ATOM)). CommunicationLoggercapturessender_timezoneandreceiver_timezonefrom user profiles as tz strings for display context — the stored timestamps are still UTC.- Frontend converts UTC to local time using
users.timezonefor 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.configanduser_transport_preferences.config_override: encrypted at rest using libsodium beforepersist(). 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 toCommunicationLogger. - 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/—WebTestCaseagainst 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
AckIngestionServiceonly - ACK cross-stream rule applied (all deliveries marked, log message names stream + count)
-
NotificationDeliveryrecords created alongside everyNotification - 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
SensitiveFieldEncryptionListenerandEncryptExistingFieldsCommand
Encrypted Fields
All sensitive fields are encrypted transparently by SensitiveFieldEncryptionListener using
EncryptionService (XChaCha20-Poly1305, per-agency subkey). Ciphertext format: v{n}:<base64>.
| Table | Encrypted fields |
|---|---|
incidents | title, description, address, city, state, latitude, longitude, location_raw, close_reason, assistance_requested |
incident_communications | content |
cfs_incidents | caller_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.mdfor the full architecture and compliance mapping