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.
roletop committersenior engineer (contract) · 3,553 of 8,851 commits
scale3 environmentstest · uat · production, all serverless
dates2023–2024January 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 GraphQLAbout 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 engineIt 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 progressLayouts 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 toolingArtisan 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 currentA 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 publishingThirteen 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.
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
AWS · one stack per environmentmergedeployimageagenthttpsjobsSQLfilesauth
selectedfunctions[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?