Skip to content

System Architecture Overview

Yam3at is built as a modular monolith: one deployable Laravel 11 API whose internal module boundaries are exactly the 12 product domains. One codebase, one database, one deployment pipeline — but strict internal contracts so that any module can later be extracted into a service without a rewrite.

Guiding principles:

  1. API-first. The Laravel API is the single source of truth. Web (Next.js) and mobile (Flutter) are pure clients of /api/v1. No business logic lives in a client.
  2. Event is the core entity. Every aggregate (budget, RFQ, quotation, booking, payment, conversation, review) hangs off an Event.
  3. Shared engines, never duplicated. Notifications, messaging, payments, media, and search are single shared services consumed by all user types (customer, vendor, admin).
  4. Async by default for side effects. Anything that is not needed to answer the current HTTP request (notifications, search indexing, webhooks, expiry) goes through Redis queues.
  5. Multi-country, multi-currency, multi-language ready from day one — even though launch is Kuwait-only (KWD, Arabic-first).

System Context (C4 Level 1)

flowchart TB
    customer["Customer<br/>(plans & books events)"]
    vendor["Vendor<br/>(sells event services)"]
    admin["Admin<br/>(operates the platform)"]

    yam3at["Yam3at Platform<br/>Event OS for the GCC"]

    psp["PSP: MyFatoorah / Tap<br/>KNET, Visa/MC, Apple Pay, Google Pay"]
    fcm["Firebase Cloud Messaging<br/>push notifications"]
    email["Mailgun / SES<br/>transactional email"]
    maps["Google Maps / Places<br/>geocoding & venue locations"]
    oauth["Google / Apple<br/>social sign-in"]
    s3ext["S3-compatible storage + CDN<br/>media & documents"]

    customer --> yam3at
    vendor --> yam3at
    admin --> yam3at

    yam3at -->|payments, refunds, webhooks| psp
    yam3at -->|push| fcm
    yam3at -->|email| email
    yam3at -->|geocode, autocomplete| maps
    yam3at -->|token verification| oauth
    yam3at -->|store / serve media| s3ext

Container Diagram (C4 Level 2)

flowchart TB
    subgraph clients["Clients"]
        web["Next.js 15 Web App<br/>App Router, next-intl, RTL<br/>SSR marketing + SEO listing pages"]
        flutter["Flutter Apps<br/>iOS + Android<br/>customer & vendor experiences"]
        adminui["Admin Panel<br/>(Next.js route group or Filament)<br/>MVP: same API, admin role"]
    end

    subgraph backend["Yam3at Backend (single deployable)"]
        api["Laravel 11 API (PHP 8.3)<br/>modular monolith, /api/v1<br/>Sanctum auth, policies, validation"]
        workers["Queue Workers (Horizon)<br/>notifications, indexing,<br/>expiry, webhooks, media"]
        scheduler["Scheduler (cron)<br/>subscription renewals, RFQ expiry,<br/>digest emails, cleanup"]
    end

    subgraph data["Data & Infrastructure"]
        mysql[("MySQL 8<br/>system of record")]
        redis[("Redis<br/>cache + queues + rate limits")]
        meili[("Meilisearch<br/>vendor/listing search index")]
        s3[("S3-compatible storage<br/>images, PDFs, licenses")]
    end

    subgraph ext["External Services"]
        psp2["MyFatoorah / Tap"]
        fcm2["FCM"]
        mail2["Mailgun / SES"]
        maps2["Google Maps"]
    end

    web -->|HTTPS JSON| api
    flutter -->|HTTPS JSON| api
    adminui -->|HTTPS JSON| api

    api --> mysql
    api --> redis
    api -->|search queries| meili
    api -->|signed upload/download URLs| s3

    api -->|dispatch jobs| redis
    redis --> workers
    scheduler --> workers

    workers -->|index documents| meili
    workers -->|send| fcm2
    workers -->|send| mail2
    workers -->|verify & reconcile| psp2
    api -->|create payment session| psp2
    psp2 -->|signed webhooks| api
    api --> maps2

