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>
33 KiB
stepsCompleted, lastStep, status, completedAt, inputDocuments, workflowType, project_name, user_name, date
| stepsCompleted | lastStep | status | completedAt | inputDocuments | workflowType | project_name | user_name | date | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
8 | complete | 2026-04-27 |
|
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 whenready. Doctrine transport (consistent with aqua-iq). - Image rendering status: Each image asset tracks
pending → processing → ready | failedper device model/orientation. Rotation engine and device pull endpoint filter exclusively onready. - 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
readypool 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 workersymfony/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
readystatus 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:
- DDEV setup + Symfony scaffold (
symfony new pictureframe --webapp); remove Stimulus/Turbo/AssetMapper - Vue SPA scaffold in
frontend/(npm create vite@latest frontend -- --template vue-ts); configurevite.config.tsto output topublic/build/ - Add Messenger, Scheduler, Imagick to Symfony
- Domain + Nginx config on VPS
- Core entities (User, Device, Image, RenderedAsset, Token)
- Image processing worker
- Symfony JSON API endpoints + SpaController catch-all
- Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
- Firmware (after domain + API contract confirmed)
Cross-Component Dependencies:
- Firmware cannot be finalized until
pictureframe.edholm.meis live and API endpoint format is confirmed - Device pull endpoint depends on
RenderedAsset.status = ready - Rotation engine depends on uniqueness tracking and
readyasset 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 wheredeleted_at IS NULLfindActiveReadyByDevice(Device $device)— images withdeleted_at IS NULLANDstatus = RenderStatus::ReadyfindBy*— 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 imageImageService::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}/imagemust return:
204 No Content— MAC is valid, device exists, but noreadyimage is currently available404 Not Found— MAC is not registered in the system200 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:
findActive*prefix on all repository methods filtering out soft-deleted recordsImageService::upload()andImageService::approve()are the only dispatch points forProcessImageMessage204not404when device is known but has no ready imagestorage/images/for all file storage — nevervar/images/- Relative paths in the database — never absolute filesystem paths
max_retries: 1on theimage_processingMessenger transport- PHP backed enums for
RenderStatus,TokenType,Orientation— no raw string constants - Rotation engine and device pull endpoint filter exclusively on
RenderStatus::Ready
Project Structure & Boundaries
Requirements to Structure Mapping
User & Account Management → src/Controller/SecurityController.php, src/Controller/Api/UserApiController.php, src/Entity/User.php, src/Repository/UserRepository.php
Device Management → src/Controller/Api/DeviceApiController.php, src/Entity/Device.php, src/Service/DeviceService.php, src/Repository/DeviceRepository.php, frontend/src/views/HomeView.vue
Image Library → src/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 & Sharing → src/Controller/Token/TokenActionController.php, src/Entity/Token.php, src/Service/TokenService.php, templates/token/ (email approve/decline pages), frontend/src/components/ApproveCard.vue
Device Provisioning → src/Controller/Device/DeviceProvisionController.php, templates/device/provision.html.twig
Image Rotation & Cycle Engine → src/Schedule/RotationSchedule.php, src/Service/RotationService.php
Display & Status (device pull endpoint) → src/Controller/Api/DeviceImageController.php
Admin & Moderation → src/Controller/Admin/, frontend/src/views/AdminView.vue (if applicable)
Cross-Cutting: Async Processing → src/Message/ProcessImageMessage.php, src/MessageHandler/ProcessImageMessageHandler.php, config/packages/messenger.yaml
Cross-Cutting: Scheduled Tasks → src/Schedule/RotationSchedule.php, src/Schedule/ImageCleanupSchedule.php, config/packages/scheduler.yaml
Cross-Cutting: Enums → src/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.examplenot in project tree — add alongside.envon scaffolddevice_modelfield type onRenderedAssetnot 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 tovar/ - Confirm
204 vs 404on every implementation of the device pull endpoint - Reference this document for all architectural questions; do not invent decisions not documented here