Files
pictureFrame-webApp/_bmad-output/planning-artifacts/architecture.md
T
football2801 94dae685e2 docs: update architecture to reflect Vue 3 SPA frontend decision
Replace AssetMapper + Stimulus + Turbo with Vue 3 SPA (Vite,
TypeScript strict, SCSS modules, Konva.js). Authenticated app is
now a full SPA served by Symfony catch-all; public flows (provisioning,
email approve/decline) remain Symfony + Twig. Add JSON API controllers
for SPA, SpaController catch-all, updated directory structure, and
revised implementation sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 21:57:06 -04:00

33 KiB
Raw Blame History

stepsCompleted, lastStep, status, completedAt, inputDocuments, workflowType, project_name, user_name, date
stepsCompleted lastStep status completedAt inputDocuments workflowType project_name user_name date
1
2
3
4
5
6
7
8
8 complete 2026-04-27
prd.md
architecture pictureFrame Matt.edholm 2026-04-27

Architecture Decision Document

This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together.

Project Context Analysis

Requirements Overview

Functional Requirements: 44 FRs across 8 domains — User & Account Management, Device Management, Image Library, Image Approval & Sharing, Device Provisioning, Image Rotation & Cycle Engine, Display & Status, Admin & Moderation.

Non-Functional Requirements:

  • Image pre-rendering completes within 10 seconds of upload/approval trigger (async job target)
  • Device image pull endpoint ≤10s on home broadband
  • Web pages load ≤3s on standard broadband
  • Image rotation ±5 minutes of configured interval
  • HTTPS all device-server communication
  • MAC-authenticated device endpoint
  • Single-use authorization links with configurable TTL
  • Scheduled cleanup without manual intervention
  • Zero blank screens; last image persists through outages

Scale & Complexity:

  • Primary domain: Full-stack web app with IoT device client
  • Complexity level: Medium
  • Deployment target: Single VPS, solo developer, personal-scale traffic
  • Estimated architectural components: Web app (Symfony), Async image processing worker, Device API endpoint, Email/token service, Scheduler/cron runner, ESP32 firmware (separate codebase)

Technical Constraints & Dependencies

  • Stack: Symfony (PHP), PostgreSQL, Nginx-FPM, DDEV local dev — mirrors aqua-iq
  • APP_BASE_URL is a firmware build constant — domain must be established before firmware development
  • API contract is stable by design; breaking changes require physical reflash of all deployed devices
  • No OTA firmware updates in V1
  • Infrastructure patterns from ~/src/aqua-iq (DDEV, server, SSH)

Cross-Cutting Concerns Identified

  • Async image processing pipeline: Upload/approval dispatches a Symfony Messenger message → worker processes (resize, 4bpp palette quantization) → sets image rendering_status to ready. Images only enter device pull pool when ready. Doctrine transport (consistent with aqua-iq).
  • Image rendering status: Each image asset tracks pending → processing → ready | failed per device model/orientation. Rotation engine and device pull endpoint filter exclusively on ready.
  • Device identity: MAC address as device identifier throughout — provisioning, image pull auth, ownership transfer.
  • Authorization tokens: Single-use links with TTL for email approve/decline and hard-delete confirmation.
  • Soft-delete with retention rules: Images retained until last approval removed; scheduled cleanup handles hard-delete.
  • Rotation engine: Per-device schedule tracking, uniqueness window enforcement, next-image selection from ready pool only.

Starter Template Evaluation

Primary Technology Domain

Symfony full-stack web application with IoT device client. Stack pre-determined by user.

Selected Starter: Symfony 8.0 --webapp

Initialization Command:

symfony new pictureframe --webapp

Architectural Decisions Provided by Starter:

Language & Runtime: PHP 8.4+, Symfony 8.0

Templating: Twig — used only for public flows (provisioning setup, email approve/decline pages). Authenticated app is a Vue 3 SPA; Twig is not used there.

