Architecture
Map the moving parts — two services, three databases, three API surfaces, and the data flows that connect them.
The Big Picture
Portfolio looks like one application but it's actually two services sharing a database, fronted by a React UI, with several companion services wired in. Here's everything laid out:
Let's walk through each piece.
Service 1: The Portfolio API (Laravel)
This is the main application — a Laravel 11 app running PHP 8.3. It's responsible for:
- Data persistence — all CRUD operations for investors, assets, transactions, valuations
- Business logic — subscription creation, capital call allocation, distribution splitting, fee pro-rating
- Import pipeline — XLSX parsing, validation, row-by-row processing
- Authorization — multi-tenant isolation, role-based permissions, gate checks
- Report generation — PDF rendering for quarterly reports, capital call notices, distribution notices
- Cache orchestration — wrapping every calculation call with tenant-tagged Redis cache
The Laravel app owns all writes (creating and modifying data) and handles read orchestration (deciding what to fetch, caching results). But it delegates the actual number-crunching to the Go service.
The Action Pattern
Business logic in Portfolio follows a strict pattern: Controller → DTO → Action. Controllers don't contain business logic — they validate the request, build a Data Transfer Object, and hand it to an Action class.
For example, creating a capital call:
CapitalCallControllervalidates the request via a Form Request- Builds a
CreateCapitalCallDtowith the validated data - Calls
CreateCapitalCallAction::execute($dto) - The Action creates the Transaction, CashFlows, updates unit holdings, and fires events
- Event listeners clear caches and trigger NAV recalculations
There are Action classes for every operation: CreateDrawdownAction, CreateRedemptionTransactionAction, CreateFeeAction, ReverseImportAction, and dozens more.
Service 2: The Calculation Service (Go)
This is a dedicated Go microservice that performs all financial calculations. It communicates exclusively via gRPC on port 50051.
Financial calculations like XIRR require numerical methods (Newton's method, Secant method) with high-precision decimal arithmetic. These are computationally expensive and benefit from Go's concurrency model — the service fires parallel goroutines for different calculation types and uses worker pools for large datasets. Keeping this separate from PHP means neither blocks the other.
What It Computes (27 endpoints)
The calc service exposes 27 gRPC methods. They fall into these categories:
| Endpoint | What It Returns |
|---|---|
FundsOverview | Committed, called, uncalled, paid-in, distributions, NAV, DPI, TVPI, capital gains, XIRR — the full dashboard |
FundsOverviewPerAsset | Same metrics broken out per asset |
FundsOverviewPerInvestor | Same metrics broken out per investor |
CommittedCapital | Committed capital for specific scope |
PaidInPerInvestor | Paid-in per investor (used for pro-rata distribution %) |
FundsInvestedOverview | Sub-position investment overview (weighted/unweighted) |
CalculatedValuationPerAsset | NAV per asset computed from units x VPU |
| Endpoint | What It Returns |
|---|---|
DirectOverview | Investment totals, valuation, income, yield, return on capital, multiple |
DirectOverviewPerAsset | Same metrics per direct asset |
DirectOverviewPerInvestor | Same metrics per investor |
AggregatesOverview | Combined funds + directs overview |
| Endpoint | What It Returns |
|---|---|
ValuationBridge | NAV waterfall: start → paid-in → distributions → valuation change → end |
AllocationByGeography | Portfolio allocation by geography (pie chart data) |
AllocationBySector | Portfolio allocation by sector |
AllocationByVintage | Portfolio allocation by vintage year |
AllocationByStyle | Portfolio allocation by investment style |
DistributionTimeline | Cumulative distributions over time by type |
ValuationTimeline | Valuation changes over a date range |
NetPosition | Net cash position by quarter |
| Endpoint | What It Returns |
|---|---|
CapitalCallDocument | Per-investor capital call calculations for notice PDFs |
DistributionNoticeCalcs | Per-investor distribution metrics for notice PDFs |
RedemptionCalcs | Redemption values: total, PPU, return of capital, realised gains |
AssetTreeTransferCalcs | Transfer calculations across asset hierarchy |
How It Works Under the Hood
Every request to the calc service includes:
- SchemaName — which tenant's data to query (e.g.,
tenant_42) - DB credentials — username/password for that request
- Scope — which investor(s) and/or asset(s) to calculate for
- Optional: DestinationCurrency — convert all amounts to this currency
- Optional: PreDatetime — point-in-time cutoff ("show me the numbers as of Q4 2025")
The service then:
- Opens a connection to the tenant's PostgreSQL schema
- Fires parallel goroutines for each core calculation (committed, called, paid-in, distributions, etc.)
- Aggregates raw results into derived metrics (DPI = distributions / paid-in, etc.)
- Converts currencies using the rates database if needed
- Returns the computed protobuf response
Both the Laravel app and the Go calc service query the same PostgreSQL tables. The Laravel app writes data; the Go service reads it to compute metrics. There's no separate analytics database or ETL pipeline. This is simple but means the calc service's queries can compete with the app's writes for database resources.
The Three API Surfaces
Portfolio exposes three distinct APIs, each serving a different consumer. This is a critical architectural detail — data enters the system through different doors.
/api/*This is what the React frontend calls. It handles everything users do in the UI: browsing assets, recording transactions, uploading imports, viewing dashboards. Permissions are enforced via middleware gates (can-access-portfolio, can-add-investor-transaction, etc.).
~120+ endpoints across investors, assets, transactions, valuations, tables, widgets, graphs, reports, and bulk import.
/machine/*Used by Delio Core to programmatically create investors, push subscriptions, investments, and capital calls into Portfolio. This is how the main platform syncs data without human intervention.
Limited surface: investors (create, update, reconcile), subscriptions, investments, capital calls, asset show — about 7 endpoints.
/mcp/portfolioA Model Context Protocol server that exposes Portfolio as a tool for AI agents. Used by delio-ai (chat) and Morph (document-to-data transformation). Provides 12 read tools and 8 import tools.
Entity resolution by client_reference. All imports use JSON arrays, validated first, then processed through the same Actions as the XLSX pipeline.
The Machine API only covers ~7 of the 17+ transaction types. Distributions, fees, fund cash movements, asset transfers, and valuations cannot be created via Machine API — they must go through the REST API (UI) or the XLSX bulk import. This is a significant gap that affects onboarding automation.
Multi-Tenant Data Isolation
Portfolio uses Spatie Multi-Tenancy with a schema-per-tenant pattern in PostgreSQL:
Landlord Database
One database, shared across all tenants. Contains only:
tenantstable — id, name, database/schema, status- Tenant CRUD is managed via the Landlord API
Tenant Schemas
Each tenant gets an isolated PostgreSQL schema with the full table set:
investors,assets,transactions,cash_flowsasset_valuations,fees,taxes, etc.- ~25 tables per tenant
Tenant resolution happens via middleware. For the REST API, the tenant is determined from the request domain/subdomain. For the Machine API, it's from the DELIOTENANT header. The Go calc service receives the schema name explicitly with every gRPC request.
The Cache Layer
All calculation results from the Go service are cached in Redis by the Laravel app. This is critical for performance — without caching, every page load would trigger expensive gRPC computation.
| Aspect | How It Works |
|---|---|
| Cache key structure | Tagged by tenant + asset ID(s) + investor ID(s) for surgical invalidation |
| When caches are cleared | On any data mutation — transaction creation, valuation update, deletion. Events fire ClearAllCaches or targeted cache tags. |
| Cache warming | cache:clear-and-warm --tenant={id} artisan command pre-computes common dashboard calculations |
| Go-side caching | The Go service also caches exchange rates in-memory (bigcache) with 24h TTL |
Companion Services
Horizon is Delio's identity and permissions platform. Portfolio delegates authentication and user management to Horizon. When a new investor is added to Portfolio, it may auto-create a Horizon user so the investor can log into the platform.
Portfolio's permission constants (~80 of them) are defined in the HorizonPermissions enum and checked via gate middleware.
A separate PostgreSQL database that stores daily exchange rates. Both the Laravel app and the Go calc service connect to this database independently.
Two conversion strategies:
- By cash flow date — for paid-in, distributions, capital calls (converts at the rate on the settlement date)
- By cutoff date — for committed capital, NAV, investments (converts at the reporting date rate)
XLSX import files are stored on S3 before processing. Generated PDF reports (QPRs, capital call notices, distribution notices) are also stored on S3 and linked to assets/investors via attachment records in the database.
delio-ai is a Python/FastAPI chat application that uses Portfolio's MCP tools to answer investor questions in natural language.
Morph is an AI-powered data transformation tool that reads unstructured documents (fund admin statements, cap tables) and pushes structured data into Portfolio via the MCP import tools.
Request Lifecycle: End to End
Let's trace what happens when an internal user views the Portfolio dashboard:
- Browser → Frontend: User navigates to the Portfolio overview page in
delio-frontend - Frontend → REST API: React fires parallel calls:
GET /widgets/capital-overview,GET /graphs/overview-bridge,GET /tables/investors - REST API → Middleware: Tenant middleware resolves the client from the request domain. Permission gates verify
can-access-portfolio. - Laravel → Redis: Check cache for this tenant + scope. Cache hit? Return immediately.
- Laravel → gRPC: Cache miss? Build a protobuf request with the schema name, investor/asset scope, and preferred currency. Send to the Go service on port 50051.
- Go service → PostgreSQL: Opens connection to the tenant schema. Fires parallel goroutines for each core calculation (committed, called, paid-in, distributions, etc.).
- Go service → Rates DB: If currency conversion is needed, fetches exchange rates (from cache or rates database).
- Go service → Laravel: Returns computed metrics via protobuf response.
- Laravel → Redis: Cache the result tagged by tenant + scope.
- Laravel → Frontend: Return JSON response. React renders widgets, charts, and tables.
Knowledge Check
A developer needs to add a new metric to the Portfolio dashboard. Where does the calculation logic go?
Delio Core needs to automatically create an investor in Portfolio when a new LP is onboarded. Which API surface should it use?
/machine/* routes), authenticated with DELIOTENANT/DELIOSECRET headers. The REST API requires JWT user tokens, and the MCP API is designed for AI agents.What's Next
You now understand the system's moving parts and how they connect. Next, we'll zoom in on the data model — the entities stored in those tenant schemas, how they relate to each other, and the dependency chain you need to follow when setting up a new client.