Loreax — Backend Technical Design Document
Version: 0.1 (MVP Design) Status: Draft for implementation Last updated: April 2026 Audience: Engineering, Product, DevOps, Security
Table of Contents
- Executive Summary
- Tech Stack
- Architectural Standards & Conventions
- Domain Map
- Domain: Infrastructure
- Domain: Identity
- Domain: Ledger & Wallet
- Domain: Payments
- Domain: Content
- Domain: Access & Entitlements
- Domain: Monetization
- Domain: Social Graph
- Domain: Fan Club
- Domain: Discovery & Ranking
- Domain: Promotions
- Domain: Notifications
- Domain: Moderation
- Domain: Admin & Back Office
- Cross-Cutting: Observability
- Cross-Cutting: Jobs, Scheduling & Events
- Cross-Cutting: Security & Compliance
- Cross-Cutting: Testing Strategy
- Cross-Cutting: Deployment & Operations
- Cross-Cutting: Documentation
- Appendices
1. Executive Summary
Loreax is a content platform where creators publish exclusive content (video, audio, images, text, links, polls, livestreams) and monetize via three combinable access paths: tier subscriptions, one-off purchases, and free-for-active-fans allowances. The platform handles real money end-to-end — top-ups, purchases, creator earnings, and MPESA-denominated withdrawals — through a double-entry ledger.
This document is the canonical technical design for the MVP backend. It specifies the domain-driven decomposition, entity model, contracts, endpoints, and implementation conventions that all engineers and code-generation agents must follow.
1.1 Product Pillars
- Creator monetization — flexible access rules per post, tier subscriptions, promotions, and fast MPESA payouts.
- Viewer experience — discover, purchase, subscribe, save, follow, and join fan clubs with minimal friction.
- Back-office control — Filament-powered admin with fine-grained scopes, audit trails, and configurable platform policies.
- Real-money integrity — every cent traceable via an immutable double-entry ledger.
- Observability by default — every request, job, and state change logged with correlation IDs.
1.2 Non-Goals (v1)
- Native mobile apps (web + PWA only — mobile designs inform API shape)
- Tips/donations (deferred to v2)
- PayPal & bank payouts (MPESA only in v1)
- Explicit content capability (SFW by default; capability stubbed behind config flag)
- AI pre-screen and manual pre-screen moderation modes (stubbed; default is
reactive_only) - Elasticsearch / Meilisearch (custom
DiscoveryServicefor v1) - Multi-tenant organizations / agencies
- Internationalized content (English only; i18n infrastructure scaffolded)
1.3 Success Criteria
| Criterion | Target |
|---|---|
| Test coverage | ≥ 80% (enforced in CI) |
| p95 API latency (read) | < 300 ms |
| p95 API latency (write) | < 600 ms |
| Ledger integrity (sum of signed entries per transaction) | = 0, always |
| MPESA top-up success rate | ≥ 98% |
| Uptime target | 99.5% (MVP; upgrade to 99.9% post-GA) |
| OpenAPI coverage | 100% of public endpoints |
2. Tech Stack
2.1 Runtime & Language
- PHP 8.4+ —
declare(strict_types=1)on every file,final classby default. Projects are free to use PHP 8.4 language features (property hooks, asymmetric visibility,#[\Override],array_find/array_any/array_all) where they clarify intent. - Laravel 12.x — framework (PHP 8.4 compatible)
- Composer 2.x — dependency management
2.2 Core Laravel Packages
| Package | Purpose |
|---|---|
laravel/sanctum |
API authentication (SPA cookies + personal access tokens) |
laravel/horizon |
Queue worker management with Redis |
laravel/scout |
Scaffolded but unused in v1 (DiscoveryService is custom) |
lorisleiva/laravel-actions |
Business logic layer — thin controllers delegate to Actions |
spatie/laravel-permission |
Role → scope mapping (scopes static, roles DB-backed) |
spatie/laravel-activitylog |
System-wide audit trail |
spatie/laravel-data |
DTOs for request/response payloads |
spatie/laravel-query-builder |
Query-string filtering/sorting for list endpoints |
propaganistas/laravel-phone |
Phone number validation/formatting (MPESA) |
simplesoftwareio/simple-qrcode |
TOTP QR code generation |
pragmarx/google2fa-laravel |
TOTP MFA provider |
laravel/socialite |
OAuth with Google/Facebook/LinkedIn |
socialiteproviders/apple |
Apple Sign-In |
intervention/image |
Self-hosted image resizing |
spatie/laravel-medialibrary |
Media attachment library for posts and derived assets |
pbmedia/laravel-ffmpeg |
Laravel adapter around FFmpeg/FFprobe for queued video/audio processing |
bervant/laravel-sms |
Provider-agnostic SMS abstraction for notifications and MFA delivery |
bervant/kashier-laravel-sdk |
Laravel SDK for Kashier payment aggregation and MPESA/STK workflows |
league/flysystem-aws-s3-v3 |
S3-compatible storage adapter |
mongodb/mongodb |
MongoDB PHP driver for RequestLogger |
filament/filament |
Admin back office |
darkaonline/l5-swagger |
OpenAPI/Swagger generation from annotations |
2.3 Data Stores
| Store | Use |
|---|---|
| PostgreSQL 16+ (RDS) | Primary OLTP database |
| Redis 7+ | Cache, queues (Horizon), feed cache, session, rate limits, SSE pubsub |
| MongoDB 7+ (Atlas) | Request logs (via MongoRequestLogger) |
| AWS S3 | User media (disk abstracted; swap to local/R2 via env) |
2.4 External Services
| Service | Purpose | v1 Provider | Abstracted By |
|---|---|---|---|
| Payments (C2B/B2C) | MPESA-first top-ups & withdrawals | Kashier SDK package / Kashier gateway | IPaymentProvider |
| Video transcoding | Post video encoding | pbmedia/laravel-ffmpeg + local FFmpeg |
IVideoProvider |
| Audio transcoding | Post audio encoding | pbmedia/laravel-ffmpeg + local FFmpeg |
IAudioProvider |
| Image processing | Thumbnails, variants | Intervention Image | IImageProvider |
| Media attachment lifecycle | Persist media records, conversions, and responsive variants | spatie/laravel-medialibrary |
IMediaService |
| SMS delivery | OTP and critical notifications | bervant/laravel-sms with selectable driver |
INotificationService / MFA provider adapters |
| Livestream ingest/playback | RTMP → HLS | Mux Live | ILivestreamProvider |
| Transactional email | Account, receipts | AWS SES | Laravel Mail |
| Object storage | Media persistence | AWS S3 | Laravel Filesystem |
| AI moderation (stubbed) | Content review | OpenAI Moderation + Claude | IModerationAiProvider |
2.5 Infrastructure
- AWS EC2 — self-managed, Nginx + PHP-FPM
- Ansible — idempotent server provisioning (
deploy/ansible/) - Docker — parallel container setup (
Dockerfile,docker-compose.yml,docker-compose.prod.yml) - GitHub Actions — CI (lint, static analysis, tests, coverage) + CD (build → push → deploy)
- CloudWatch — infra metrics, log aggregation
- Route 53 — DNS
- ACM — TLS certificates
2.6 Tooling
| Tool | Purpose |
|---|---|
| PHPUnit 11+ | Test runner (unit + feature + contract + invariant) |
| Pint | Code style (Laravel preset with customizations) |
| Larastan (level 8) | Static analysis |
| Rector | Automated refactoring |
| OpenAPI Generator | Client SDK generation from swagger spec |
3. Architectural Standards & Conventions
3.1 Domain-Driven Monolith
Loreax is a modular monolith organized by bounded context, not by technical layer. Each domain owns its entities, actions, policies, events, and API contracts. Domains communicate via:
- Direct method calls through well-defined
I*contracts (preferred for synchronous read operations). - Domain events (
App\Domain\*\Events\*) dispatched via Laravel's event bus (for decoupled side effects). - Queued jobs for long-running or cross-cutting work.
No domain reaches directly into another domain's Eloquent models except through published contracts or events. Violations fail code review.
3.2 Directory Layout
app/
├── Core/ ← Cross-cutting infrastructure & contracts
│ ├── Contracts/ ← I*-prefixed interfaces (IRequestLogger, IIdentityService, ...)
│ ├── Infrastructure/
│ │ ├── Logging/ ← MongoRequestLogger and siblings
│ │ ├── Storage/ ← Filesystem providers
│ │ ├── Media/ ← Video/Audio/Image provider adapters
│ │ └── Payments/ ← Kashier-backed payment adapter
│ ├── Http/
│ │ ├── Middleware/ ← AssignRequestId, AssignTraceId, AssignConversationId, LogRequest, ResolveRealm, ...
│ │ ├── Responses/ ← Standardized API response shapes
│ │ └── Formatters/ ← camelCase ↔ snake_case translators
│ ├── Authorization/
│ │ ├── Scopes.php ← Typed scope constants
│ │ ├── ScopeRegistry.php ← Boot-time validator
│ │ └── PermissionGate.php
│ ├── Support/ ← Shared helpers, enums, value objects
│ └── Exceptions/ ← Platform-wide exception hierarchy
│
├── Identity/ ← Domain
│ ├── Contracts/ ← IIdentityService, IMfaProvider, ...
│ ├── Models/ ← User, AdminUser, Session, MfaChallenge, ...
│ ├── Actions/ ← RegisterUserAction, LoginAction, EnableMfaAction, ...
│ ├── Enums/ ← Realm, MfaProvider, ...
│ ├── Events/ ← UserRegistered, LoginSucceeded, MfaChallenged, ...
│ ├── Listeners/
│ ├── Policies/
│ ├── Data/ ← DTOs (RegisterUserData, LoginData, ...)
│ ├── Http/
│ │ ├── Controllers/
│ │ ├── Requests/
│ │ └── Resources/
│ └── Services/ ← IdentityService implementation
│
├── Ledger/ ← Domain
├── Payments/ ← Domain
├── Content/ ← Domain
├── Access/ ← Domain
├── Monetization/ ← Domain
├── Social/ ← Domain
├── FanClub/ ← Domain
├── Discovery/ ← Domain
├── Promotions/ ← Domain
├── Notifications/ ← Domain
├── Moderation/ ← Domain
└── AdminBackoffice/ ← Filament resources, custom admin actions
3.3 Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Class | PascalCase |
RegisterUserAction |
| Interface | I-prefixed PascalCase |
IRequestLogger, IIdentityService |
| Method | camelCase |
currentRealm() |
| Property | camelCase |
$requestId |
| DB table | snake_case, plural |
post_purchases, ledger_entries |
| DB column | snake_case |
created_at, withdrawable_after |
| API field (request/response) | camelCase |
{"phoneNumber": "..."} |
| Route | kebab-case |
/v1/post-purchases/{id} |
| Env var | SCREAMING_SNAKE_CASE |
APP_REQUEST_LOGGER_DB_CONNECTION |
| Config key | snake_case |
config('app.custom.service') |
| Event class | Past-tense PascalCase |
UserRegistered, PostPurchased |
| Action class | Verb + Action |
PurchasePostAction |
Payload translation: API payloads are camelCase; internal DB columns are snake_case. The translation happens in:
- Incoming:
FormRequest::validated()returns camelCase → DTO (spatie/laravel-datawith#[MapInputName]attributes converts to snake_case where needed) → Action. - Outgoing: Eloquent model →
JsonResourcetransformer returns camelCase payload.
A CamelCaseResource base class and SnakeCaseRequest trait centralize this.
3.4 Thin Controllers, Laravel Actions
Controllers are I/O adapters, nothing more. Every controller method:
- Receives a
FormRequest(validation + authorization). - Builds a DTO from validated input.
- Dispatches a single
Action::run(...). - Wraps the result in a
JsonResource. - Returns the HTTP response.
No business logic in controllers. No direct Eloquent in controllers. No domain events dispatched in controllers.
<?php
declare(strict_types=1);
namespace App\Identity\Http\Controllers;
use App\Core\Http\Controllers\Controller;
use App\Identity\Actions\RegisterUserAction;
use App\Identity\Data\RegisterUserData;
use App\Identity\Http\Requests\RegisterUserRequest;
use App\Identity\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;
final class RegisterUserController extends Controller
{
public function __construct(
private readonly RegisterUserAction $registerUser,
) {}
#[OA\Post(
path: '/v1/identity/register',
summary: 'Register a new user account',
tags: ['Identity'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: '#/components/schemas/RegisterUserRequest'),
),
responses: [
new OA\Response(response: 201, description: 'Created', content: new OA\JsonContent(ref: '#/components/schemas/UserResponse')),
new OA\Response(response: 422, description: 'Validation error'),
],
)]
public function __invoke(RegisterUserRequest $request): JsonResponse
{
$data = RegisterUserData::from($request->validated());
$user = $this->registerUser->run($data);
return UserResource::make($user)
->response()
->setStatusCode(201);
}
}
3.5 Actions Contract
Every action follows this shape:
<?php
declare(strict_types=1);
namespace App\Identity\Actions;
use App\Identity\Data\RegisterUserData;
use App\Identity\Events\UserRegistered;
use App\Identity\Models\User;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
final class RegisterUserAction
{
use AsAction;
/**
* Register a new user account.
*
* Preconditions:
* - Email is not already registered (verified or unverified)
* - Handle is available and valid
*
* Postconditions:
* - User row created with hashed password
* - Email verification sent
* - UserRegistered event dispatched
* - Referral attribution applied if referral cookie present
*
* @throws \App\Identity\Exceptions\EmailAlreadyRegisteredException
* @throws \App\Identity\Exceptions\HandleUnavailableException
*/
public function run(RegisterUserData $data): User
{
return DB::transaction(function () use ($data): User {
// ... implementation
$user = User::create([...]);
UserRegistered::dispatch($user);
return $user;
});
}
}
Rules:
- One action = one use case. No multi-purpose actions.
- Actions return domain models, not responses.
- Actions throw domain exceptions; controllers/exception handlers translate to HTTP.
- Side effects (events, jobs) are dispatched inside the action's transaction via
DB::afterCommit()where appropriate. - Actions are testable in isolation (no HTTP layer needed).
3.6 Eloquent Usage
- No repository pattern. Eloquent is the data-access primitive.
- Global scopes for universally applied filters (e.g.
SoftDeletingScope, platformTenantScopewhen multi-tenant lands). - Local scopes for reusable query fragments:
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PostStatus::Published);
}
public function scopeVisibleTo(Builder $query, User $viewer): Builder
{
return $query->whereHas('accessRules', fn ($q) => $q->visibleTo($viewer));
}
- Model events via observers, not closures in
boot(). - Strict mode enabled in
AppServiceProvider::boot():
Model::shouldBeStrict(! app()->environment('prod'));
3.7 DTOs & Validation
- Request validation:
FormRequestclasses inApp\<Domain>\Http\Requests\*. - DTOs:
spatie/laravel-dataclasses inApp\<Domain>\Data\*. - One DTO per logical input/output shape.
- DTOs carry typed properties and can be rehydrated from array/JSON.
<?php
declare(strict_types=1);
namespace App\Identity\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
final class RegisterUserData extends Data
{
public function __construct(
#[Email, Max(255)]
public string $email,
#[Max(72)]
public string $password,
#[Max(64)]
public string $firstName,
#[Max(64)]
public string $lastName,
#[Max(32)]
public string $handle,
public ?string $referralCode = null,
) {}
}
3.8 API Response Contract
The response shape is derived from HTTP status code, not a payload flag. Three shapes exist.
A. Success (2xx status codes) — normal state or resource payload
{
"message": "Created",
"data": { ... },
"meta": {
"requestId": "01HQ...",
"traceId": "0af7651916cd43dd8448eb211c80319c",
"timestamp": "2026-04-20T14:05:00Z"
}
}
message is a short human-readable summary appropriate for UI toasts. Defaults to the HTTP reason phrase ("OK", "Created", "Accepted") when not set explicitly.
B. Validation Error (422 Unprocessable Entity) — input validation failed
{
"message": "Invalid input",
"errors": {
"email": ["The email field is required."],
"amount": ["Must be a positive integer."]
},
"meta": {
"requestId": "01HQ...",
"traceId": "0af7651916cd43dd8448eb211c80319c",
"timestamp": "2026-04-20T14:05:00Z"
}
}
Always emitted by ValidationException (Laravel's default). The errors object maps field paths (camelCase, matching request input) to arrays of translated messages.
C. Application Error (430 Loreax Application Error + other non-validation 4xx/5xx) — business rule violation or infrastructure failure
{
"errorCode": "INSUFFICIENT_FUNDS",
"message": "Wallet balance is lower than the requested withdrawal amount.",
"meta": {
"requestId": "01HQ...",
"traceId": "0af7651916cd43dd8448eb211c80319c",
"timestamp": "2026-04-20T14:05:00Z"
}
}
errorCodeis a stableSCREAMING_SNAKE_CASEidentifier suitable for client-sideswitchstatements. The catalog lives inApp\Core\Exceptions\ErrorCodeenum; every domain exception exposes anErrorCodecase.messageis a human-readable, user-safe explanation (never contains stack traces or internal system detail).
Status code → response shape:
| Status | Shape | Used for |
|---|---|---|
| 200 OK | A | Successful reads/updates |
| 201 Created | A | Successful creates |
| 202 Accepted | A | Async enqueued |
| 204 No Content | (empty body) | Deletes, idempotent noops |
| 400 Bad Request | C | Malformed JSON, bad headers |
| 401 Unauthenticated | C | Missing/invalid auth |
| 403 Forbidden | C | Missing scope / policy denied |
| 404 Not Found | C | Missing resource |
| 409 Conflict | C | Idempotency/state conflict |
| 422 Unprocessable Entity | B | Validation failure |
| 429 Too Many Requests | C | Rate limited |
| 430 Loreax Application Error | C | Business rule violation (insufficient funds, already purchased, tier archived, handle cooldown, MFA challenge required, etc.) |
| 500 Internal Server Error | C | Unhandled exception |
| 502 Bad Gateway | C | Downstream provider (MPESA, Mux) failed |
| 503 Service Unavailable | C | Maintenance mode / queue saturated |
Why 430? Distinct from 422 ("your payload was malformed") and 409 ("stateful conflict") to give clients a predictable switch on "your payload was valid and parsed, but a business rule says no." 430 is an application-space status code in the 4xx range, treated as a client-addressable error by standard HTTP libraries while signaling that the response carries a stable errorCode the client branches on. The distinction lets front-ends render field-level feedback (422) versus toast-level error messaging (430) without string-sniffing.
Meta fields:
| Field | Source |
|---|---|
requestId |
ULID. X-Request-ID header if supplied by client; otherwise generated by AssignRequestId middleware. Also echoed in the X-Request-ID response header. |
traceId |
W3C Trace Context trace ID. Extracted from traceparent header if present; auto-generated (W3C-compliant 32-hex-char) if absent. Also echoed in the traceparent response header for downstream propagation. |
timestamp |
now()->toIso8601String() at response build time (UTC). |
conversationId (multi-request correlation for long user journeys) remains captured internally via X-Conversation-ID header and included in RequestLog entries, but is not included in the response meta — it is a server-side observability field only.
3.9 Immutable, Idempotent Writes
Every mutating endpoint accepts an optional Idempotency-Key header. Payment-related endpoints (/v1/top-ups, /v1/withdrawals, /v1/purchases) require it. The key + user + endpoint is stored in Redis with a 24h TTL; replays return the cached response.
3.10 Pagination
All list endpoints return cursor-based pagination:
{
"data": [...],
"meta": {
"cursor": { "next": "...", "prev": "..." },
"perPage": 20
}
}
Cursor encodes (sort_column, id) tuples, base64-URL-encoded.
3.11 Filament Back Office
- Separate guard:
admin - Separate login route:
/admin/login - Every Filament resource declares its required scope:
protected static ?Scope $requiredScope = Scope::PostReadWrite;
- Destructive actions require a
reasonfield; two-person approval onWithdrawal.Approval,Refund.Approval,Ledger.Approval.
3.12 Documentation Standards
- PHPDoc on every public method — purpose, preconditions, postconditions, thrown exceptions.
- OpenAPI attributes on every public controller method (uses
zircote/swagger-php). - Domain README in every
app/<Domain>/README.md— purpose, owned entities, key invariants, external dependencies. - ADRs (Architecture Decision Records) in
docs/adr/for significant architectural choices.
4. Domain Map
┌─────────────────────────────────────────────────────────────────┐
│ Infrastructure (Core) │
│ Response envelopes • Middleware • Logging • Errors • Scopes │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Identity │ │ Ledger & │ │ Observability│
│ │ │ Wallet │ │ (cross-cut) │
└────┬─────┘ └─────┬────┘ └──────────────┘
│ │
├────┬───────────┼───────────┬────────┐
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐
│Social│ │Content │ │Payments│ │Admin │ │Moderation│
└──┬───┘ └───┬────┘ └────────┘ └──────┘ └──────────┘
│ │
▼ ▼
┌────────┐ ┌────────────┐
│FanClub │ │ Access & │
│ │ │Entitlements│
└────────┘ └──────┬─────┘
│
▼
┌──────────────┐ ┌──────────┐
│Monetization │───▶│Promotions│
│(tiers, price)│ └──────────┘
└──────┬───────┘
│
▼
┌───────────┐
│ Discovery │
│ & Ranking │
└───────────┘
│
▼
┌──────────────┐
│Notifications │
└──────────────┘
Dependency rules:
- Infrastructure (Core) depends on nothing in the domain layer.
- Identity, Ledger, and Observability are foundational — depended upon by most domains.
- Content & Access have a tight mutual relationship — Access rules are a property of a Post but live in their own domain for reuse.
- Discovery reads from every domain but writes only to its own tables (caches + interactions).
- Notifications is the terminal sink — receives events from every domain, emits nothing.
5. Domain: Infrastructure
5.1 Responsibility
Infrastructure defines the shape of the system before any business domain exists:
- Standard response envelope & error contract
- Standard middleware pipeline
- Request correlation (requestId, conversationId)
- Authentication guards & realm resolution
- Rate limiting
- Exception translation (domain → HTTP)
- CamelCase ↔ snake_case translation
- Scope dictionary & permission gate registration
- Health checks & readiness probes
- Feature flags
- Platform settings (runtime-tunable config)
This domain has no Eloquent models of its own other than PlatformSetting and FeatureFlag.
5.2 Middleware Pipeline
In order (outermost first):
TrustProxies— AWS ELB forwardingAssignRequestId— generate ULID ifX-Request-IDabsent; attach to$request->attributesasrequest_idAssignTraceId— parsetraceparentheader (W3C Trace Context); if absent, generate W3C-compliant trace-id (32 hex chars) + span-id (16 hex chars); attach to$request->attributesastrace_id/span_id; set outboundtraceparentheader on responseAssignConversationId—X-Conversation-IDheader or fall back torequestId(internal only; not emitted in response meta)ResolveClientContext— captureX-Platform,X-Scenario,X-App-VersionheadersHandleCorsForceHttps(prodonly)ResolveRealm— setrealmattribute based on matched route group (useroradmin)RateLimit— tiered limiter per route group- Authentication guard —
auth:sanctum(user) orauth:admin(Filament) VerifyEmailIfRequired— enforce email verification on write endpointsEnforceMfaChallenge— challenge MFA for sensitive endpointsCaptureProcessorName— route-matched Action FQCN attached to requestTranslateCamelToSnake— inbound payload normalization- Controller
TranslateSnakeToCamel— outbound payload normalization (viaJsonResource)LogRequest(terminable) — persists to MongoDB viaIRequestLogger
5.3 Standard Response Envelope
All responses pass through ApiResponse:
<?php
declare(strict_types=1);
namespace App\Core\Http\Responses;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response as HttpStatus;
final class ApiResponse implements Responsable
{
private function __construct(
private readonly int $status,
private readonly ?string $message = null,
private readonly mixed $data = null,
private readonly ?string $errorCode = null,
private readonly ?array $validationErrors = null,
) {}
// ─── Success ─────────────────────────────────────────────────────
public static function ok(mixed $data = null, ?string $message = 'OK'): self
{
return new self(status: 200, message: $message, data: $data);
}
public static function created(mixed $data, ?string $message = 'Created'): self
{
return new self(status: 201, message: $message, data: $data);
}
public static function accepted(mixed $data = null, ?string $message = 'Accepted'): self
{
return new self(status: 202, message: $message, data: $data);
}
public static function noContent(): self
{
return new self(status: 204);
}
// ─── Validation Error (shape B) ──────────────────────────────────
public static function validationError(array $errors, string $message = 'Invalid input'): self
{
return new self(status: 422, message: $message, validationErrors: $errors);
}
// ─── Application Error (shape C) ─────────────────────────────────
public static function applicationError(string $errorCode, string $message): self
{
return new self(status: 430, message: $message, errorCode: $errorCode);
}
/** Generic non-422 error (401, 403, 404, 409, 429, 500, 502, 503). */
public static function error(int $status, string $errorCode, string $message): self
{
return new self(status: $status, message: $message, errorCode: $errorCode);
}
public function toResponse($request): JsonResponse
{
if ($this->status === HttpStatus::HTTP_NO_CONTENT) {
return response()->json(null, 204);
}
$meta = [
'requestId' => (string) $request->attributes->get('request_id'),
'traceId' => (string) $request->attributes->get('trace_id'),
'timestamp' => now()->toIso8601String(),
];
$body = match (true) {
$this->validationErrors !== null => [
'message' => $this->message,
'errors' => $this->validationErrors,
'meta' => $meta,
],
$this->errorCode !== null => [
'errorCode' => $this->errorCode,
'message' => $this->message,
'meta' => $meta,
],
default => [
'message' => $this->message,
'data' => $this->data,
'meta' => $meta,
],
};
return response()->json($body, $this->status);
}
}
5.4 Exception Handler
App\Core\Exceptions\Handler translates exceptions into the response shapes from §3.8:
ValidationException→ shape B (422), witherrorsextracted from the validatorAuthenticationException,AuthorizationException,ModelNotFoundException,ThrottleRequestsException, generic HTTP exceptions → shape C with standard status + stableerrorCodeDomainExceptionsubclasses → shape C with status430by default (overrideable) anderrorCodefrom the attachedErrorCodecase- Unhandled
\Throwable→ shape C (500) witherrorCode=INTERNAL_ERROR; message scrubbed to generic text inprod
| Exception | Status | Error Code |
|---|---|---|
AuthenticationException |
401 | UNAUTHENTICATED |
AuthorizationException |
403 | INSUFFICIENT_SCOPE |
ModelNotFoundException |
404 | NOT_FOUND |
ValidationException |
422 | (shape B — no errorCode) |
ThrottleRequestsException |
429 | RATE_LIMITED |
InsufficientFundsException |
430 | INSUFFICIENT_FUNDS |
PostAlreadyPurchasedException |
430 | POST_ALREADY_PURCHASED |
TierArchivedException |
430 | TIER_ARCHIVED |
WithdrawalBelowMinimumException |
430 | WITHDRAWAL_BELOW_MINIMUM |
WithdrawalAboveDailyLimitException |
430 | WITHDRAWAL_ABOVE_DAILY_LIMIT |
MfaChallengeRequiredException |
430 | MFA_CHALLENGE_REQUIRED |
MfaCodeInvalidException |
430 | MFA_CODE_INVALID |
HandleCooldownNotElapsedException |
430 | HANDLE_COOLDOWN_NOT_ELAPSED |
EmailAlreadyRegisteredException |
430 | EMAIL_ALREADY_REGISTERED |
HandleUnavailableException |
430 | HANDLE_UNAVAILABLE |
IdempotencyConflictException |
409 | IDEMPOTENCY_CONFLICT |
PaymentProviderException |
502 | PAYMENT_PROVIDER_FAILED |
LedgerIntegrityException |
500 | LEDGER_INTEGRITY_VIOLATION (alerts ops) |
\Throwable (catch-all) |
500 | INTERNAL_ERROR |
Base domain exception:
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
abstract class DomainException extends \Exception
{
abstract public function errorCode(): ErrorCode;
/** Default: business rule violation → 430. Override for infra/other. */
public function httpStatus(): int
{
return 430;
}
}
ErrorCode enum (full catalog grows per domain; extract):
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
enum ErrorCode: string
{
case Unauthenticated = 'UNAUTHENTICATED';
case InsufficientScope = 'INSUFFICIENT_SCOPE';
case NotFound = 'NOT_FOUND';
case RateLimited = 'RATE_LIMITED';
case IdempotencyConflict = 'IDEMPOTENCY_CONFLICT';
case InsufficientFunds = 'INSUFFICIENT_FUNDS';
case PostAlreadyPurchased = 'POST_ALREADY_PURCHASED';
case TierArchived = 'TIER_ARCHIVED';
case WithdrawalBelowMinimum = 'WITHDRAWAL_BELOW_MINIMUM';
case WithdrawalAboveDailyLimit = 'WITHDRAWAL_ABOVE_DAILY_LIMIT';
case MfaChallengeRequired = 'MFA_CHALLENGE_REQUIRED';
case MfaCodeInvalid = 'MFA_CODE_INVALID';
case HandleCooldownNotElapsed = 'HANDLE_COOLDOWN_NOT_ELAPSED';
case HandleUnavailable = 'HANDLE_UNAVAILABLE';
case EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED';
case PaymentProviderFailed = 'PAYMENT_PROVIDER_FAILED';
case LedgerIntegrityViolation = 'LEDGER_INTEGRITY_VIOLATION';
case InternalError = 'INTERNAL_ERROR';
// ... extended per domain; each new case PR updates this file + tests + API docs
}
Exception example:
<?php
declare(strict_types=1);
namespace App\Payments\Exceptions;
use App\Core\Exceptions\DomainException;
use App\Core\Exceptions\ErrorCode;
final class WithdrawalBelowMinimumException extends DomainException
{
public function __construct(int $attemptedAmount, int $minimumAmount)
{
parent::__construct(sprintf(
'Withdrawal of %d is below the minimum allowed (%d).',
$attemptedAmount,
$minimumAmount,
));
}
public function errorCode(): ErrorCode
{
return ErrorCode::WithdrawalBelowMinimum;
}
}
Every domain exception must include a unit test asserting (a) the errorCode() returns the intended case and (b) a controller throwing it produces the expected 430 + shape C response.
5.5 Rate Limiting
config/rate_limits.php:
return [
'auth' => ['decayMinutes' => 1, 'max' => 10, 'by' => 'ip'],
'payment' => ['decayMinutes' => 1, 'max' => 5, 'by' => 'user'],
'write' => ['decayMinutes' => 1, 'max' => 60, 'by' => 'user'],
'read' => ['decayMinutes' => 1, 'max' => 300, 'by' => 'user'],
'public_read' => ['decayMinutes' => 1, 'max' => 60, 'by' => 'ip'],
'admin' => ['decayMinutes' => 1, 'max' => 120, 'by' => 'admin'],
];
Applied via RateLimit::for('auth', fn ($req) => Limit::perMinute(10)->by($req->ip())) in RouteServiceProvider.
5.6 Platform Settings
Runtime-tunable config keyed by dotted namespace:
platform_settings table:
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
key |
string, unique | e.g. payment.platform_fee_rate |
value |
jsonb | Strongly typed per key |
description |
text | Human-readable explanation |
updated_by_admin_id |
bigint, FK → admin_users | Last editor |
updated_at |
timestamptz |
Managed via Filament PlatformSetting resource (PlatformSetting.ReadWrite scope). Accessed in code via app(IPlatformSettings::class)->get('payment.platform_fee_rate') — cached in Redis with cache-tag invalidation on write.
Canonical keys (v1):
| Key | Type | Default | Owner Domain |
|---|---|---|---|
payment.platform_fee_rate |
decimal | 0.15 |
Monetization |
payment.premium_fee_rate |
decimal | 0.12 |
Monetization |
payment.top_up_min |
int | 5000 (Ksh 50, minor units) |
Payments |
payment.top_up_max |
int | 7000000 (Ksh 70k, minor units) |
Payments |
payment.withdrawal_min |
int | 50000 (Ksh 500, minor units) |
Payments |
payment.withdrawal_max_per_txn |
int | 15000000 (minor units) |
Payments |
payment.withdrawal_max_per_day |
int | 30000000 (minor units) |
Payments |
payment.withdrawal_max_per_day_count |
int | 3 |
Payments |
payment.withdrawal_max_per_day_count_premium |
int | 6 |
Payments |
payment.earnings_hold_days |
int | 3 |
Payments |
payment.mpesa_callback_poll_interval_seconds |
int | 5 |
Payments |
payment.mpesa_callback_poll_timeout_seconds |
int | 120 |
Payments |
content.access_rules_enabled |
array<string> |
["tier_gated","one_off_purchase","free_for_active_fans","public_free"] |
Content |
content.explicit_content_enabled |
bool | false |
Content |
discovery.ranker_weights |
json | (see §14) | Discovery |
discovery.active_fan_weights |
json | (see §10) | Access |
discovery.active_fan_threshold |
decimal | 50.0 |
Access |
moderation.mode |
enum | reactive_only |
Moderation |
referral.commission_rate |
decimal | 0.10 |
Promotions |
referral.attribution_window_days |
int | 30 |
Promotions |
referral.welcome_credit |
int | 10000 (Ksh 100, minor units) |
Promotions |
notifications.digest_hour |
int | 8 |
Notifications |
5.7 Feature Flags
Separate from platform settings: binary toggles for in-progress features.
feature_flags table:
| Column | Type |
|---|---|
id |
bigint, PK |
key |
string, unique |
is_enabled |
bool |
rollout_percentage |
int (0-100, for gradual rollout) |
allowed_user_ids |
jsonb (override: specific users always see it) |
description |
text |
updated_by_admin_id |
bigint |
updated_at |
timestamptz |
Accessed via app(IFeatureFlags::class)->enabled('livestream', $user).
5.8 Scope Dictionary
Static, PHP enum–driven. The scope dictionary is a single backed string enum, which is the canonical source of truth for:
- Application code (
authorizeScope(Scope::LedgerRead)) - The Filament admin UI (dropdowns, role editor)
- The seeder that populates DB-backed roles (
role_scopesstores thevalue) - Static analysis (custom Larastan rule forbids raw string literals matching
.*\\.(Read|ReadWrite|Approval)outside the enum file)
<?php
declare(strict_types=1);
namespace App\Core\Authorization;
enum Scope: string
{
// Identity
case UserRead = 'User.Read';
case UserReadWrite = 'User.ReadWrite';
case AdminUserRead = 'AdminUser.Read';
case AdminUserReadWrite = 'AdminUser.ReadWrite';
case RoleRead = 'Role.Read';
// Creator lifecycle
case CreatorRead = 'Creator.Read';
case CreatorReadWrite = 'Creator.ReadWrite';
case CreatorApproval = 'Creator.Approval';
case VerificationRequestRead = 'VerificationRequest.Read';
case VerificationRequestReadWrite = 'VerificationRequest.ReadWrite';
case VerificationRequestApproval = 'VerificationRequest.Approval';
// Content
case PostRead = 'Post.Read';
case PostReadWrite = 'Post.ReadWrite';
case PostApproval = 'Post.Approval';
case CommentRead = 'Comment.Read';
case CommentReadWrite = 'Comment.ReadWrite';
case CollectionRead = 'Collection.Read';
case CollectionReadWrite = 'Collection.ReadWrite';
case TaxonomyRead = 'Taxonomy.Read';
case TaxonomyReadWrite = 'Taxonomy.ReadWrite';
// Finance
case LedgerRead = 'Ledger.Read';
case LedgerReadWrite = 'Ledger.ReadWrite';
case LedgerApproval = 'Ledger.Approval';
case WalletRead = 'Wallet.Read';
case TopUpRead = 'TopUp.Read';
case TopUpReadWrite = 'TopUp.ReadWrite';
case WithdrawalRead = 'Withdrawal.Read';
case WithdrawalReadWrite = 'Withdrawal.ReadWrite';
case WithdrawalApproval = 'Withdrawal.Approval';
case RefundRead = 'Refund.Read';
case RefundReadWrite = 'Refund.ReadWrite';
case RefundApproval = 'Refund.Approval';
// Monetization
case TierRead = 'Tier.Read';
case TierSubscriptionRead = 'TierSubscription.Read';
case PremiumSubscriptionRead = 'PremiumSubscription.Read';
case PremiumSubscriptionReadWrite = 'PremiumSubscription.ReadWrite';
// Moderation
case ReportRead = 'Report.Read';
case ReportReadWrite = 'Report.ReadWrite';
// Promotions
case PromoCodeRead = 'PromoCode.Read';
case PromoCodeReadWrite = 'PromoCode.ReadWrite';
case BoostedPostRead = 'BoostedPost.Read';
case BoostedPostReadWrite = 'BoostedPost.ReadWrite';
case BoostedPostApproval = 'BoostedPost.Approval';
case ReferralRead = 'Referral.Read';
// Platform
case PlatformSettingRead = 'PlatformSetting.Read';
case PlatformSettingReadWrite = 'PlatformSetting.ReadWrite';
case FeatureFlagRead = 'FeatureFlag.Read';
case FeatureFlagReadWrite = 'FeatureFlag.ReadWrite';
case NicheRead = 'Niche.Read';
case NicheReadWrite = 'Niche.ReadWrite';
case CategoryRead = 'Category.Read';
case CategoryReadWrite = 'Category.ReadWrite';
case FanClubRead = 'FanClub.Read';
case FanClubReadWrite = 'FanClub.ReadWrite';
// Observability & governance
case AuditLogRead = 'AuditLog.Read';
case RequestLogRead = 'RequestLog.Read';
case ApprovalRequestRead = 'ApprovalRequest.Read';
case NotificationRead = 'Notification.Read';
case NotificationReadWrite = 'Notification.ReadWrite';
case SystemMaintenance = 'System.Maintenance';
/** Human-readable description surfaced in Filament role editor. */
public function description(): string
{
return match ($this) {
self::UserRead => 'View user accounts (non-PII scope)',
self::UserReadWrite => 'Edit user accounts, reset passwords, impersonate',
self::CreatorApproval => 'Grant/revoke notable verification',
self::LedgerRead => 'View ledger transactions & entries',
self::LedgerReadWrite => 'Request manual adjustment',
self::LedgerApproval => 'Approve manual adjustments (two-person)',
self::WithdrawalApproval => 'Force-fail / manually settle withdrawals (two-person)',
self::RefundApproval => 'Approve refunds (two-person)',
self::AdminUserReadWrite => 'Create/edit admin users (Super Admin only)',
self::SystemMaintenance => 'Enter maintenance mode, emergency feature flag toggles',
// ... exhaustive match arm per case, no default
};
}
/** Grouping for UI / docs. */
public function group(): ScopeGroup
{
return match (true) {
str_starts_with($this->value, 'User.'),
str_starts_with($this->value, 'AdminUser.'),
str_starts_with($this->value, 'Role.') => ScopeGroup::Identity,
str_starts_with($this->value, 'Ledger.'),
str_starts_with($this->value, 'Wallet.'),
str_starts_with($this->value, 'TopUp.'),
str_starts_with($this->value, 'Withdrawal.'),
str_starts_with($this->value, 'Refund.') => ScopeGroup::Finance,
str_starts_with($this->value, 'Post.'),
str_starts_with($this->value, 'Comment.'),
str_starts_with($this->value, 'Collection.'),
str_starts_with($this->value, 'Taxonomy.'),
str_starts_with($this->value, 'Niche.'),
str_starts_with($this->value, 'Category.') => ScopeGroup::Content,
str_starts_with($this->value, 'Report.') => ScopeGroup::Moderation,
str_starts_with($this->value, 'Creator.'),
str_starts_with($this->value, 'VerificationRequest.'),
str_starts_with($this->value, 'Tier.'),
str_starts_with($this->value, 'PremiumSubscription.'),
str_starts_with($this->value, 'PromoCode.'),
str_starts_with($this->value, 'BoostedPost.'),
str_starts_with($this->value, 'Referral.') => ScopeGroup::Monetization,
str_starts_with($this->value, 'AuditLog.'),
str_starts_with($this->value, 'RequestLog.'),
str_starts_with($this->value, 'ApprovalRequest.') => ScopeGroup::Observability,
default => ScopeGroup::Platform,
};
}
/** True for `.Approval`-suffixed scopes — must use `ApprovalRequest` workflow. */
public function requiresDualApproval(): bool
{
return str_ends_with($this->value, '.Approval');
}
}
Companion enum for UI grouping:
<?php
declare(strict_types=1);
namespace App\Core\Authorization;
enum ScopeGroup: string
{
case Identity = 'Identity';
case Finance = 'Finance';
case Content = 'Content';
case Moderation = 'Moderation';
case Monetization = 'Monetization';
case Observability = 'Observability';
case Platform = 'Platform';
}
Usage:
// Authorize
$this->identity->authorizeScope(Scope::LedgerRead);
// Filament resource declaration
protected static ?Scope $requiredScope = Scope::PostReadWrite;
// Role seeding (role_scopes.scope column stores the string value)
Role::super_admin()->scopes()->sync(
array_map(fn (Scope $s) => $s->value, Scope::cases())
);
// Two-person gate at runtime
if ($scope->requiresDualApproval()) {
ApprovalRequest::open($approvable, $scope, $currentAdmin, $reason);
return;
}
ScopeRegistry validates at boot:
- Every enum case has a description arm (tested via
ScopeRegistryTest::EveryCase_HasDescription). - Every role seeder references only existing enum cases (impossible to regress — enum typing).
- DB
role_scopes.scoperows all correspond to current enum values (any dangling row blocks deploy).
5.9 Health & Readiness
Two endpoints:
GET /health— liveness probe. Returns 200 if PHP is running. No DB check.GET /ready— readiness probe. Checks: PostgreSQL reachable, Redis reachable, MongoDB reachable, S3 reachable (HEAD on bucket), external payment provider reachable (cached 30s). Returns 200 only if all green.
5.10 Endpoints (Infrastructure-owned)
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/health |
public | Liveness |
GET |
/ready |
public | Readiness |
GET |
/v1/platform/settings/public |
public | Subset of settings safe to expose (fee rate, limits for UI) |
GET |
/v1/platform/feature-flags |
user | Flags for the current user |
6. Domain: Identity
6.1 Responsibility
Owns all authentication, authorization foundations, user account lifecycle, and realm resolution. Split across two realms:
- Realm
user— end users (viewers and creators; the distinction is a status, not a table). - Realm
admin— Filament back-office administrators.
A Realm enum is the system-wide source of truth:
<?php
declare(strict_types=1);
namespace App\Identity\Enums;
enum Realm: string
{
case User = 'user';
case Admin = 'admin';
}
6.2 Entities
6.2.1 users
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
public_id |
ulid, unique | External identifier |
email |
citext, unique | Case-insensitive |
email_verified_at |
timestamptz, nullable | |
password |
string, nullable | Nullable for social-only accounts |
handle |
citext, unique | URL-safe, [a-z0-9_]{3,32}, case-insensitive |
handle_changed_at |
timestamptz, nullable | For 30-day cooldown |
first_name |
string | |
last_name |
string | |
display_name |
string, nullable | Fallback to {first} {last} |
bio |
string(280), nullable | |
avatar_path |
string, nullable | S3 key |
banner_path |
string, nullable | S3 key |
city |
string, nullable | |
country_code |
char(2), nullable | ISO 3166-1 alpha-2 |
phone_number |
string, nullable | E.164 format |
phone_verified_at |
timestamptz, nullable | |
niche_id |
bigint, FK → niches, nullable | |
is_creator |
bool, default false | Flipped true on first post or tier creation |
is_notable_verified |
bool, default false | Admin-granted |
is_profile_public |
bool, default true | |
has_explicit_content |
bool, default false | Creator self-attestation |
mfa_enabled |
bool, default false | |
mfa_provider |
enum('totp','otp_sms','otp_email','passkey'), nullable | |
mfa_secret |
text, nullable | Encrypted via Laravel Crypt cast |
mfa_backup_codes |
jsonb, nullable | Encrypted; 8 single-use codes |
mfa_confirmed_at |
timestamptz, nullable | |
locale |
string(5), default 'en' | |
timezone |
string(64), default 'Africa/Nairobi' | |
last_login_at |
timestamptz, nullable | |
last_login_ip |
inet, nullable | |
lifetime_earnings_cents |
bigint, default 0 | Denormalized, reconciled from ledger nightly |
created_at |
timestamptz | |
updated_at |
timestamptz | |
deleted_at |
timestamptz, nullable | Soft delete; GDPR/KDPA erasure grace period |
Indexes: email, handle, niche_id, is_creator, created_at, (is_creator, created_at) for creator directory.
6.2.2 admin_users
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
public_id |
ulid, unique | |
email |
citext, unique | |
password |
string | Required |
first_name |
string | |
last_name |
string | |
mfa_enabled |
bool, default false | Always required for admins (enforced in middleware, not at signup) |
mfa_provider |
enum | |
mfa_secret |
text, encrypted | |
mfa_backup_codes |
jsonb, encrypted | |
mfa_confirmed_at |
timestamptz, nullable | |
is_active |
bool, default true | Soft-disable without delete |
last_login_at |
timestamptz, nullable | |
last_login_ip |
inet, nullable | |
created_by_admin_id |
bigint, FK → admin_users, nullable | Who provisioned this admin |
created_at |
timestamptz | |
updated_at |
timestamptz |
No public signup. Provisioned via seeders (initial Super Admin) or Filament by existing Super Admin.
6.2.3 roles
| Column | Type |
|---|---|
id |
bigint, PK |
name |
string, unique (e.g. super_admin) |
display_name |
string (e.g. Super Admin) |
description |
text |
Seeded from config/roles.php. Managed via RoleSeeder — reseeding is idempotent and reverts manual drift.
6.2.4 role_scopes
| Column | Type |
|---|---|
id |
bigint, PK |
role_id |
bigint, FK |
scope |
string (matches a Scope enum case value) |
Unique on (role_id, scope). Seeded alongside roles.
6.2.5 admin_user_roles
| Column | Type |
|---|---|
admin_user_id |
bigint, FK |
role_id |
bigint, FK |
assigned_at |
timestamptz |
assigned_by_admin_id |
bigint, FK |
Unique on (admin_user_id, role_id). An admin can hold multiple roles; effective scopes = union.
6.2.6 social_accounts
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK → users |
provider |
enum('google','apple','facebook','linkedin') |
provider_user_id |
string |
provider_email |
string, nullable |
linked_at |
timestamptz |
Unique on (provider, provider_user_id). One user may link multiple providers.
6.2.7 handle_history
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
old_handle |
citext |
released_at |
timestamptz |
created_at |
timestamptz |
Queried during handle availability check: handle is unavailable if currently held by any user OR present in handle_history with released_at > now().
6.2.8 email_verifications
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
token_hash |
string |
expires_at |
timestamptz |
consumed_at |
timestamptz, nullable |
6.2.9 password_reset_tokens
Standard Laravel table, augmented with ip_address, user_agent, consumed_at.
6.2.10 mfa_challenges
| Column | Type |
|---|---|
id |
bigint, PK |
challenger_type |
string (user/admin_user — polymorphic) |
challenger_id |
bigint |
provider |
enum |
challenge_token |
string, unique |
payload |
jsonb, nullable |
verified_at |
timestamptz, nullable |
expires_at |
timestamptz |
created_at |
timestamptz |
6.2.11 personal_access_tokens
Standard Sanctum table. Augmented with device_name, last_used_ip, last_used_user_agent.
6.2.12 user_consents
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
consent_type |
enum('terms_of_service','privacy_policy','marketing_emails','explicit_content_acknowledgment','age_verification') |
document_version |
string (e.g. 2026-04-20) |
granted_at |
timestamptz |
revoked_at |
timestamptz, nullable |
ip_address |
inet |
user_agent |
string |
6.3 Relationships
User hasMany SocialAccountsUser hasMany HandleHistoryUser hasMany UserConsentsUser hasOne Wallet(Ledger domain)User belongsTo Niche(Content domain)User hasMany Posts(Content domain — whenis_creator=true)AdminUser belongsToMany Rolesthroughadmin_user_rolesRole belongsToMany Scopesthroughrole_scopes
6.4 Key Services & Actions
IIdentityService (App\Identity\Contracts\IIdentityService)
Single domain-facing surface. No code reaches into auth() directly.
<?php
declare(strict_types=1);
namespace App\Identity\Contracts;
use App\Identity\Enums\Realm;
use App\Identity\Models\AdminUser;
use App\Identity\Models\User;
interface IIdentityService
{
public function currentUser(): ?User;
public function currentAdmin(): ?AdminUser;
public function currentRealm(): Realm;
public function isAuthenticated(): bool;
public function hasScope(string $scope): bool;
/** Throws \App\Core\Exceptions\AuthorizationException if missing. */
public function authorizeScope(string $scope): void;
}
Actions
| Action | Responsibility |
|---|---|
RegisterUserAction |
Create user, hash password, send verification email, apply referral |
VerifyEmailAction |
Consume token, mark email_verified_at |
LoginAction |
Credential check, MFA challenge issuance, session start |
LogoutAction |
Revoke current token / session |
RequestPasswordResetAction |
Generate token, send reset email, rate limit |
ResetPasswordAction |
Consume token, rehash, invalidate all sessions |
ChangePasswordAction |
Require current password, rehash, invalidate other sessions |
EnableMfaAction |
Initiate provider-specific setup (returns QR/secret for TOTP) |
ConfirmMfaAction |
Verify first code, set mfa_confirmed_at, generate backup codes |
DisableMfaAction |
Require re-auth + current MFA code; clear secret |
ChallengeMfaAction |
Issue MfaChallenge during sensitive op |
VerifyMfaChallengeAction |
Verify challenge code; mark verified_at |
LinkSocialAccountAction |
OAuth callback → attach SocialAccount to user; merge if email matches and is verified |
CompleteOnboardingAction |
Set handle, niche, bio, profile media (first-time user setup) |
UpdateProfileAction |
General profile edits |
ChangeUsernameAction |
Enforce 30-day cooldown, move old handle to handle_history |
UploadAvatarAction / UploadBannerAction |
Validate, process, persist |
ExportUserDataAction |
KDPA/GDPR data export — dispatches job returning signed S3 URL |
DeleteAccountAction |
Soft-delete; full erasure after 30-day grace |
GrantConsentAction |
Record a UserConsent row |
MFA Provider Interface
<?php
declare(strict_types=1);
namespace App\Identity\Contracts;
use App\Identity\Models\MfaChallenge;
interface IMfaProvider
{
public function identifier(): string; // 'totp' | 'otp_email' | ...
/** Returns provider-specific setup payload (e.g. TOTP secret + QR). */
public function initiate(mixed $challenger): array;
/** Creates a challenge record and returns the challenge token. */
public function challenge(mixed $challenger): MfaChallenge;
/** Verifies the user-supplied code against the challenge. */
public function verify(MfaChallenge $challenge, string $code): bool;
}
v1 implementations:
TotpMfaProvider(active)EmailOtpMfaProvider(active)SmsOtpMfaProvider(stubbed; throwsFeatureNotEnabledException)PasskeyMfaProvider(stubbed)
6.5 Social Login Flow
GET /v1/identity/social/{provider}/redirect→ returns provider authorization URL- User authorizes at provider
GET /v1/identity/social/{provider}/callback?code=...LinkSocialAccountActionruns:- Exchanges code for provider user
- If
social_accountsrow exists → log in that user - Else if user with same verified email exists → attach
SocialAccount, log in, audit log - Else → create new user (social-only, password=null), dispatch
UserRegisteredevent, log in
- Issue Sanctum token + redirect to app
6.6 MFA Enforcement Logic
Evaluated per-request by EnforceMfaChallenge middleware:
IF realm = admin → MFA required on every login session
IF realm = user:
IF current endpoint is in SENSITIVE_ENDPOINTS (withdrawals, enable MFA, change password, delete account)
→ MFA challenge required this session
IF user.lifetime_earnings_cents > 0 AND user.mfa_enabled = false
→ Block request with 403, error code `mfa_required_for_earnings`
SENSITIVE_ENDPOINTS is configurable in config/mfa.php.
6.7 Endpoints
All under /v1/identity/* unless noted.
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
POST |
/register |
public | — | Email/password registration |
POST |
/login |
public | — | Email/password login |
POST |
/logout |
user | — | Revoke current token |
POST |
/email/verify/send |
user | — | Resend verification email |
POST |
/email/verify/confirm |
public | — | Consume verification token |
POST |
/password/forgot |
public | — | Request password reset |
POST |
/password/reset |
public | — | Consume reset token |
POST |
/password/change |
user | — | Change password (requires current) |
GET |
/social/{provider}/redirect |
public | — | Start OAuth flow |
GET |
/social/{provider}/callback |
public | — | OAuth callback |
POST |
/social/link |
user | — | Link additional provider |
DELETE |
/social/{provider} |
user | — | Unlink provider |
POST |
/mfa/enable |
user | — | Initiate MFA setup |
POST |
/mfa/confirm |
user | — | Confirm first code |
POST |
/mfa/disable |
user | — | Disable MFA (requires current code) |
POST |
/mfa/challenge |
user | — | Issue challenge |
POST |
/mfa/verify |
user | — | Verify challenge |
POST |
/mfa/backup-codes/regenerate |
user | — | Regenerate backup codes |
GET |
/me |
user | — | Current user profile |
PATCH |
/me |
user | — | Update profile |
POST |
/me/avatar |
user | — | Upload avatar |
POST |
/me/banner |
user | — | Upload banner |
POST |
/me/handle |
user | — | Change handle |
POST |
/me/onboarding/complete |
user | — | Finish first-time setup |
POST |
/me/consents |
user | — | Record consent |
POST |
/me/data-export |
user | — | Trigger data export job |
DELETE |
/me |
user | — | Delete account (soft, 30d grace) |
6.8 OpenAPI Example (Login)
#[OA\Post(
path: '/v1/identity/login',
summary: 'Authenticate and receive access token',
tags: ['Identity'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['email', 'password'],
properties: [
new OA\Property(property: 'email', type: 'string', format: 'email'),
new OA\Property(property: 'password', type: 'string', format: 'password'),
new OA\Property(property: 'deviceName', type: 'string', nullable: true),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Authenticated', content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'OK'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
new OA\Property(property: 'accessToken', type: 'string'),
new OA\Property(property: 'mfaChallengeToken', type: 'string', nullable: true),
], type: 'object'),
new OA\Property(property: 'meta', ref: '#/components/schemas/ResponseMeta'),
],
)),
new OA\Response(response: 401, description: 'Invalid credentials', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationError')),
new OA\Response(response: 422, description: 'Validation error', content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')),
new OA\Response(response: 429, description: 'Rate limited', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationError')),
],
)]
7. Domain: Ledger & Wallet
7.1 Responsibility
The Ledger domain is the single source of truth for all money movement on the platform. Every cent that enters, moves within, or leaves the system is recorded as a balanced pair (or set) of ledger entries. This domain provides:
- Immutable, append-only double-entry ledger
- Per-user wallet (balance computed from ledger; denormalized for fast read)
- 3-day earnings hold (withdrawable-after semantics)
- Idempotent posting primitive
- Reconciliation job (nightly integrity check)
- Platform revenue accounting
Every other domain that moves money (Payments, Monetization, Promotions, Moderation/refunds) calls ILedgerService::post(). No direct wallet balance writes exist anywhere else in the codebase.
7.2 Entities
7.2.1 ledger_accounts
Represents a logical account. Balances are derived from entries.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
public_id |
ulid | |
type |
enum | user_wallet, user_pending_earnings, platform_revenue, platform_mpesa_float, platform_mpesa_payouts, platform_processor_fees, platform_marketing_expense, platform_refund_liability |
owner_type |
string, nullable | Polymorphic (user) |
owner_id |
bigint, nullable | |
currency |
char(3) | ISO 4217; KES in v1 |
is_active |
bool, default true | |
created_at |
timestamptz |
Per-user accounts (created on user registration):
user_wallet(spendable + settled earnings)user_pending_earnings(within 3-day hold)
Platform accounts (singletons, seeded):
platform_revenue— where fees landplatform_mpesa_float— represents money in the MPESA merchant accountplatform_mpesa_payouts— outgoing MPESA B2C floatplatform_processor_fees— MPESA transaction fees paidplatform_marketing_expense— referral welcome credits, promotional give-backsplatform_refund_liability— refund IOUs awaiting settlement
Unique constraint: (type, owner_type, owner_id, currency) where applicable.
7.2.2 ledger_transactions
One row per business event. Each transaction has ≥2 entries that sum to zero.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
public_id |
ulid, unique | |
purpose |
enum | top_up, post_purchase, tier_subscription_payment, tier_subscription_refund, post_purchase_refund, earnings_release, withdrawal, withdrawal_failure_reversal, platform_fee, processor_fee, referral_commission, welcome_credit, manual_adjustment, promo_discount, premium_subscription_payment |
reference_type |
string, nullable | Polymorphic to source entity (top_up, post_purchase, etc.) |
reference_id |
bigint, nullable | |
idempotency_key |
string, unique, nullable | Deduplication |
memo |
string, nullable | Human-readable context |
metadata |
jsonb | Arbitrary context (MPESA ref, IPs, user agent, etc.) |
platform_fee_rate_snapshot |
decimal(5,4), nullable | Snapshot of active fee rate when relevant |
posted_by_admin_id |
bigint, nullable | Set only for manual adjustments |
approval_request_id |
bigint, nullable | Set only for approval-gated transactions |
created_at |
timestamptz |
Immutability: no updated_at. Corrections are new reversing transactions.
7.2.3 ledger_entries
One row per account impact. Paired to balance each transaction.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
ledger_transaction_id |
bigint, FK | |
ledger_account_id |
bigint, FK | |
direction |
enum('debit','credit') | |
amount_minor_units |
bigint | Always positive |
signed_amount_minor_units |
bigint | Generated: credit = +amount, debit = -amount |
currency |
char(3) | Must match ledger_account.currency |
withdrawable_after |
timestamptz, nullable | Only set for entries crediting user_pending_earnings or releasing to user_wallet |
created_at |
timestamptz |
Constraints:
- CHECK:
amount_minor_units > 0 - CHECK:
currency = (SELECT currency FROM ledger_accounts WHERE id = ledger_account_id) - DB trigger on INSERT: forbids inserts that would cause
SUM(signed_amount_minor_units) WHERE ledger_transaction_id = Xto be non-zero. Enforced transactionally at commit.
Indexes: ledger_transaction_id, ledger_account_id, (ledger_account_id, created_at) for balance windows, withdrawable_after for release job.
7.2.4 wallets
Denormalized projection for fast read. One row per user.
| Column | Type | Notes |
|---|---|---|
id |
bigint, PK | |
user_id |
bigint, FK, unique | |
currency |
char(3) | |
available_balance_minor_units |
bigint, default 0 | Spendable + settled earnings |
pending_balance_minor_units |
bigint, default 0 | Earnings in 3-day hold |
lifetime_earnings_minor_units |
bigint, default 0 | |
lifetime_spend_minor_units |
bigint, default 0 | |
lifetime_withdrawn_minor_units |
bigint, default 0 | |
updated_at |
timestamptz | Last change |
Maintained atomically alongside ledger writes in the same DB transaction.
Reconciliation job (nightly, ReconcileWalletsJob) recomputes each wallet from ledger_entries and logs any drift to WalletDriftIncident for admin review.
7.2.5 approval_requests
Generic two-person-approval gate.
| Column | Type |
|---|---|
id |
bigint, PK |
approvable_type |
string (polymorphic) |
approvable_id |
bigint |
required_scope |
string (e.g. Ledger.Approval) |
requested_by_admin_id |
bigint |
requested_at |
timestamptz |
reason |
text |
approved_by_admin_id |
bigint, nullable |
approved_at |
timestamptz, nullable |
rejected_by_admin_id |
bigint, nullable |
rejected_at |
timestamptz, nullable |
rejection_reason |
text, nullable |
expires_at |
timestamptz |
7.2.6 wallet_drift_incidents
Written by reconciliation job when wallet.balance != SUM(ledger_entries). Surfaces in Filament, alerts ops.
7.3 Posting Rules Catalog
The canonical set of double-entry postings. Every money movement matches exactly one rule.
Rule: top_up
Viewer top-ups wallet via MPESA.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_mpesa_float |
gross |
| CREDIT | user_wallet (viewer) |
gross |
Net effect: platform's MPESA balance goes down (it owes the user now), user's wallet goes up.
Rule: post_purchase (paid via wallet)
Viewer buys a post using their wallet.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | user_wallet (viewer) |
gross |
| CREDIT | platform_revenue |
gross × fee_rate |
| CREDIT | user_pending_earnings (creator) |
gross × (1 − fee_rate), withdrawable_after = now + 3d |
Rule: post_purchase (paid via direct MPESA STK)
Viewer buys a post with a fresh STK push (no wallet top-up step).
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_mpesa_float |
gross |
| CREDIT | platform_revenue |
gross × fee_rate |
| CREDIT | user_pending_earnings (creator) |
gross × (1 − fee_rate), withdrawable_after = now + 3d |
Rule: tier_subscription_payment
Recurring tier charge.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | user_wallet OR platform_mpesa_float |
gross (depending on payment source) |
| CREDIT | platform_revenue |
gross × fee_rate |
| CREDIT | user_pending_earnings (creator) |
gross × (1 − fee_rate), withdrawable_after = now + 3d |
Rule: earnings_release
Nightly ReleaseEarningsJob sweeps pending entries with withdrawable_after <= now.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | user_pending_earnings (creator) |
amount |
| CREDIT | user_wallet (creator) |
amount |
Rule: withdrawal
Creator withdraws from wallet to MPESA.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | user_wallet (creator) |
gross |
| CREDIT | platform_mpesa_payouts |
gross − processor_fee |
| CREDIT | platform_processor_fees |
processor_fee |
Note: processor_fee reduces what the user ultimately receives. The full gross is debited from the user's wallet; the processor fee is shown as a transparent line item in the Withdrawal reference record.
Rule: withdrawal_failure_reversal
MPESA B2C fails; funds return to user.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_mpesa_payouts |
net |
| DEBIT | platform_processor_fees |
processor_fee |
| CREDIT | user_wallet (creator) |
gross |
Rule: post_purchase_refund
Admin issues refund for a past purchase.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_revenue |
original fee |
| DEBIT | user_wallet (creator) OR user_pending_earnings (creator) |
original creator share |
| CREDIT | user_wallet (viewer) |
gross |
If original purchase was via direct MPESA and platform needs to return via MPESA B2C, the CREDIT side goes to platform_refund_liability, and a subsequent withdrawal-style posting to platform_mpesa_payouts clears it.
Rule: referral_commission
Referral attribution pays out a slice of referred user's spend.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_revenue |
commission |
| CREDIT | user_pending_earnings (referrer) |
commission, withdrawable_after = now + 3d |
Rule: welcome_credit
New user referred through a referral link gets platform-funded credit.
| Direction | Account | Amount |
|---|---|---|
| DEBIT | platform_marketing_expense |
credit |
| CREDIT | user_wallet (new user) |
credit |
Note: Welcome credits have a used_before expiration (30 days) enforced at spend-time via an accounting tag on the entry metadata. Unused credits are clawed back by a scheduled job that posts a reversing manual_adjustment.
Rule: premium_subscription_payment
| Direction | Account | Amount |
|---|---|---|
| DEBIT | user_wallet OR platform_mpesa_float |
gross |
| CREDIT | platform_revenue |
gross |
(No creator share — premium is platform revenue.)
Rule: manual_adjustment
Admin-initiated, requires Ledger.Approval two-person gate, always writes an ApprovalRequest and a reason.
Entries are free-form but must balance. Used for reconciliation, compensation, correction.
7.4 Ledger Service
<?php
declare(strict_types=1);
namespace App\Ledger\Contracts;
use App\Ledger\Data\LedgerPostingData;
use App\Ledger\Models\LedgerTransaction;
interface ILedgerService
{
/**
* Post a balanced transaction. All entries committed in a single DB transaction.
* Idempotent on `idempotencyKey` — replays return the existing transaction.
*
* @throws \App\Ledger\Exceptions\UnbalancedLedgerException
* @throws \App\Ledger\Exceptions\InsufficientFundsException
* @throws \App\Ledger\Exceptions\CurrencyMismatchException
*/
public function post(LedgerPostingData $posting): LedgerTransaction;
public function availableBalance(int $userId, string $currency = 'KES'): int;
public function pendingBalance(int $userId, string $currency = 'KES'): int;
/** For reconciliation jobs only. */
public function recomputeBalance(int $accountId): int;
}
LedgerPostingData DTO:
<?php
declare(strict_types=1);
namespace App\Ledger\Data;
use App\Ledger\Enums\LedgerPurpose;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
final class LedgerPostingData extends Data
{
public function __construct(
public LedgerPurpose $purpose,
/** @var DataCollection<LedgerEntryData> */
public DataCollection $entries,
public ?string $idempotencyKey = null,
public ?string $referenceType = null,
public ?int $referenceId = null,
public ?string $memo = null,
public ?array $metadata = null,
public ?string $platformFeeRateSnapshot = null,
public ?int $postedByAdminId = null,
public ?int $approvalRequestId = null,
) {}
}
final class LedgerEntryData extends Data
{
public function __construct(
public int $ledgerAccountId,
public string $direction, // 'debit' | 'credit'
public int $amountMinorUnits,
public string $currency = 'KES',
public ?\DateTimeImmutable $withdrawableAfter = null,
) {}
}
Example usage (inside PurchasePostAction):
$this->ledger->post(new LedgerPostingData(
purpose: LedgerPurpose::PostPurchase,
entries: LedgerEntryData::collect([
['ledgerAccountId' => $viewerWallet->id, 'direction' => 'debit', 'amountMinorUnits' => $grossCents],
['ledgerAccountId' => $platformRevenue->id, 'direction' => 'credit', 'amountMinorUnits' => $feeCents],
['ledgerAccountId' => $creatorPending->id, 'direction' => 'credit', 'amountMinorUnits' => $netCents, 'withdrawableAfter' => now()->addDays($holdDays)],
], DataCollection::class),
idempotencyKey: $idempotencyKey,
referenceType: 'post_purchase',
referenceId: $postPurchase->id,
memo: "Purchase of post {$post->public_id}",
platformFeeRateSnapshot: (string) $feeRate,
));
7.5 Endpoints
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET |
/v1/wallet |
user | — | Current user wallet (available + pending balances) |
GET |
/v1/wallet/transactions |
user | — | Paginated ledger entries affecting current user |
GET |
/v1/admin/ledger/transactions |
admin | Ledger.Read |
Filament list |
POST |
/v1/admin/ledger/manual-adjustment |
admin | Ledger.ReadWrite |
Create ApprovalRequest for manual adjustment |
POST |
/v1/admin/ledger/approvals/{id}/approve |
admin | Ledger.Approval |
Second-person approval |
POST |
/v1/admin/ledger/approvals/{id}/reject |
admin | Ledger.Approval |
Reject |
7.6 Invariants (enforced by tests and DB constraints)
- Zero-sum per transaction.
SUM(signed_amount_minor_units) GROUP BY ledger_transaction_id = 0for every transaction. Enforced via deferred DB trigger + unit tests. - Currency consistency. Every entry in a transaction shares the same currency. Enforced in application layer (enum + checks).
- Wallet consistency.
wallet.available_balance = SUM(signed_amount WHERE account=user_wallet AND user=X). Reconciled nightly. - Pending held. Entries in
user_pending_earningsalways have non-nullwithdrawable_after. - Immutability. No UPDATE or DELETE on
ledger_transactionsorledger_entries. Enforced via DB privileges (app role has INSERT/SELECT only; DELETE requires a separateledger_adminrole not used at runtime).
8. Domain: Payments
8.1 Responsibility
Payments owns the external money movement — the bridge between the platform and MPESA (and, in v2, PayPal/Bank). It does not own the ledger; it calls ILedgerService::post() after successful external settlement.
Responsibilities:
- MPESA C2B (top-ups, direct post-purchase STK pushes)
- MPESA B2C (creator withdrawals)
- Payment method management (withdrawal destinations)
- Webhook/callback handling from the configured payment gateway via Kashier
- Polling & timeout for stuck payments
- Retry logic for failed B2C
- Refund execution path (coordinating with Ledger)
8.2 Entities
8.2.1 payment_intents
Represents a pending external money movement in either direction.
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid, unique |
user_id |
bigint, FK |
direction |
enum('inbound','outbound') |
purpose |
enum('top_up','post_purchase','tier_subscription','premium_subscription','withdrawal') |
provider |
enum('mpesa') — extensible |
provider_method |
string ('c2b_stk', 'b2c') |
amount_minor_units |
bigint |
currency |
char(3) |
status |
enum('pending','processing','succeeded','failed','cancelled','expired') |
idempotency_key |
string, unique |
reference_type |
string, nullable |
reference_id |
bigint, nullable |
provider_request |
jsonb |
provider_response |
jsonb, nullable |
provider_transaction_id |
string, nullable |
provider_receipt |
string, nullable |
failure_reason |
string, nullable |
expires_at |
timestamptz |
settled_at |
timestamptz, nullable |
ledger_transaction_id |
bigint, FK, nullable |
metadata |
jsonb |
created_at |
timestamptz |
updated_at |
timestamptz |
8.2.2 withdrawal_methods
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
user_id |
bigint, FK |
type |
enum('mpesa','paypal','bank') |
is_primary |
bool |
is_verified |
bool |
label |
string, nullable |
encrypted_details |
text |
masked_display |
string |
created_at |
timestamptz |
deleted_at |
timestamptz, nullable |
For MPESA: encrypted_details = {"phone": "254712345678"}, masked_display = "Phone ending in 678".
8.2.3 withdrawals
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
user_id |
bigint, FK |
withdrawal_method_id |
bigint, FK |
amount_minor_units |
bigint |
processor_fee_minor_units |
bigint |
net_minor_units |
bigint |
currency |
char(3) |
status |
enum('queued','processing','succeeded','failed','reversed') |
failure_reason |
string, nullable |
payment_intent_id |
bigint, FK |
ledger_transaction_id |
bigint, FK, nullable |
requested_at |
timestamptz |
processed_at |
timestamptz, nullable |
settled_at |
timestamptz, nullable |
created_at / updated_at |
timestamptz |
8.2.4 top_ups
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
user_id |
bigint, FK |
amount_minor_units |
bigint |
currency |
char(3) |
payment_intent_id |
bigint, FK |
status |
enum('pending','succeeded','failed','expired') |
ledger_transaction_id |
bigint, FK, nullable |
created_at / updated_at |
timestamptz |
8.3 Payment Provider Interface
<?php
declare(strict_types=1);
namespace App\Payments\Contracts;
use App\Payments\Data\C2bStkRequestData;
use App\Payments\Data\C2bStkResponseData;
use App\Payments\Data\B2cRequestData;
use App\Payments\Data\B2cResponseData;
use App\Payments\Data\ProviderTransactionStatus;
interface IPaymentProvider
{
public function identifier(): string;
public function initiateC2bStk(C2bStkRequestData $data): C2bStkResponseData;
public function initiateB2c(B2cRequestData $data): B2cResponseData;
public function queryTransaction(string $providerTransactionId): ProviderTransactionStatus;
public function verifyCallback(array $callbackPayload): bool;
}
Implementations:
KashierPaymentProvider(v1 active)- Stubs reserved for
PaypalPaymentProvider,BankTransferPaymentProvider
8.4 Top-up Flow
Client → POST /v1/payments/top-ups { amount, phoneNumber, idempotencyKey }
↓
InitiateTopUpAction
├── Validate amount (within min/max from platform_settings)
├── Check rate limit (5 top-ups per 5 min)
├── Create PaymentIntent (status=pending)
├── Create TopUp (status=pending)
├── IPaymentProvider::initiateC2bStk(...)
│ └── Kashier SDK initiates the provider-side STK / collection request
├── PaymentIntent.provider_transaction_id = provider reference
├── Dispatch PollPaymentIntentStatusJob (delay=5s)
└── Return PaymentIntent payload to client
PollPaymentIntentStatusJob (runs every 5s up to 120s):
├── IPaymentProvider::queryTransaction(provider reference)
├── IF status=success:
│ └── SettleTopUpAction
│ ├── LedgerService::post(top_up rule)
│ ├── Update PaymentIntent.status=succeeded, TopUp.status=succeeded
│ ├── Dispatch TopUpSucceeded event
│ └── Send notification
├── IF status=failure:
│ └── FailTopUpAction (PaymentIntent.status=failed, Notify user)
└── ELSE re-queue self until timeout
MpesaCallbackController (if Safaricom calls us back first):
├── Verify callback signature
├── Lookup PaymentIntent by CheckoutRequestID
├── SettleTopUpAction (idempotent — if already settled, noop)
└── 200 OK
Polling and callback are both wired; whichever arrives first settles the intent. Settlement itself is idempotent by payment_intent_id.
8.5 Post-Purchase Direct STK Flow
Same primitives as top-up, but at settlement also writes the post_purchase ledger rule with the creator as the earning party. PurchasePostAction decides the payment source:
IF data.paymentMethod = 'wallet':
check wallet.available_balance >= amount
LedgerService::post(post_purchase via wallet)
return PostPurchase
IF data.paymentMethod = 'mpesa_direct':
InitiateTopUpAction-like flow BUT with purpose=post_purchase
PaymentIntent carries reference to post_purchase intent
Settlement runs the post_purchase rule (not top_up)
8.6 Withdrawal Flow
Client → POST /v1/payments/withdrawals { amount, withdrawalMethodId, idempotencyKey }
↓
RequestWithdrawalAction
├── Enforce MFA challenge (middleware)
├── Validate amount within [min, max_per_txn]
├── Enforce daily limits (count + cumulative)
├── Lock wallet row (SELECT ... FOR UPDATE)
├── Check wallet.available_balance >= amount
├── Create PaymentIntent (direction=outbound, status=pending)
├── Create Withdrawal (status=queued)
├── Dispatch ProcessWithdrawalJob (queue=transactional)
└── Return Withdrawal payload (status=queued)
ProcessWithdrawalJob:
├── Withdrawal.status=processing
├── IPaymentProvider::initiateB2c(...)
├── PaymentIntent.provider_transaction_id = ConversationID
├── Wait for callback OR poll (30s up to 5min) since B2C is slower
├── IF success:
│ └── SettleWithdrawalAction
│ ├── Record processor_fee (from callback)
│ ├── LedgerService::post(withdrawal rule)
│ ├── Update Withdrawal.status=succeeded, settled_at
│ ├── Mark WithdrawalMethod.is_verified=true (if first success)
│ └── Notify user
└── IF failure:
└── FailWithdrawalAction
├── LedgerService::post(withdrawal_failure_reversal rule)
├── Update Withdrawal.status=failed
└── Notify user (+ Filament visibility)
All B2C callbacks are signed/verified. Failures surface in Filament under the Withdrawal resource with a Retry action (requires Withdrawal.ReadWrite; auto-completed retries don't require approval).
8.7 Endpoints
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
POST |
/v1/payments/top-ups |
user | — | Initiate MPESA top-up |
GET |
/v1/payments/top-ups/{id} |
user | — | Status poll (client-side fallback) |
POST |
/v1/payments/withdrawal-methods |
user | — | Add MPESA method |
GET |
/v1/payments/withdrawal-methods |
user | — | List own methods |
PATCH |
/v1/payments/withdrawal-methods/{id} |
user | — | Set primary, update label |
DELETE |
/v1/payments/withdrawal-methods/{id} |
user | — | Remove method |
POST |
/v1/payments/withdrawals |
user | — | Request withdrawal (MFA gated) |
GET |
/v1/payments/withdrawals |
user | — | List own withdrawals |
GET |
/v1/payments/withdrawals/{id} |
user | — | Withdrawal detail |
POST |
/v1/payments/kashier/callbacks/c2b |
public (signed) | — | Kashier C2B callback |
POST |
/v1/payments/kashier/callbacks/b2c |
public (signed) | — | Kashier B2C callback |
POST |
/v1/payments/kashier/callbacks/timeout |
public (signed) | — | Kashier timeout callback |
GET |
/v1/admin/payments/withdrawals |
admin | Withdrawal.Read |
Ops view |
POST |
/v1/admin/payments/withdrawals/{id}/retry |
admin | Withdrawal.ReadWrite |
Manual retry after failure |
POST |
/v1/admin/payments/withdrawals/{id}/force-fail |
admin | Withdrawal.Approval |
Force-fail a stuck withdrawal (2-person) |
8.8 Safety Rails
- Row locking on wallet during withdrawal request prevents concurrent withdrawal of same balance.
- Rate limits: 5 top-ups per 5 min, 3 withdrawals per day (6 for premium), enforced by
RateLimitmiddleware keyed by user. - Minimum/maximum enforced per platform setting; configurable live.
- MFA challenge required before withdrawal endpoint is accepted.
- Idempotency key required on top-up, purchase, withdrawal endpoints.
9. Domain: Content
9.1 Responsibility
Content owns the lifecycle of creator-published material:
- Posts (seven formats: text, video, audio, image, link, poll, livestream)
- Media assets & transcoding pipeline
- Categorization taxonomy (Niche → Category → Tag)
- Collections (creator-curated groupings)
- Comments on posts
- Post state machine (draft → processing → published → hidden/removed)
Content does not own: access rules (see §10 Access & Entitlements), pricing (see §11 Monetization), or saved-posts/bookmarks (see §12 Social Graph).
9.2 Entities
9.2.1 niches
| Column | Type |
|---|---|
id |
bigint, PK |
slug |
string, unique (music, gaming, ...) |
name |
string |
icon |
string, nullable |
display_order |
int |
is_active |
bool |
created_at / updated_at |
timestamptz |
Seeded from config/taxonomy.php. Managed via Filament Niche resource.
9.2.2 categories
| Column | Type |
|---|---|
id |
bigint, PK |
niche_id |
bigint, FK |
slug |
string |
name |
string |
display_order |
int |
is_active |
bool |
Unique on (niche_id, slug).
9.2.3 tags
| Column | Type |
|---|---|
id |
bigint, PK |
slug |
string, unique (lowercase, normalized) |
name |
string |
use_count |
int, default 0 |
created_at |
timestamptz |
9.2.4 taggables (polymorphic pivot)
| Column | Type |
|---|---|
tag_id |
bigint, FK |
taggable_type |
string (post, collection) |
taggable_id |
bigint |
Unique on (tag_id, taggable_type, taggable_id).
9.2.5 posts
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
creator_id |
bigint, FK → users |
type |
enum('text','video','audio','image','link','poll','livestream') |
status |
enum('draft','processing','published','hidden_by_admin','removed_by_creator','quarantined','pending_review') |
title |
string(180) |
body |
text, nullable |
link_url |
string, nullable |
category_id |
bigint, FK, nullable |
impression_count |
bigint, default 0 |
like_count |
int, default 0 |
comment_count |
int, default 0 |
share_count |
int, default 0 |
save_count |
int, default 0 |
purchase_count |
int, default 0 |
published_at |
timestamptz, nullable |
scheduled_at |
timestamptz, nullable |
creator_self_flagged_explicit |
bool, default false |
moderation_notes |
jsonb, nullable |
moderation_state |
enum('clean','flagged','under_review','cleared_after_review') |
metadata |
jsonb |
created_at / updated_at |
timestamptz |
deleted_at |
timestamptz, nullable |
Indexes: (creator_id, status, published_at), (status, published_at) for feeds, category_id, type.
9.2.6 post_media
Represents media files attached to a post (a post can have multiple — e.g. image gallery).
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK |
ordinal |
int |
kind |
enum('video','audio','image','thumbnail','transcript','livestream_recording') |
storage_disk |
string |
storage_path |
string |
mime_type |
string |
size_bytes |
bigint |
duration_ms |
int, nullable |
width |
int, nullable |
height |
int, nullable |
processing_status |
enum('pending','processing','ready','failed') |
processing_error |
string, nullable |
variants |
jsonb |
provider_asset_id |
string, nullable |
created_at / updated_at |
timestamptz |
9.2.7 livestreams
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK, unique |
provider |
enum('mux','self_hosted') |
provider_stream_id |
string |
stream_key_encrypted |
text |
ingest_url |
string |
playback_policy |
enum('public','signed') |
status |
enum('idle','live','ended','errored') |
started_at |
timestamptz, nullable |
ended_at |
timestamptz, nullable |
peak_concurrent_viewers |
int, default 0 |
recording_asset_id |
string, nullable |
auto_record |
bool, default true |
9.2.8 livestream_viewer_pings
Ephemeral — written to Redis as sorted sets with TTL, materialized to table only for post-stream analytics:
| Column | Type |
|---|---|
id |
bigint, PK |
livestream_id |
bigint, FK |
user_id |
bigint, FK |
minute_bucket |
timestamptz |
count |
int |
Unique on (livestream_id, user_id, minute_bucket).
9.2.9 livestream_chat_messages
| Column | Type |
|---|---|
id |
bigint, PK |
livestream_id |
bigint, FK |
user_id |
bigint, FK |
message |
string(500) |
is_pinned |
bool |
is_deleted |
bool |
deleted_by_user_id |
bigint, nullable |
created_at |
timestamptz |
Also broadcast via Redis pubsub → SSE stream.
9.2.10 polls
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK, unique |
question |
string(280) |
is_multiple_choice |
bool, default false |
closes_at |
timestamptz, nullable |
9.2.11 poll_options
| Column | Type |
|---|---|
id |
bigint, PK |
poll_id |
bigint, FK |
label |
string(120) |
ordinal |
int |
vote_count |
int, default 0 |
9.2.12 poll_votes
| Column | Type |
|---|---|
id |
bigint, PK |
poll_option_id |
bigint, FK |
user_id |
bigint, FK |
created_at |
timestamptz |
Unique on (poll_id, user_id) when !is_multiple_choice; on (poll_option_id, user_id) always.
9.2.13 collections
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
creator_id |
bigint, FK |
slug |
string |
title |
string |
description |
text, nullable |
cover_path |
string, nullable |
visibility |
enum('public','tier_gated','private') |
min_tier_level |
int, nullable |
is_active |
bool |
created_at / updated_at |
timestamptz |
Unique on (creator_id, slug).
9.2.14 collection_posts
| Column | Type |
|---|---|
collection_id |
bigint, FK |
post_id |
bigint, FK |
ordinal |
int |
added_at |
timestamptz |
Unique on (collection_id, post_id).
9.2.15 comments
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
post_id |
bigint, FK |
user_id |
bigint, FK |
parent_comment_id |
bigint, FK, nullable |
body |
text(1000) |
like_count |
int, default 0 |
is_pinned |
bool, default false |
is_hidden |
bool, default false |
hidden_reason |
string, nullable |
hidden_by_admin_id |
bigint, nullable |
created_at / updated_at |
timestamptz |
deleted_at |
timestamptz, nullable |
9.3 Post State Machine
┌─────┐
(save) │draft│
◄────────┤ │
│ └──┬──┘
│ │ submit (may upload media)
│ ▼
│ ┌──────────┐
│ │processing│── media failed ──► draft (with error)
│ └────┬─────┘
│ │ media ready AND moderation=reactive_only
│ ▼
│ ┌─────────────┐
│ │ published │── creator removes ──► removed_by_creator
│ └──────┬──────┘
│ │ admin takedown
│ ▼
│ ┌────────────────┐
│ │hidden_by_admin │
│ └────────────────┘
│
│ ┌──────────────┐
│◄─────────┤pending_review│ (moderation=manual_prescreen)
└──────┬───────┘
│ approve
▼
published
┌────────────┐
│quarantined │ (AI auto-hide)
└──────┬─────┘
│ admin approve → published
│ admin confirm violation → hidden_by_admin
State transitions are methods on Post via a HasPostState trait, each fires a domain event.
9.4 Media Provider Interfaces
<?php
declare(strict_types=1);
namespace App\Content\Contracts;
interface IVideoProvider
{
public function identifier(): string;
/** Upload and enqueue transcoding. Returns provider asset ID + polling contract. */
public function uploadFromPath(string $localPath): VideoProviderAsset;
/** Check transcoding status. */
public function status(string $providerAssetId): VideoProviderStatus;
/** Signed playback URLs (HLS, mp4 variants). */
public function playbackUrls(string $providerAssetId, ?int $ttlSeconds = 300): array;
public function delete(string $providerAssetId): void;
}
interface IAudioProvider { /* similar shape */ }
interface IImageProvider { /* resize + variant generation */ }
interface ILivestreamProvider
{
public function createStream(array $options): LivestreamAsset;
public function endStream(string $streamId): void;
public function signedPlaybackUrl(string $streamId, int $ttlSeconds = 120): string;
public function fetchRecording(string $streamId): ?string;
}
v1 default adapters (self-hosted):
FFmpegLocalVideoProvider— transcodes to HLS via queuedTranscodeVideoJobFFmpegLocalAudioProvider— normalizes to AACInterventionImageProvider— resizes tothumb,small,medium,largepreset variantsMuxLivestreamProvider— livestream via Mux Live (the one provider that's external in v1 per Q12/Q13)
Env-driven: VIDEO_PROVIDER, AUDIO_PROVIDER, IMAGE_PROVIDER, LIVESTREAM_PROVIDER.
Package readiness note:
spatie/laravel-medialibraryis already installed via Composer and available through Laravel package discovery, so Content-domain media models can begin integrating it without additional package bootstrap work.pbmedia/laravel-ffmpegis already installed via Composer and available through Laravel package discovery; runtime binary paths are scaffolded in.env.examplethroughFFMPEG_BINARY_PATHandFFPROBE_BINARY_PATH.bervant/laravel-smsis already installed via Composer and available through Laravel package discovery; project env documentation should useSMS_DRIVER,SMS_API_USERNAME,SMS_API_KEY,SMS_SHORTCODE, and the optional Twilio/Bonga/Envisage variables exposed by the package.bervant/kashier-laravel-sdkis already installed via Composer and available through Laravel package discovery; project env documentation should useKASHIER_ENABLED,KASHIER_URL,KASHIER_MERCHANT_KEY,KASHIER_MERCHANT_SECRET,KASHIER_SERVICE_ID,KASHIER_PAYBILL_NO, andKASHIER_PAYBILL_ACCOUNT.
9.5 Post Upload Flow (Video Example)
Client → POST /v1/content/posts (body: type=video, title, metadata)
↓
CreatePostDraftAction
├── Create Post (status=draft, no media yet)
├── Generate signed upload URL for direct upload (S3 presigned)
└── Return { post, uploadUrl, uploadFields }
Client → PUT uploadUrl (binary video bytes direct to S3)
Client → POST /v1/content/posts/{id}/media (registers uploaded file)
↓
AttachPostMediaAction
├── Validate file exists in S3
├── Create PostMedia (kind=video, status=pending)
├── Dispatch TranscodeVideoJob
└── Update Post.status=processing, return Post
TranscodeVideoJob:
├── IVideoProvider::uploadFromPath(s3_path) → providerAssetId (v1 self-hosted: transcodes locally, writes HLS to S3)
├── Poll IVideoProvider::status()
├── When ready: update PostMedia.variants, status=ready
├── Mark Post.status=published (if moderation=reactive_only) or pending_review
└── Dispatch PostPublished event
Client → POST /v1/content/posts/{id}/publish (finalizes: sets access rules, tags, category)
↓
PublishPostAction
├── Validate post is in publishable state
├── Attach tags (normalizes slugs, increments use_count)
├── Attach AccessRules (handled by Access domain — see §10)
├── If livestream: create Livestream + ingest
└── Post.status=published, published_at=now
Note: publish is a separate step from create + attach media because content can be edited (access rules, tags, title) until published.
9.6 Polls
Polls are a post type with an embedded Poll and PollOptions. Vote submission goes through:
POST /v1/content/posts/{id}/poll/vote { optionIds: [...] }
↓
SubmitPollVoteAction
├── Verify post type is 'poll', not closed
├── Verify user has access to post (Access domain)
├── Verify isMultipleChoice compliance
├── Upsert vote (idempotent — change vote allowed until close)
├── Update vote_counts atomically
└── Return PollResults
9.7 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/v1/taxonomy/niches |
public | List active niches |
GET |
/v1/taxonomy/categories?nicheId=.. |
public | Categories |
GET |
/v1/taxonomy/tags?prefix=.. |
public | Tag autocomplete |
POST |
/v1/content/posts |
user (verified) | Create draft |
GET |
/v1/content/posts/{id} |
public* | Read post (access-gated) |
PATCH |
/v1/content/posts/{id} |
user | Edit draft/published |
POST |
/v1/content/posts/{id}/media |
user | Attach uploaded media |
POST |
/v1/content/posts/{id}/publish |
user | Finalize & publish |
POST |
/v1/content/posts/{id}/unpublish |
user | Remove from feed |
DELETE |
/v1/content/posts/{id} |
user | Delete own post |
POST |
/v1/content/posts/{id}/poll/vote |
user | Vote on poll |
GET |
/v1/content/posts/{id}/comments |
user | Comment tree |
POST |
/v1/content/posts/{id}/comments |
user | Add comment |
PATCH |
/v1/content/comments/{id} |
user | Edit own |
DELETE |
/v1/content/comments/{id} |
user | Delete own |
POST |
/v1/content/comments/{id}/pin |
user (creator of post) | Pin comment |
POST |
/v1/content/collections |
user | Create collection |
GET |
/v1/content/collections/{id} |
public* | Read collection |
PATCH |
/v1/content/collections/{id} |
user | Update |
DELETE |
/v1/content/collections/{id} |
user | Delete |
POST |
/v1/content/collections/{id}/posts |
user | Add posts |
DELETE |
/v1/content/collections/{id}/posts/{postId} |
user | Remove post |
POST |
/v1/content/livestreams/{id}/start |
user (creator) | Begin stream |
POST |
/v1/content/livestreams/{id}/end |
user (creator) | End stream |
GET |
/v1/content/livestreams/{id}/playback |
user | Get signed playback URL |
GET |
/v1/content/livestreams/{id}/chat/stream |
user (SSE) | Chat SSE stream |
POST |
/v1/content/livestreams/{id}/chat |
user | Post chat message |
POST |
/v1/content/livestreams/{id}/chat/{msgId}/pin |
user (creator/mod) | Pin |
POST |
/v1/content/livestreams/{id}/chat/{msgId}/delete |
user (creator/mod) | Delete |
POST |
/v1/content/livestreams/{id}/timeout-user |
user (creator/mod) | Timeout user N minutes |
public* = access-gated read: post returns full content if user has access; a sanitized teaser (title, cover, price) otherwise.
10. Domain: Access & Entitlements
10.1 Responsibility
Access is the authoritative decision point for "Can user X view/interact with post Y?" It owns:
AccessRulepolymorphic model (per-post, per-collection access declarations)- The three access rule types:
tier_gated,one_off_purchase,free_for_active_fans, plus the implicitpublic_free PostPurchaserecordsFanEngagementScore(computed)IAccessService— the single API other domains call to check access
Every read of a gated resource funnels through IAccessService::canView($user, $post). There is no other path.
10.2 Entities
10.2.1 access_rules
| Column | Type |
|---|---|
id |
bigint, PK |
gated_type |
string (polymorphic: post, collection) |
gated_id |
bigint |
rule_type |
enum('public_free','one_off_purchase','tier_gated','free_for_active_fans') |
price_minor_units |
bigint, nullable |
currency |
char(3), nullable |
min_tier_level |
int, nullable |
is_active |
bool |
created_at / updated_at |
timestamptz |
Multiple rules can exist per post (per Q1: combinable access models). Access is granted if any applicable rule grants it. The public_free rule is the lowest-privilege bypass (if present, everyone can view).
Globally toggling rule types via content.access_rules_enabled setting — if one_off_purchase is disabled platform-wide, those rules are ignored in access checks (and hidden in creator UI).
10.2.2 post_purchases
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
post_id |
bigint, FK |
buyer_user_id |
bigint, FK |
creator_user_id |
bigint, FK |
gross_minor_units |
bigint |
platform_fee_minor_units |
bigint |
creator_net_minor_units |
bigint |
platform_fee_rate_snapshot |
decimal(5,4) |
promo_code_id |
bigint, FK, nullable |
discount_minor_units |
bigint, default 0 |
currency |
char(3) |
payment_intent_id |
bigint, FK, nullable |
ledger_transaction_id |
bigint, FK |
status |
enum('completed','refunded') |
expires_at |
timestamptz, nullable |
purchased_at |
timestamptz |
Unique on (post_id, buyer_user_id) where status='completed'. A refunded purchase can be re-bought.
10.2.3 fan_engagement_scores
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
creator_id |
bigint, FK |
score |
decimal(8,2) |
signals_breakdown |
jsonb |
last_computed_at |
timestamptz |
Unique on (user_id, creator_id).
10.2.4 tier_subscriptions (lives in Monetization §11 but referenced here)
IAccessService queries this to check "is user X currently subscribed to creator Y at level ≥ N."
10.3 Access Check Algorithm
function canView(User viewer, Post post): bool {
if viewer.id == post.creator_id:
return true // own post
if post.status != 'published':
return false
rules = post.access_rules.where(is_active=true)
enabled_rule_types = PlatformSettings::get('content.access_rules_enabled')
rules = rules.filter(r => r.rule_type in enabled_rule_types)
for rule in rules:
if rule.rule_type == 'public_free':
return true
if rule.rule_type == 'one_off_purchase':
if PostPurchase::exists(post.id, viewer.id, status=completed):
return true
if rule.rule_type == 'tier_gated':
sub = TierSubscription::activeFor(viewer.id, post.creator_id)
if sub && sub.tier.level >= rule.min_tier_level:
return true
if rule.rule_type == 'free_for_active_fans':
if FanStatusService::isActiveFan(viewer, post.creator):
return true
return false
}
Access checks are cached per (user, post) pair for 60 seconds in Redis. Invalidation triggered by: new purchase, tier sub change, engagement score recomputation.
10.4 Active Fan Status
Weighted engagement score per (user, creator) pair. Computed by:
score = w_like × likes_last_30d
+ w_comment × comments_last_30d
+ w_share × shares_last_30d
+ w_save × saves_last_30d
+ w_purchase × purchase_count_last_30d
+ w_spend × (spend_cents_last_30d / 100)
+ w_tenure × min(tenure_days / 30, 12) // caps at 1 year worth of tenure weight
+ w_recency × recency_factor(days_since_last_interaction)
Weights in discovery.active_fan_weights:
{
"w_like": 1.0,
"w_comment": 3.0,
"w_share": 4.0,
"w_save": 2.0,
"w_purchase": 15.0,
"w_spend": 0.5,
"w_tenure": 2.0,
"w_recency": 5.0
}
Threshold discovery.active_fan_threshold = 50.0 (default).
Tier subscribers auto-qualify — if TierSubscription::activeFor(user, creator) !== null, isActiveFan() returns true regardless of score.
Recomputation:
- Incremental: every interaction bumps the score for that
(user, creator)pair (small update) - Full: scheduled job
RecomputeFanEngagementScoresJobruns every 4h for any pair with recent activity; nightly full rebuild for all active pairs
10.5 Service Contracts
<?php
declare(strict_types=1);
namespace App\Access\Contracts;
use App\Content\Models\Post;
use App\Identity\Models\User;
interface IAccessService
{
public function canView(User $viewer, Post $post): bool;
public function canComment(User $viewer, Post $post): bool;
public function canInteract(User $viewer, Post $post): bool; // like, share, save
public function priceFor(User $viewer, Post $post): ?int; // null if free or already has access
public function accessReason(User $viewer, Post $post): AccessReason; // enum explaining why granted/denied
}
interface IFanStatusService
{
public function isActiveFan(User $viewer, User $creator): bool;
public function score(User $viewer, User $creator): float;
public function recompute(User $viewer, User $creator): FanEngagementScore;
}
10.6 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/access/purchases |
user | Purchase a post (body: postId, paymentMethod, promoCode?, idempotencyKey) |
GET |
/v1/access/purchases |
user | List own purchases |
GET |
/v1/access/posts/{id}/access |
user | Explicit access check + reason (for UI to show paywall state) |
GET |
/v1/access/posts/{id}/price-quote |
user | Returns effective price (after promo/active-fan) |
POST |
/v1/content/posts/{id}/access-rules |
user (creator) | Define rules for own post |
PATCH |
/v1/content/posts/{id}/access-rules/{ruleId} |
user (creator) | Modify a rule |
DELETE |
/v1/content/posts/{id}/access-rules/{ruleId} |
user (creator) | Remove a rule |
GET |
/v1/creators/{id}/fans |
user (creator of the profile) | List fan engagement scores (their own fans only) |
11. Domain: Monetization
11.1 Responsibility
Owns the creator-side revenue primitives:
- Tiers (ordered subscription levels, one active sub per user per creator)
- TierSubscriptions + renewals + grace period
- Premium (platform-side subscription with badge + perks)
- Earnings aggregation (materialized views)
- Fee calculation (delegates to
config+platform_settings)
Does not own: ledger (§7), payment rails (§8), or promotions (§15).
11.2 Entities
11.2.1 tiers
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
creator_id |
bigint, FK |
level |
int |
name |
string |
description |
text |
price_minor_units |
bigint |
currency |
char(3) |
billing_cycle |
enum('monthly') |
benefits |
jsonb |
max_subscribers |
int, nullable |
is_active |
bool |
archived_at |
timestamptz, nullable |
created_at / updated_at |
timestamptz |
Unique on (creator_id, level) where archived_at is null.
11.2.2 tier_subscriptions
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
user_id |
bigint, FK |
creator_id |
bigint, FK |
tier_id |
bigint, FK |
status |
enum('active','past_due','cancelled','grace_period','expired') |
started_at |
timestamptz |
current_period_start |
timestamptz |
current_period_end |
timestamptz |
cancels_at |
timestamptz, nullable |
cancelled_at |
timestamptz, nullable |
grace_period_ends_at |
timestamptz, nullable |
preferred_payment_method |
enum('wallet','mpesa_direct') |
default_withdrawal_method_id |
bigint, nullable |
created_at / updated_at |
timestamptz |
Unique partial: WHERE user_id = X AND creator_id = Y AND status IN ('active','past_due','grace_period') — at most one per (user, creator).
11.2.3 tier_subscription_payments
One row per billing cycle.
| Column | Type |
|---|---|
id |
bigint, PK |
tier_subscription_id |
bigint, FK |
amount_minor_units |
bigint |
platform_fee_minor_units |
bigint |
creator_net_minor_units |
bigint |
platform_fee_rate_snapshot |
decimal(5,4) |
currency |
char(3) |
payment_intent_id |
bigint, FK, nullable |
ledger_transaction_id |
bigint, FK |
status |
enum('succeeded','failed') |
period_start |
timestamptz |
period_end |
timestamptz |
charged_at |
timestamptz |
11.2.4 premium_subscriptions
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
plan |
enum('premium') |
status |
enum('active','past_due','cancelled','grace_period','expired') |
started_at |
timestamptz |
current_period_start |
timestamptz |
current_period_end |
timestamptz |
cancels_at |
timestamptz, nullable |
preferred_payment_method |
enum |
created_at / updated_at |
timestamptz |
Unique on user_id (at most one active).
11.2.5 verification_requests
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
identity_document_path |
string (S3 key, encrypted disk) |
supporting_links |
jsonb |
notes |
text, nullable |
status |
enum('pending','approved','rejected','withdrawn') |
reviewed_by_admin_id |
bigint, nullable |
reviewed_at |
timestamptz, nullable |
review_notes |
text, nullable |
approval_request_id |
bigint, nullable |
submitted_at |
timestamptz |
11.3 Subscription Lifecycle
Create tier → Creator owns Tier
Subscribe → TierSubscription (status=active, first payment via payment_intent)
Monthly cron → BillActiveTierSubscriptionsJob (day of current_period_end)
├── Attempt charge (wallet preferred → mpesa_direct fallback with OTP)
├── IF success: extend current_period by 1 month, create TierSubscriptionPayment
└── IF fail: status=past_due, grace_period_ends_at=now+3d, notify user
Grace period → User has 3 days to fix payment method
└── Daily retry job until grace ends
Grace end without success → status=expired, lose access
Cancel → status=cancelled, cancels_at=current_period_end. Access continues until period end.
11.4 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/monetization/tiers |
user (creator) | Create tier |
GET |
/v1/creators/{id}/tiers |
public | List creator's active tiers |
PATCH |
/v1/monetization/tiers/{id} |
user (creator) | Update tier |
POST |
/v1/monetization/tiers/{id}/archive |
user (creator) | Archive (existing subs continue) |
POST |
/v1/monetization/tier-subscriptions |
user | Subscribe to a tier |
GET |
/v1/monetization/tier-subscriptions |
user | Own subscriptions |
POST |
/v1/monetization/tier-subscriptions/{id}/cancel |
user | Cancel at period end |
POST |
/v1/monetization/tier-subscriptions/{id}/resume |
user | Undo cancellation |
POST |
/v1/monetization/tier-subscriptions/{id}/change-tier |
user | Upgrade/downgrade |
POST |
/v1/monetization/premium |
user | Start premium sub |
GET |
/v1/monetization/premium |
user | Current premium status |
POST |
/v1/monetization/premium/cancel |
user | Cancel |
POST |
/v1/monetization/verification-requests |
user | Submit for notable verification |
GET |
/v1/monetization/verification-requests |
user | Status of own submission |
GET |
/v1/admin/verification-requests |
admin | VerificationRequest.Read — queue |
POST |
/v1/admin/verification-requests/{id}/approve |
admin | VerificationRequest.Approval |
POST |
/v1/admin/verification-requests/{id}/reject |
admin | VerificationRequest.ReadWrite |
GET |
/v1/creators/{id}/earnings-summary |
user (self) | Insights aggregate |
12. Domain: Social Graph
12.1 Responsibility
Lightweight social primitives:
- Follow (User follows Creator)
- Like (Post)
- Share (Post — outbound share event tracking)
- Save (bookmark a Post)
- List (user-curated grouping of creators)
12.2 Entities
12.2.1 follows
| Column | Type |
|---|---|
id |
bigint, PK |
follower_user_id |
bigint, FK |
followed_user_id |
bigint, FK |
created_at |
timestamptz |
Unique on (follower_user_id, followed_user_id).
12.2.2 post_likes
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK |
user_id |
bigint, FK |
created_at |
timestamptz |
Unique on (post_id, user_id). Increment/decrement posts.like_count via observer.
12.2.3 comment_likes
Same shape as post_likes for comments.
12.2.4 post_shares
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK |
user_id |
bigint, FK, nullable |
destination |
enum('whatsapp','twitter','facebook','email','copy_link','other') |
ip_hash |
string, nullable |
created_at |
timestamptz |
12.2.5 saved_posts
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
post_id |
bigint, FK |
note |
string(280), nullable |
saved_at |
timestamptz |
Unique on (user_id, post_id).
12.2.6 lists
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
user_id |
bigint, FK |
slug |
string |
name |
string |
description |
text, nullable |
visibility |
enum('public','private') |
created_at / updated_at |
timestamptz |
Unique on (user_id, slug).
12.2.7 list_members
| Column | Type |
|---|---|
list_id |
bigint, FK |
user_id |
bigint, FK |
added_at |
timestamptz |
Unique on (list_id, user_id).
12.3 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/social/follow/{userId} |
user | Follow |
DELETE |
/v1/social/follow/{userId} |
user | Unfollow |
GET |
/v1/users/{id}/followers |
public | Paginated followers |
GET |
/v1/users/{id}/following |
public | Paginated following |
POST |
/v1/social/posts/{id}/like |
user | Like |
DELETE |
/v1/social/posts/{id}/like |
user | Unlike |
POST |
/v1/social/posts/{id}/share |
user? | Log share event |
POST |
/v1/social/posts/{id}/save |
user | Save |
DELETE |
/v1/social/posts/{id}/save |
user | Unsave |
GET |
/v1/social/saves |
user | Own saves |
POST |
/v1/social/comments/{id}/like |
user | |
DELETE |
/v1/social/comments/{id}/like |
user | |
POST |
/v1/social/lists |
user | Create list |
GET |
/v1/social/lists |
user | Own lists |
GET |
/v1/social/lists/{id} |
public if public | View list |
PATCH |
/v1/social/lists/{id} |
user (owner) | Edit |
DELETE |
/v1/social/lists/{id} |
user (owner) | Delete |
POST |
/v1/social/lists/{id}/members/{userId} |
user (owner) | Add creator |
DELETE |
/v1/social/lists/{id}/members/{userId} |
user (owner) | Remove |
13. Domain: Fan Club
13.1 Responsibility
Community hub per creator, auto-populated from fan engagement + tier subscriptions. Provides:
- Per-creator community space with channels
- Redis + SSE-based real-time chat (reusing livestream chat primitive)
- Creator moderation tools (pin, delete, timeout)
- Creator-side dashboard view (engagement analytics, top fans, bulk message)
- Automatic membership based on Access domain's
isActiveFan+ tier subscription status
13.2 Entities
13.2.1 fan_clubs
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
creator_id |
bigint, FK, unique |
name |
string |
description |
text, nullable |
rules |
text, nullable |
is_active |
bool |
created_at / updated_at |
timestamptz |
Auto-created via CreateFanClubOnCreatorStatus listener on UserBecameCreator event.
13.2.2 fan_club_channels
| Column | Type |
|---|---|
id |
bigint, PK |
fan_club_id |
bigint, FK |
slug |
string |
name |
string |
min_tier_level |
int, nullable |
is_default |
bool |
display_order |
int |
Default general channel created with fan club.
13.2.3 fan_club_memberships
| Column | Type |
|---|---|
id |
bigint, PK |
fan_club_id |
bigint, FK |
user_id |
bigint, FK |
role |
enum('member','moderator') |
source |
enum('active_fan','tier_subscriber','manual_grant') |
source_meta |
jsonb, nullable |
joined_at |
timestamptz |
last_active_at |
timestamptz |
Unique on (fan_club_id, user_id).
SyncFanClubMembershipsJob runs every 30 minutes:
- Adds members for users who newly cross
isActiveFanthreshold OR have new tier subscription - Removes members whose engagement fell below threshold AND no active tier sub (after 7-day grace)
13.2.4 fan_club_messages
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
channel_id |
bigint, FK |
user_id |
bigint, FK |
message |
string(1000) |
is_pinned |
bool |
is_deleted |
bool |
deleted_by_user_id |
bigint, nullable |
deleted_reason |
string, nullable |
reply_to_message_id |
bigint, FK, nullable |
created_at |
timestamptz |
13.2.5 fan_club_timeouts
| Column | Type |
|---|---|
id |
bigint, PK |
fan_club_id |
bigint, FK |
user_id |
bigint, FK |
moderator_user_id |
bigint, FK |
reason |
string, nullable |
expires_at |
timestamptz |
created_at |
timestamptz |
User-with-active-timeout cannot post. Enforced in PostFanClubMessageAction.
13.3 Chat Delivery
Reuses livestream chat primitive:
- Write:
POST /v1/fanclubs/{id}/channels/{slug}/messages- Persists
FanClubMessagerow - Publishes to Redis channel
fanclub:{id}:{channelSlug}
- Persists
- Read (real-time):
GET /v1/fanclubs/{id}/channels/{slug}/stream→ SSE- Subscribes to Redis channel, streams events
- Read (historical):
GET /v1/fanclubs/{id}/channels/{slug}/messages?cursor=...paginated
13.4 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/v1/fanclubs |
user | Fan clubs the user belongs to |
GET |
/v1/fanclubs/{id} |
user (member) | Fan club detail |
GET |
/v1/fanclubs/{id}/channels |
user (member) | Channels |
GET |
/v1/fanclubs/{id}/channels/{slug}/messages |
user (member) | Historical messages |
GET |
/v1/fanclubs/{id}/channels/{slug}/stream |
user (member) | SSE stream |
POST |
/v1/fanclubs/{id}/channels/{slug}/messages |
user (member) | Post message |
POST |
/v1/fanclubs/{id}/messages/{msgId}/pin |
user (mod/creator) | Pin |
POST |
/v1/fanclubs/{id}/messages/{msgId}/delete |
user (mod/creator) | Delete |
POST |
/v1/fanclubs/{id}/timeout-user |
user (mod/creator) | Timeout user |
POST |
/v1/fanclubs/{id}/channels |
user (creator) | Create channel |
PATCH |
/v1/fanclubs/{id} |
user (creator) | Update fan club meta |
GET |
/v1/fanclubs/{id}/members |
user (creator) | List members w/ engagement |
POST |
/v1/fanclubs/{id}/bulk-message |
user (creator) | Send DM to member segment |
POST |
/v1/fanclubs/{id}/free-grant |
user (creator) | Grant free access to N posts |
14. Domain: Discovery & Ranking
14.1 Responsibility
Custom primitive ranking + search service. No external search engine in v1. Provides two entry points:
- Search — keyword query, returns scored posts/creators/collections
- Rank — personalized or global-trending feed
Both share a pipeline: candidate generation → scoring → filtering → diversification → pagination.
14.2 Entities
14.2.1 interactions
Append-only event log powering every ranker signal.
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
interactable_type |
string (post, creator, collection) |
interactable_id |
bigint |
type |
enum('impression','click','dwell','like','unlike','comment','share','save','unsave','purchase','subscribe','skip','report') |
weight |
decimal(4,2), default 1.0 |
context |
jsonb |
occurred_at |
timestamptz |
Partitioned by month via PostgreSQL declarative partitioning for write scaling. Indexes on (user_id, occurred_at), (interactable_type, interactable_id, occurred_at).
14.2.2 post_signal_cache
Denormalized per-post features refreshed every 10 minutes.
| Column | Type |
|---|---|
post_id |
bigint, PK, FK |
engagement_rate |
decimal(5,4) |
trending_score |
decimal(8,2) |
impressions_24h |
int |
impressions_7d |
int |
purchases_24h |
int |
total_engagement_score |
decimal(8,2) |
last_computed_at |
timestamptz |
Rebuilt by RefreshPostSignalsJob every 10 min.
14.2.3 user_affinity_cache
Per-user denormalized affinities.
| Column | Type |
|---|---|
user_id |
bigint, PK, FK |
niche_affinity |
jsonb |
category_affinity |
jsonb |
tag_affinity |
jsonb |
creator_affinity |
jsonb |
avg_session_length_seconds |
int |
last_computed_at |
timestamptz |
Rebuilt nightly by RefreshUserAffinityJob. Incremental updates on each interaction via a debounced queued job (coalesces into one rebuild per user per 30 min).
14.2.4 user_post_impressions (Redis-backed, not a table)
Sorted set per user, capped at 500 most recent post IDs for already-seen suppression. TTL 7 days.
14.3 Ranker Configuration
discovery.ranker_weights:
{
"w_recency": 3.0,
"w_engagement_rate": 2.5,
"w_creator_affinity": 4.0,
"w_niche_affinity": 2.0,
"w_category_affinity": 1.5,
"w_tag_overlap": 1.0,
"w_trending_boost": 1.5,
"w_text_match": 3.0,
"w_premium_boost": 0.05,
"w_boosted_post": 2.0,
"w_already_seen_penalty": -2.0,
"w_cold_start_bootstrap": 1.2,
"recency_half_life_hours": 48,
"max_per_creator_in_top_20": 2,
"candidate_pool_size": 500
}
Every weight live-tunable via Filament.
14.4 Service Contracts
<?php
declare(strict_types=1);
namespace App\Discovery\Contracts;
use App\Discovery\Data\DiscoveryQuery;
use App\Discovery\Data\DiscoveryResult;
interface IDiscoveryService
{
public function search(DiscoveryQuery $query): DiscoveryResult;
public function feed(DiscoveryQuery $query): DiscoveryResult;
public function trending(DiscoveryQuery $query): DiscoveryResult;
}
interface ISignalContributor
{
public function identifier(): string;
public function contribute(DiscoveryCandidate $candidate, DiscoveryContext $context): float;
}
DiscoveryService is composed of an ordered array of ISignalContributor implementations, each returning a float. Final score = weighted sum.
Signal contributors (v1):
RecencyDecaySignalEngagementRateSignalCreatorAffinitySignalNicheAffinitySignalCategoryAffinitySignalTagOverlapSignalTrendingBoostSignalTextMatchSignal(only whenDiscoveryQuery::$keywordis present)PremiumBoostSignalBoostedPostSignal(promotions)AlreadySeenPenaltySignalColdStartBootstrapSignal
14.5 Pipeline
IDiscoveryService::feed(query):
├── 1. Candidate Generation
│ ├── Posts from followed creators (last 14d) → up to 200
│ ├── Trending posts in user's top niches (last 48h) → up to 200
│ ├── Boosted posts (active, budget remaining) → up to 50
│ ├── Recent posts globally (cold start fallback) → up to 100
│ └── Dedupe, cap at 500, exclude already-dismissed
├── 2. Access Filter
│ └── Drop posts viewer can't see the existence of (tier-gated by a private creator, etc.)
├── 3. Scoring
│ └── Foreach candidate: sum of weighted signals
├── 4. Diversification
│ └── Round-robin to respect max_per_creator_in_top_20
├── 5. Slot for Promoted
│ └── Insert BoostedPosts at prescribed positions (e.g. positions 3, 8, 15)
├── 6. Sort by score DESC
├── 7. Paginate via cursor (score, postId)
└── Return + log impressions (bulk `InteractionType::Impression` inserts)
For search, candidate generation uses Postgres FTS:
SELECT id, ts_rank(search_vector, websearch_to_tsquery('simple', :keyword)) as text_score
FROM posts
WHERE search_vector @@ websearch_to_tsquery('simple', :keyword)
AND status = 'published'
ORDER BY text_score DESC
LIMIT 500
search_vector is a GENERATED tsvector column indexed with GIN:
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX posts_search_vector_idx ON posts USING GIN(search_vector);
Tags joined in via a separate subquery to boost text match when tags align.
14.6 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/v1/discovery/feed |
user | Personalized home feed |
GET |
/v1/discovery/trending?niche=.. |
user or public | Global/niche trending |
GET |
/v1/discovery/search?q=..&type=.. |
user or public | Search posts/creators/collections/tags |
GET |
/v1/discovery/creators |
public | Browse creators with filters (niche, category, verified) |
POST |
/v1/discovery/interactions |
user | Log interaction batch (client-side collection → server flush) |
15. Domain: Promotions
15.1 Responsibility
Creator growth & revenue-accelerating primitives:
- Boosted posts (paid ranker boost)
- Promo codes (discount codes for purchases & subscriptions)
- Referral program (personal link → commission on referred user's spend)
15.2 Entities
15.2.1 boosted_posts
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK |
creator_id |
bigint, FK |
budget_minor_units |
bigint |
spent_minor_units |
bigint, default 0 |
cost_model |
enum('cpm','cpc') |
rate_minor_units_per_1000_impressions |
bigint |
starts_at |
timestamptz |
ends_at |
timestamptz |
status |
enum('pending_payment','active','paused','completed','exhausted','cancelled') |
payment_intent_id |
bigint, FK, nullable |
ledger_transaction_id |
bigint, FK |
created_at / updated_at |
timestamptz |
DebitBoostedPostImpressionsJob runs every minute aggregating impressions (from interactions) and debiting spent_minor_units. When spent >= budget → status=exhausted.
15.2.2 promo_codes
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
code |
citext, unique |
creator_id |
bigint, FK, nullable |
discount_type |
enum('percent','fixed') |
discount_value |
decimal(10,2) |
applies_to |
enum('any','tiers','posts','specific_post','specific_tier') |
applies_to_id |
bigint, nullable |
max_uses_total |
int, nullable |
max_uses_per_user |
int, default 1 |
min_purchase_minor_units |
bigint, default 0 |
uses_count |
int, default 0 |
starts_at |
timestamptz |
expires_at |
timestamptz, nullable |
is_active |
bool |
created_at / updated_at |
timestamptz |
15.2.3 promo_code_redemptions
| Column | Type |
|---|---|
id |
bigint, PK |
promo_code_id |
bigint, FK |
user_id |
bigint, FK |
subject_type |
string (post_purchase, tier_subscription, premium_subscription) |
subject_id |
bigint |
discount_applied_minor_units |
bigint |
redeemed_at |
timestamptz |
Unique on (promo_code_id, user_id, subject_type, subject_id).
15.2.4 referral_links
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK, unique |
slug |
string(16), unique |
clicks |
int, default 0 |
signups |
int, default 0 |
commissioned_amount_minor_units |
bigint, default 0 |
created_at |
timestamptz |
15.2.5 referral_attributions
| Column | Type |
|---|---|
id |
bigint, PK |
referral_link_id |
bigint, FK |
referred_user_id |
bigint, FK, unique |
commission_rate |
decimal(5,4) |
attribution_expires_at |
timestamptz |
welcome_credit_minor_units |
bigint |
welcome_credit_ledger_transaction_id |
bigint, FK |
attributed_at |
timestamptz |
15.2.6 referral_commissions
| Column | Type |
|---|---|
id |
bigint, PK |
referral_attribution_id |
bigint, FK |
source_type |
string |
source_id |
bigint |
referred_user_spend_minor_units |
bigint |
commission_minor_units |
bigint |
ledger_transaction_id |
bigint, FK |
created_at |
timestamptz |
15.3 Flows
Promo code redemption happens inline at purchase/subscribe time:
PurchasePostAction (if promoCode provided):
├── Resolve PromoCode (by code, not expired, active)
├── Check applies_to compatibility
├── Check max_uses_per_user (redeemed by this user already?)
├── Calculate discount
├── Final gross = original - discount (but not less than 0)
├── Fee calculation uses discounted gross
├── Post-settlement: insert PromoCodeRedemption, increment uses_count
└── Return PostPurchase with discount fields populated
Referral attribution captured at signup:
User clicks loreax.com/r/{slug} → AttributeReferralAction
├── Set HttpOnly cookie `loreax_ref=<slug>` for 30 days
└── Increment ReferralLink.clicks
User signs up (RegisterUserAction):
├── Read cookie; if present:
│ ├── Resolve ReferralLink
│ ├── Create ReferralAttribution (referred_user_id = new user)
│ ├── Issue welcome_credit ledger transaction
│ └── Increment ReferralLink.signups
└── Proceed with registration
Commission on spend — every PostPurchased, TierSubscriptionPaid, PremiumSubscriptionPaid event listened by CreditReferralCommissionListener:
If referred user has active ReferralAttribution (attribution_expires_at > now):
commission = spend * attribution.commission_rate
LedgerService::post(referral_commission rule)
Create ReferralCommission row
15.4 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/promotions/boosts |
user (creator) | Boost a post |
GET |
/v1/promotions/boosts |
user | Own boosts |
POST |
/v1/promotions/boosts/{id}/pause |
user | Pause |
POST |
/v1/promotions/boosts/{id}/resume |
user | Resume |
POST |
/v1/promotions/boosts/{id}/cancel |
user | Cancel (refund unspent) |
POST |
/v1/promotions/promo-codes |
user (creator) | Create code |
GET |
/v1/promotions/promo-codes |
user | Own codes + analytics |
PATCH |
/v1/promotions/promo-codes/{id} |
user | Update |
POST |
/v1/promotions/promo-codes/validate |
user | Check if code is valid for a purchase |
GET |
/v1/promotions/referral-link |
user | Own link (create on first call) |
GET |
/v1/promotions/referral-stats |
user | Clicks, signups, commissions |
POST |
/v1/promotions/referral-track/{slug} |
public | Track click, set cookie |
GET |
/v1/admin/promotions/boosts |
admin | BoostedPost.Read |
POST |
/v1/admin/promotions/boosts/{id}/approve |
admin | BoostedPost.Approval (if approval required) |
GET |
/v1/admin/promotions/promo-codes |
admin | PromoCode.Read platform-wide |
16. Domain: Notifications
16.1 Responsibility
Delivers notifications across in-app, email, web push, and SMS channels. Leverages Laravel's notification system with a centralized preferences layer. Terminal sink — no notification triggers another domain.
16.2 Channels
| Channel | v1 | Laravel Channel | Backend |
|---|---|---|---|
| In-app | ✅ Mandatory | database |
PostgreSQL |
| ✅ Mandatory | mail |
AWS SES | |
| Web Push | ✅ Opt-in | webpush (laravel-notification-channels/webpush) |
Custom |
| SMS | ⚠️ Critical only | sms |
bervant/laravel-sms with configured driver |
16.3 Entities
16.3.1 notifications (Laravel default)
Laravel's database channel table with id (UUID), type, notifiable_type, notifiable_id, data (jsonb), read_at, created_at.
16.3.2 notification_preferences
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
notification_type |
string |
channel |
enum('in_app','email','web_push','sms') |
is_enabled |
bool |
updated_at |
timestamptz |
Unique on (user_id, notification_type, channel).
16.3.3 web_push_subscriptions
Standard schema from laravel-notification-channels/webpush.
16.3.4 failed_notifications
| Column | Type |
|---|---|
id |
bigint, PK |
notifiable_type |
string |
notifiable_id |
bigint |
notification_type |
string |
channel |
string |
payload |
jsonb |
error |
text |
attempts |
int |
failed_at |
timestamptz |
resolved_at |
timestamptz, nullable |
resolved_by_admin_id |
bigint, nullable |
16.4 Notification Types (v1 catalog)
App\Notifications\Enums\NotificationType:
account.email_verifiedaccount.password_changedaccount.mfa_enabledaccount.handle_changedaccount.suspicious_loginsocial.new_followersocial.new_fan_club_membersocial.comment_mentionsocial.comment_replycontent.new_post_from_followed(digested)content.saved_post_now_freecontent.post_you_bought_removedcommerce.top_up_succeededcommerce.top_up_failedcommerce.purchase_receiptcommerce.tier_chargedcommerce.tier_renewal_upcoming_7dcommerce.tier_renewal_upcoming_1dcommerce.tier_payment_failedcommerce.withdrawal_requestedcommerce.withdrawal_succeededcommerce.withdrawal_failedcommerce.refund_issuedcreator.new_purchasecreator.new_tier_subscribercreator.new_fan_club_membercreator.tier_subscriber_cancelledcreator.boost_budget_exhaustedcreator.promo_code_redeemedcreator.moderation_actioncreator.verification_statussystem.maintenance_scheduledsystem.policy_updatesystem.policy_violation_warning
Each has default channel routing in config/notifications.php:
return [
'types' => [
'commerce.top_up_succeeded' => [
'default_channels' => ['in_app', 'email'],
'user_overridable' => ['email'],
'digest' => false,
],
'content.new_post_from_followed' => [
'default_channels' => ['in_app', 'email'],
'user_overridable' => ['in_app', 'email', 'web_push'],
'digest' => 'daily_8am',
],
'account.suspicious_login' => [
'default_channels' => ['in_app', 'email', 'sms'],
'user_overridable' => [], // Non-optional
'digest' => false,
],
],
];
16.5 Digest Delivery
DigestNotificationsJob runs daily at user's local 8am (batched by timezone):
- Collects all pending digest notifications for the user from the last 24h
- Groups by
notification_type - Renders one digest email with the groups
- Clears the pending digest queue
"Pending digest" is maintained in a Redis list per user: notifications:digest:{userId}.
16.6 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/v1/notifications |
user | Paginated in-app |
PATCH |
/v1/notifications/{id}/read |
user | Mark read |
POST |
/v1/notifications/read-all |
user | Bulk mark read |
GET |
/v1/notifications/preferences |
user | Current preferences |
PATCH |
/v1/notifications/preferences |
user | Bulk update |
POST |
/v1/notifications/web-push/subscribe |
user | Register browser for push |
DELETE |
/v1/notifications/web-push/unsubscribe |
user | Unregister |
GET |
/v1/admin/notifications/failed |
admin | Failed-delivery queue |
POST |
/v1/admin/notifications/failed/{id}/retry |
admin | Manual retry |
17. Domain: Moderation
17.1 Responsibility
Enforces content policy in three configurable modes (reactive_only default; ai_prescreen and manual_prescreen stubbed and activatable via platform setting). Owns:
- User reports
- Review queue
- Takedown workflow
- AI moderation provider stubs (OpenAI Moderation, Claude review notes)
- Creator warnings & suspensions
17.2 Entities
17.2.1 reports
| Column | Type |
|---|---|
id |
bigint, PK |
public_id |
ulid |
reporter_user_id |
bigint, FK, nullable |
reportable_type |
string (post, comment, user, collection, fan_club_message) |
reportable_id |
bigint |
reason |
enum('spam','nudity','hate','violence','harassment','impersonation','copyright','misinformation','illegal','other') |
details |
text, nullable |
priority |
enum('p0','p1','p2','p3') |
status |
enum('pending','triaged','resolved','dismissed') |
duplicate_count |
int, default 1 |
resolved_by_admin_id |
bigint, nullable |
resolved_at |
timestamptz, nullable |
resolution |
enum('no_action','content_removed','creator_warned','creator_suspended','creator_banned','false_report','other'), nullable |
resolution_notes |
text, nullable |
created_at / updated_at |
timestamptz |
Uniqueness constraint relaxed: multiple reports on same subject allowed (counted in duplicate_count on a canonical primary row via service).
17.2.2 moderation_actions
Audit trail of moderator decisions beyond report resolution.
| Column | Type |
|---|---|
id |
bigint, PK |
target_type |
string |
target_id |
bigint |
action |
enum('takedown','restore','warn','suspend','ban','clear_report','dismiss_report') |
reason |
text |
admin_id |
bigint, FK |
expires_at |
timestamptz, nullable |
source_report_id |
bigint, nullable |
created_at |
timestamptz |
17.2.3 creator_warnings
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
severity |
enum('notice','warning','strike','final_strike') |
reason |
text |
issued_by_admin_id |
bigint |
related_post_id |
bigint, nullable |
acknowledged_at |
timestamptz, nullable |
created_at |
timestamptz |
Three strikes → automatic 7-day suspension (configurable).
17.2.4 user_suspensions
| Column | Type |
|---|---|
id |
bigint, PK |
user_id |
bigint, FK |
reason |
text |
starts_at |
timestamptz |
ends_at |
timestamptz, nullable |
issued_by_admin_id |
bigint |
lifted_by_admin_id |
bigint, nullable |
lifted_at |
timestamptz, nullable |
created_at |
timestamptz |
Active suspensions block login and write endpoints; read-only browsing continues.
17.2.5 ai_moderation_results
Stubbed; only populated when moderation.mode != reactive_only.
| Column | Type |
|---|---|
id |
bigint, PK |
post_id |
bigint, FK, unique |
provider |
enum('openai_moderation','claude','custom') |
verdict |
enum('auto_pass','auto_hide','needs_review') |
confidence_scores |
jsonb |
reviewer_notes_md |
text, nullable |
evaluated_at |
timestamptz |
17.3 Provider Interface
<?php
declare(strict_types=1);
namespace App\Moderation\Contracts;
interface IModerationAiProvider
{
public function identifier(): string;
public function evaluatePost(Post $post): AiModerationResult;
}
v1 stubs: OpenAiModerationProvider, ClaudeReviewNotesProvider. Wired but never called when moderation.mode = reactive_only.
17.4 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/reports |
user | Submit a report |
GET |
/v1/admin/reports |
admin | Report.Read queue |
POST |
/v1/admin/reports/{id}/resolve |
admin | Report.ReadWrite |
POST |
/v1/admin/reports/{id}/dismiss |
admin | Report.ReadWrite |
POST |
/v1/admin/posts/{id}/takedown |
admin | Post.Approval |
POST |
/v1/admin/posts/{id}/restore |
admin | Post.ReadWrite |
POST |
/v1/admin/users/{id}/warn |
admin | User.ReadWrite |
POST |
/v1/admin/users/{id}/suspend |
admin | User.ReadWrite |
POST |
/v1/admin/users/{id}/lift-suspension |
admin | User.ReadWrite |
18. Domain: Admin & Back Office
18.1 Responsibility
Filament-powered back office, separate admin guard, RBAC via static scope dictionary + DB-backed roles.
18.2 Key Modules (Filament Panel /admin)
- Dashboard — system-wide KPIs (MAU, DAU, GMV, platform revenue, top creators, pending review count)
- Users — search, impersonate (with audit), suspend, unsuspend
- Creators — creator directory, notable verification queue, earnings summary, warning history
- Admin Users — provision new admins (Super Admin only), assign roles
- Roles — read roles and scopes (immutable from UI; modified via code + seeders for safety)
- Posts — moderation, bulk takedown
- Comments — moderation
- Reports — review queue with filters, bulk resolve
- Ledger — transactions browser, manual adjustment (with
Ledger.Approvaltwo-person gate) - Withdrawals — ops queue, retry, force-fail, CSV export
- Top-ups — view, manually reconcile stuck ones
- Promotions — boosts, promo codes, referral stats
- Tiers — read-only view of creator tiers
- Niches & Categories — edit taxonomy
- Platform Settings — all runtime-tunable keys
- Feature Flags — toggle & rollout %
- Audit Log —
activity_logbrowser with rich filters - Request Log — Mongo-backed log browser (read-only)
- Failed Notifications — retry queue
- Jobs — Horizon embedded
- Wallet Drift Incidents — reconciliation alerts
- Approval Requests — pending two-person approvals across the system
18.3 Role Definitions (seeded)
Full mapping in Appendix A. Summary:
| Role | Scope Count | Key Access |
|---|---|---|
| Super Admin | * (wildcard) | Everything |
| Platform Manager | ~25 | Taxonomy, settings, flags, premium, boosts; no ledger write, no admin user mgmt |
| Content Reviewer | ~15 | Moderation queue, takedowns, warnings |
| Finance Ops | ~12 | Ledger read, withdrawal/refund with approval gates |
| Support | ~10 | User lookup, ticket management, password reset triggers |
| Read-Only Auditor | all .Read |
View-only system-wide |
18.4 Two-Person Approval Pattern
Scopes ending in .Approval on destructive financial actions require a distinct second admin to approve via the ApprovalRequest workflow.
Admin A → InitiateLedgerAdjustmentAction → creates ApprovalRequest (status=pending)
Admin B → ApproveAction → executes the adjustment inside DB::transaction
→ ApprovalRequest.status=approved, sets approved_by
Admin A cannot approve their own request. Enforced in ApproveAction.
18.5 Audit Logging
spatie/laravel-activitylog wired on every Eloquent model through a base LogsActivityStrict trait:
causer_type/causer_idpolymorphic to User or AdminUsersubject_type/subject_idpolymorphic to affected modelevent— e.g.post.published,withdrawal.succeeded,user.suspendedproperties— before/after diffrequest_id— correlation to RequestLogreason— required on destructive events
Activity log table is append-only enforced by revoking DELETE privilege from the application DB role.
19. Cross-Cutting: Observability
19.1 Goals
Every request, every job, every state change must be reconstructible from logs. Three observability streams correlate via requestId and conversationId:
- RequestLog (MongoDB) — every inbound HTTP request
- ActivityLog (Postgres, via
spatie/laravel-activitylog) — every domain state change - JobLog (Mongo) — every queued job execution
- FileLog (local/runtime) — size-rotated files where sequence
0is always newest (laravel.0.log,laravel-YYYY-MM-DD.0.log) - Database query log (PHP runtime sampling → CloudWatch) — slow queries only
Correlation flow:
Inbound request → assign requestId → attach to Log context
→ any job dispatched inherits requestId via JobMiddleware
→ any activity log entry records requestId in properties
→ downstream HTTP calls propagate X-Request-ID header
19.2 RequestLogger
Follows the user-provided MongoRequestLogger blueprint. The contract:
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
use App\Identity\Enums\Realm;
interface IRequestLogger
{
/**
* Persist a single request log entry.
*
* @throws \App\Core\Exceptions\RequestLogWriteException on unrecoverable failure
*/
public function log(RequestLogEntry $entry): void;
}
RequestLogEntry value object carries:
requestId— ULID, mandatoryconversationId— propagated from client or defaults to requestIdtraceId— W3C Trace Context trace-id (32 hex chars), mandatoryspanId— W3C Trace Context span-id (16 hex chars), mandatoryrealm—user|admin|system(system for job-triggered requests)processorName— matched controller action or job class FQCNplatform— fromX-Platformheader (web,ios,android,admin_web)scenario— fromX-Scenarioheader (e.g.onboarding,purchase_flow)appVersion— fromX-App-VersionheaderbackendVersion— from config (app.version, git sha)method— HTTP methodpath— route pattern (not raw URI, to avoid cardinality explosion)statusCodedurationMsisSuccessful— status < 400 AND no unhandled exceptionuserId— nullableadminId— nullablerequestPayload— jsonb, redacted (passwords, tokens, MPESA details stripped)responsePayload— jsonb, redacted, only if 4xx/5xx or explicitly opted-in via route configerrorCode— from exception, if anyerrorMessage— safe-to-log, never PIIuserAgent,ipAddress,refereroccurredAt— UTC timestamp
Implementation (MongoDB, strict-by-design):
<?php
declare(strict_types=1);
namespace App\Core\Infrastructure\Logging;
use App\Core\Contracts\IRequestLogger;
use App\Core\Contracts\RequestLogEntry;
use App\Core\Exceptions\RequestLogWriteException;
use MongoDB\Client as MongoClient;
use MongoDB\Collection;
use MongoDB\Driver\Exception\Exception as MongoException;
use Psr\Log\LoggerInterface;
final class MongoRequestLogger implements IRequestLogger
{
private readonly Collection $collection;
public function __construct(
MongoClient $client,
private readonly LoggerInterface $fallbackLogger,
string $database,
string $collectionName,
) {
$this->collection = $client->selectCollection($database, $collectionName);
}
public function log(RequestLogEntry $entry): void
{
$document = [
'requestId' => $entry->requestId,
'conversationId' => $entry->conversationId,
'traceId' => $entry->traceId,
'spanId' => $entry->spanId,
'realm' => $entry->realm->value,
'processorName' => $entry->processorName,
'platform' => $entry->platform,
'scenario' => $entry->scenario,
'appVersion' => $entry->appVersion,
'backendVersion' => $entry->backendVersion,
'method' => $entry->method,
'path' => $entry->path,
'statusCode' => $entry->statusCode,
'durationMs' => $entry->durationMs,
'isSuccessful' => $entry->isSuccessful,
'userId' => $entry->userId,
'adminId' => $entry->adminId,
'requestPayload' => $entry->requestPayload,
'responsePayload' => $entry->responsePayload,
'errorCode' => $entry->errorCode,
'errorMessage' => $entry->errorMessage,
'userAgent' => $entry->userAgent,
'ipAddress' => $entry->ipAddress,
'referer' => $entry->referer,
'occurredAt' => new \MongoDB\BSON\UTCDateTime($entry->occurredAt->getTimestamp() * 1000),
];
try {
$this->collection->insertOne($document);
} catch (MongoException $e) {
$this->fallbackLogger->error('RequestLog write failed', [
'requestId' => $entry->requestId,
'error' => $e->getMessage(),
]);
throw new RequestLogWriteException(
'Unable to persist request log for ' . $entry->requestId,
previous: $e,
);
}
}
}
Registered in a custom LoggingServiceProvider, wired from config/app.php custom namespace (app.custom.request_logger.*).
Terminable middleware ensures the log write happens after response send — it never blocks response latency:
final class LogRequestMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$start = hrtime(true);
$request->attributes->set('request_start_hrtime', $start);
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
try {
$entry = RequestLogEntryFactory::fromRequestResponse($request, $response);
app(IRequestLogger::class)->log($entry);
} catch (Throwable $e) {
Log::error('Terminal request log failed', ['exception' => $e]);
// Do not re-throw — terminable middleware swallows.
}
}
}
19.3 Redaction Rules
RequestLogEntryFactory applies field-level redaction:
- Request paths containing
password,passwordConfirmation,currentPassword,mfaCode,mfaBackupCode,otp,token,providerToken,providerIdToken→ replaced with[REDACTED] - Authorization headers stripped entirely
- MPESA
phoneNumbermasked to last 3 digits:xxxxx678 - Credit card-like patterns regex-stripped
- Request body for
/v1/payments/*→ only non-sensitive fields kept (amount, currency, idempotencyKey) - Response body persisted only for 4xx/5xx or when route declares
logResponsePayload=true
Redaction list centralized in config/request_logger.php — editable without code change.
19.4 JobLog
Every queued job wraps in LogsJobExecution middleware (via Horizon/Laravel job middleware):
- Job class, payload hash, attempt, queue, requestId (if dispatched in request context), duration, success/failure, exception class + message
- Written to Mongo
job_logscollection viaIJobLoggersibling ofIRequestLogger - Failed jobs also live in standard Laravel
failed_jobstable for Horizon UI
19.5 ActivityLog
spatie/laravel-activitylog augmented:
properties.requestIdalways populated if activity happens within a request scope (read from context viaAssignRequestIdmiddleware)- Log names segmented by domain:
identity,ledger,content, etc. — filterable in Filament - Sensitive models (e.g.
MfaChallenge) excluded from activity log via$logAttributes = false
19.6 Metrics
CloudWatch namespace Loreax/:
- HTTP: request_count, request_duration (p50/p95/p99), error_rate by path pattern & status class
- Ledger: transactions_per_minute, unbalanced_transaction_attempts (should always be zero), wallet_drift_count
- Payments: top_up_success_rate, withdrawal_success_rate, mpesa_callback_lag
- Queues: queue_depth per Horizon queue, job_duration per job class, failed_job_count
- DB: connection_count, slow_query_count
- Discovery: feed_latency, candidate_pool_size
Emitted via spatie/laravel-server-monitor equivalent + a lightweight custom MetricsRecorder shim wrapping CloudWatch PutMetricData calls.
19.7 Alerts
CloudWatch alarms (v1 baseline):
| Alarm | Threshold | Action |
|---|---|---|
high_5xx_rate |
>2% over 5 min | SNS → PagerDuty |
ledger_unbalanced_attempts |
>0 over 1 min | SNS → PagerDuty (P0) |
wallet_drift_incidents |
any | SNS → PagerDuty (P0) |
mpesa_callback_timeout_rate |
>5% over 15 min | SNS → ops email |
horizon_queue_depth_transactional |
>500 | SNS → ops email |
failed_notification_count |
>50/hr | SNS → ops email |
rds_cpu |
>80% for 10 min | SNS → ops email |
rds_free_storage |
<20GB |
SNS → ops email |
20. Cross-Cutting: Jobs, Scheduling & Events
20.1 Queue Configuration
Horizon queues (configured in config/horizon.php):
| Queue | Purpose | Worker Count | Timeout | Retries |
|---|---|---|---|---|
transactional |
Payments, ledger-touching jobs | 4 | 120s | 3 with exponential backoff |
notifications |
Email, SMS, push delivery | 6 | 60s | 5 |
media |
Video/audio/image processing | 2 (long-running) | 1800s (30min) | 2 |
logs |
RequestLog flushes, metrics emission | 2 | 30s | 2 |
discovery |
Affinity recomputation, signal refresh | 2 | 300s | 2 |
default |
Miscellaneous | 3 | 60s | 3 |
20.2 Scheduled Jobs Catalog
Registered in App\Console\Kernel::schedule():
| Job | Frequency | Queue | Purpose |
|---|---|---|---|
ReleaseEarningsJob |
every 30 min | transactional | Release pending earnings with withdrawable_after <= now |
BillActiveTierSubscriptionsJob |
hourly | transactional | Charge tier subs whose period ends today |
BillActivePremiumSubscriptionsJob |
hourly | transactional | Charge premium subs |
RetryFailedTierPaymentsJob |
daily | transactional | Retry past-due tier subs within grace |
ExpireOverdueTierSubscriptionsJob |
daily | transactional | Mark expired after grace |
ExpireBoostedPostsJob |
every 5 min | default | Complete boosts past ends_at |
DebitBoostedPostImpressionsJob |
every minute | default | Aggregate impressions into spent_minor_units |
ClaimExpiredPromoCodesJob |
daily | default | Deactivate expired codes |
ExpireReferralAttributionsJob |
daily | default | Stop commissions past 30d window |
ClawbackUnusedWelcomeCreditJob |
daily | transactional | Post reversing adjustment if credit unused |
RecomputeFanEngagementScoresJob |
every 4 hours | discovery | Incremental recompute |
RefreshPostSignalsJob |
every 10 min | discovery | Refresh post_signal_cache |
RefreshUserAffinityJob |
nightly 3am | discovery | Full affinity cache rebuild |
SyncFanClubMembershipsJob |
every 30 min | default | Enroll/remove members by current status |
DigestNotificationsJob |
hourly (timezone-aware dispatch) | notifications | Send 8am-local digest per user |
ReconcileWalletsJob |
nightly 2am | transactional | Integrity check wallet balances vs ledger |
PruneInteractionsJob |
weekly | default | Drop interactions partitions older than 13 months |
PruneExpiredMfaChallengesJob |
hourly | default | Remove expired unverified challenges |
PruneExpiredPaymentIntentsJob |
every 15 min | transactional | Mark stale intents as expired |
PruneExpiredApprovalRequestsJob |
every 30 min | default | Auto-reject stale approval requests |
PurgeSoftDeletedUsersJob |
daily | default | Permanently delete users past 30d grace |
ExportUserDataJob |
on-demand | default | KDPA data export |
PruneRequestLogsJob |
daily | logs | Drop mongo request logs older than 180 days (configurable) |
FlushActivityLogToColdStorageJob |
weekly | logs | Archive activity_log > 1 year to S3 as parquet |
WarmDiscoveryCacheForActiveUsersJob |
every 6 hours | discovery | Pre-compute feeds for top-N active users |
20.3 Domain Event Catalog
Identity: UserRegistered, UserEmailVerified, UserLoggedIn, UserPasswordChanged, UserSuspended, UserSuspensionLifted, UserBecameCreator, UserBecamePremium, UserMfaEnabled, UserMfaDisabled, UserHandleChanged, UserDeleted, SocialAccountLinked
Ledger: LedgerTransactionPosted, WalletBalanceChanged, WalletDriftDetected, ManualAdjustmentApproved
Payments: TopUpInitiated, TopUpSucceeded, TopUpFailed, WithdrawalRequested, WithdrawalSucceeded, WithdrawalFailed, MpesaCallbackReceived
Content: PostDrafted, PostPublished, PostTakenDown, PostRestored, PostEdited, MediaProcessed, MediaProcessingFailed, LivestreamStarted, LivestreamEnded, PollVoted
Access: PostPurchased, PostRefunded, FanEngagementRecomputed
Monetization: TierCreated, TierArchived, TierSubscribed, TierSubscriptionRenewed, TierSubscriptionCancelled, TierSubscriptionExpired, PremiumSubscribed, PremiumCancelled, CreatorVerified
Social: UserFollowed, UserUnfollowed, PostLiked, PostCommented, CommentLiked, PostShared, PostSaved
FanClub: FanClubMembershipGranted, FanClubMembershipRevoked, FanClubMessagePosted
Discovery: InteractionLogged, FeedServed, SearchServed
Promotions: BoostedPostCreated, BoostedPostExhausted, PromoCodeRedeemed, ReferralAttributed, ReferralCommissionEarned
Moderation: ReportSubmitted, ReportResolved, CreatorWarned, ContentAutoHidden
Notifications: NotificationSent, NotificationFailed, NotificationPreferenceChanged
20.4 Event → Listener Mapping (key ones)
UserRegistered→SendEmailVerification,CreateUserWalletListener,ApplyReferralAttributionListener,IssueWelcomeCreditListenerPostPurchased→CreditReferralCommissionListener,UpdateFanEngagementListener,NotifyCreatorListener,RecordBuyerInteractionListenerTierSubscribed→SyncFanClubMembershipListener,NotifyCreatorListener,CreditReferralCommissionListenerPostPublished→DispatchNewPostDigestListener,InvalidateFeedCacheListener,ScheduleModerationIfConfiguredListenerLedgerTransactionPosted→UpdateDenormalizedWalletListenerWalletDriftDetected→AlertOpsListener(PagerDuty)UserBecameCreator→CreateFanClubListener,IssueReferralLinkListener
21. Cross-Cutting: Security & Compliance
21.1 Authentication Security
- Password hashing:
bcryptwith cost factor 12 (Laravel default) - Password policy: min 12 chars, at least one letter + one number; validated via Laravel's
Passwordrule - Failed login rate limiting: 5 attempts per 10 min per IP+email; 15min lockout thereafter
- Suspicious login detection: new country or new device fingerprint → challenge MFA + notify
- Session expiry: 14 days inactive, 30 days absolute; Sanctum personal tokens roll every 90 days
- Admin sessions: 2h inactive, 12h absolute; mandatory MFA
21.2 Authorization
- All mutation endpoints pass through explicit policy checks via
Gate::authorize()or scope-checking middleware - No implicit
visible-to-allreads — every resource has explicit visibility semantics - Creator-owned resources verified via
Auth::user()->owns($resource)helper backed bycreator_idmatch - Admin scope checks are deny by default — if a scope is not declared required, endpoint is admin-route-level blocked
21.3 Data at Rest
- RDS Postgres: AWS-managed encryption at rest (AES-256)
- S3: SSE-S3 encryption at rest; separate buckets for public-read avatars vs. private purchased content (signed URLs, 60s TTL)
- MongoDB Atlas: encryption at rest
- Encrypted Eloquent columns:
mfa_secret,mfa_backup_codes,withdrawal_methods.encrypted_details,livestreams.stream_key_encrypted,verification_requests.identity_document_path(path is encrypted; the document itself is on a private S3 bucket) - Application-level encryption via Laravel
encrypted:jsonandencrypted:arraycasts usingAPP_KEY
21.4 Data in Transit
- TLS 1.2+ only, enforced at ALB
- Internal service communication (app → RDS, app → Redis, app → MongoDB Atlas) all TLS
- HSTS header with
max-age=31536000; includeSubDomains; preload - CSP: strict, self + explicit allowlist for Mux, S3, fonts.gstatic, SES unsubscribe links
- CORS: explicit allowlist from
config/cors.php, credentials only for web origin
21.5 Secrets Management
.envnever committed (enforced via git secret scanning + pre-commit hook)- Production secrets injected via AWS Systems Manager Parameter Store at deploy time
- Ansible playbook pulls SSM params into
/etc/loreax/envwithroot:www-data 640permissions - Per-environment KMS keys for SSM encryption
- Kashier/Mux/SES credentials rotated quarterly (ops runbook)
21.6 KDPA / GDPR Compliance
Kenya Data Protection Act (KDPA) compliance features:
- Consent tracking: versioned
user_consentsrecords with document version snapshots - Data export:
POST /v1/identity/me/data-export→ asyncExportUserDataJob→ signed S3 URL (48h TTL), includes profile, posts, purchases, subscriptions, ledger entries, fan club messages, interactions (subject to retention limits) - Right to erasure:
DELETE /v1/identity/me→ soft delete + 30d grace;PurgeSoftDeletedUsersJobthen hard-deletes PII fromusers, rotates PII out ofledger_transactions.metadatainto pseudonymous token - Data minimization: only collect fields with a stated purpose; onboarding form shorter than profile edit form
- Breach runbook:
docs/runbooks/data-breach.mdwith 72-hour notification procedure to Office of the Data Protection Commissioner - DPO contact surfaced in privacy policy + Filament setting
- Explicit opt-in for marketing email + SMS (pre-selected checkbox forbidden, logged in
user_consents) - Cross-border transfer note: MongoDB Atlas may host in another region; disclosed in privacy policy; covered via Atlas's standard contractual clauses
21.7 Payment Compliance
- No PCI scope: all payment credentials (MPESA PIN) stay with Safaricom; we never touch card PANs
- B2C tills and shortcodes configured in SSM; never in app code
- Kashier credentials rotated through a zero-downtime config reload procedure documented in
docs/runbooks/mpesa-credential-rotation.md
21.8 Input Validation & Sanitization
- All inputs pass through
FormRequestwith whitelist validation; unknown fields rejected via custom rule - HTML stored in post body is sanitized via
mews/purifierwith a strict allowlist (p, br, strong, em, a, ul, ol, li, blockquote, h3, h4, code, pre) - User-generated URLs validated for scheme (
http/httpsonly), nojavascript:,data:,file: - File uploads: MIME sniffed server-side (not trusting client content-type), size caps, antivirus scan via ClamAV daemon on upload worker (stubbed v1, activatable)
21.9 SSRF / Link Preview Hardening
Link-type posts fetch OG metadata via a dedicated worker that:
- Resolves hostname and rejects private/link-local/metadata-service IPs (AWS 169.254.169.254, 10/8, 172.16/12, 192.168/16, ::1)
- Fetches with 5s timeout, 2MB body cap
- Follows at most 3 redirects
- Isolated network namespace (security group restricts egress to 80/443 public)
21.10 Abuse Prevention
- Registration rate limit: 3 per IP per hour
- Referral signup same-IP/device-fingerprint blocked
- Duplicate reports from same user coalesced (prevents report bombing)
- Comment/chat profanity filter (stubbed; activatable); rate limit 20 comments/minute per user
- Boosted post approval gate (configurable; off in v1)
- Content hash deduplication: reject uploads with same SHA-256 as existing post from another creator (possible plagiarism signal for moderation)
22. Cross-Cutting: Testing Strategy
22.1 Tooling
- PHPUnit 11+ (Laravel 12, fully PHP 8.4 compatible)
#[Test]attribute (PHPUnit\Framework\Attributes\Test); the legacytest*method-prefix style is forbiddenRefreshDatabasetrait for feature tests (transaction-per-test, rolled back after each)- Laravel's built-in
TestCase+ Eloquent factories - Mockery for interface stubs (e.g.
IPaymentProvider,IMfaProvider) - PCOV (or Xdebug coverage mode) for coverage collection — CI uses PCOV for speed
- Infection (recommended, not CI-enforced) for mutation testing of Ledger + Payments + Access domains before finance-adjacent PRs merge
22.2 Coverage Target
≥ 80% line coverage enforced in CI via a post-run script that parses the Clover XML report; builds fail below threshold. Branch coverage encouraged, not blocking.
22.3 Test Method Naming Conventions
Every test method is public, returns void, carries the #[Test] attribute, and names itself by behavior, not mechanics. Two patterns allowed:
Pattern 1 — SubjectInPascalCase (single, unambiguous happy-path scenario)
LoginSuccess
RegistrationSuccess
WithdrawalAccepted
PostPurchased
TierSubscribed
Pattern 2 — Subject_Condition (scenarios with a distinct outcome per precondition — underscore-separated words, PascalCase on each side)
LoginWithValidCredential_Success
LoginWithInvalidPassword_ReturnsUnauthenticated
LoginWithUnverifiedEmail_ReturnsApplicationError
WithdrawalBelowMinimum_Returns430WithCode
WithdrawalAboveDailyLimit_Returns430WithCode
PurchasePost_WhenInsufficientFunds_Returns430
EveryPostingRule_SumsToZero
RandomTransactionSequence_NoWalletDrift
Challenge_WhenVerifiedWithCorrectCode_ReturnsTrue
Rules enforced in code review:
- No snake_case method names (Laravel's
test_function_like_this()style is forbidden) - No English-sentence methods (
it_should_reject_withdrawals_below_the_minimum()is forbidden) #[Test]attribute on every test method — notest*prefix- Class names end in
Testand live intests/{Unit,Feature,Contract,Invariant}/<Domain>/<Subject>Test.php - Multiple conditions in one name → separate with
_; maximum three underscore-separated segments (Subject_Condition_Outcome) — more than that is a sign the test needs splitting
22.4 Test Layers
| Layer | Directory | Purpose |
|---|---|---|
| Unit | tests/Unit/<Domain>/ |
Pure business logic, no DB, no HTTP (policies, fee math, score calc, DTO validation) |
| Feature | tests/Feature/<Domain>/ |
Full HTTP flow against real DB (RefreshDatabase) |
| Contract | tests/Contract/<Domain>/ |
Interface-implementation conformance: every impl satisfies the same suite |
| Invariant | tests/Invariant/<Domain>/ |
Ledger zero-sum, wallet drift, property-style checks |
| Smoke | tests/Smoke/ |
End-to-end critical path against staging (manually triggered; not in normal CI) |
phpunit.xml.dist declares one <testsuite> per layer:
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Contract">
<directory>tests/Contract</directory>
</testsuite>
<testsuite name="Invariant">
<directory>tests/Invariant</directory>
</testsuite>
</testsuites>
22.5 Example — Feature Test
<?php
declare(strict_types=1);
namespace Tests\Feature\Identity;
use App\Identity\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class LoginTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function LoginWithValidCredential_Success(): void
{
$user = User::factory()->create([
'password' => bcrypt('correct-horse-battery-staple'),
]);
$response = $this->postJson('/v1/identity/login', [
'email' => $user->email,
'password' => 'correct-horse-battery-staple',
]);
$response->assertStatus(200);
$response->assertJsonPath('message', 'OK');
$response->assertJsonPath('data.user.email', $user->email);
$this->assertIsString($response->json('data.accessToken'));
$this->assertIsString($response->json('meta.requestId'));
$this->assertIsString($response->json('meta.traceId'));
}
#[Test]
public function LoginWithInvalidPassword_ReturnsUnauthenticated(): void
{
$user = User::factory()->create([
'password' => bcrypt('correct-horse-battery-staple'),
]);
$response = $this->postJson('/v1/identity/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(401);
$response->assertJsonPath('errorCode', 'UNAUTHENTICATED');
}
#[Test]
public function LoginWithMissingEmail_ReturnsValidationError(): void
{
$response = $this->postJson('/v1/identity/login', [
'password' => 'anything',
]);
$response->assertStatus(422);
$response->assertJsonStructure([
'message',
'errors' => ['email'],
'meta' => ['requestId', 'traceId', 'timestamp'],
]);
}
#[Test]
public function LoginWhenRateLimited_Returns429(): void
{
$user = User::factory()->create();
for ($i = 0; $i < 10; $i++) {
$this->postJson('/v1/identity/login', [
'email' => $user->email,
'password' => 'wrong',
]);
}
$response = $this->postJson('/v1/identity/login', [
'email' => $user->email,
'password' => 'wrong',
]);
$response->assertStatus(429);
$response->assertJsonPath('errorCode', 'RATE_LIMITED');
}
}
22.6 Example — Invariant Test (Ledger)
<?php
declare(strict_types=1);
namespace Tests\Invariant\Ledger;
use App\Identity\Models\User;
use App\Ledger\Jobs\ReconcileWalletsJob;
use App\Ledger\Models\Wallet;
use App\Ledger\Models\WalletDriftIncident;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\Support\LedgerScenarios;
use Tests\TestCase;
final class LedgerIntegrityTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function EveryPostingRule_SumsToZero(): void
{
$rules = [
'top_up',
'post_purchase',
'tier_subscription_payment',
'earnings_release',
'withdrawal',
'post_purchase_refund',
'referral_commission',
'welcome_credit',
'premium_subscription_payment',
];
foreach ($rules as $rule) {
$txn = LedgerScenarios::make($rule)->post();
$sum = $txn->entries->sum('signed_amount_minor_units');
$this->assertSame(0, $sum, "Rule {$rule} is not balanced.");
}
}
#[Test]
public function RandomTransactionSequence_NoWalletDrift(): void
{
$users = User::factory()->count(10)->create();
foreach ($users as $user) {
Wallet::factory()->for($user)->create();
}
for ($i = 0; $i < 1000; $i++) {
LedgerScenarios::random($users)->post();
}
ReconcileWalletsJob::dispatchSync();
$this->assertSame(0, WalletDriftIncident::count());
}
}
22.7 Example — Contract Test with DataProvider
<?php
declare(strict_types=1);
namespace Tests\Contract\Identity;
use App\Identity\Contracts\IMfaProvider;
use App\Identity\Infrastructure\Mfa\EmailOtpMfaProvider;
use App\Identity\Infrastructure\Mfa\TotpMfaProvider;
use App\Identity\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\Support\MfaTestHelpers;
use Tests\TestCase;
final class MfaProviderContractTest extends TestCase
{
use RefreshDatabase;
#[Test]
#[DataProvider('mfaProvidersSource')]
public function Challenge_WhenVerifiedWithCorrectCode_ReturnsTrue(string $binding): void
{
/** @var IMfaProvider $provider */
$provider = app($binding);
$user = User::factory()->create();
$setup = $provider->initiate($user);
$challenge = $provider->challenge($user);
$correctCode = MfaTestHelpers::generateCorrectCode($provider, $setup, $challenge);
$this->assertTrue($provider->verify($challenge, $correctCode));
}
#[Test]
#[DataProvider('mfaProvidersSource')]
public function Challenge_WhenVerifiedWithIncorrectCode_ReturnsFalse(string $binding): void
{
/** @var IMfaProvider $provider */
$provider = app($binding);
$user = User::factory()->create();
$provider->initiate($user);
$challenge = $provider->challenge($user);
$this->assertFalse($provider->verify($challenge, '000000'));
}
/** @return array<string, array{0: class-string<IMfaProvider>}> */
public static function mfaProvidersSource(): array
{
return [
'totp' => [TotpMfaProvider::class],
'email_otp' => [EmailOtpMfaProvider::class],
];
}
}
22.8 Example — Unit Test (Domain Exception + Error Code)
<?php
declare(strict_types=1);
namespace Tests\Unit\Payments\Exceptions;
use App\Core\Exceptions\ErrorCode;
use App\Payments\Exceptions\WithdrawalBelowMinimumException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class WithdrawalBelowMinimumExceptionTest extends TestCase
{
#[Test]
public function ErrorCode_ReturnsWithdrawalBelowMinimum(): void
{
$ex = new WithdrawalBelowMinimumException(100, 500);
$this->assertSame(ErrorCode::WithdrawalBelowMinimum, $ex->errorCode());
}
#[Test]
public function HttpStatus_Defaults_To430(): void
{
$ex = new WithdrawalBelowMinimumException(100, 500);
$this->assertSame(430, $ex->httpStatus());
}
#[Test]
public function Message_IncludesAttemptedAndMinimumAmounts(): void
{
$ex = new WithdrawalBelowMinimumException(100, 500);
$this->assertStringContainsString('100', $ex->getMessage());
$this->assertStringContainsString('500', $ex->getMessage());
}
}
22.9 Required Scenario Tests
Before a PR touching these areas can merge, these suites must exist and pass:
- Ledger: every posting rule verified zero-sum; 1000-transaction random scenario verified drift-free
- Payments: every
IPaymentProviderimpl passes the shared provider behavior suite (mocked); idempotency-key replay returns identical response for top-up, withdrawal, purchase - Identity: every
IMfaProviderimpl passesMfaProviderContractTest; handle cooldown 30-day enforced; MFA required for admin + withdrawal endpoints - Access:
canViewmatrix — 4 rule types × 3 viewer states (subscriber / purchaser / active-fan / none) × 2 post statuses (published / draft) covered - Response envelope: every 2xx route produces shape A; every
ValidationExceptionpath produces shape B; everyDomainExceptionsubclass produces shape C with the correcterrorCode
22.10 CI Test Matrix
GitHub Actions ci.yml jobs:
- Lint — Pint dry-run
- Static analysis — Larastan level 8
- Test — PHPUnit across all suites against Postgres + Redis + Mongo service containers, coverage via PCOV, Clover XML output
- Coverage threshold — inline Clover XML parsing fails if < 80%
- OpenAPI —
l5-swagger:generatethen JSON schema validation
Target runtime: < 6 minutes on a cached job.
22.11 Running Tests Locally
# All suites
./vendor/bin/phpunit
# One suite
./vendor/bin/phpunit --testsuite=Feature
# One class
./vendor/bin/phpunit tests/Feature/Identity/LoginTest.php
# One method
./vendor/bin/phpunit --filter=LoginWithValidCredential_Success
# With coverage (requires PCOV ext or XDEBUG_MODE=coverage)
./vendor/bin/phpunit --coverage-clover build/coverage.xml --coverage-html build/coverage-html
php -r "... parse Clover and fail below 80% ..."
.env.testing uses ephemeral Postgres/Redis/Mongo via docker compose -f docker-compose.testing.yml for feature/contract/invariant suites. Unit suite runs without containers.
23. Cross-Cutting: Deployment & Operations
23.1 Environments
| Env | Purpose | Infrastructure |
|---|---|---|
local |
Developer machines | Docker Compose |
test |
CI test runs | Docker Compose (ephemeral) |
hyena |
Pre-prod validation | Single EC2 t3.medium, shared RDS hyena instance |
prod |
Live | EC2 t3.xlarge (vertical scale first), RDS PostgreSQL m6i.large, ElastiCache Redis, MongoDB Atlas M10 |
APP_ENV is boot-validated and must be one of: local, test, hyena, prod.
23.2 Self-Managed EC2 Deployment
Base image: Ubuntu 24.04 LTS (Noble).
Provisioning via Ansible — idempotent, committed at deploy/ansible/:
deploy/ansible/
├── inventory/
│ ├── staging.yml
│ └── production.yml
├── group_vars/
│ ├── all.yml
│ ├── staging.yml
│ └── production.yml
├── roles/
│ ├── base/ # packages, users, UFW, fail2ban, ntp, unattended-upgrades
│ ├── nginx/ # Nginx + TLS cert pickup from ACM-ssl
│ ├── php/ # PHP 8.4 + fpm + extensions (intl, gd, bcmath, redis, mongodb, pcntl, exif)
│ ├── ffmpeg/ # FFmpeg from static build
│ ├── horizon/ # Supervisor config for Horizon workers
│ ├── cloudwatch_agent/ # Log shipping config
│ ├── ssm_params/ # Fetch secrets into /etc/loreax/env
│ └── app_deploy/ # git clone, composer install, migrate, cache, reload php-fpm
├── playbooks/
│ ├── provision.yml # First-time setup
│ ├── update.yml # System updates only
│ └── deploy.yml # App deployment (also invokable by CI)
└── ansible.cfg
First-time provisioning:
ansible-playbook -i deploy/ansible/inventory/production.yml deploy/ansible/playbooks/provision.yml
Ongoing deploys: triggered by GitHub Actions on push to master (see §23.4).
23.3 Docker Parallel Track
Docker support committed alongside Ansible so local dev and v2 migration to ECS/EKS is zero-refactor:
Dockerfile— multi-stage:composer-deps→php-fpm-base→app(final)Dockerfile.worker— extendsapp, runsphp artisan horizondocker-compose.yml— local dev stack (app + nginx + postgres + redis + mongo + mailhog + laravel-echo-server-like)docker-compose.prod.yml— production-shape (minus local dev tooling); run-ready under ECS task definitions
Makefile:
make dev # docker-compose up
make prod-build # build prod images
make deploy # trigger ansible deploy
make test # run CI-equivalent locally
make logs-app # tail app logs
make fresh-db # wipe + migrate + seed local DB
23.4 GitHub Actions CI/CD
.github/workflows/ci.yml — runs on PR + push to any branch:
jobs:
lint:
runs-on: ubuntu-latest
steps: [checkout, setup-php@8.4, composer install, ./vendor/bin/pint --test]
static-analysis:
runs-on: ubuntu-latest
steps: [checkout, setup-php, composer install, ./vendor/bin/phpstan analyse]
test:
runs-on: ubuntu-latest
services:
postgres: { image: postgres:16, env: {...} }
redis: { image: redis:7 }
mongo: { image: mongo:7 }
steps:
- checkout
- setup-php
- composer install
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate
- ./vendor/bin/phpunit --coverage-clover build/coverage.xml
- php -r "... parse Clover and fail below 80% ..."
openapi:
needs: [lint, static-analysis]
steps: [php artisan l5-swagger:generate, validate-swagger schema]
.github/workflows/deploy-production.yml — runs on push to master:
on:
push:
branches: [master]
jobs:
deploy:
needs: [ci-pass] # via workflow_run gate
runs-on: ubuntu-latest
steps:
- checkout
- configure-aws-credentials (OIDC role, not keys)
- setup-ssh-agent with ed25519 deploy key
- run: ansible-playbook -i deploy/ansible/inventory/production.yml deploy/ansible/playbooks/deploy.yml
- run: curl https://api.loreax.com/ready # smoke check
- on-failure: rollback playbook
Deploy playbook steps (zero-downtime):
git fetch && git checkout <sha>into/srv/loreax/releases/<timestamp>composer install --no-dev --optimize-autoloaderphp artisan migrate --forcephp artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache- Symlink
/srv/loreax/current → releases/<timestamp> sudo systemctl reload php-fpmphp artisan horizon:terminate(Horizon restarts via supervisor)- Keep last 5 releases for rollback
23.5 Rollback
ansible-playbook ... playbooks/rollback.yml --extra-vars="target_release=<timestamp>" — flips the current symlink, reloads php-fpm, terminates Horizon. No DB rollback — migrations must be backward-compatible-by-default (see Migration Rules below).
23.6 Migration Rules
Migrations are backward-compatible:
- New columns: nullable or with default
- No column drops or renames in a single deploy — expand/contract pattern over two deploys
- New required columns added nullable first, backfilled via data migration, then marked non-null in a follow-up deploy
- Indexes created
CONCURRENTLYon large tables (DB::unprepared)
23.7 Environment Variables (v1 catalog)
Grouped in .env.example:
- App:
APP_NAME,APP_ENV,APP_KEY,APP_URL,APP_VERSION,APP_DEBUG - Database:
DB_CONNECTION,DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME,DB_PASSWORD,DB_SSLMODE - Redis:
REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,REDIS_DB - Mongo:
MONGO_URI,MONGO_DB_REQUEST_LOGS,MONGO_DB_JOB_LOGS - S3:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION,AWS_BUCKET_PUBLIC,AWS_BUCKET_PRIVATE,AWS_BUCKET_UPLOADS - Sanctum:
SANCTUM_STATEFUL_DOMAINS - Mail:
MAIL_MAILER=ses,MAIL_FROM_ADDRESS,MAIL_FROM_NAME, SES region - Kashier:
KASHIER_ENABLED,KASHIER_URL,KASHIER_MERCHANT_KEY,KASHIER_MERCHANT_SECRET,KASHIER_SERVICE_ID,KASHIER_PAYBILL_NO,KASHIER_PAYBILL_ACCOUNT - Mux:
MUX_TOKEN_ID,MUX_TOKEN_SECRET,MUX_WEBHOOK_SECRET - SMS package:
SMS_DRIVER,SMS_API_USERNAME,SMS_API_KEY,SMS_SHORTCODE, plus optionalTWILIO_*,BONGA_*, andENVISAGE_* - OAuth:
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,APPLE_*,FACEBOOK_*,LINKEDIN_* - Provider selection:
VIDEO_PROVIDER=ffmpeg_local,AUDIO_PROVIDER=ffmpeg_local,IMAGE_PROVIDER=intervention_local,LIVESTREAM_PROVIDER=mux,STORAGE_DISK=s3,PAYMENT_PROVIDER=kashier - Feature flags (bootstrap):
FEATURE_EXPLICIT_CONTENT_ENABLED=false,FEATURE_AI_MODERATION_ENABLED=false - Logging:
LOG_CHANNEL=stack,LOG_LEVEL=info,LOG_MAX_FILE_SIZE_BYTES=10485760,LOG_MAX_FILES=10,REQUEST_LOGGER_DB_CONNECTION=mongo,REQUEST_LOGGER_COLLECTION=request_logs - Rate limits: (reference
config/rate_limits.php, env-overridable per key)
24. Cross-Cutting: Documentation
24.1 OpenAPI / Swagger
zircote/swagger-phpattributes on every public controller methoddarkaonline/l5-swaggergeneratesstorage/api-docs/api-docs.jsonat build time- Served at
/docs/api(admin-auth gated in production; public in staging) - Redoc UI embedded
- Client SDK generation documented in
docs/api/client-sdk.mdfor future mobile clients
24.2 Domain READMEs
app/<Domain>/README.md — required for every domain, structure:
# <Domain>
## Purpose
<one paragraph: what this domain owns>
## Owned entities
- Entity A — purpose
- Entity B — purpose
## Key invariants
- <each testable invariant>
## External dependencies
- Upstream: <domains this reads from or calls>
- Downstream: <domains that depend on events/APIs from this domain>
## Public contracts
- `I<Something>Service`
- `App\<Domain>\Events\*`
## Key workflows
<concise flow descriptions>
## Known limitations
<v1 stubs, deferred features>
24.3 ADRs
Architecture Decision Records in docs/adr/, format per MADR 3.0:
0001-modular-monolith-over-microservices.md0002-eloquent-without-repositories.md0003-double-entry-ledger-design.md0004-camelcase-api-snakecase-db.md0005-mongo-for-request-logs.md0006-realm-enum-separate-auth-guards.md0007-static-scope-dictionary-dynamic-role-assignment.md0008-self-managed-ec2-vs-managed-paas.md0009-custom-discovery-service-over-elasticsearch.md0010-three-combinable-access-rules.md0011-mpesa-first-withdrawal-flow.md0012-active-fan-weighted-engagement-score.md0013-laravel-actions-for-business-logic.md
Each ADR: status, context, decision, consequences, alternatives considered.
24.4 Runbooks
docs/runbooks/:
data-breach.mdmpesa-credential-rotation.mdwallet-drift-response.mddeploy-rollback.mdrds-failover.mdhorizon-backlog-recovery.mdstuck-withdrawal.mdledger-manual-adjustment.md
24.5 Code Documentation Standards
- Every interface method: PHPDoc with purpose,
@throws, and contract notes - Every Action class:
run()docblock with preconditions, postconditions, side effects, exceptions - Complex queries or algorithms: inline comments explaining intent, not mechanics
- Migrations: top-of-file comment explaining the change rationale
@internal/@apiannotations used to mark public-stable vs. internal classes
25. Appendices
Appendix A — Full Scope Dictionary
Source of truth: App\Core\Authorization\Scope enum (§5.8). This table lists every case's PascalCase identifier, its backed string value (stored in role_scopes.scope), and the description returned by Scope::description().
| Enum case | Value | Description |
|---|---|---|
UserRead |
User.Read |
View user accounts (non-PII scope) |
UserReadWrite |
User.ReadWrite |
Edit user accounts, reset passwords, impersonate |
CreatorRead |
Creator.Read |
View creator profiles & earnings (redacted) |
CreatorReadWrite |
Creator.ReadWrite |
Edit creator profile meta |
CreatorApproval |
Creator.Approval |
Grant/revoke notable verification |
PostRead |
Post.Read |
View all posts (incl. drafts) |
PostReadWrite |
Post.ReadWrite |
Edit, takedown, restore posts |
PostApproval |
Post.Approval |
Approve posts in manual-prescreen mode |
CommentRead |
Comment.Read |
View comments |
CommentReadWrite |
Comment.ReadWrite |
Edit, delete, pin/unpin comments |
CollectionRead |
Collection.Read |
View collections |
CollectionReadWrite |
Collection.ReadWrite |
Edit collections |
TaxonomyRead |
Taxonomy.Read |
View niches/categories/tags |
TaxonomyReadWrite |
Taxonomy.ReadWrite |
Edit niches/categories/tags |
PlatformSettingRead |
PlatformSetting.Read |
View platform settings |
PlatformSettingReadWrite |
PlatformSetting.ReadWrite |
Edit platform settings |
FeatureFlagRead |
FeatureFlag.Read |
View feature flags |
FeatureFlagReadWrite |
FeatureFlag.ReadWrite |
Edit feature flags |
LedgerRead |
Ledger.Read |
View ledger transactions & entries |
LedgerReadWrite |
Ledger.ReadWrite |
Request manual adjustment |
LedgerApproval |
Ledger.Approval |
Approve manual adjustments (two-person) |
WalletRead |
Wallet.Read |
View user wallets |
TopUpRead |
TopUp.Read |
View top-ups |
TopUpReadWrite |
TopUp.ReadWrite |
Manually reconcile stuck top-ups |
WithdrawalRead |
Withdrawal.Read |
View withdrawals |
WithdrawalReadWrite |
Withdrawal.ReadWrite |
Retry failed withdrawals |
WithdrawalApproval |
Withdrawal.Approval |
Force-fail / manually settle (two-person) |
RefundRead |
Refund.Read |
View refunds |
RefundReadWrite |
Refund.ReadWrite |
Initiate refund |
RefundApproval |
Refund.Approval |
Approve refund (two-person) |
TierRead |
Tier.Read |
View tiers & subscriptions |
TierSubscriptionRead |
TierSubscription.Read |
View tier subscriptions |
PremiumSubscriptionRead |
PremiumSubscription.Read |
View premium subs |
PremiumSubscriptionReadWrite |
PremiumSubscription.ReadWrite |
Comp/revoke premium |
ReportRead |
Report.Read |
View reports |
ReportReadWrite |
Report.ReadWrite |
Resolve/dismiss reports |
VerificationRequestRead |
VerificationRequest.Read |
View verification queue |
VerificationRequestReadWrite |
VerificationRequest.ReadWrite |
Process verification submissions |
VerificationRequestApproval |
VerificationRequest.Approval |
Approve verification |
PromoCodeRead |
PromoCode.Read |
View promo codes |
PromoCodeReadWrite |
PromoCode.ReadWrite |
Create/edit platform-wide promo codes |
BoostedPostRead |
BoostedPost.Read |
View boosts |
BoostedPostReadWrite |
BoostedPost.ReadWrite |
Pause/cancel boosts |
BoostedPostApproval |
BoostedPost.Approval |
Approve (if approval required) |
ReferralRead |
Referral.Read |
View referral program data |
AdminUserRead |
AdminUser.Read |
View admin users |
AdminUserReadWrite |
AdminUser.ReadWrite |
Create/edit admin users (Super Admin only) |
RoleRead |
Role.Read |
View roles & scope mappings |
AuditLogRead |
AuditLog.Read |
View activity log |
RequestLogRead |
RequestLog.Read |
View request log (Mongo) |
ApprovalRequestRead |
ApprovalRequest.Read |
View approval requests |
NicheRead |
Niche.Read |
View niche taxonomy |
NicheReadWrite |
Niche.ReadWrite |
Edit niche taxonomy |
CategoryRead |
Category.Read |
View category taxonomy |
CategoryReadWrite |
Category.ReadWrite |
Edit category taxonomy |
FanClubRead |
FanClub.Read |
Fan club admin view |
FanClubReadWrite |
FanClub.ReadWrite |
Fan club admin edits |
NotificationRead |
Notification.Read |
Inspect notification queue |
NotificationReadWrite |
Notification.ReadWrite |
Retry failed notifications |
SystemMaintenance |
System.Maintenance |
Enter maintenance mode, emergency feature flag toggles |
Appendix B — Role → Scope Matrix
| Scope | Super Admin | Platform Manager | Content Reviewer | Finance Ops | Support | Read-Only Auditor |
|---|---|---|---|---|---|---|
User.Read |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
User.ReadWrite |
✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
Creator.Read |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Creator.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
Creator.Approval |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
Post.Read |
✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Post.ReadWrite |
✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
Post.Approval |
✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
Comment.ReadWrite |
✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
Taxonomy.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
PlatformSetting.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
FeatureFlag.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
Ledger.Read |
✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
Ledger.ReadWrite |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Ledger.Approval |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Withdrawal.ReadWrite |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Withdrawal.Approval |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Refund.ReadWrite |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Refund.Approval |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Report.ReadWrite |
✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
VerificationRequest.Approval |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
BoostedPost.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
PromoCode.ReadWrite |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
AdminUser.ReadWrite |
✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
AuditLog.Read |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
RequestLog.Read |
✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
System.Maintenance |
✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
(All .Read scopes included in Read-Only Auditor regardless of above.)
Appendix C — Notification Type Catalog
(See §16.4 for full list.)
Appendix D — Platform Settings Keys
(See §5.6 canonical table.)
Appendix E — Ledger Posting Rules Summary
(See §7.3 catalog.)
Appendix F — Glossary
| Term | Meaning |
|---|---|
| Active Fan | User with engagement score ≥ threshold for a creator, OR active tier subscriber |
| Boost | Paid placement of a post in discovery ranking |
| C2B | Customer-to-Business — MPESA flow for viewers paying platform |
| B2C | Business-to-Customer — MPESA flow for platform paying out to creators |
| Collection | Creator-curated grouping of own posts |
| Conversation ID | Correlates multiple requests belonging to one logical user flow |
| Creator | Any user who has published ≥1 post or created ≥1 tier (is_creator=true) |
| Kashier | Payment gateway layer currently used for MPESA/STK integration in Loreax |
| Earnings Hold | 3-day delay before creator earnings become withdrawable |
| Fan Club | Creator-owned community chat space |
| Fan Engagement Score | Weighted sum of interactions powering active-fan status |
| Idempotency Key | Client-supplied unique key deduplicating retried writes |
| KDPA | Kenya Data Protection Act |
| Ledger Transaction | One business-event posting (contains ≥ 2 balanced entries) |
| List | User-curated grouping of creators (bookmarks of people) |
| Net model | Platform fee deducted from creator's share; viewer pays advertised price |
| Niche | Top-level creator category (e.g. Music, Gaming) |
| Notable Verified | Admin-granted trust badge (is_notable_verified) |
| Premium | Paid viewer subscription (is_premium) with badge, perks |
| Processor | Controller/Action class name recorded in logs |
| Realm | Authentication boundary: user or admin |
| Request ID | ULID assigned per HTTP request, threaded through logs |
| Saved Post | User bookmark of a post |
| Scenario | Client-supplied tag describing current user flow (e.g. onboarding) |
| Scope | Permission token granting access to a resource action (e.g. Ledger.Read) |
| STK Push | MPESA C2B flow where user gets a prompt on their phone |
| Tier | Recurring subscription level offered by a creator |
| Transaction (ledger) | See Ledger Transaction |
| Wallet | Per-user aggregated balance derived from ledger |
Appendix G — Entity Relationship Diagram References
Full ERD lives in docs/diagrams/erd.drawio — regenerated per migration. Key cross-domain relationships:
User←→Wallet(1:1)User←→Post(1:N as creator)Post←→AccessRule(1:N)Post←→PostPurchase(1:N)User←→TierSubscription(1:N)Tier←→TierSubscription(1:N)PaymentIntent←→LedgerTransaction(1:1 after settlement)LedgerTransaction←→LedgerEntry(1:N ≥ 2, sum to zero)LedgerEntry←→LedgerAccount(N:1)User←→LedgerAccount(1:N, per type)Post←→BoostedPost(1:1 at a time)ReferralLink←→ReferralAttribution(1:N)ReferralAttribution←→ReferralCommission(1:N)
Document Control
- Owner: Backend Engineering
- Reviewers: Product, Finance Ops, Security, DPO
- Review cadence: Monthly during MVP; quarterly post-GA
- Version bumps: Semantic (0.X.Y). Breaking change to domain contracts = major bump.
- Change log:
docs/CHANGELOG.md
End of document.