Architecture Overview

This document provides a high-level overview of the CMS architecture. For detailed technical specifications, see .context/global/architecture.md.

System Components

The CMS consists of three main applications:
┌─────────────────────────────────────────────────────────────┐
│                       CMS ECOSYSTEM                          │
└─────────────────────────────────────────────────────────────┘

┌──────────────┐       ┌──────────────┐       ┌──────────────┐
│ Front-Office │◄─────►│   CMS API    │◄─────►│ Back-Office  │
│  (Nuxt 3)    │       │(Symfony 7.4) │       │   (Vue 3)    │
│  Public Site │       │API Platform  │       │Content Mgmt  │
└──────────────┘       └──────┬───────┘       └──────────────┘

                 ┌────────────┼────────────┐
                 │            │            │
        ┌────────▼─────┐ ┌───▼────┐ ┌────▼─────┐
        │ PostgreSQL   │ │ Redis  │ │  MinIO   │
        │   (DB)       │ │(Cache) │ │(Storage) │
        └──────────────┘ └────────┘ └──────────┘

1. CMS API (Backend)

Technology Stack:
  • Framework: Symfony 7.4 (PHP 8.3+)
  • API Layer: API Platform 3.4
  • Database: PostgreSQL 16
  • Cache: Redis 7
  • Storage: MinIO / AWS S3
Responsibilities:
  • Multi-tenant data isolation
  • Content management (articles, pages, media)
  • Authentication & authorization
  • RESTful API (JSON-LD + Hydra)
  • Workflow management
  • Content versioning

2. Front-Office (Public Website)

Technology Stack:
  • Framework: Nuxt 3 (Vue 3)
  • Rendering: SSR (Server-Side Rendering)
  • State: Pinia
  • Styling: Tailwind CSS
Responsibilities:
  • Display published content
  • Multi-language support
  • SEO optimization
  • Dynamic block rendering
  • Navigation menus

3. Back-Office (Admin Panel)

Technology Stack:
  • Framework: Vue 3 + Vite
  • UI: Custom component library
  • State: Pinia
  • Forms: VeeValidate
Responsibilities:
  • Content creation/editing
  • Media library management
  • User management
  • Workflow actions
  • Settings configuration

Multi-Tenancy Architecture

Schema-Per-Tenant Isolation

Each tenant gets a dedicated PostgreSQL schema for complete data isolation:
cms_master (database)
├── cms_tenants       # Tenant registry
├── cms_sites         # Multi-site configuration
└── cms_users         # CMS administrators

cms_tenant_demo (schema)     cms_tenant_acme (schema)
├── cms_articles              ├── cms_articles
├── cms_pages                 ├── cms_pages
├── cms_media                 ├── cms_media
└── ...                       └── ...
Key Benefits:
  • Complete isolation: No cross-tenant queries possible
  • Independent scaling: Schemas can be moved to separate databases
  • Security: Database-level isolation
  • Backup flexibility: Per-tenant backups

Multi-Site Support (v0.13+)

One tenant can manage multiple sites:
Tenant: "demo"
├── Site 1: "Main Website" (site_id: 1)
│   ├── Articles (site: 1)
│   ├── Pages (site: 1)
│   └── Settings (site: 1)
└── Site 2: "Blog" (site_id: 2)
    ├── Articles (site: 2)
    ├── Pages (site: 2)
    └── Settings (site: 2)
Isolation: Content isolated by site column, filtered via X-Site-Id header

Communication Patterns

REST + JSON-LD/Hydra

All API responses follow JSON-LD format with Hydra vocabulary:
{
  "@context": "/contexts/Article",
  "@id": "/admin/articles/1",
  "@type": "Article",
  "id": 1,
  "title": {"fr": "Mon Article"},
  "hydra:member": [],
  "hydra:totalItems": 0
}
Benefits:
  • Self-documenting API
  • Hypermedia controls
  • Standard vocabulary
  • Type safety

Authentication Flow

1. User Login (Keycloak)
   ├─> POST https://auth.example.com/token
   └─> Response: JWT token (RS256)

2. API Request
   ├─> Headers:
   │   ├─ Authorization: Bearer <jwt>
   │   ├─ X-Tenant-Id: demo
   │   └─ X-Site-Id: 1
   ├─> Security Layer:
   │   ├─ Validate JWT signature
   │   ├─ Check token expiration
   │   ├─ Load user from database
   │   ├─ Verify tenant membership
   │   └─ Check role permissions
   └─> Controller executes

Caching Strategy

