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:
- 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. - Event is the core entity. Every aggregate (budget, RFQ, quotation, booking, payment, conversation, review) hangs off an Event.
- Shared engines, never duplicated. Notifications, messaging, payments, media, and search are single shared services consumed by all user types (customer, vendor, admin).
- 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.
- 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_logsby 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:
- Own tables only — no cross-module foreign-key writes; cross-module references are by ID through service interfaces.
- Public surface = service classes + events — an extracted service swaps the implementation behind the same contract (HTTP/gRPC client instead of in-process call).
- Queued, idempotent side effects — already broker-shaped; Redis queues can be replaced with SQS/Kafka per consumer without producer changes.
- 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