ORM & Database: Doctrine ORM, PostgreSQL 16 (DDEV local), migrations via doctrine/migrations

Security: Symfony Security component — firewalls, voters, role hierarchy

Email: Symfony Mailer — handles sharing flows and hard-delete confirmations

Build Tooling: Vite (Vue SPA, outputs to public/build/). AssetMapper not used — no Stimulus, no Turbo, no importmap.php.

Testing: PHPUnit via symfony/test-pack

Code Organization: Standard Symfony structure — src/Entity, src/Controller, src/Repository, src/Service, templates/ (public flows only) + frontend/ (Vue SPA source)

Additional packages required:

  • symfony/messenger + Doctrine transport — async image processing worker
  • symfony/scheduler — rotation engine and scheduled cleanup
  • Image processing library (TBD step 4: GD vs Imagick)

Post-scaffold cleanup: After symfony new pictureframe --webapp, remove symfony/stimulus-bundle, symfony/ux-turbo, and AssetMapper. Initialize the Vue SPA in frontend/ with npm create vite@latest frontend -- --template vue-ts.

Local Dev: DDEV configured to mirror aqua-iq (PHP 8.4, Nginx-FPM, PostgreSQL 16)

Note: Project initialization using this command is the first implementation story.

Core Architectural Decisions

Decision Priority Analysis

Critical Decisions (Block Implementation):

  • APP_BASE_URL established: https://pictureframe.edholm.me — bake into firmware as build constant
  • Image rendering status lifecycle required before rotation engine can select images
  • Imagick required on VPS before image processing worker can run

Important Decisions (Shape Architecture):

  • Local filesystem image storage (not object storage)
  • Symfony Messenger async processing with ready status gate
  • Token entity pattern for all single-use authorization links

Deferred Decisions (Post-MVP):

  • Caching layer (Redis) — add if query performance becomes an issue
  • Additional display models beyond Waveshare 7.3" 800×480

Data Architecture

Image Storage: Local filesystem on VPS. Pre-rendered assets stored at storage/images/{image_id}/{device_model}_{orientation}.bin. Originals stored separately at storage/images/{image_id}/original.{ext}. Paths stored in DB are relative to STORAGE_PATH env var. No object storage — personal scale doesn't warrant the complexity.

Image Processing Library: Imagick (PHP extension). Chosen for Floyd-Steinberg dithering quality on the 6-color e-ink palette. GD's palette quantization produces inferior results for this use case. Imagick must be installed on VPS and in DDEV container.

Image Rendering Status: Each pre-rendered asset (per device model + orientation) tracks status independently: pending → processing → ready | failed. Rotation engine and device pull endpoint filter exclusively on ready. Stored as an enum column on a RenderedAsset entity.

Caching: None in V1. PostgreSQL at personal scale is sufficient. Add Redis later if needed.

Migrations: Doctrine Migrations (doctrine/migrations). Standard Symfony approach.

Authentication & Security

Web Authentication: Symfony form login — email + password, remember_me cookie. Standard Symfony Security firewall.

Roles: Two roles only — ROLE_USER and ROLE_SUPER_ADMIN. Configured in security.yaml with role hierarchy. Super admin is a single designated account.

Authorization Token Pattern: A Token entity — UUID primary key, type enum (share_approve, share_decline, hard_delete_confirm), expires_at datetime, used_at nullable datetime. Single-use enforced: token is invalid if used_at IS NOT NULL or expires_at < NOW(). TTL is configurable per token type.

Device MAC Authentication: Symfony controller guard on the image pull endpoint. Device sends MAC address via URL segment; server validates it exists in the Device entity and is linked to an active account before serving the binary asset.

HTTPS: Enforced at Nginx level on VPS. DDEV provides HTTPS locally.

API & Communication Patterns

Device API: Single endpoint — GET /api/device/{mac}/image — returns raw binary (application/octet-stream). This is the only machine-to-machine API surface. No versioning scheme beyond URL stability guarantee (no breaking changes in V1).

