Skip to content

Architecture Overview

Miranda is an API-first platform with two consumer surfaces:

  1. Foundation Dashboard — a Vue 3 SPA for foundation administrators (served at app.miranda.foundation)
  2. Donor Portal — a separate Vue 3 SPA for donors (served at miranda.gives)

Both SPAs communicate with a single Laravel 12 API backend. Public-facing pages (donation forms, scholarship applications) are server-rendered Blade templates.

┌─────────────────────┐ ┌─────────────────────┐
│ Foundation SPA │ │ Donor Portal SPA │
│ Vue 3 + PrimeVue │ │ Vue 3 │
│ app.miranda.fdn │ │ miranda.gives │
└────────┬────────────┘ └────────┬────────────┘
│ Bearer JWT │ Bearer JWT
│ X-Foundation-Id │ (donor role)
▼ ▼
┌─────────────────────────────────────────────────┐
│ Laravel 12 API │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Foundation │ │ Donor │ │Public │ │Webhooks│ │
│ │Endpoints │ │Endpts │ │Endpts │ │(Stripe)│ │
│ └─────┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │
│ └──────────┴──────────┴───────────┘ │
│ Services Layer │
└──────────┬──────────────────┬───────────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ PostgreSQL │ │ Stripe │
│ (Supabase) │ │ Connect │
└─────────────┘ └─────────────┘

Miranda uses a shared database model. Every tenant-scoped table includes a foundation_id foreign key.

  • Tenant model: Foundation — represents a nonprofit organization on the platform
  • Scoping: All queries in controllers filter by $request->user()->foundation_id
  • Multi-foundation users: A single Supabase auth user can have User records in multiple foundations. The X-Foundation-Id request header selects the active context.
Request
→ SupabaseAuth middleware (decode JWT, resolve User by auth_user_id + X-Foundation-Id)
→ EnsureFoundationContext middleware (verify user has foundation_id)
→ Controller (all queries scoped to foundation_id)

Miranda uses a dual-layer auth system:

Supabase handles registration, login, password reset, and JWT issuance. The SupabaseAuthService decodes tokens using:

  • JWKS (production) — fetches public keys from {SUPABASE_URL}/auth/v1/.well-known/jwks.json, cached for 1 hour
  • HMAC secret (testing) — falls back to SUPABASE_JWT_SECRET for HS256 tokens
// SupabaseAuthService key methods
$decoded = $authService->decodeToken($jwt); // Returns claims object
$userId = $authService->getAuthUserId($decoded); // Supabase UUID (sub claim)
$email = $authService->getEmail($decoded); // Email from token
$roles = $authService->getRoles($decoded); // app_metadata.roles array

Layer 2: Miranda User records (authorization)

Section titled “Layer 2: Miranda User records (authorization)”

Each Supabase auth user maps to one or more User records in Miranda’s database. The User model holds foundation_id, role (legacy UserRole enum), and role_id (new Role model FK).

MiddlewareAliasPurpose
SupabaseAuthauth.supabaseDecodes JWT, resolves User. strict (default) requires existing User; relaxed allows registration
EnsureFoundationContextfoundationRejects requests where user has no foundation_id
EnsureRolerole:{roles}Checks app_metadata.roles from the Supabase token
GroupMiddlewarePurpose
public/*NoneConsumed by miranda.gives and public pages
donor/*auth.supabase:relaxed, role:donorDonor portal endpoints
/registerauth.supabase:relaxedFoundation registration (no User record yet)
Foundation routesauth.supabase, foundationAll admin operations

Business logic is extracted into dedicated service classes. Controllers remain thin.

ServiceResponsibility
DonationServicePayment processing, contact creation, fee calculation, donor stats
StripeConnectServiceExpress account lifecycle, onboarding links, fee rate lookup
CampaignServiceAudience resolution via SegmentationService, batch job dispatch
SequenceServiceContact enrollment, step scheduling, due-step processing
EmailServiceResend API integration, merge field replacement, communication logging
SmsServiceTwilio integration, opt-in enforcement, communication logging
ImportServiceCSV parsing, column mapping, deduplication, Stripe import
SegmentationServiceDynamic query builder for contact filtering
ActivityServiceTimeline event recording on contacts
ScholarshipServiceApplication intake, stage transitions, award disbursement
RolePermissionServicePermission checks against Role model and legacy UserRole
SupabaseAuthServiceJWT decode/verify, claim extraction
  • Engine: PostgreSQL (Supabase-hosted)
  • Primary keys: UUIDs on all tables via HasUuid trait
  • Amounts: Stored in cents (integer) — $12.50 is stored as 1250
  • JSON columns: Used for flexible data (settings, metadata, communication_preferences, brand_colors, form_fields, responses, segment_criteria)
  • Migrations: All schema changes via Laravel migrations; every migration must be idempotent
LayerTechnology
FrameworkVue 3.5 (Composition API)
UIPrimeVue 4.5
StylingTailwind CSS 4
StatePinia 3
RoutingVue Router 4
Auth@supabase/supabase-js
HTTPAxios
BuildVite 8

The SPA is served by a catch-all route at /app/{any} and communicates exclusively with /api/* endpoints.

A separate Vue 3 SPA at miranda.gives consuming /api/donor/* endpoints. Donors authenticate via Supabase with the donor role in their JWT app_metadata.roles.

Donation pages and scholarship forms are server-rendered Blade templates for SEO and fast initial load. Card data is handled client-side via Stripe.js (PCI SAQ-A compliance).

Miranda uses Stripe Connect Express with destination charges:

  1. Each foundation onboards as a Stripe Express connected account
  2. Donations create PaymentIntent objects on the platform account with transfer_data.destination routing funds to the foundation
  3. application_fee_amount deducts Miranda’s transaction fee (2% / 1.5% / 1% by plan tier)
  4. Donors can optionally cover Stripe processing fees + platform fees

Miranda is the merchant of record. Foundations receive funds directly into their connected Stripe accounts.

ComponentTechnology
HostingLaravel Forge on Linode
ServerUbuntu, PHP 8.4, Nginx
DatabaseSupabase PostgreSQL
File storageSupabase S3
EmailResend
SMSTwilio
PaymentsStripe Connect Express
DNS/CDNCloudflare
DeployGit push triggers Forge Quick Deploy

Long-running operations are dispatched to the Laravel queue:

JobTrigger
SendCampaignBatchCampaign send (batches of 50 contacts)
SendDonationReceiptSuccessful donation with contact email
ProcessSequenceStepSequence enrollment reaches next step time
ImportContactsBatchLarge CSV import processing
EnrichCharityDataJobFoundation data enrichment