Skolerom | Automata
sections (10)
$ cd .. // back to projects
automata@latam: ~/projects/skolerom
USE CASE · slug: skolerom ·2025 · 4 min read · 849 words

> Skolerom

// K-12 LMS used by Norwegian schools: dual-store backend (PostgreSQL + MongoDB), React frontend, multi-environment Docker Compose deploy on GitLab CI.

Laravel 11React 18PostgreSQLMongoDBElasticsearchDocker ComposeGitLab CI
Skolerom
▸ role
Senior Backend · DevOps
▸ status
online
// section 01 · discovery

$ cat ./discovery.md

▸ overview

Skolerom (app.skolerom.no) is the LMS used by Norwegian K-12 schools to run classes, assignments, content and assessments. The Laravel API manages users, courses, grades and curriculum mappings; MongoDB stores the flexible, often-edited content (draft assignments, teaching paths) while PostgreSQL (with ltree) holds the relational + hierarchical structured data. React frontend renders the teacher and student views. Search runs on Elasticsearch. Deploy is one docker-compose per environment + GitLab CI per repo (back / front / search / devops).

▸ problem

A multi-tenant Norwegian K-12 platform has to model the national UDIR curriculum, isolate data per school, ship features fast across three independent repos, and keep production stable across dev/stage/prod. Doing this with a single SQL store would force schema changes for every content tweak; a single Mongo store would lose transactional guarantees.

▸ audience

Norwegian K-12 schools, teachers and students; IT directors managing per-school deploys.

// section 03 · architecture

$ cat ./architecture.md

// section 03b · sequences

$ cat ./sequences.md

// runtime flows — who talks to whom, in what order

// A single user action that writes to PostgreSQL (structured) and MongoDB (flexible) without leaving a partial state.

Cross-store write with rollback · flow-01
loading…
// section 04 · infrastructure

$ cat ./infrastructure.md

▸ services
provider: Laravel 11 + PostgreSQL + MongoDB + Redis + Elasticsearch on AWS
  • php-api (Laravel HTTP)
  • php-queue-worker (Laravel queue)
  • nginx (reverse proxy)
  • postgres 16 (relational + ltree)
  • mongo 4.4 (flexible content)
  • redis 7 (cache + queue)
  • elasticsearch (curriculum search)
  • S3 (file storage)
// section 05 · implementation

$ cat ./implementation.md

▸ frontend
  • · React 18
  • · TypeScript
  • · Redux
  • · Webpack
  • · Tailwind
▸ backend
  • · Laravel 11
  • · PHP 8.2
  • · Passport (OAuth2)
  • · spatie/laravel-permission
  • · mongodb/laravel-mongodb 4.9
  • · Doctrine DBAL
  • · spatie/laravel-fractal
▸ data
  • · PostgreSQL 16 with ltree (hierarchical curriculum)
  • · MongoDB 4.4 (drafts + flexible content)
  • · Redis 7 (cache + queues)
  • · Elasticsearch (curriculum search)
  • · AWS S3
▸ devops
  • · Docker Compose with per-environment overrides (dev / staging2 / production / new-production)
  • · GitLab CI on each repo (back, front, search, devops)
  • · Sentry + Logtail
  • · Bitbucket Pipelines on some repos
// section 06 · technical challenges