Web Controllers (authenticated app): Symfony controllers return JSON responses for all authenticated app API calls under the /api/ prefix. No Twig rendering for authenticated routes — Symfony serves the SPA shell only. Controllers use JsonResponse or Symfony Serializer.

Web Controllers (public flows): /setup/{mac}, /token/{uuid}/approve, /token/{uuid}/decline return Twig responses. These are the only controllers that render HTML directly.

Email: Symfony Mailer. Transactional emails: image share notification (with approve link), hard-delete confirmation. Authorization links embedded as tokenized URLs pointing to Symfony routes.

Async Messaging: Symfony Messenger with Doctrine transport. Messages dispatched on image upload and image approval. Worker consumes queue and processes images via Imagick. Sets RenderedAsset status to ready on completion, failed on exception.

Scheduled Tasks: Symfony Scheduler. Two recurring tasks:

  • Rotation engine: per-device, fires on each device's configured interval to advance the current image pointer
  • Cleanup job: periodic hard-delete of soft-deleted images with no remaining approvals

Frontend Architecture

Authenticated App — Vue 3 SPA: All authenticated routes are served by a Vue 3 SPA built with Vite + TypeScript strict mode. Vue Router handles client-side navigation; Pinia manages shared state (current user, device list, upload funnel state). SCSS modules scoped per SFC for component styles. Konva.js + Vue-Konva for the sticker canvas editor. No Stimulus, no Turbo, no AssetMapper.

Symfony serves the SPA shell (public/build/index.html) via a catch-all route for all authenticated paths. Vue Router takes over client-side navigation from that point.

Public Flows — Symfony + Twig: Three routes remain as Symfony Twig pages with no Vue dependency:

  • /setup/{mac} — device provisioning setup (post-QR scan)
  • /token/{uuid}/approve — email approve page (no login required)
  • /token/{uuid}/decline — email decline page (no login required)

These must work with images disabled, CSS disabled, and screen reader only.

TypeScript: Strict mode. frontend/src/types/ holds interfaces mirroring every Symfony API response shape: Device, Image, StickerLayer, RenderedAsset, Token. The compiler surfaces API contract drift before it reaches deployed devices.

SCSS: Modules scoped per SFC (<style scoped lang="scss">). Global tokens in frontend/src/styles/global.scss. No Tailwind, no utility CSS framework, no pre-built component library.

Build: Vite outputs to public/build/. vite.config.ts sets outDir: '../public/build'. The Symfony catch-all controller renders public/build/index.html directly.

Forms: Symfony Form component is not used for authenticated app forms — Vue handles all form logic. Symfony Form is only used in Twig public flows if needed.

Infrastructure & Deployment

Domain: pictureframe.edholm.me — APP_BASE_URL and API base URL. Must be configured on VPS before firmware build constants are set.

VPS: Single server. Nginx reverse proxy → PHP-FPM → Symfony. PostgreSQL on same host. Mirrors aqua-iq production setup.

Local Dev: DDEV — PHP 8.4, Nginx-FPM, PostgreSQL 16. Config mirrors aqua-iq .ddev/config.yaml.

Git Hosting: git.edholm.me (self-hosted Gitea/Forgejo). Credentials from existing projects.

CI/CD: Gitea Actions (if enabled on git.edholm.me) or SSH-based deploy script. Mirror aqua-iq deployment pattern.

SSH/Server Access: Documented in aqua-iq project. Same VPS, same access pattern.

Decision Impact Analysis

Implementation Sequence:

  1. DDEV setup + Symfony scaffold (symfony new pictureframe --webapp); remove Stimulus/Turbo/AssetMapper
  2. Vue SPA scaffold in frontend/ (npm create vite@latest frontend -- --template vue-ts); configure vite.config.ts to output to public/build/
  3. Add Messenger, Scheduler, Imagick to Symfony
  4. Domain + Nginx config on VPS
  5. Core entities (User, Device, Image, RenderedAsset, Token)
  6. Image processing worker
  7. Symfony JSON API endpoints + SpaController catch-all
  8. Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
  9. Firmware (after domain + API contract confirmed)