Tag-Aware Cache (Redis):
// Cache article list (1 hour TTL)
$articles = $cache->get('articles_published_site_1', function () {
    return $this->articleRepository->findPublished();
}, 3600, ['articles', 'site_1']);

// Invalidate on article update
$cache->invalidateTags(['articles', 'site_1']);
Cache Tags:
  • articles - All article-related cache
  • pages - All page-related cache
  • site_{id} - Site-specific cache
  • menu_{identifier} - Menu-specific cache

Data Flow

Content Creation Flow

┌──────────────┐
│ Back-Office  │
└──────┬───────┘
       │ 1. POST /admin/articles
       │    {title: "...", body: "..."}

┌────────────────────────────┐
│     API Controller         │
│  - Validate input          │
│  - Check permissions       │
└──────┬─────────────────────┘
       │ 2. Persist to DB

┌────────────────────────────┐
│   Doctrine ORM             │
│  - Save to tenant schema   │
│  - Trigger lifecycle events│
└──────┬─────────────────────┘
       │ 3. Post-persist event

┌────────────────────────────┐
│ ContentVersioningSubscriber│
│  - Create version snapshot │
└──────┬─────────────────────┘
       │ 4. Invalidate cache

┌────────────────────────────┐
│      Redis Cache           │
│  - Clear article tags      │
└────────────────────────────┘

Public Content Display Flow

┌──────────────┐
│ Front-Office │
└──────┬───────┘
       │ 1. GET /public/articles/my-article

┌────────────────────────────┐
│    API Controller          │
│  - Check cache first       │
└──────┬─────────────────────┘
       │ 2. Cache MISS

┌────────────────────────────┐
│   Article Repository       │
│  - Query tenant schema     │
│  - Filter: status=published│
└──────┬─────────────────────┘
       │ 3. Serialize response

┌────────────────────────────┐
│   Serializer               │
│  - Apply article:public    │
│  - Include tags, media     │
└──────┬─────────────────────┘
       │ 4. Cache for 1 hour

┌────────────────────────────┐
│      Redis Cache           │
│  - Store with tags         │
└──────┬─────────────────────┘
       │ 5. Return JSON

┌──────────────┐
│ Front-Office │
└──────────────┘

Deployment Architecture

Development

Docker Compose (localhost)
├── api:8080         # Symfony dev server
├── db:5432          # PostgreSQL
├── redis:6379       # Redis
├── minio:9000       # Object storage
└── adminer:8081     # Database UI
┌─────────────────────────────────────────┐
│             Load Balancer               │
└──────────┬──────────────────────────────┘

    ┌──────┴──────┐
    │             │
┌───▼────┐   ┌───▼────┐
│ API #1 │   │ API #2 │  (Kubernetes Pods)
└───┬────┘   └───┬────┘
    │             │
    └──────┬──────┘

    ┌──────▼──────────────┐
    │                     │
┌───▼────────┐  ┌────────▼──┐
│ PostgreSQL │  │   Redis   │  (Managed Services)
│   RDS      │  │ ElastiCache│
└────────────┘  └───────────┘
    
┌──────────────┐
│      S3      │  (Object Storage)
└──────────────┘
Scaling Considerations:
  • API: Horizontal scaling (stateless)
  • Database: Vertical scaling + read replicas
  • Redis: Cluster mode for high availability
  • S3: Unlimited storage, CDN integration

Design Patterns

Repository Pattern

class ArticleRepository extends ServiceEntityRepository
{
    public function findPublished(int $site): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.status = :status')
            ->andWhere('a.site = :site')
            ->setParameter('status', ContentStatus::PUBLISHED)
            ->setParameter('site', $site)
            ->orderBy('a.published_at', 'DESC')
            ->getQuery()
            ->getResult();
    }
}

Service Layer

class ArticleService
{
    public function publish(Article $article, User $user): void
    {
        // Business logic
        $article->setStatus(ContentStatus::PUBLISHED);
        $article->setPublishedAt(new \DateTimeImmutable());
        
        // Persist
        $this->entityManager->flush();
        
        // Side effects
        $this->historyService->log($article, 'published', $user);
        $this->cache->invalidateTags(['articles', "site_{$article->getSite()}"]);
    }
}

Event-Driven Architecture

#[AsEventListener(event: Events::postPersist)]
class ContentVersioningSubscriber
{
    public function postPersist(PostPersistEventArgs $args): void
    {
        $entity = $args->getObject();
        
        if ($entity instanceof Article || $entity instanceof Page) {
            $this->versioningService->createSnapshot($entity);
        }
    }
}

Further Reading


Detailed Technical Specs: See .context/global/architecture.md