← cd ~/work

case study · contract · University of East Anglia

uea/wave

WAVE is the system the University of East Anglia built to replace its Liferay CMS: one content API for courses, people, news and events, with a headless CMS (Storyblok) for the pages. I was the top committer for two years. I designed the course-catalogue API and built most of the tooling that moved the old site across.

role top committer senior engineer (contract) · 3,553 of 8,851 commits
stack Laravel · Vapor GraphQL (Lighthouse) · React · Next.js · Algolia
scale 3 environments test · uat · production, all serverless
dates 2023–2024 January 2023 to December 2024

1 · the problem

A whole university website to move off Liferay, without breaking a link.

The university’s site ran on Liferay. It was moving to a headless CMS, so the pages and the data behind them had to come apart: editors would build pages in Storyblok, and courses, people, news and events would come from one API that any front end could read.

Course data starts in the student records system and changes every day, and prospective students need the catalogue to be right. The old site held years of pages, templates and assets, and every old URL had to keep working after the switch.

2 · what I built

One content API, and the machinery that moved the old site into the new one.

WAVE is a Laravel application on serverless AWS (Laravel Vapor) with a React admin for staff. I designed its GraphQL API and wrote most of the migration tooling: by current line count, 91% of the GraphQL layer, 85% of the Liferay parsers and 95% of the Storyblok services are mine.

  • A course-catalogue API in GraphQL About 63 types and 73 query fields built with Lighthouse. Courses can be filtered by award, course type, category, attendance, academic year, clearing and study abroad, and the public front ends read them directly.
  • A Liferay-to-Storyblok migration engine It reads the old Liferay database directly. About 120 parsers turn templates, portlets and layouts into 41 Storyblok component builders, written through the management API under a rate limiter.
  • A migration dashboard with live progress Layouts and assets migrate as queued jobs, and staff watch status stream in over Server-Sent Events. Reports flag duplicate assets and content.
  • Clean-up and redirect tooling Artisan commands deduplicate assets and content, resolve links, check availability, rebuild navigation and generate the old-to-new redirect maps for the web gateway.
  • Imports that keep the catalogue current A daily import from the student records system on its own queue, plus imports of people data and course reviews. Course revisions keep a history of every change.
  • Search kept in step with publishing Thirteen models are searchable through Algolia. Publishing a course queues it for the crawler, and CMS webhooks keep the index in step.

What talks to WAVE

Select any box to see what it does. WAVE sits in the middle: data comes in from university systems and the old CMS, and goes out to Storyblok, search and the public sites.

architecture
selected WAVE [LARAVEL] [LIGHTHOUSE] [GRAPHQL]

The content API for courses, people, news and events, built with Lighthouse GraphQL; a small REST surface for webhooks and the student portal dashboard; and the parsers and builders that migrate Liferay content into Storyblok.

$ php artisan liferay:generic-content-migration

3 · infrastructure

Serverless Laravel, deployed by merging a branch

Select any box. Each environment (test, uat and production) is its own copy of the right-hand side.

infrastructure
selected functions [LAMBDA] [SERVERLESS]

The same image runs as the web function, the command-line function and the queue workers. Production keeps instances warm to avoid cold starts; deploy hooks run migrations and clear the GraphQL cache.

4 · decisions & trade-offs

Every choice had a cost. Here is what it was.

4.1 · GraphQL for the content API

chose
Lighthouse GraphQL, read-only, schema first
because
Several front ends needed different slices of the same courses, people and events. A schema lets each ask for exactly what it renders, and the schema files document the API.
cost
Query cost needs watching: I spent real time optimising the heavier category and course queries.

4.2 · Migrate from the database, not the HTML

chose
Parse Liferay’s own records into Storyblok components
because
Reading templates, portlets and layouts directly keeps the structure: a hero stays a hero and an accordion stays an accordion, instead of becoming a blob of HTML that editors cannot change.
cost
A parser per template and portlet, about 120 in all, plus clean-up tools for what did not map cleanly.

4.3 · Serverless on Vapor

chose
Laravel Vapor on Lambda, container runtime
because
Traffic peaks hard around clearing and open days. Lambda scales with it and there are no servers for a small team to patch.
cost
Lambda limits shape the code: long imports are chunked into queued jobs, and the container runtime was needed for the app’s size.

4.4 · Throttle the migration, not the editors

chose
A client-side rate limiter in front of the CMS API
because
The CMS management API has a rate limit. Holding the migration to a steady six requests a second let it run for hours without failing jobs or locking editors out.
cost
A full migration run takes hours, so it runs as queued jobs with live progress rather than one command.

5 · testing & delivery

Merge to deploy, with static analysis and tests in the way.

Every deploy ran PHPStan (Larastan level 5), a lint pass and the PHPUnit suite in parallel before vapor deploy was allowed to run. Promotion from test to uat to production was a merge between release branches.

Tests cover the GraphQL course queries, course search, revisions, the imports, the Storyblok API client, the inbound email path and permissions.

$ composer prep # phpstan, then the test suite
test files
43
test methods
~377
static analysis
Larastan level 5
pipeline
CodeBuild → vapor deploy
environments
test · uat · production
monitoring
APM with deploy markers

6 · results

$ git shortlog -sn | head -1
my commits
3,553 of 8,851
rank by commits
#1, over twice #2
GraphQL layer written by me
91%
Storyblok services written by me
95%
Liferay parsers written by me
85%
Liferay parsers
~120
Storyblok component builders
41
searchable models
13

Moving off an old CMS, or building the API behind a new one?

$ curl sala.dev/contact -d "re: uea-wave"
NORMAL /work/uea-wave available UK · remote tony@sala.dev updated
available Start a project →