Cross-Component Dependencies:

  • Firmware cannot be finalized until pictureframe.edholm.me is live and API endpoint format is confirmed
  • Device pull endpoint depends on RenderedAsset.status = ready
  • Rotation engine depends on uniqueness tracking and ready asset pool
  • Email sharing depends on Token entity and Mailer configuration

Implementation Patterns & Consistency Rules

Entity & Enum Patterns

PHP Backed Enums: Use PHP 8.1 backed enums for all finite value sets.

enum RenderStatus: string {
    case Pending = 'pending';
    case Processing = 'processing';
    case Ready = 'ready';
    case Failed = 'failed';
}

enum TokenType: string {
    case ShareApprove = 'share_approve';
    case ShareDecline = 'share_decline';
    case HardDeleteConfirm = 'hard_delete_confirm';
}

enum Orientation: string {
    case Landscape = 'landscape';
    case Portrait = 'portrait';
}

Doctrine maps backed enums to VARCHAR columns via built-in PHP 8.1 enum type support. No string constants elsewhere.

Repository Naming Convention

Soft-Delete Awareness: All repository methods that exclude soft-deleted records use the findActive* prefix.

  • findActiveByDevice(Device $device) — images where deleted_at IS NULL
  • findActiveReadyByDevice(Device $device) — images with deleted_at IS NULL AND status = RenderStatus::Ready
  • findBy* — raw Doctrine finders, may include soft-deleted records

This makes soft-delete awareness visible at the call site rather than hidden in query logic.

Image Storage Pattern

Storage Location: storage/images/{image_id}/{device_model}_{orientation}.bin for pre-rendered assets. Originals at storage/images/{image_id}/original.{ext}. Never var/images/.

Database References: Store paths relative to STORAGE_PATH root, not absolute paths. The storage root can move without a data migration.

Environment Variable: STORAGE_PATH — configured per environment. In DDEV, mapped to a local volume. On VPS, absolute path under the project root.

Async Image Processing Pattern

Exclusive Dispatch Points: ProcessImageMessage is dispatched in exactly two places:

  • ImageService::upload() — after persisting a new uploaded image
  • ImageService::approve() — after a share-approval token is consumed

No other code dispatches ProcessImageMessage.

Messenger Transport Configuration:

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            image_processing:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: image_processing
                retry_strategy:
                    max_retries: 1
                    delay: 1000
                    multiplier: 2

max_retries: 1 — a single retry on transient failures. Failed messages set RenderedAsset.status to RenderStatus::Failed so the error surfaces in the admin UI.

Device Pull Endpoint — Critical Response Codes

CRITICAL RULE: GET /api/device/{mac}/image must return:

  • 204 No Content — MAC is valid, device exists, but no ready image is currently available
  • 404 Not Found — MAC is not registered in the system
  • 200 OK — returns raw binary (application/octet-stream)

Never return 404 when the device is known but has no ready image. The ESP32 firmware treats 204 as "wait and retry" and 404 as "this device is not configured." Confusing them causes the device to enter a permanent error state.

Testing Strategy

Unit Tests (tests/Unit/): Pure logic, no database or I/O. Covers: enum behavior, token TTL calculation, image processing pipeline steps, rotation selection algorithm.

Integration Tests (tests/Integration/): Hit the real test database via Doctrine, using Symfony's KernelTestCase. Covers: repository findActive* methods, Messenger handler with real entity persistence, token single-use enforcement.

Functional Tests (tests/Functional/): Full HTTP stack via WebTestCase. Covers: device pull endpoint (204/404/200 response codes), form submissions, auth flows, email dispatch.

No database mocks. Integration tests use a real test database — mirrors the pattern from aqua-iq.

Enforcement Guidelines

