UareUx

What I did Development and infrastructure
Built with Laravel 12, Twill 3, Postgres
Runs on One VPS under Coolify, media on R2
Moved from WordPress, 186 posts and the portfolio
Test suite Past 700 tests
Where uareux.com
The UareUx home page: cream type on near-black reading Two people. Eighteen years between them. Zero account managers, beside a photograph of two designers working, overlaid with blue blocks.
The site Two designers, one photograph, no account managers. None of this half is mine.

What I own

Their design, their words. Everything under both.

UareUx is a UI/UX studio: two designers, one in London and one in Berlin, working under a name they took in early 2026 after eight years as someone else. The site is Laravel 12 and Twill 3 on Postgres, with the media in Cloudflare R2, and it replaced a WordPress install that had been accumulating since 2019.

Nothing you can see is mine. The design arrived as finished HTML from their designer, the copy arrived as a deck from their copywriter, and my job was to turn both into a content-managed site without a single decision of my own leaking into either. That is a narrower brief than it sounds, and a harder one: every place the design was silent, the temptation is to answer for it.

What I own is the part that has to still be true in a year. The block editor behind it, the migration of everything the old site had, the box it runs on, and the tests that stop the next change from taking something out.

The archive table on the UareUx work page, headed Every other project. One line each, listing clients, industries, services, years and a status dot.
The ledger Fifty-eight projects, one line each. The dot in the last column is checked by a machine, twice a month.
The UareUx blog archive page, headed Archive, with the note The early years. Everything the studio published before it became UareUx, 2019 to 2025, kept as written.
The old years Everything the studio published under its former name, kept as written and kept out of the index.

Building to someone else’s drawings

Twenty-nine blocks, three places to get each one wrong.

A block-based CMS has an admin form on one side and a render template on the other, and the two agree only by convention. Rename a field in the form, forget the template, and nothing breaks: the page renders, the section is empty, and it stays empty until someone happens to look at that one page with that one block filled in. It fails at the moment least likely to be watched, which is a designer’s first integration.

The registry that documents those fields is now checked mechanically. One command reads the admin forms, reads the render templates, reads the written registry, and diffs all three; the test suite runs it, so drift fails the build rather than surfacing months later as a blank strip on a live page. The first run found four blind spots in the reader and no drift in the registry, which is the correct order for that pair of results.

Two rules came out of the integration and are now written down. Block filenames are immutable, because the filename is the value stored on every block ever saved, so a tidy rename would take the whole site’s content with it. And the circled numbers beside each section are counted at render time rather than typed by an editor, because a hand-typed number is wrong the first time anyone reorders a page, and nobody reorders a page and then re-reads it from the top.

Moving fifteen years of WordPress

186 posts, 972 images, and a portfolio nobody had a copy of.

The old site had to come across whole, including the parts the studio had outgrown. Everything published under the former brand – 186 posts – imported into an archive section of its own: the titles cleaned of their trailing brand suffix, the internal links repointed, the CTA to a dead contact page mapped to the live one, every other link on the dying domain unwrapped rather than left to rot.

The archive is deliberately unfindable. Both its routes force noindex, every imported post carries the same flag the admin panel sets, and it is excluded from the blog stream, the tag listings, the feed, both sitemaps, the related-posts strips and the latest-posts block. Anyone who wants it can read it. Google is not being invited to weigh it against the new writing.

The 972 body images went to R2 under a prefix of their own, with the content keeping portable paths and a route redirecting them to the bucket – a folder inside the container would not survive the next rebuild, and finding that out later is expensive. The old URL map became 22 redirect rules, editable in the admin panel rather than in the codebase, which matters the day someone notices a link that never worked.

A work page that checks itself

Fifty-eight projects. Forty-three still answer the door.

Most of the studio’s back catalogue never got a case study, so it is a table: client, description, industry, service, year, and a dot in the last column for whether the thing is still online. A portfolio of dead links is worse than a short portfolio, and a portfolio that claims a client is live when the domain now sells something else is worse than both.

So the dot is not typed by anyone. A job runs twice a month, fetches each client site, and awards the dot only when the page answers and the brand appears in the body. A squatter’s cheerful 200 loses it. A dead host loses it. A 403 from bot protection proves nothing and changes nothing, because refusing to talk to a crawler is not the same as being gone.

The interesting half is the exceptions, each of which was a wrong answer first. One retry before any demotion, because a single dropped connection is not a closed business. Compound names match on their stem, because PontemGroup’s site says Pontem. Timeouts went from ten seconds to fifteen after a verifiably alive charity took twenty-five seconds to say anything – slow hosting must not read as dead. And one row will never be linked automatically again: the domain belongs to a different product of the same name now, and a checker that only asks “does this resolve” would send a visitor somewhere the studio never worked.

A first pass found seven brands that had simply moved – new domains, successor identities, one absorbed into a larger firm – and two NDA-gated projects the first crawl of the old portfolio missed entirely.

The faults on the way

An empty title tag, an empty schema block, and mail that would have thrown into a catch.

The home page was shipping a literal empty title tag and an og:title of “/”. The cause was almost funny: the home page record is titled “/”, because an editor set the slug and Twill mirrors it into the title. Titles carrying no letter or digit fall back to the site name now. The same sweep found og:image emitted twice on every page that had one, twitter:image emitted nowhere, and the home page’s structured data an entirely empty script tag – invalid markup served on every render of the front door.

The worst one never fired. Laravel ships a mailer entry for Resend, but the transport needs a package that was in neither require nor require-dev. The site was on log mail, so nothing was attempted and nothing complained; the day someone flipped that switch, the contact form would have thrown while constructing the transport, straight into the catch around the route, which logs and returns a thank-you page. Every enquiry lost, and the visitor told it went through. That exact defect had already cost the sister studio months of unnoticed leads, which is the only reason I went looking for it here.

And editing a post’s title killed its URL. Twill regenerates the slug and keeps the old one as an inactive row, but every route resolved active-only, so the published address and every backlink pointing at it died on the spot, with no error anywhere. All five post routes fall back to a redirect now: any published post that has ever owned the slug 301s to wherever it lives today.

Where it runs

One rented box, and the documentation that lied about it.

Push to master, a webhook rebuilds the container on the VPS, the entrypoint migrates and re-caches, and a health check decides whether the new one is allowed to take over. Postgres is a container reachable only from inside, so working on production data means going in through SSH rather than pointing a client at a public port. Past 700 tests run against the same code on the way.

The move off the managed platform was straightforward. What was not straightforward was that the project documentation went on describing the old setup for weeks afterwards, and following it cost an afternoon: the local environment pointed at a database that had been switched off, so every request answered with a 500 and lied about why. The sync script that pulled production data down had been written for the old platform and had not survived the move at all, so the local database had rotted until its schema no longer matched its own migration history.

Rewriting it was the easy part. The part worth keeping is that it now validates the dump before trusting it, because a failed export leaves a small file and exits zero through a pipe, and restoring that over a working database is a bad afternoon that starts by looking like a good one.