Local development
Needs review
Needs review: First run on a fresh clone: vendor/ is not in the image because the API container mounts api/ over /var/www/html, and admin/.env must exist for `yarn dev:admin` (env-cmd). Check the exact order end to end.
The API and its services run in Docker; the JavaScript apps run from source on the host and
reach the API through Caddy on port 80. Every host name ends in .localhost, which current
browsers resolve to 127.0.0.1 without any /etc/hosts change.
Requirements
Section titled “Requirements”| Tool | Version | Where it is set |
|---|---|---|
| Node.js | 22 or 24 | .nvmrc contains 22; the root package.json declares "node": ">=22.12" |
| Yarn | 1.22.22 (classic), through Corepack | packageManager in the root package.json |
| Docker | Docker Engine or Desktop with Compose v2 (docker compose) |
api/docker-compose.yml |
| make | any | api/makefile |
You do not need PHP or Composer on the host: every PHP command runs inside the api container.
First run
Section titled “First run”-
Install the JavaScript workspaces (one root
yarn.lockforadmin,front,front/sdk,front/ui,front/web,front/docs-site,api,api/h5pandapi/pdf):Terminal window corepack enablecorepack yarn installinstallalso runshusky(the rootpreparescript), which installs the pre-commit hook. -
Start the API stack.
yarn dev:apiisdocker compose -f api/docker-compose.yml up -d; the first start builds theapi,h5pandpdfimages.Terminal window corepack yarn dev:api -
Install the PHP dependencies. The
apiservice mountsapi/over/var/www/html, so thevendor/directory built into the image is hidden by your checkout:Terminal window docker compose -f api/docker-compose.yml exec api composer installdocker compose -f api/docker-compose.yml restart apiOn restart the container runs
api/init.sh: it writes theLARAVEL_*variables of the compose file intoapi/.env, runs migrations, rebuilds tenant env files (ulams:tenant:sync-env --migrate), creates Passport keys if they are missing, seeds permissions and starts php-fpm, the lean queue workers and the scheduler under supervisord (Horizon stays off, see Memory). -
Load the platform database and the H5P content types:
Terminal window make -C api migrate-freshThis runs
migrate:fresh --seed, creates the Passport keys and personal client, and imports H5P libraries and samples (h5p-seed). It wipes the platform database. -
Create the demo tenants and their courses (see Demo tenants below).
-
Start the apps you need:
corepack yarn dev:admin,corepack yarn dev:weband so on.
The Docker stack
Section titled “The Docker stack”Services in api/docker-compose.yml, all on the ulams network:
| Service | Image | What it does | Reachable at |
|---|---|---|---|
caddy |
caddy |
Routes every *.localhost host (config: api/docker/conf/Caddyfile) |
ports 80 and 443 |
api |
built from api/Dockerfile.develop |
php-fpm on port 9000, plus the queue workers and the scheduler (lean mode) | http://api.localhost, http://<slug>.localhost |
h5p |
built from api/h5p/Dockerfile |
H5P player, editor and content API | /h5p/* on every API host |
pdf |
built from api/pdf/Dockerfile |
PDF renderer for certificates and templates | internal only |
postgres |
postgres:12 |
Database; data in api/docker/postgres-data |
internal; Adminer |
redis |
valkey/valkey:8-alpine |
Cache, queues, Horizon (password ulams) |
internal |
minio |
bitnamilegacy/minio:latest |
S3-compatible storage; data in api/docker/minio_storage |
http://storage.localhost, console at http://minio.localhost |
mailhog |
mailhog/mailhog |
Catches outgoing mail | http://localhost:8025 |
adminer |
adminer |
Database UI (server postgres, user default, password secret) |
http://localhost:8078 |
mjml |
danihodovic/mjml-server |
MJML rendering for e-mail templates | internal |
soketi |
quay.io/soketi/soketi:latest |
WebSocket server | http://ws.localhost, metrics at http://metrics.localhost |
clamav |
clamav/clamav:1.4 |
Optional virus scanning of uploads | only with the av profile |
ClamAV is the only service behind a Compose profile. Start it with:
docker compose -f api/docker-compose.yml --profile av up -d clamavThe api package also wraps a few compose commands, run with corepack yarn workspace api <script>:
up, down, logs (follows the api service), artisan and test.
Host routing
Section titled “Host routing”Caddy sends each host to a service:
| Host | Goes to |
|---|---|
api.localhost, <slug>.localhost |
Laravel (php-fpm), /h5p/* to the H5P service |
<slug>.app.localhost |
front/web dev server on the host, port 4321 |
<slug>.admin.localhost |
admin dev server on the host, port 8000 |
content.localhost, <slug>.content.localhost |
read-only content origin for SCORM, cmi5, Adapt and LiaScript packages |
storage.localhost |
MinIO |
The legacy React front is not behind Caddy: open it at http://localhost:3000.
Running PHP commands
Section titled “Running PHP commands”Run artisan, composer and PHPUnit inside the api container:
docker compose -f api/docker-compose.yml exec api bash -c "php artisan about"# or open a shellmake -C api bashArtisan targets the platform unless you pass --domain. To run a command for one tenant,
add --domain=<slug>.localhost:
docker compose -f api/docker-compose.yml exec api \ php artisan migrate --force --domain=coffee.localhostmake targets
Section titled “make targets”All targets are in api/makefile and run
docker compose exec from api/. Call them as make -C api <target>.
| Target | What it does |
|---|---|
bash |
Shell in the api container |
tinker |
php artisan tinker in a loop (Ctrl+C quits, Ctrl+D restarts) |
migrate-fresh-quick |
migrate:fresh --seed, Passport keys and personal client |
migrate-fresh |
migrate-fresh-quick plus h5p-seed |
h5p-seed |
Imports H5P content types and sample content through the H5P service |
demo-packages |
Builds the gravity, poland and five ulam interactive packages (demo-content/) into api/database/seeds/Demo/assets/cache/interactive/: the API container sees only api/, so the seeders read the zips from there |
demo-seed |
Demo courses on the platform tenant (DemoCoursesSeeder, all six experiences) |
demo-seed-tenant |
One tenant: make -C api demo-seed-tenant DOMAIN=coffee.localhost (the experience defaults to the first label of DOMAIN), then works through the video long-job queue once |
demo-seed-tenants |
demo-seed-tenant for coffee, oncall, nightsky, gravity, poland and ulam |
demo-create-tenants |
ulams:tenant:create for the six demo tenants with their names, themes and accents |
demo-mode-on |
Turns demo mode on for the six demo tenants (ulams:tenant:create <slug> --demo=on) |
demo-reset |
Resets one demo tenant now: make -C api demo-reset DOMAIN=coffee.localhost |
demo-reset-all |
Resets all six demo tenants now, one after the other |
swagger-generate |
php artisan l5-swagger:generate |
test-phpunit |
./vendor/bin/phpunit (see Testing for the database settings) |
backup-postgres |
pg_dump into api/docker/postgres-backups (backup-<date>.sql and backup-latest.sql) |
import-postgres |
Restores a dump: make -C api import-postgres BACKUP_FILE=backup-latest.sql |
restart_queue_cron |
Restarts the ulams_queue_cron service |
composer-update |
composer update in the container, then restarts ulams_queue_cron |
Demo seeding keeps existing demo courses; pass DEMO_REFRESH=1 to rebuild them.
Demo tenants
Section titled “Demo tenants”Each tenant has its own database, bucket, env file (api/.env.<slug>.localhost), Passport keys
and Redis prefix. Create the six demo tenants on the platform (no --domain), then seed them:
docker compose -f api/docker-compose.yml exec api bashphp artisan ulams:tenant:create coffee --name="The Coffee Atlas" --theme=coffee --accent="#C2552D"php artisan ulams:tenant:create oncall --name="On-Call" --theme=oncall --accent="#58A6FF"php artisan ulams:tenant:create nightsky --name="Night Sky Explorers" --theme=nightsky --accent="#FFD23F"php artisan ulams:tenant:create gravity --name="Gravity Lab" --theme=gravity --accent="#3DD6F5"php artisan ulams:tenant:create poland --name="Poland, Measured" --theme=poland --accent="#C8102E"php artisan ulams:tenant:create ulam --name="The Scottish Book" --theme=ulam --accent="#1D3B8F"exitmake -C api demo-packages demo-seed demo-seed-tenantsmake -C api demo-mode-on # optional: password-less demo login and hourly resetulams:tenant:create is safe to re-run: finished steps are skipped, and --redo=<step> runs one
again. Demo users per tenant are admin@<slug>.ulams.app, tutor@<slug>.ulams.app and
student1@<slug>.ulams.app and up; their password is TENANT_DEMO_PASSWORD from api/.env
(dev only). The full command set is in the
tenancy package README;
see also Tenancy and Demo mode.
Running the apps
Section titled “Running the apps”| Command | App | URL |
|---|---|---|
corepack yarn dev:admin |
Admin panel (umi/max) | http://localhost:8000 (platform), http://<slug>.admin.localhost |
corepack yarn dev:web |
Reference learner frontend (@ulams/web, Astro) |
http://app.localhost:4321, http://<slug>.app.localhost:4321, or http://<slug>.app.localhost through Caddy |
corepack yarn dev:front |
Legacy React learner front (Vite) | http://localhost:3000 |
corepack yarn dev |
Admin and legacy front together | as above |
corepack yarn dev:docs |
This documentation site | http://localhost:4322 |
Notes per app:
The dev script runs env-cmd -f .env, so admin/.env must exist. The admin derives the
tenant API from its host (<slug>.admin.localhost to http://<slug>.localhost); the build-time
REACT_APP_API_URL is the fallback for the platform, for example:
echo "REACT_APP_API_URL='http://api.localhost'" > admin/.envPlatform login: admin@ulams.app / secret.
Copy the example environment and set the demo password:
cp front/web/.env.example front/web/.env # set DEMO_STUDENT_PASSWORD = TENANT_DEMO_PASSWORDEvery ULAMS_* variable is read on the server at runtime. See the
front/web README and
Reference frontend.
Vite on port 3000. Variables are in
front/.env.example; the tenant
API comes from VITE_APP_TENANT_API_HOST_PATTERN. The tenant hosts <slug>.app.localhost now go
to front/web, so open the legacy front directly on localhost:3000.
yarn dev:docs runs yarn sync (copies ADRs, the roadmap, package READMEs and the UI catalogue
into the site) and astro dev --port 4322.
The Node services can also run from source with tsx watch: corepack yarn workspace api-h5p dev
and corepack yarn workspace api-pdf dev. In the default setup they run in Docker.
Memory
Section titled “Memory”The api container is sized for a laptop. Docker Desktop on a 16 GB Mac gives the VM about 7.6 GB, and
with seven domains the old layout (a long-lived queue:work for three queues per tenant, a scheduler
loop per tenant and Horizon) was 29 PHP processes and about 4 GB at idle, enough to take the Docker VM
down.
ULAMS_WORKERS_MODE=lean(the default here) runs the background work from one loop per kind and no long-lived PHP process per tenant. SetULAMS_WORKERS_MODE=per-tenantin the environment ofdocker composeto get the old layout back. Details, timeouts and trade-offs: Queues and the scheduler.- Horizon is off in lean mode; set
ENABLE_HORIZON=trueif you need its dashboard. - php-fpm runs
pm = ondemandwith at mostPHP_FPM_MAX_CHILDREN(default 8) children that exit afterPHP_FPM_IDLE_TIMEOUT(default 20s) without a request. The demo profile also lowers opcache to 192 MB, 16 MB of interned strings and a 32 MB JIT buffer (api/docker/conf/php/ulams-demo-sizing-php.ini). - Check it with
docker stats --no-streamanddocker exec api-api-1 ps aux.
Useful URLs
Section titled “Useful URLs”| URL | What |
|---|---|
| http://api.localhost/api/documentation | Swagger UI of the platform API |
| http://api.localhost/horizon | Horizon dashboard (only with ENABLE_HORIZON=true; off in lean mode) |
| http://api.localhost/api/healthcheck | Health check results (database, Redis) as JSON |
| http://localhost:8025 | MailHog |
| http://localhost:8078 | Adminer |