Everything inside Yam3at Backend ships as one Docker image with three process types (web, worker, scheduler), deployed via GitHub Actions.

Module Boundaries — the 12 Domains

Modules live under app/Modules/<Domain> (or modules/ via a package such as nwidart/laravel-modules). Every feature belongs to exactly one domain; cross-domain calls go through the domain's public service class or via domain events — never by reaching into another module's models directly.

# Module Owns Key aggregates
1 Identity Auth, OTP, social login, roles, profiles User, Role
2 Event Customer events, event metadata, (Scale: budget, checklist, guests, timeline) Event
3 Marketplace Categories, discovery, search facade, featured placement Category
4 Vendor Vendor onboarding, KYC/license review, profiles, services, packages, team Vendor, Service, Package
5 Booking RFQs, quotations (owned here), quote comparison, bookings Rfq, Quotation, Booking
6 Financial Plans, subscriptions, invoices, payments, refunds, PSP abstraction Subscription, Payment, Refund, Invoice
7 Communication Conversations, messages, notification engine Conversation, Message, Notification
8 Experience Reviews, ratings, favorites, (Scale: loyalty) Review, Favorite
9 Intelligence Scale — AI recommendations, AI quote drafting
10 Operations Admin actions, moderation, support tooling, CMS pages CmsPage
11 Platform Media/files, Meilisearch indexing, localization (countries/cities/currencies), feature flags, audit log Media, AuditLog, Country, City, Currency
12 Growth Scale — promo codes, referrals, marketing analytics

Module skeleton (identical for every domain):

app/Modules/Booking/
├── Http/            # controllers, requests, resources (thin)
├── Models/          # Eloquent models private to the module
├── Services/        # public API of the module (what other modules may call)
├── Events/          # domain events (see below)
├── Listeners/
├── Jobs/            # queued work
├── Policies/
├── Database/        # migrations, factories, seeders
└── routes.php       # mounted under /api/v1

Enforcement is convention plus tooling: deptrac/phpat rules in CI fail the build if module A imports module B's Models or Http namespaces (only Services, Events, and shared Contracts are allowed).

Request Flow and Async Work

Synchronous path (must stay fast, < 300 ms p95):

  • Validate → authorize (policy) → execute domain service → persist to MySQL → dispatch domain event → return JSON resource.

Everything else is queued. Redis + Laravel Horizon, with named queues so a flood of one job type cannot starve another:

Queue Jobs Priority / notes
payments PSP webhook processing, payment reconciliation, refund execution Highest; single dedicated worker pool; jobs are idempotent
notifications Push (FCM), email (Mailgun/SES), in-app notification fan-out High; per-user throttling
search Meilisearch index/update/delete of vendors, services, packages Medium; debounced per document
default Media processing (thumbnails, EXIF strip), exports, audit fan-out Medium
scheduled RFQ expiry, quotation validity expiry, subscription renewal & dunning, booking reminders Driven by the scheduler every minute

Retry policy: exponential backoff (1m, 5m, 30m, 2h, 12h for external providers), then dead-letter to a failed_jobs table with an admin alert. Every job that touches an external system carries an idempotency key.

Internal Domain Events

Modules communicate through Laravel events (synchronous listeners for in-process invariants, queued listeners for side effects). Events are the seam that makes later service extraction possible — an extracted service replaces the in-process listener with a consumer on a message broker, and producers don't change.

Representative catalog (all MVP unless tagged):

Event Producer Queued consumers
UserRegistered Identity Communication (welcome email), Growth (Scale: attribution)
VendorApproved / VendorRejected Operations Communication (notify vendor), Platform (index vendor in Meilisearch)
SubscriptionActivated / SubscriptionExpired Financial Vendor (unlock/lock plan limits), Communication
RfqPublished Booking Communication (notify targeted vendors), Platform (audit)
QuotationSubmitted Booking Communication (notify customer), Financial (decrement vendor quota)
QuotationAccepted Booking Booking (create booking), Communication
BookingConfirmed Booking Communication, Event (attach to event timeline)
PaymentSucceeded / PaymentFailed Financial Booking (confirm booking) / Financial (dunning), Communication
RefundProcessed Financial Booking (update status), Communication
ReviewPublished Experience Vendor (recompute rating aggregates), Platform (re-index vendor)
MediaUploaded Platform Platform (thumbnails, moderation queue)

Rules:

  • Events are facts, past tense, immutable payloads (IDs + minimal data, consumers re-fetch what they need).
  • A consumer failing never rolls back the producer; queued listeners retry independently.
  • Every event is also appended to audit_logs by a Platform listener where it is compliance-relevant (see Security).

Multi-Country / Multi-Currency / Multi-Language Readiness

Kuwait-only at launch, but the schema and API never assume it:

Concern Mechanism
Country countries table; vendors, cities, plans, and events carry country_id. All queries in country-scoped contexts filter by it. Launch seed: Kuwait only.
Currency currencies table with decimal_places (KWD = 3, SAR/AED = 2). Every money column is DECIMAL(12,3) plus a currency_code. No floats, ever. See tables.md.
Language All user-facing entity text is bilingual (*_en / *_ar columns). API localizes via Accept-Language (see API conventions). Next.js uses next-intl with full RTL; Flutter uses ARB localization.
Timezone All timestamps stored UTC; Asia/Kuwait (UTC+3, no DST) applied at presentation. Event dates additionally store the event's local date/time intent.
Payments PSP abstraction (PaymentProviderInterface) — MyFatoorah/Tap for Kuwait; a KSA/UAE expansion adds a provider config per country, not new code paths. See Integrations.
Regulations Per-country toggles via Platform feature flags (e.g., invoice tax fields for KSA VAT at 15% — Kuwait has no VAT today).

Future Service-Extraction Path

The monolith is the right choice until it isn't. Extraction order, when scale demands it:

flowchart LR
    mono["Modular Monolith<br/>(MVP → Scale)"] --> s1["1. Search service<br/>already isolated behind<br/>Meilisearch + indexing jobs"]
    mono --> s2["2. Notification service<br/>pure fan-out, no shared writes"]
    mono --> s3["3. Messaging service<br/>high write volume,<br/>websocket-friendly runtime"]
    mono --> s4["4. Payments service<br/>compliance isolation,<br/>PSP webhook ingestion"]
    s4 --> s5["5+. Per-country cells<br/>(Vision: KSA/UAE data residency)"]

Preconditions each module already satisfies, making extraction mechanical:

  1. Own tables only — no cross-module foreign-key writes; cross-module references are by ID through service interfaces.
  2. Public surface = service classes + events — an extracted service swaps the implementation behind the same contract (HTTP/gRPC client instead of in-process call).
  3. Queued, idempotent side effects — already broker-shaped; Redis queues can be replaced with SQS/Kafka per consumer without producer changes.
  4. Config-scoped external credentials — each module owns its provider keys (PSP in Financial, FCM/email in Communication), so credentials move with the module.

What we deliberately do not do before extraction is justified: no network hops between modules, no separate databases, no service mesh, no duplicated auth. See ADR-001.

Environments & Deployment

Environment Purpose Notes
local Docker Compose: app, MySQL, Redis, Meilisearch, MinIO, Mailpit PSP in sandbox mode
staging Full stack mirror, PSP sandbox, FCM test project Seeded demo data; every PR deploys a preview of the web app
production Managed MySQL + Redis, object storage + CDN, Horizon dashboard Blue/green via GitHub Actions; migrations run with --force gated by a manual approval step

Related pages: Tech Stack & ADRs · Integrations · Security Baseline · Database ERD · API Conventions