Skip to content

YourBOX Architecture

YourBOX gathers a Symfony 7 application under app/ and the legacy 2020 codebase under YourBOX/. The Symfony kernel owns routing, dependency injection, security and templating. The legacy tree is mounted as a fallback bundle that serves any URL the new controllers do not yet claim.

flowchart LR
Browser[User browser] -- HTTPS --> Edge[Traefik or nginx HTTPS termination]
Edge --> PhpFpm[php-fpm under Symfony kernel]
PhpFpm --> Router[Symfony router]
Router -- new route --> Controllers[Symfony controllers]
Router -- no match --> Legacy[LegacyController fallback]
Legacy -- include --> Files[YourBOX/src/**/*.php]
Controllers --> Domain[Bounded contexts]
Domain --> Adapters[Doctrine, Meilisearch, Flysystem, Mailer, OIDC]

The strangler bundle is a deliberate intermediate state. As each route is rewritten, the matching legacy file is removed and the new controller wins on its URL. The migration order is documented in the Symfony Migration page of this service section.

The application code is organized into four bounded contexts under app/src/<Context>/:

flowchart TB
subgraph Catalog[Catalog]
CatalogD[Domain: Video, Tag, MediaFile]
CatalogA[Application: UploadVideo, PublishVideo, ListCatalog]
CatalogI[Infrastructure: Doctrine, Flysystem, Meilisearch]
CatalogU[UI: VideoController, Twig templates]
end
subgraph Engagement[Engagement]
EngagementD[Domain: Comment, Vote]
EngagementA[Application: PostComment, CastVote, MapThread]
EngagementI[Infrastructure: Doctrine repositories]
EngagementU[UI: CommentController, vote endpoints]
end
subgraph Identity[Identity]
IdentityD[Domain: User, Role, Profile]
IdentityA[Application: RegisterUser, MirrorOidcSubject]
IdentityI[Infrastructure: Doctrine, Authentik claim mapper]
IdentityU[UI: ProfileController, OIDC callback]
end
subgraph Moderation[Moderation]
ModerationD[Domain: Report, AdminAction]
ModerationA[Application: ReviewReport, DeleteVideo, BanUser]
ModerationI[Infrastructure: Doctrine, audit log writer]
ModerationU[UI: EasyAdmin dashboards]
end
Catalog -- domain event: video published --> Engagement
Catalog -- domain event: video published --> Moderation
Engagement -- domain event: comment flagged --> Moderation
Identity -- domain event: role changed --> Moderation

Cross context communication uses Symfony Messenger transports. Events propagate asynchronously. Commands inside a context run synchronously by default and switch to async only for heavy work such as video transcoding.

PostgreSQL 16 holds users, videos, comments and votes. Doctrine migrations rebuild the schema with foreign keys, partial indexes and a unique constraint on (votes.ref, votes.ref_id, votes.user_id). Meilisearch holds a denormalized projection of the video catalog and is refreshed by a Messenger consumer.

flowchart LR
Controllers -- write --> PG[(PostgreSQL 16)]
PG -- domain event --> Bus[Symfony Messenger]
Bus -- VideoIndexerHandler --> Meili[(Meilisearch)]
Bus -- TranscodeVideo --> Worker[FFmpeg worker]
Worker -- HLS segments --> Storage
Controllers -- read catalog --> PG
Controllers -- search query --> Meili
Controllers -- signed read URL --> Storage[(MinIO or Azure Blob)]

The replication topology is delegated to the database provider. Azure attaches a read replica to the Flexible Server. CloudNativePG in the homelab runs a primary plus a hot standby with WAL archiving to MinIO. The application reads from the primary today and will route read only queries to the replica once the read endpoint is declared.

Videos enter the catalog through the upload flow. The application writes the original file to object storage under videos/YYYY/MM/<ulid>/source.<ext>, then dispatches a TranscodeVideo message. The Messenger consumer runs FFmpeg, writes the HLS manifest and segments under videos/YYYY/MM/<ulid>/hls/index.m3u8, then updates the database row with the manifest key and duration.

sequenceDiagram
participant Browser
participant Controller
participant Storage as Object storage
participant Bus as Messenger bus
participant Worker as FFmpeg worker
participant PG as PostgreSQL
participant Meili as Meilisearch
Browser->>Controller: POST upload (multipart)
Controller->>Storage: PUT source key
Controller->>PG: INSERT video row (status pending)
Controller->>Bus: TranscodeVideo message
Controller-->>Browser: 202 Accepted with status URL
Bus->>Worker: dispatch
Worker->>Storage: GET source key
Worker->>Worker: ffmpeg transcode to HLS
Worker->>Storage: PUT manifest and segments
Worker->>PG: UPDATE video row (status published, manifest key)
Worker->>Bus: VideoPublished event
Bus->>Meili: reindex video document

Playback uses HLS. The browser requests the manifest URL and signed segment URLs through the Flysystem adapter. The HTTPS layer is provided by Traefik on the VPS and on the homelab Kubernetes cluster, by the Azure App Service in Azure, and by Traefik with a local self signed certificate on docker compose.

In production the Symfony Security firewall stack delegates authentication to Authentik through OIDC. The first time a subject logs in, the application creates a row in its local user table linking the subject by oidc_sub. Subsequent logins refresh display_name, email and last_seen_at.

flowchart LR
Browser -- /login --> Symfony[Symfony firewall]
Symfony -- redirect --> Authentik
Authentik -- callback with code --> Symfony
Symfony -- token exchange --> Authentik
Symfony -- find or create by oidc_sub --> PG[(PostgreSQL users)]
Symfony -- session cookie --> Browser

A second firewall is enabled when APP_ENV is dev or vps. It exposes a form login backed by Argon2id password hashes in the same user table, seeded by a Doctrine fixture with admin and user accounts.

The same container image deploys to four targets. Each target binds the application to its env contract from ADR-0008 and to the storage adapter from ADR-0005.

flowchart TD
Image[GitLab registry: yourbox:tag@digest]
Image --> Compose[docker compose: workstation]
Image --> VPSAnsible[VPS via Ansible playbook]
Image --> Azure[Azure Web App for containers]
Image --> K8s[Kubernetes via Argo CD]
Compose --> ComposeStack[PostgreSQL, Meilisearch, MinIO, Mailpit, Traefik]
VPSAnsible --> VPSStack[Same compose, Traefik with ACME, SOPS decryption on tmpfs]
Azure --> AzureStack[Azure Database for PostgreSQL, Azure Blob, App Service]
K8s --> K8sStack[CloudNativePG, Meilisearch chart, MinIO chart, sealed secrets]

Each target runs the same smoke test: the health endpoint returns 200, an upload followed by transcoding and playback succeeds, and an OIDC or form login completes. The test runs in CI for docker compose, in the deployment job for the VPS, in a post deploy job for Azure, and through a Kubernetes job in the homelab.

  • The container image is stateless. The application filesystem can be read only at runtime in production.
  • The legacy YourBOX/ tree is treated as untrusted from a security review standpoint during the transition. Authentik, network policies and the WAF mitigate that risk until the strangler bundle empties.
  • Cross context coupling goes through published domain events only. Reviewers reject direct method calls across contexts.
  • Image references use the immutable form <registry>/<image>:<tag>@sha256:<digest> as mandated by ADR-0009 and the Plumber configuration.