What Makes a Laravel App Hard to Maintain - hero image illustrating Laravel maintainability and technical debt: fat controllers, hidden observers, inconsistent architecture, and temporary hacks that quietly become permanent

What Makes a Laravel App Hard to Maintain (and How I Avoid It)

Rahmat Ullah profile photoRahmat Ullah
8 min readLaravel, Architecture, Technical Debt, Maintainability

Most Laravel apps do not become hard to maintain because of one bad decision. They become hard to maintain because of a thousand convenient ones. A model observer added at 2 AM to fix a webhook. A helper class created because nobody could think of a better name. A controller that grew from 80 lines to 800 over six months because each PR only added "a little bit more." None of those felt wrong in isolation. Together they form the codebase nobody wants to touch.

This is the tenth post in my Laravel series. I have written about mistakes I stopped making, scalable application structure, performance in production, API design, authentication and authorization, abstractions, third party integrations, production debugging, and most recently scalable database schema design. This one sits underneath all of them: long-term maintainability, technical debt, and the engineering decisions that age badly after one to three years in production.

The Real Cost of Convenience

After one to three years in production, the codebase tells you which of your decisions aged well and which did not. Maintainability problems do not show up as crashes or red alerts. They show up as estimates that quietly double, refactors that nobody wants to start, and onboarding sessions that take longer every quarter. The code still works. The cost of changing it has gone up.

That cost is the only honest measure of maintainability. Everything in this post comes back to it.

Maintainability Is Change Cost, Not Code Cleanliness

The trap is thinking maintainability means clean code. It does not.

Maintainability is the cost of changing the system. How long does it take to add a feature. How confidently can you ship a bug fix on a Friday. How many files does a junior engineer need to read before they can move a button. That is the metric.

A codebase can look clean and still be brutal to change. A codebase can look messy in places and still be a joy to work in. The PSR formatter does not save you. Architecture decisions do.

The reason maintainability problems are so easy to miss early on is that they do not produce loud failures. The app works. Tests pass. Features ship. Then one day a "two day" task takes three weeks, and you start asking why.

What Actually Makes Laravel Apps Hard to Maintain

A short tour of the patterns I see again and again.

Fat controllers

A controller that does validation, business logic, third party calls, response shaping, and a sprinkle of caching. Each line was added by a different developer trying to be polite. The result is one file that nobody owns and everyone is afraid of.

This is what it usually looks like a year in:

public function store(Request $request)
{
    $validated = $request->validate([
        'email' => 'required|email',
        'plan'  => 'required|in:basic,pro',
    ]);

    $user = User::create($validated);

    Stripe::customers()->create([
        'email' => $user->email,
        'name'  => $user->name,
    ]);

    Mail::to($user)->send(new WelcomeEmail($user));
    Cache::tags(['users'])->flush();

    if ($validated['plan'] === 'pro') {
        ProvisionProAccount::dispatch($user);
    }

    return response()->json(['id' => $user->id]);
}

The same logic, delegated:

public function store(RegisterUserRequest $request, RegisterUser $register)
{
    $user = $register($request->validated());

    return response()->json(['id' => $user->id], 201);
}

The controller now does what controllers are for: validate the request, call the thing, shape the response. The interesting decisions (provisioning, billing, email, cache invalidation) live in one place that has a name and an owner. If billing rules change next quarter, you know exactly which file to open. The first version, you find out by grepping.

Hidden business-critical side effects

Observers, events, model hooks, and global scopes are not the problem. They are good Laravel features when used for the work they were designed for: audit logs, cache invalidation, broadcasting, search index updates. Operational concerns that the calling code does not need to know about.

The problem is when business-critical logic ends up there. An observer that decides whether to send an invoice. An event listener that mutates pricing. A global scope that silently filters out half the records based on session state. Now the same line of code behaves differently depending on context the caller cannot see.

// Looks innocent. Triggers three observers, dispatches one event,
// invalidates two cache keys, and recalculates a totals column.
$order->save();

