ulams is a headless LMS: one Laravel API serves every tenant, and separate frontends (the Astro
reference frontend, the admin panel and the legacy React app) talk to it over HTTP. Two small Node
services sit next to the API for work that must stay out of PHP: H5P (GPL, isolated) and PDF
rendering. Everything is fronted by Caddy, which picks the backend from the host name.
The development stack is defined in
api/docker-compose.yml
and api/docker/conf/Caddyfile.
The admin and the reference frontend run on the host (yarn dev) and Caddy proxies to them through
host.docker.internal.
flowchart LR
browser([Browser])
lms([Other LMS / LTI tool])
subgraph edge[Caddy]
caddy{{"host-based routing"}}
end
subgraph hostapps[Host processes in development]
web["front/web<br/>Astro SSR + BFF<br/>:4321"]
admin["admin<br/>umi/max SPA<br/>:8000"]
legacy["front (legacy)<br/>React + Vite<br/>:3000"]
end
subgraph apictr[api container]
fpm["php-fpm<br/>Laravel 13, PHP 8.4<br/>api/packages/*"]
horizon["Horizon<br/>platform queue"]
workers["queue.sh / broadcast.sh<br/>tenant queues"]
sched["scheduler.sh<br/>schedule:run per domain"]
end
h5p["h5p<br/>Lumi h5p-nodejs-library<br/>GPL, :8080"]
pdf["pdf<br/>pdfme renderer<br/>:3000, internal"]
pg[(PostgreSQL<br/>db per tenant)]
valkey[(Valkey<br/>queues, cache)]
minio[(MinIO<br/>bucket per tenant)]
soketi["Soketi<br/>websockets"]
mailhog["MailHog"]
browser --> caddy
lms --> caddy
caddy -- "*.app.localhost" --> web
caddy -- "*.admin.localhost" --> admin
caddy -- "*.localhost /h5p/*" --> h5p
caddy -- "*.localhost (FastCGI)" --> fpm
caddy -- "*.content.localhost" --> fpm
caddy -- "storage.localhost" --> minio
caddy -- "ws.localhost" --> soketi
browser -. "localhost:3000" .-> legacy
web -- "server-side fetch" --> caddy
fpm --> pg
fpm --> valkey
fpm --> minio
fpm -- "X-Internal-Token" --> h5p
fpm -- "X-Internal-Token" --> pdf
fpm -- SMTP --> mailhog
horizon --> valkey
workers --> valkey
h5p --> pg
h5p --> valkey
h5p --> minio
soketi --> valkey
A Laravel 13 application on PHP 8.4 (api/composer.json), served by php-fpm on port 9000 inside the
api container. Almost all domain code lives in the vendored packages under api/packages/*,
registered explicitly in api/config/app.php; see API packages.
Tenancy comes from gecche/laravel-multidomain: the host of each request selects a .env.<host>
file, so one deployment serves the platform and every tenant with separate databases, buckets and
keys (Tenancy). Authentication is Laravel Passport
(Authentication).
The same container runs background processes under supervisor (api/init.sh, configs in
api/docker/conf/supervisor/services/). Each can be switched off with an environment variable:
| Process |
What it does |
Switch |
php-fpm |
Serves HTTP through Caddy (FastCGI) |
DISABLE_PHP_FPM=true |
horizon |
artisan horizon for the platform queue (not started when MULTI_DOMAINS is set) |
DISABLE_HORIZON=true |
multidomain_queue |
queue.sh: queue:work --domain=<host> for every tenant, re-reading the domain list on every pass; long video jobs use their own connection and queue |
DISABLE_QUEUE=true |
multidomain_broadcast |
broadcast.sh: the broadcast queue per domain; only started in MULTI_DOMAINS mode (init_multidomains.sh) |
DISABLE_BROADCAST=true |
scheduler |
scheduler.sh: schedule:run every minute for the platform and each tenant domain |
DISABLE_SCHEDULER=true |
The domain list comes from api/domains.sh (MULTI_DOMAINS plus registered tenants), so a tenant
created at runtime gets workers and scheduled jobs without a restart. Scheduled jobs are listed in
the scheduled jobs reference.
api/h5p is a Node service (Express 5) built on Lumi’s @lumieducation/h5p-server and
h5p-express. It serves the H5P player, editor, AJAX endpoints, library administration and a small
REST API. It is GPL-licensed and isolated by ADR 0003:
frontends only frame its pages and talk to it over postMessage; no H5P code is bundled into the
MIT applications, and lint rules in admin and front block @lumieducation/* imports.
- Browsers reach it same-origin at
http://<tenant>.localhost/h5p/*, which Caddy proxies to
h5p:8080. The reference frontend proxies it once more through its own origin
(Reference frontend).
- It is multi-tenant with
TENANCY_MODE=env-files: the host selects the tenant’s Laravel env file
(read-only mount of api/) for its database credentials, bucket and Passport public key.
- Users are authenticated by verifying the Passport RS256 JWT and calling
GET /api/profile/me on
the tenant API; Laravel calls the service with X-Internal-Token.
- Content rows live in schema
h5p of each tenant database, files in the tenant bucket under
h5p/, installed libraries on a shared volume.
The Laravel side is the h5p package. Full details: api/h5p/README.md.
api/pdf renders certificates and PDF templates with pdfme (MIT). It replaced
ReportBro. It is internal only: Laravel (templates-pdf package) calls POST /render with
X-Internal-Token; browsers never reach it, and the admin designer loads fonts through
/api/pdfs/fonts on the API. Limits on body size, inputs, concurrency and queue length are
environment variables (PDF_MAX_*, PDF_RENDER_TIMEOUT_MS). See
api/pdf/README.md.
front/web (reference frontend)
Astro SSR on Node, port 4321. Resolves the tenant from the Host header, renders pages on the
server, and keeps the API token in an httpOnly cookie behind a small BFF. Served on
*.app.localhost. See Reference frontend.
admin
umi/max (React, Ant Design Pro) single-page app, port 8000, served on *.admin.localhost. Talks
to the tenant API directly with a bearer token. See Administrators.
front (legacy)
The React 18 + Vite learner app inherited from EscolaLMS, port 3000, reachable directly at
http://localhost:3000. See Legacy learner app.
| Service |
Image |
Used for |
| PostgreSQL |
postgres:12 |
Platform database default; one database and role per tenant (ulams_<slug>); schema h5p per tenant for the H5P service |
| Valkey |
valkey/valkey:8-alpine |
Queues, cache, Horizon, H5P caches and locks, Soketi adapter. BSD-3-Clause drop-in for Redis; the service is still called redis |
| MinIO |
bitnamilegacy/minio |
S3-compatible storage; bucket ulams for the platform, ulams-<slug> per tenant, public read |
| Soketi |
quay.io/soketi/soketi |
Pusher-protocol websockets on ws.localhost (the dev API sets BROADCAST_DRIVER=log) |
| MailHog |
mailhog/mailhog |
Catches outgoing mail; web UI on localhost:8025 |
| mjml |
danihodovic/mjml-server |
MJML API for e-mail templates (templates-email, used when MJML_API_URL is set) |
| Adminer |
adminer |
Database UI on localhost:8078 |
| ClamAV |
clamav/clamav:1.4 |
Optional upload scanning (UPLOADS_SCANNER=clamd), compose profile av |
Caddy decides everything from the host name. In development all names are under localhost; the
production names are set per deployment (Hosts).
| Host |
Goes to |
Notes |
api.localhost, <slug>.localhost |
/h5p/* → h5p:8080; everything else → api:9000 (FastCGI) |
Tenant API. Upload routes for SCORM, cmi5 and course zips accept up to 1100 MB; the rest 600 MB. The _token query and auth headers are redacted from the access log |
<slug>.app.localhost, app.localhost |
host :4321 |
Reference frontend (front/web); report-only CSP |
<slug>.admin.localhost |
host :8000 |
Admin panel; report-only CSP |
<slug>.content.localhost, content.localhost |
api:9000 as GET /api/content/... |
Content origin: only GET/HEAD on /scorm/*, /cmi5/*, /adapt/*, /liascript/*; cookies and auth headers stripped; own CSP |
storage.localhost |
minio:9000 |
Public bucket files; nosniff, SVG served with script-src 'none'; sandbox |
minio.localhost |
minio:9001 |
MinIO console |
ws.localhost, metrics.localhost |
soketi:6001, soketi:9601 |
Websockets and Soketi metrics |
sequenceDiagram
participant B as Browser
participant C as Caddy
participant W as front/web
participant A as API (php-fpm)
participant H as h5p service
participant O as Content origin
B->>C: GET coffee.app.localhost/learn/1/7
C->>W: proxy to :4321
W->>W: middleware: tenant from Host, session cookie or demo login
W->>C: GET coffee.localhost/api/courses/1/program (Bearer token)
C->>A: FastCGI, Host = coffee.localhost
A->>A: laravel-multidomain loads .env.coffee.localhost
A-->>W: JSON
W-->>B: HTML (zero JS unless the topic needs an island)
B->>W: /h5p/embed/play/42 (iframe, same origin)
W->>H: proxy via coffee.localhost/h5p/...
B->>W: POST /bff/api/courses/progress/1 (no token in the browser)
W->>A: forwards with the session token
B->>O: SCORM / cmi5 / LiaScript files in a sandboxed iframe
O->>A: GET /api/content/... with X-Ulams-Content-Origin
Third-party package code (SCORM, cmi5, Adapt builds, LiaScript) never runs on the API, front or
admin origins: it is served from the per-tenant content origin, which holds no cookies and no API.
How the SCORM player gets a scoped tracking token is described in
api/docs/content-origin.md
and summarised in Tenancy.
| From |
To |
Authentication |
| front/web |
tenant API |
Passport bearer token from the session cookie |
| API |
h5p service (LARAVEL_H5P_SERVICE_URL) |
X-Internal-Token (H5P_INTERNAL_TOKEN) |
| h5p service |
tenant API through Caddy (LARAVEL_API_URL) |
the learner’s token, for GET /api/profile/me |
| API |
pdf service (LARAVEL_PDF_SERVICE_URL) |
X-Internal-Token (PDF_INTERNAL_TOKEN) |
| API |
Adapt build worker (ADAPT_BUILDER_URL) |
X-Internal-Token; the worker is not built yet (ADR 0013) |
The roadmap adds a Sylius 2.x commerce service, which does not exist in the code yet. The LLM layer
and Course Builder are built (Phase 2, M2.1); see LLM layer and the roadmap.
Production topologies (separate workers, replicas, managed databases) are covered in
High availability.