Mandatory rules for all code in this project:

  1. findActive* prefix on all repository methods filtering out soft-deleted records
  2. ImageService::upload() and ImageService::approve() are the only dispatch points for ProcessImageMessage
  3. 204 not 404 when device is known but has no ready image
  4. storage/images/ for all file storage — never var/images/
  5. Relative paths in the database — never absolute filesystem paths
  6. max_retries: 1 on the image_processing Messenger transport
  7. PHP backed enums for RenderStatus, TokenType, Orientation — no raw string constants
  8. Rotation engine and device pull endpoint filter exclusively on RenderStatus::Ready

Project Structure & Boundaries

Requirements to Structure Mapping

User & Account Managementsrc/Controller/SecurityController.php, src/Controller/Api/UserApiController.php, src/Entity/User.php, src/Repository/UserRepository.php

Device Managementsrc/Controller/Api/DeviceApiController.php, src/Entity/Device.php, src/Service/DeviceService.php, src/Repository/DeviceRepository.php, frontend/src/views/HomeView.vue

Image Librarysrc/Controller/Api/ImageApiController.php, src/Entity/Image.php, src/Entity/RenderedAsset.php, src/Service/ImageService.php, src/Service/ImageProcessingService.php, frontend/src/views/LibraryView.vue

Image Approval & Sharingsrc/Controller/Token/TokenActionController.php, src/Entity/Token.php, src/Service/TokenService.php, templates/token/ (email approve/decline pages), frontend/src/components/ApproveCard.vue

Device Provisioningsrc/Controller/Device/DeviceProvisionController.php, templates/device/provision.html.twig

Image Rotation & Cycle Enginesrc/Schedule/RotationSchedule.php, src/Service/RotationService.php

Display & Status (device pull endpoint)src/Controller/Api/DeviceImageController.php

Admin & Moderationsrc/Controller/Admin/, frontend/src/views/AdminView.vue (if applicable)

Cross-Cutting: Async Processingsrc/Message/ProcessImageMessage.php, src/MessageHandler/ProcessImageMessageHandler.php, config/packages/messenger.yaml

Cross-Cutting: Scheduled Taskssrc/Schedule/RotationSchedule.php, src/Schedule/ImageCleanupSchedule.php, config/packages/scheduler.yaml

Cross-Cutting: Enumssrc/Enum/RenderStatus.php, src/Enum/TokenType.php, src/Enum/Orientation.php

Complete Project Directory Structure