The rule of thumb I use: operational side effects in observers are fine, business rules are not. If a junior engineer cannot answer "what business rules just ran" by reading the method, the business logic is hidden, and you will pay for it every time a requirement changes.

Inconsistent architecture

Half the features use Action classes. A quarter use service objects. The rest live in controllers. Some modules have a Repository layer. Some hit Eloquent directly. There is no rule, which means every developer reinvents one. Onboarding becomes a tour of seven different ways to do the same thing. This is the single biggest enemy of scalable Laravel architecture.

Weak boundaries

Controllers calling Stripe SDKs directly. Models that know about HTTP status codes. Blade views querying the database. The boundary between "code that handles a request" and "code that does the work" disappears, and you lose the ability to test or replace either side.

Copy-paste duplication

The most expensive duplication is not "the same code in two places." It is "almost the same code in two places that have quietly drifted." A bug fix in one copy does not reach the other. A spec change reaches one and not the other. You ship two slightly different behaviors and a support ticket.

Ambiguous naming

OrderManager. OrderHelper. OrderProcessor. OrderService. OrderHandler. Five classes in the same namespace, none of which tells you what it does. Names like these are a tell that nobody could define the responsibility, so they reached for a generic word and moved on.

Technical debt disguised as "temporary"

Every temporary hack survives at least one Laravel upgrade. The // TODO: refactor this comment from 2022 will outlive your tenure. If a workaround does not have an owner, a ticket, and a date, it is not temporary. It is permanent and undocumented Laravel technical debt.

Missing operational visibility

Maintenance includes debugging. A codebase with no structured logging, no request correlation IDs, and no useful metrics is a codebase where every production incident becomes a guessing game. The first hour of every outage is "where do I even look." If you have not invested here yet, the production debugging post earlier in this series goes into the specifics.

Fragile migrations

Schema changes that everyone fears. Migrations that drop and recreate. Tables with thirty nullable columns because nobody dared make one required. A real maintainability problem hides here: if your team cannot confidently change the schema, they will work around it forever.

Tight coupling to the framework and packages

Every Laravel app accrues dependencies. A package for Stripe, one for image manipulation, one for permissions, one for activity logs. The maintenance bill comes due at Laravel upgrade time. Half of those packages turn out to not be maintained for the new version yet, and the abandonware ones have woven themselves so deeply into your models and controllers that ripping them out is a project of its own.

The same problem shows up with Laravel itself when you lean too hard on the magic. Facades sprinkled through every layer. Eloquent collections leaking into HTTP responses. Trait-based behavior assumed in places that should have been explicit. None of these are wrong by themselves. They become expensive when you cannot tell which of your code depends on which Laravel feature, and the upgrade guide reads like a list of things you have done.

The mitigation is boring: keep third party packages behind a thin interface you own, avoid pulling vendor types into your domain code, and budget time to upgrade. An app that has fallen two major versions behind is harder to maintain than an app with active technical debt, because the technical debt at least has names.

How I Design for Maintainability Now

What I look for in code review and what I push for in new modules.

Explicit architecture

Pick a structure and apply it everywhere. The specific shape matters less than the consistency. In my own Laravel apps I happen to lean on Action classes (one verb per class) for business logic, with infrastructure concerns behind a small set of services. That is my preference, not a universal Laravel best practice. Plenty of teams ship great codebases with service objects, with handlers, with explicit use cases, with domain services. Pick whichever shape your team can hold in its head and apply it everywhere. The cost of "we used to do it that way but the new modules are different" is bigger than the cost of any particular pattern.

Business logic ownership

Every business rule should have one obvious home. If "an order can only be cancelled within 30 minutes of placement" is checked in a controller, an observer, a job, and a Blade template, it is going to drift. Pick one place. Make every other caller go through it.

Consistency over cleverness

The clever pattern that solves your problem elegantly today is the pattern the next developer has to learn from scratch tomorrow. I would rather have ten classes that look almost identical than one beautiful abstraction that requires a paragraph of explanation. Predictable shapes are easier to maintain than inventive ones, even when the inventive ones are objectively better.

