Skip to content

Which MVP shortcuts you can take back, and which ones you cannot

Most rebuilds don't happen because the first version was written badly. They happen because a handful of early decisions got baked into every table and every endpoint, and this post covers which ones those are.

Laravel, Architecture, APIs, SaaS

A pattern shows up often enough to be worth naming. A company builds the first version of a product. It works. It gets customers. Eighteen months later someone says the word "rewrite", and the reason given is usually "the code is a mess" or "we've outgrown it".

When you actually look at the code, it's rarely a mess. It's ordinary. Slightly repetitive controllers, some fat models, tests that cover the happy path. None of that forces a rewrite. You can clean that up a file at a time while the product keeps running.

What forces a rewrite is a small number of decisions that ended up embedded in every table, every query and every integration. Change one of those and you're touching two hundred files and migrating live customer data at the same time. That's the point where a rebuild starts to look cheaper than a fix, and it's usually a fair assessment by then.

So the useful question when you're building the first version isn't "how do we make this perfect". It's "which decisions can we take back later, and which ones can't we". Spend your care on the second list. Move fast on the first.

The test: how many places change if you change your mind?

That's it. That's the whole heuristic.

If changing your mind means editing one class, one config value, or one screen, it's reversible. Be quick, be rough, ship it.

If changing your mind means a migration across every table plus a backfill plus coordinating with customers who have integrated against you, it's not reversible in any practical sense. Slow down for an afternoon and think.

Here's how that shakes out in a typical SaaS build.

Cheap to take back

The UI. All of it. Layout, component library, whether you used Blade or Inertia or Livewire. Rewriting a screen is a day. Rewriting all your screens is a few weeks and you get to do it incrementally.

Query style. Eloquent versus the query builder versus raw SQL, N+1s, missing indexes. These are local problems with local fixes. A slow endpoint is a slow endpoint, not an architectural failure.

Queue and cache drivers. Database queue now, Redis later, SQS after that. It's a config change plus some infrastructure work.

Framework and package versions. Staying current is work, but it's steady, predictable work. It does not accumulate into a rewrite unless you ignore it for five years.

Most business logic. The rules about what a discount does or when a notification fires will change constantly. Write them plainly, put them somewhere obvious, and accept you'll rewrite them.

Expensive, or impossible, to take back

The tenancy model. Is a user's data scoped to a user, an organisation, or a workspace inside an organisation? Teams routinely start with "one user owns their data" because the first ten customers are individuals. Then a customer asks to add a colleague, and every single table needs a new owner column, every query needs a new filter, and every permission check needs rewriting.

Adding a nullable organisation_id on day one costs you almost nothing:

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('organisation_id')->constrained();
    $table->foreignId('created_by_user_id')->constrained('users');
    // ...
});

Even if your UI shows exactly one organisation per user for the first year, the column is there and the scoping is habitual. Retrofitting it later is a genuinely horrible job because you have to invent an owner for historical rows, and sometimes the right answer is genuinely ambiguous.

The cost of doing it early: one extra column, one global scope, and a bit of discipline in tests. That's a fair price.

How you store money. Floats in a decimal column will bite you. Store minor units as integers, and store the currency next to the amount even if you only sell in pounds today.

$table->unsignedBigInteger('amount_pence');
$table->char('currency', 3)->default('GBP');

Converting an existing system from floats to integers means auditing every calculation, every report and every historical invoice. Nobody enjoys that week.

Time. Store UTC, always, in a column type that keeps the timezone straight, and keep the user's timezone on the user record. If you store local times with no zone, you cannot reconstruct what actually happened. That data is gone.

History you didn't record. This is the one that really can't be undone. If you write $subscription->update(['plan' => 'pro']) and nothing else, you have destroyed the fact that they were on the free plan until Tuesday. Six months later someone asks how long the average trial lasts, or a customer disputes a charge, and the answer is not in your database.

An append-only table alongside the current state is cheap:

Schema::create('subscription_changes', function (Blueprint $table) {
    $table->id();
    $table->foreignId('subscription_id')->constrained();
    $table->string('from_plan')->nullable();
    $table->string('to_plan');
    $table->foreignId('changed_by_user_id')->nullable()->constrained('users');
    $table->timestamp('created_at');
});

The cost is storage, which is nearly free, and remembering to write to it. The benefit is that every future question about "what happened" has an answer. You cannot backfill this. Once the old value is overwritten, it's genuinely gone.

Identifiers you give to other people. The moment an ID appears in a URL a customer bookmarks, or in a webhook payload someone's integration parses, or in a CSV export, it's a public contract. Changing from sequential integers to UUIDs afterwards means running both for a long transition. Decide early whether external IDs are exposed, and prefer a separate public identifier if you're unsure.

Integration semantics. If your API sends a webhook on every status change, someone will build a workflow on that. If you later realise you should have sent one event per batch, you can't just change it. Version your API from the first public endpoint, even if v1 is the only version for three years. A /v1/ prefix costs nothing today and buys you an exit later.

What you should still skip

Being careful about the irreversible list is not permission to gold-plate everything. Skip these in the first version and skip them confidently.

Billing edge cases. Proration, mid-cycle upgrades, dunning. Use Stripe's hosted flows and accept that a human handles the odd case by hand for the first year.

A custom admin panel. Use Nova, Filament, or php artisan tinker and a runbook. Internal tools are the cheapest thing in the world to replace later.

SSO, SCIM, audit log exports, granular role builders. These are enterprise sales requirements. Build them when an enterprise customer is asking, not before, because the requirements you imagine will be the wrong ones.

Multi-region anything. Horizontal scaling. Microservices. A single well-indexed Postgres database on a decent server will carry most B2B SaaS products past the point where you can afford to fix it properly.

And a note on testing, because it sits awkwardly between the two lists. Tests are reversible in that you can add them later, but the ones covering money, permissions and tenancy scoping are the cheapest insurance you'll buy. Those are exactly the areas where a bug is silent, expensive and discovered by a customer.

What to check this week

If you have a build in progress or one about to start, sit down with whoever is writing the migrations and go through five questions.

One: if a customer asks to add a second user to their account tomorrow, how many tables change? Two: how is money stored, and what happens the first time you sell in euros? Three: pick any record whose status changes over time, and ask what it looked like last month. If you can't answer, you're losing data right now. Four: what identifiers have you already given to customers or third parties, and are you happy being stuck with them? Five: is there a version prefix on the API?

None of these take long to fix at the start. All of them take months to fix once you have real customers and real data. That difference is most of what separates a first version you build on from a first version you replace.

Keep reading
Build

Decide how tenancy works before you write the first migration

Multi-tenancy is one of the few decisions in a new SaaS product that is genuinely expensive to change later. This post covers the three common models, how to pick one, and how to implement shared-schema tenancy in Laravel so it does not leak.

Laravel, Architecture, Security, SaaS

Tell us what you are building

A short conversation, let's get to know each other. If we are not the right people we will say so.

Start a conversation