pictureframe/
├── .ddev/
│   ├── config.yaml                          ← PHP 8.4, nginx-fpm, pgsql 16 — mirror aqua-iq
│   └── docker-compose.imagick.yaml          ← adds Imagick to web container
├── .gitea/
│   └── workflows/
│       └── ci.yml                           ← Gitea Actions: lint + test + vite build on push
├── frontend/                                ← Vue 3 SPA source (Vite + TypeScript)
│   ├── src/
│   │   ├── assets/
│   │   │   └── stickers/                    ← SVG sticker assets (Seasonal, Holidays, Fun, Family, Nature)
│   │   ├── components/
│   │   │   ├── base/
│   │   │   │   ├── BaseButton.vue
│   │   │   │   ├── BaseInput.vue
│   │   │   │   ├── BaseBottomSheet.vue
│   │   │   │   ├── BaseCard.vue
│   │   │   │   ├── BaseChip.vue
│   │   │   │   └── BaseToast.vue
│   │   │   ├── FrameCard.vue
│   │   │   ├── CropEditor.vue
│   │   │   ├── StickerCanvas.vue
│   │   │   ├── StickerTray.vue
│   │   │   ├── DevicePicker.vue
│   │   │   ├── PhotoThumb.vue
│   │   │   ├── ShareSheet.vue
│   │   │   ├── ApproveCard.vue
│   │   │   └── BottomNav.vue
│   │   ├── router/
│   │   │   └── index.ts                     ← Vue Router; catch-all handled by Symfony
│   │   ├── stores/
│   │   │   ├── auth.ts                      ← current user, login state
│   │   │   ├── devices.ts                   ← device list, current device
│   │   │   └── upload.ts                    ← upload funnel state, sticker composition
│   │   ├── styles/
│   │   │   ├── _breakpoints.scss            ← $bp-tablet: 640px, $bp-desktop: 960px
│   │   │   ├── _tokens.scss                 ← CSS custom properties for all 6 themes
│   │   │   └── global.scss                  ← reset, typography, base layout
│   │   ├── types/
│   │   │   ├── Device.ts
│   │   │   ├── Image.ts
│   │   │   ├── StickerLayer.ts              ← { id, type, x, y, scale, rotation }
│   │   │   ├── RenderedAsset.ts
│   │   │   └── Token.ts
│   │   ├── views/
│   │   │   ├── HomeView.vue                 ← FrameList — FrameCard per device
│   │   │   ├── LibraryView.vue              ← photo grid, search, tabs (All/Mine/Shared)
│   │   │   ├── UploadView.vue               ← funnel: crop → sticker → device picker
│   │   │   └── SettingsView.vue
│   │   ├── App.vue
│   │   └── main.ts
│   ├── index.html
│   ├── package.json
│   ├── tsconfig.json
│   └── vite.config.ts                       ← outDir: '../public/build'
├── bin/
│   └── console
├── config/
│   ├── packages/
│   │   ├── doctrine.yaml
│   │   ├── doctrine_migrations.yaml
│   │   ├── mailer.yaml
│   │   ├── messenger.yaml                   ← image_processing transport, max_retries: 1
│   │   ├── scheduler.yaml
│   │   ├── security.yaml                    ← form_login, remember_me, ROLE_SUPER_ADMIN hierarchy
│   │   └── twig.yaml
│   ├── routes.yaml                          ← catch-all route → SpaController for authenticated paths
│   ├── routes/
│   │   └── api.yaml                         ← /api/device/{mac}/image + /api/* app endpoints
│   └── services.yaml
├── migrations/
├── public/
│   ├── build/                               ← gitignored; Vite output (index.html + hashed assets)
│   └── index.php
├── src/
│   ├── Controller/
│   │   ├── Api/
│   │   │   ├── DeviceImageController.php    ← GET /api/device/{mac}/image → 200/204/404 (binary)
│   │   │   ├── DeviceApiController.php      ← JSON CRUD for devices (Vue SPA)
│   │   │   ├── ImageApiController.php       ← JSON CRUD for images, upload, share (Vue SPA)
│   │   │   └── UserApiController.php        ← current user, settings (Vue SPA)
│   │   ├── Admin/
│   │   │   ├── AdminDashboardController.php
│   │   │   └── AdminModerationController.php
│   │   ├── SpaController.php                ← catch-all; renders public/build/index.html
│   │   ├── SecurityController.php           ← login/logout (form POST, JSON response)
│   │   ├── Device/
│   │   │   └── DeviceProvisionController.php ← GET/POST /setup/{mac} → Twig
│   │   └── Token/
│   │       └── TokenActionController.php    ← /token/{uuid}/approve|decline → Twig
│   ├── Entity/
│   │   ├── Device.php
│   │   ├── Image.php
│   │   ├── RenderedAsset.php                ← per device-model+orientation, RenderStatus
│   │   ├── Token.php                        ← UUID PK, TokenType, expires_at, used_at
│   │   └── User.php
│   ├── Enum/
│   │   ├── Orientation.php
│   │   ├── RenderStatus.php
│   │   └── TokenType.php
│   ├── Message/
│   │   └── ProcessImageMessage.php          ← DTO: imageId, deviceModel, orientation
│   ├── MessageHandler/
│   │   └── ProcessImageMessageHandler.php   ← Imagick → .bin → sets RenderStatus::Ready
│   ├── Repository/
│   │   ├── DeviceRepository.php
│   │   ├── ImageRepository.php              ← findActiveByDevice(), findActiveReadyByDevice()
│   │   ├── RenderedAssetRepository.php      ← findReadyForDevice()
│   │   ├── TokenRepository.php              ← findValidToken() (unused + unexpired)
│   │   └── UserRepository.php
│   ├── Schedule/
│   │   ├── ImageCleanupSchedule.php         ← hard-delete orphaned soft-deleted images
│   │   └── RotationSchedule.php             ← advance current_image pointer per device
│   ├── Service/
│   │   ├── DeviceService.php
│   │   ├── ImageProcessingService.php       ← Imagick: resize → 4bpp dither → .bin
│   │   ├── ImageService.php                 ← upload() and approve() — exclusive dispatch points
│   │   ├── RotationService.php              ← next-image selection, uniqueness window
│   │   └── TokenService.php                 ← issue and consume single-use tokens
│   └── Kernel.php
├── storage/
│   └── images/                              ← gitignored; STORAGE_PATH points here
├── templates/
│   ├── device/
│   │   └── provision.html.twig              ← /setup/{mac} public provisioning page
│   ├── token/
│   │   ├── approve.html.twig                ← email approve page (no login)
│   │   └── decline.html.twig                ← email decline page (no login)
│   └── base_public.html.twig                ← minimal base for public Twig pages only
├── tests/
│   ├── Functional/
│   │   ├── Api/
│   │   │   ├── DeviceImageControllerTest.php
│   │   │   ├── DeviceApiControllerTest.php
│   │   │   └── ImageApiControllerTest.php
│   │   └── Token/
│   │       └── TokenActionControllerTest.php
│   ├── Integration/
│   │   ├── MessageHandler/
│   │   │   └── ProcessImageMessageHandlerTest.php
│   │   ├── Repository/
│   │   │   ├── ImageRepositoryTest.php
│   │   │   └── RenderedAssetRepositoryTest.php
│   │   └── Service/
│   │       └── TokenServiceTest.php
│   └── Unit/
│       ├── Enum/
│       │   └── RenderStatusTest.php
│       └── Service/
│           ├── ImageProcessingServiceTest.php
│           └── RotationServiceTest.php
├── var/
│   ├── cache/
│   └── log/
├── .env
├── .env.local                               ← gitignored; STORAGE_PATH, DATABASE_URL, MAILER_DSN
├── .env.test
├── .gitignore
├── composer.json
├── composer.lock
├── phpunit.xml.dist
└── symfony.lock

Architectural Boundaries

API Boundary — Device Pull Endpoint GET /api/device/{mac}/image is the only machine-to-machine surface. MAC address validated against Device entity before serving. Returns application/octet-stream, 204, or 404. Isolated in config/routes/api.yaml.

Web Application Boundary Authenticated routes are gated by Symfony form-login firewall. SpaController serves public/build/index.html as the catch-all — Vue Router handles client-side navigation from there. All authenticated data flows through /api/* JSON endpoints. ROLE_SUPER_ADMIN gates admin API controllers. Public flows (/setup/{mac}, /token/{uuid}/approve|decline) are outside the firewall and return Twig responses — no Vue involved.

Async Processing Boundary ImageService → Messenger bus → ProcessImageMessageHandler. Handler is the only component writing to storage/images/. RenderedAsset.status is the only signal crossing this boundary.

Scheduled Task Boundary RotationSchedule and ImageCleanupSchedule run in the worker process — no HTTP context, no session access. Database reads and writes only.

Storage Boundary storage/images/ written by ProcessImageMessageHandler, read by DeviceImageController. All other code uses STORAGE_PATH env var. Paths in DB are always relative.

Integration Points & Data Flow

Image Upload → Display

User uploads → ImageService::upload() persists Image → dispatches ProcessImageMessage
  → Worker: Imagick resize + dither → storage/images/{id}/waveshare73_landscape.bin
  → RenderedAsset.status = Ready
  → RotationSchedule advances device pointer
  → ESP32: GET /api/device/{mac}/image → 200 + binary stream

Image Sharing Flow

User shares image with owner → ImageService creates Token (ShareApprove/ShareDecline)
  → Mailer sends tokenized URL
  → Owner clicks link → TokenActionController::approve()
  → TokenService consumes token → ImageService::approve() dispatches ProcessImageMessage
  → Image enters ready pool for recipient device

Soft-Delete & Cleanup Flow

User soft-deletes image → Image.deleted_at set (excluded by findActive*)
  → If last approval removed → ImageCleanupSchedule hard-deletes image + storage files

Architecture Validation Results

Coherence Validation

Decision Compatibility: PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. Vue 3 + Vite + TypeScript strict + SCSS modules + Konva.js is a standard, well-supported SPA stack. The Symfony/Vue boundary is clean: Symfony serves the SPA shell and JSON API; Vue owns all authenticated UI rendering. No build-step conflicts — Vite and Symfony operate independently.

Pattern Consistency: findActive* naming, storage/images/ path convention, exclusive Messenger dispatch points, and backed enum usage are consistently applied. One contradiction (var/images/ in Data Architecture section) found during validation and corrected — all references now point to storage/images/.

Structure Alignment: Every controller, service, entity, and test file has a defined home. All five boundaries (API, web, async, storage, scheduled) are respected in the project structure.

Requirements Coverage Validation

All 8 FR domains covered:

Domain Architectural Support
User & Account Management User entity, form login, SecurityController
Device Management Device entity, DeviceController, DeviceService
Image Library Image entity, ImageLibraryController, ImageService
Image Approval & Sharing Token entity, TokenService, TokenActionController
Device Provisioning DeviceProvisionController
Image Rotation & Cycle Engine RotationSchedule, RotationService
Display & Status DeviceImageController, RenderedAsset
Admin & Moderation AdminDashboardController, AdminModerationController

All 9 NFRs covered: pre-rendering ≤10s (async Messenger), device pull ≤10s (direct binary stream), web pages ≤3s (standard Symfony), rotation ±5min (Scheduler), HTTPS (Nginx), MAC auth (controller guard), single-use links (Token entity), scheduled cleanup (ImageCleanupSchedule), zero blank screens (204 not 404 + last image persistence).

Implementation Readiness Validation

All critical decisions documented. Enforcement guidelines provide 8 explicit, checkable rules. Project tree is specific with no generic placeholders. The 204/404 distinction is called out as a critical rule with the reason documented.

Gap Analysis

Critical Gaps: None.

Minor (non-blocking):

  • .env.example not in project tree — add alongside .env on scaffold
  • device_model field type on RenderedAsset not fully specified — for V1 with a single display (Waveshare 7.3" 800×480), a string constant or single-value enum is sufficient

Architecture Completeness Checklist

Requirements Analysis — context, scale, constraints, cross-cutting concerns

Architectural Decisions — stack, data architecture, auth, API patterns, infrastructure

Implementation Patterns — enums, findActive*, storage, Messenger, 204/404, testing, enforcement rules

Project Structure — complete directory tree, boundaries, FR mapping, data flow diagrams

Validation — coherence, coverage, readiness, gaps

Architecture Readiness Assessment

Overall Status: READY FOR IMPLEMENTATION

Confidence: High. Coherent, fully covers the PRD, no critical gaps, sufficient specificity for consistent implementation.

First Implementation Step:

symfony new pictureframe --webapp

Implementation Handoff

AI Agent Guidelines:

  • Follow all architectural decisions exactly as documented
  • Apply the 8 enforcement rules in every code generation task
  • Respect the storage/images/ boundary — never write image assets to var/
  • Confirm 204 vs 404 on every implementation of the device pull endpoint
  • Reference this document for all architectural questions; do not invent decisions not documented here