Skip to content

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.

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.

  1. Install the JavaScript workspaces (one root yarn.lock for admin, front, front/sdk, front/ui, front/web, front/docs-site, api, api/h5p and api/pdf):

    Terminal window
    corepack enable
    corepack yarn install

    install also runs husky (the root prepare script), which installs the pre-commit hook.

  2. Start the API stack. yarn dev:api is docker compose -f api/docker-compose.yml up -d; the first start builds the api, h5p and pdf images.

    Terminal window
    corepack yarn dev:api
  3. Install the PHP dependencies. The api service mounts api/ over /var/www/html, so the vendor/ directory built into the image is hidden by your checkout:

    Terminal window
    docker compose -f api/docker-compose.yml exec api composer install
    docker compose -f api/docker-compose.yml restart api

    On restart the container runs api/init.sh: it writes the LARAVEL_* variables of the compose file into api/.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).

  4. Load the platform database and the H5P content types:

    Terminal window
    make -C api migrate-fresh

    This runs migrate:fresh --seed, creates the Passport keys and personal client, and imports H5P libraries and samples (h5p-seed). It wipes the platform database.

  5. Create the demo tenants and their courses (see Demo tenants below).

  6. Start the apps you need: corepack yarn dev:admin, corepack yarn dev:web and so on.

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:

Terminal window
docker compose -f api/docker-compose.yml --profile av up -d clamav

The 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.

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.

Run artisan, composer and PHPUnit inside the api container:

Terminal window
docker compose -f api/docker-compose.yml exec api bash -c "php artisan about"
# or open a shell
make -C api bash

Artisan targets the platform unless you pass --domain. To run a command for one tenant, add --domain=<slug>.localhost:

Terminal window
docker compose -f api/docker-compose.yml exec api \
php artisan migrate --force --domain=coffee.localhost

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.

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:

Terminal window
docker compose -f api/docker-compose.yml exec api bash
php 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"
exit
make -C api demo-packages demo-seed demo-seed-tenants
make -C api demo-mode-on # optional: password-less demo login and hourly reset

ulams: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.

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:

Terminal window
echo "REACT_APP_API_URL='http://api.localhost'" > admin/.env

Platform login: admin@ulams.app / secret.

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.

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. Set ULAMS_WORKERS_MODE=per-tenant in the environment of docker compose to get the old layout back. Details, timeouts and trade-offs: Queues and the scheduler.
  • Horizon is off in lean mode; set ENABLE_HORIZON=true if you need its dashboard.
  • php-fpm runs pm = ondemand with at most PHP_FPM_MAX_CHILDREN (default 8) children that exit after PHP_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-stream and docker exec api-api-1 ps aux.
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