LWT 3.0.0 welcome page

A few weeks ago, I published LWT 3.0.0. That’s the biggest release in the project’s history — over 1,000 commits, roughly half of all commits since the project ever started. The entire structure was rewritten: new database engine, new backend, new frontend framework, and even a new security model.

It took almost two years since the last release. I went to other projects, stepped away from this one, and actually recommended useful alternatives. So why did I go back to building this ship of Theseus?

Why I stopped

In 2024, I had run out of what I could do. The original codebase dated back to PHP 5 — I had forced the migration to PHP 7.4 in 2021, then added PHP 8+ support in 2022, but the structure still carried all the baggage of that era. I was relatively new to the LAMP stack — actually, it was the first time I was working on a real LAMP project.

There was a lot of experimenting and a lot of learning. Composer, REST API, frontend and backend separation. But I was unable to handle all the complexity. Some files were 3,000 lines long, and because of all the circular dependencies that PHP allowed at the time, it was just too hard to wrap my head around. Too many things were breaking outside of my control.

The improvements I shipped in version 2 were real, but they were not enough and I had a lot more to do. Over time, the project gained traction and attracted the attention of Jeff Zohrab, who became a great friend for this project. He started contributing, then decided to build something from scratch in Python. Since he had more time than me, he built Lute. It was a really great success and helped a lot of people, which I’m glad of — I wrote about that transition at the time.

On my side, it was more maintenance work. Checking that things were not breaking, maintaining the existing releases, and I was quickly moving to other things because I had no time to continue. But it was still something I would do as a side hustle.

Why I came back

I never really stopped thinking about the project. While doing other things, I was building the skills I would need. I did a lot of frontend development, got my hands on Vue.js and Nuxt.js, and dug deeper into PHP to understand how you can build a clean infrastructure with it. I got much better at managing databases too — I recommend Jeff Zohrab’s guide on database change management, which was really formative for me.

By the end of 2025, I had a much better picture of what a rewrite would look like. I didn’t want to start from scratch, because the logic was already tested and already there. The issue was the structure, not the algorithms. So in December 2025, I started.

The backend: from flat PHP to modular monolith

Originally, the app had 62 PHP files sitting in the root folder.

I started by writing unit tests so that we would keep the same functionalities. Then, using LLMs to help with the mechanical parts, I split the code into much smaller files. I wanted to use an MVC pattern — Controllers, Services, and Views. That was the first step, and it was really important to finally see what each file was responsible for.

But it revealed a new problem. The numbers told the story: 80 View files, 36 Services, 34 Core files, 14 Controllers. The Views folder was bigger than everything else combined — because the app was still fundamentally doing server-side rendering, just with better file organisation. They were shorter, the dependencies were cleaner, but it was still really hard to understand the main use cases of the app. On top of that, the View layer was using PHP’s require pattern, which was not compatible with static analysis. Variables passed from models to views became untyped globals — Psalm was not able to infer the types, which was defeating the purpose of all the validation I had set up.

I was hitting a ceiling on code quality.

So I decided to do a second restructure, this time to a modular monolith. It took me a lot of time to decide on that, but it appeared as the clear winner. Each module maps to a core feature: vocabulary, text management, feeds, languages. Each one follows the same internal patterns. It was much easier to understand how the codebase was working, because now we were naturally splitting along different concerns, and it mirrored how the app actually works.

Would starting from scratch have been faster? Yes. But it would have thrown away years of edge-case handling. And honestly, iterative refactoring was a more interesting way of working. The goal of this project is also for me to have fun and enjoy it — and that’s a good way to keep the main maintainer motivated.

The role of LLMs

Since we are in 2026, I should be transparent: I used LLMs a lot in that process. They were really invaluable for the mechanical parts of refactoring — changing all the signatures, converting patterns across hundreds of files, catching inconsistencies.

But it’s important to understand how I worked: they didn’t design the architecture. They implemented the code from my technical decisions. They reorganized code from my technical decisions. In the end, it’s thousands of lines changed, but keeping the same algorithms.

The database: MyISAM to InnoDB, prefixes to users

The original LWT had multi-project support through a variable called $tbpref (table prefix). It was prepended to the name of every table at runtime. The idea was that different users would use a different $tbpref. It was a terrible pattern, because it meant PHP was dynamically modifying the database schema, which made it impossible to have proper foreign keys or validate the schema statically.

The migration had two parts. First, converting all tables from MyISAM to InnoDB, which brought ACID transactions, foreign key constraints so that tables would not lose relationships with each other, and crash recovery. Then, replacing the prefix system with a proper users table and real multi-user support — authentication, registration, role-based access.

Now there is a clean schema, predictable, that doesn’t change at runtime and supports idempotency.

The frontend: jQuery to Alpine.js, PNG to SVG

That 80-file Views folder was the clearest signal: the majority of the backend code was doing visual rendering. Most of the PHP was generating HTML pages, because the app was from 2006. In modern web development, that doesn’t make sense — and it was inconsistent too, pages with different patterns and different layouts, a nightmare to manage.

So I did a full cleanup in layers.

CSS: I stripped out all the CSS and moved to Bulma. For the anecdote, back in 2021 I had evaluated it and thought it was good. Going back in 2025, I re-evaluated from scratch all the different alternatives, and it was still the best one. It was instantly making the app look and feel more modern.

Icons: There were 97 legacy Fugue-style PNG icons that felt like the app was from the 2000s. I switched to Lucide SVG icons — scalable, customizable via CSS, which was also making the app look polished without doing much.