$ cat ./challenges/*.md

// 3 technical problems solved

01 / 03
challenge-01.md · multi-store · consistency · domain
▸ problem

Keeping PostgreSQL and MongoDB consistent when a single user action (publishing a teaching path) writes to both.

constraint: There is no cross-store transaction. A MongoDB write that fails after a PostgreSQL commit leaves the system reporting a published path that the frontend cannot fully read. Doing the writes in the opposite order has the symmetric failure mode.

▸ approach

All cross-store writes go through a service layer (e.g. StoreTeachingPathValidationService) that does PostgreSQL first (transactional), then MongoDB. If MongoDB fails, the service rolls back the PostgreSQL row through the same use case and emits an event for the retry queue. The cache (Redis) is invalidated last so a partially-written state is never readable.

app/Service/StoreTeachingPathValidationService.php php
// Pattern (simplified):
DB::transaction(function () use ($payload) {
    $row = TeachingPath::create($payload->structured());   // PostgreSQL
    try {
        $this->mongo->upsertDraft($row->id, $payload->flexible());
    } catch (\Throwable $e) {
        // throw rolls back the PG transaction
        throw new MongoSyncFailed($row->id, $e);
    }
    Cache::tags('teaching_paths')->forget($row->id);
});
challenge-02.md · devops · docker · gitlab
▸ problem

Deploying four independently-versioned repos (back, front, search, devops) into a coherent dev / staging / production stack.

constraint: Each repo has its own GitLab pipeline. Production runs on AWS with the same images as local dev. Environment-specific configuration cannot leak into the image; rollbacks have to be possible without rebuilding.

▸ approach

The devops repo owns docker-compose.yml and one override file per environment (dev / staging2 / production / new-production). Each app repo's GitLab pipeline builds an image, pushes to the registry, and tags by commit SHA. The devops repo's pipeline picks the SHA via env var and re-deploys. Frontend builds to a static artifact pushed to S3 and served via nginx.

devops/base/skolerom-devops/docker-compose.yml yaml
services:
  php-api:
    build:
      context: ./../temabok-back
      dockerfile: devops/php-api/Dockerfile
    networks: [backend]

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    depends_on: [php-api]

# Override per env:
#   docker compose -f docker-compose.yml \
#                  -f environments/dev/docker-compose.dev.yml up --build
challenge-03.md · migrations · schema · ops
▸ problem

Handling 265 PostgreSQL migrations + MongoDB schema changes without conflicts when two devs cut migrations in parallel.

constraint: Migrations need to be idempotent and run once per environment. UDIR (national curriculum) data has to seed in a known order so foreign keys land. Dev/stage/prod each track migration state independently.

▸ approach

Laravel migration system for PostgreSQL (timestamp-based, naturally ordered). Custom seeders for UDIR curriculum that check existence before insert. Docker Compose volumes persist DB state per environment so re-runs in dev do not destroy data. CI does a dry-run migrate against a copy of staging before promoting.

database/migrations/ bash
# Bring up dev stack from clean checkout:
docker compose -f docker-compose.yml \
               -f environments/dev/docker-compose.dev.yml \
               up --build

# Migrations run inside the php-api container on boot:
php artisan migrate --force
php artisan db:seed --class=UdirCurriculumSeeder
// section 07 · testing & ci

$ cat ./testing.md

▸ strategy

Pest 3 suite (36 test files) over SQLite in-memory for fast unit runs and a separate integration tier that hits real Postgres + MongoDB inside Docker. SSO flows (Dataporten / Feide / JWT) are tested with stubbed JWKS. Permission checks via spatie/laravel-permission are unit-tested per role.

▸ tools
Pest 3.8PHPUnit (via Pest)Mockery 1.6GitLab CIBitbucket Pipelines
// section 09 · results

$ cat ./results.md

01 /
2753
commits on the backend repo
02 /
55
Eloquent models
03 /
265
PostgreSQL migrations
04 /
36
Pest test files
05 /
4
deploy environments (dev, staging2, production, new-production)
▸ outcomes

Production system serving Norwegian schools at app.skolerom.no. Backend handles 265+ schema versions across PostgreSQL + MongoDB. CI deploys on merge to master. Multi-environment Docker Compose lets new envs come up from a single override file.

// section 10 · lessons learned

$ cat ./lessons.md

// if I did it again

  • 01 /

    A service layer is the only safe boundary across two stores

    Letting controllers write directly to Postgres and Mongo led to partial states the frontend would happily render. Funneling all cross-store writes through a single service made the failure mode explicit and the rollback path obvious.

  • 02 /

    Per-env Compose overrides beat one big config

    Early on the team kept staging and production in the same compose file with if-branches. It broke during the first real outage. Splitting into one base + one override per environment made the diff between envs grep-able and rollbacks one file change.

// related

$ grep -l "tech" ./projects/*

// projects that share part of the stack

// next step

$ automata deploy --your-operation

// Let's talk about adapting this to your case.

./let-s-talk.sh