It Was Fine Until We Added A Second One
Every trigger in this series has been a second something. That is not a coincidence, and it is the only signal in here reliable enough to act on.
The estimate was two weeks. It is week six.
Nothing about the feature was hard. The hard part was that the application had been written on the assumption that there was one of something, that assumption was never written down anywhere, and it turned out to be spread across forty files as the absence of a concept rather than the presence of a wrong one.
One organisation. One currency. One notification channel. One API consumer. One region. Whichever it was, the code did not say so, because nothing says “there is exactly one of these”. It just never mentions that there could be two.
Read back the last five articles
Every part of this series has ended in the same place, and I did not plan it that way at the start.
Part one: the trigger for pulling a rule out of a controller is a second caller. Part two: the file count only matters when the same knowledge is written twice. Part three: a model is dangerous when a second feature has a reason to change it. Part four: the drift starts when a second entry point appears. Part five: an interface earns its keep when there is a second implementation, or a boundary you are deliberately holding.
Five different symptoms, one trigger underneath all of them. The second instance is the only signal in here reliable enough to act on.
Everything else in these articles is a way of noticing. This is the thing that tells you to move.
Why the second one is the signal
The reason is not that two is a magic number. It is that the first instance genuinely cannot tell you what you need to know.
When you write the first version, every detail in front of you looks equally essential. The refund window and the fact that it is thirty days and the fact that it applies to card payments are all just the requirement, sitting in one undifferentiated lump. You have no way to tell which of those is the rule and which is this case’s value, because you have exactly one case.
The second one separates them. The moment there is a second market with a sixty day window, thirty stops being part of the rule and becomes an argument to it. You did not work that out by thinking harder. You worked it out because reality showed you an axis of variation you could not have predicted.
This is old ground, and the people who mapped it said it better. Martin Fowler’s rule of three in Refactoring says to wait for the third occurrence before extracting. Sandi Metz put the cost side of it more sharply: duplication is far cheaper than the wrong abstraction. Both are saying the same thing from different ends. You cannot design the general case from one example, and an abstraction built from one example is a guess that everything afterwards has to be bent around.
Where I would differ slightly, at least in application code, is that two is usually enough to move on and three is a bit late. By the third tenant the workaround has usually become load bearing.
What a hidden “one” looks like in Laravel
The reason this is expensive is that a singular assumption does not appear in the code as anything. There is no SingleTenant class to grep for. There is only the absence of a tenant_id, and absences do not show up in searches.
There are a few places they leave fingerprints.
Business facts in config. Config is for infrastructure, and every time a domain fact ends up in it, that fact is a global with one value.
grep -rhn "config('" app/ --include=*.php \ | grep -vE "config\('(app|database|services|queue|cache|mail|filesystems|session|logging|auth|broadcasting)\." \ | sed "s/.*config('\([^']*\)'.*/\1/" \ | sort | uniq -c | sort -rnAnything that comes back which is a rule rather than a connection detail is a candidate. config('billing.trial_days') is fine while there is one plan and becomes a problem the day plans have different trials, because it is reachable from everywhere and belongs to nothing.
Singular accessors. A method called currentWorkspace() or activeTeam() is a design that has already met plurality and pinned one of them. That is not a defect, it is often exactly right, but it marks the boundary where the codebase decided to stop being plural.
grep -rn "function current\|function active\|::current()" app/ --include=*.phpTables with one row. A settings table that never gets a second row is a config file with worse ergonomics, and it is the shape that becomes painful the day settings need to be per organisation.
Enums with one meaningful case. A PaymentMethod enum with a single Card case is a placeholder for a decision nobody has had to make yet.
None of these are bugs. They are places where the number one is currently hardcoded into the design, and the point of listing them is that when someone asks “how hard would it be to add a second X”, you now know where to look for the answer rather than guessing two weeks.
When the second one arrives, build for two
Here is the mistake that turns a two week job into a six week one, and it is not the one you would expect. It is not failing to generalise. It is generalising too far.
The second tenant arrives, everyone can see that the singular assumption was the problem, and the reaction is to build the general case: a full multi-tenancy layer, scoping traits, a tenant resolver, a middleware stack, per-tenant config, the lot. That is designing for the fifth tenant from a position where you have met two.
Two is enough to see the axis. It is not enough to know the shape.
// you have two cases, so the value is an argument nownew RefundWindow(days: $market->refundWindowDays);That change is small, it is driven entirely by evidence you have, and it does not commit you to anything about how markets will differ next year. If the third market varies on something else, you will find out and handle that too, and you will not have to unpick a framework you built in advance for the wrong axis.
The thing to look at for a good version of this in the wild is key rotation. Nobody builds a key management subsystem for their first signing key. When the second key arrives you end up with a current key id and a list of retired keys, which handles two and every number after it, and it costs almost nothing because it was designed with two real cases in hand rather than an imagined ten.
What not to do before it arrives
The whole of part five applies here, so I will keep it short.
Do not add the tenant column now in case. Do not build the notification channel abstraction when you only send email. Do not make the currency an argument while there is one currency, unless the second is already on a roadmap with a date on it.
You will get it wrong, and you will get it wrong in a way that costs more than the retrofit would have, because the retrofit is done with knowledge and the speculative version is done without any. Every one of those abstractions has to be maintained, read, and worked around from the day it is added until the day the second case shows up, which may be never.
The exception, and it is a real one
There is a difference between speculation and a plan, and it is not a matter of degree.
If a contract is signed and a second region ships in March, that is not a hypothetical, it is a requirement with a date. Building for it now is not speculative generalisation, it is doing the work early with real knowledge of what the second case is. You can ask what its refund window is. You can find out whether it uses a different currency.
The test is whether you can describe the second case in specifics. If you can say “the German market, sixty day window, euros, ships in March”, build for it. If you can only say “we might expand to Europe at some point”, you cannot design for it, because you do not know what it is.
The failure mode is dressing the second one up as the first. “We will probably need multi-currency eventually” is a hunch wearing a requirement’s clothes, and the way to tell is that nobody can answer any question about it.
What to do on Monday
Run the config grep. Every entry that is a business rule rather than a connection detail is a place where the number one is currently baked in, and now you know where they are.
Then take the next feature request that starts with “can we also” and, before estimating it, go and find the singular assumption it collides with. That is the actual work in the estimate, and it is the part that turns two weeks into six when it is discovered in week three instead of on day one.
You will not be able to remove those assumptions in advance, and you should not try. Knowing where they are is worth a great deal on its own, because it turns a surprise into a number.
Next in this series, and last: the rewrite you should not do, and how to price the three options honestly when leaving it alone is one of them.
Why Is This Hard To Change?
You are reading Part 6 of 7 in this learning series.
Keep Reading
Building Research
A desktop research workspace in Laravel and NativePHP. Streaming SSE into a queued job, distilling reports with a local model, and why cosine similarity cannot tell a paraphrase from a contradiction.
Aug 2026 · 22 min read
LaravelEvery Feature Touches Ten Files
Ten files open for a one line change is either layering working correctly or one idea smeared across a codebase. The count does not tell you which, and git history does.
Aug 2026 · 4 min read
LaravelNobody Wants To Touch That Model
Every Laravel codebase has a model people route around. Counting its lines is the least useful thing you can do to it, and extracting traits is how the count gets hidden rather than fixed.
Aug 2026 · 8 min read