Operational maintainability

Structured logs with a request ID. Meaningful exception messages. Metrics on the things that matter (queue depth, third party latency, error rates per endpoint). You will not regret this. You will only ever regret not doing it sooner.

Testability as a refactor signal

Code that is hard to test is code that is hard to change. A class with five injected dependencies that all need to be stubbed before you can assert anything is telling you something about its coupling. If writing the test feels disproportionate to the logic being tested, the design is wrong, not the test.

This matters because tests are the safety net under refactoring. When you go to clean up a fat controller a year from now, your confidence in that change is exactly equal to your confidence in the tests around it. Hidden dependencies (a facade call buried in a service, an observer that fires from a model save, a queued job dispatched as a side effect) all reduce that confidence. You end up shipping smaller changes than the codebase needs, because the bigger ones feel too risky to try. That is how maintainability decays even when test coverage looks healthy on the dashboard.

Designing for the next change

Before merging anything non trivial I ask one question: how will this look when the requirement changes in six months. Not "could it change," but "when it does, what will I be cursing." If the answer is obvious and bad, I rewrite now.

Migration safety mindset

Schema changes ship with a plan: backwards compatible column add, code deploy that writes to both, backfill, code deploy that reads from the new column, drop the old one. Multi step migrations feel like overhead until your first 50 million row table needs a NOT NULL constraint added on a Tuesday morning.

A Real Production Example

A checkout flow I inherited. The total price was calculated in five places: a model accessor, a controller, an observer that fired on Order::saving, a queue job that ran after payment confirmation, and a scheduled command that "reconciled" totals every hour. Each one had a slightly different rounding rule. Each one had been added by a different engineer to fix a different bug.

The symptom: a customer would see one total in their cart, a different total at checkout, and a third total on the receipt. We "fixed" it twice. Both fixes added a sixth and seventh place where the total was calculated.

The real fix took two weeks. We collapsed every total calculation into a single Action class, CalculateOrderTotal. Every controller, observer, job, and command called it. The five existing implementations were removed in one PR. Two of them had bugs that had been quietly producing wrong receipts for over a year.

The lesson was not "use Actions." The lesson was that the original architecture had no opinion about where pricing belonged, so it ended up in five places, and no single engineer ever owned the rule. The point I made in the post on abstractions applies directly here: the value of an abstraction is the ownership it creates, not the lines of code it saves.

What I Avoid Now

  • Magical side effects in observers and events when a direct call would do.
  • Architectural inconsistency between modules in the same app.
  • "Temporary" hacks without an owner and a removal date.
  • Abstractions that exist to feel clever rather than to remove duplication.
  • Tight coupling to third party SDKs in any layer above the integration layer.
  • Business rules that live nowhere in particular and everywhere a little bit.

Principles I Follow

  • Change cost is the metric, not lines of code or Laravel code quality scores.
  • Explicit beats hidden, every time.
  • Consistency beats cleverness.
  • Debugging is maintainability. Plan for the 3 AM incident.
  • Migrations are maintenance work, not setup work.
  • Temporary code becomes permanent. Treat it that way from day one.

Closing Thoughts

Maintainability is not about writing beautiful code. It is about making future change boring. Boring changes are the ones that ship on Friday, get reviewed in twenty minutes, and never page anyone in the middle of the night.

Every maintainable Laravel application I have worked on had two things in common: a consistent architecture that the team respected, and a refusal to let "temporary" mean "permanent." Every one that aged badly was a graveyard of clever ideas, each of which was the right answer in isolation.

The final takeaway, the one I would put on a sticky note next to every code review: maintainability is the cost of future change, not how clean the code looks today. Optimize for the cost. The cleanliness sorts itself out. Skip that lens and you end up with a codebase that lints perfectly and still takes three weeks to ship a button.

What made a codebase hardest for you to maintain?