JavaScript → TypeScript: Here LLMs did most of the work. The conversion itself was not hard, it’s mechanical. The hard part was moving from server-rendered JavaScript to an actual framework without breaking the user’s workflow.

Alpine.js: Since the app was very backend-reliant, the most natural fit was Alpine.js. It simplified the transition from server-rendered pages to reactive components without doing a full single-page application rewrite. It took roughly a week and mostly worked out of the box.

Now, with the modular architecture, the frontend mirrors the backend structure. Each module has its TypeScript files alongside its backend services, so you always know where things are located.

The reading interface in LWT 3.0.0, with Bulma CSS and Lucide icons

Security: the sobering part

The goal for 3.0.0 was to host an online version so that users wouldn’t have to download anything. That meant doing a serious security audit.

SQL injections were everywhere in the app. I fixed them one by one, replacing string interpolation with prepared statements and passing minimal data to reconstruct results. This part was tedious, but necessary.

Beyond SQL, I got my toes wet with new kinds of protections:

  • XSS protection: user sessions and inputs are now properly encoded, all special characters escaped.
  • CSRF tokens: I built a full middleware to intercept and validate all requests. This was the hardest part, because it needed a complete middleware layer in PHP that didn’t exist before.
  • Content Security Policy: this was the most painful. CSP compliance meant zero inline JavaScript in the templates. I was using the vanilla build of Alpine.js and had to switch to Alpine.csp, which meant a new layer of frontend refactoring — removing all the inline JavaScript we had spent time implementing and creating new functions instead. I spent hours manually checking each page, verifying interactions still worked, and fixing what broke.

On top of all that, I enforced Psalm at level 1 (the strictest) across the entire codebase. When the project started, the type coverage was around 40–50%. It’s now at 99.47%.

The hardest part: making it simple

The hardest part of the project was not specifically technical. It was rethinking how the app should work.

New features would be nice, but the app was really hard to install and hard to use. So I was at a crossroads: do I make a complete app, or a simple one?

I did something very different from what I usually do. In December and January, I looked at all the GitHub issues that were open, all the feature requests, all the fixes, and implemented every single one of them. All the features I had been thinking about for two years.

Then, since the app was complete in terms of code and services, I thought: okay, now what do the users actually want? How do language learners learn? What’s the best path to learn a language? It actually took me a lot of time — documenting myself, looking at methods — because the end goal of the app is not to write PHP code, but to be useful.

From that, I cut the fat. Features that weren’t necessary were hidden or integrated into more natural workflows. For instance, there was a feed importer, a text importer, a long text importer — that was really confusing for the community. It got merged into one page with one interface.

Then I looked at the navigation bar, because it was going out of control. Vocabulary, sub-items, languages, feeds, tags, archived texts. But the core features of the app are actually three: languages you want to learn, texts you want to read, and vocabulary associated with them. So it means three buttons, each with an “add” action. Six elements. Much cleaner.

The difference is visible: the old home screen had 15 buttons with no clear starting point. The new one has one.

Before (v2.x) After (v3.0)
LWT v2.x home — 15 buttons, no clear entry point LWT v3.0 home — a single “Add a text to read” button

I asked friends to beta-test the app and watched how they interacted. It was really nice to see their reactions — how they would get lost, what they would understand, all the bugs that surfaced. But the initial reaction was more or less always the same: “Okay, where do I start?”

Which meant I had to build a new onboarding workflow. When you open the app, you click a button to choose a language, then it brings you to add a text, and you read it. That’s it. Anything else — term settings, advanced features — creates a learning curve. And users don’t want a learning curve, they want to learn a language.

My goal in the end is not to build the perfect LAMP stack. It is to help people learn languages. The app has to be as simple as possible in service of that.

The last mile

At the end of January, I thought the app was really getting good. Then it took another month of testing. I used the app myself as the main stress test — adding texts, reading, building vocabulary, fixing everything that broke.

And rethinking: is the app really useful? Do I want to use it the way it’s intended, or do I need something else?

It took almost two months of that cycle — test, fix, think it’s ready, find more issues, fix again. I did a final review, and I published.

The release itself was really stressful, because when you put so much energy into something, you want to make it perfect. But it was not perfect, and that’s okay. The response was almost immediate — within hours, people were reporting that the Docker build was still pointing to the old version. People were actually using the app even before I had time to announce it on any social media.

What surprised me

I’m still surprised that the project worked at all, actually. It started as a side project because I was learning Japanese, and then grew very far — 200 GitHub stars without any advertising, a Discord community, people building on top of it.

I learned a lot with this adventure. Better PHP, efficient designs, teamwork, community management. It could go this far from just wanting to have an app to learn a language.

The 3.0.0 release was risky. There were so many things to change that it seemed like there would be no end to it. But now I receive only minor issues, which is a good signal. And the app does what it promises: it helps people learn languages by reading.

What’s next

I’m launching LWT-Online.org where you can actually use the app without installing anything. It’s a first trial — I want to go further, add new features, and keep improving. I’m reopening the Open Collective to raise funds for server costs, so the app can stay free and ad-free.

The app is also getting a new name. I discussed it with the original developer, and he told me that “Learning With Texts” was kind of his thing. Since I’m building something new, it makes sense to have a new name. I’m open to suggestions.

Before, I was thinking: how can I make this useful for me? Now that the foundations are solid, I would ask you: how can it be useful for you?

It was a long-way adventure, but it was worth taking the path. I hope you’ll enjoy LWT 3.0.0, and have fun learning languages.

— Hugo


Links: