DEV Community

Cover image for The Framework Trap: Why Year-4 Laravel Apps Feel Like Sediment
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Framework Trap: Why Year-4 Laravel Apps Feel Like Sediment


You know the codebase. You've worked in one, or you're working in one right now. Year one, it shipped weekly. Pull requests were small. Onboarding took an afternoon. The team called it "fast to ship."

Year four, you open app/Models/Order.php and the file is 512 lines. There are mutators that touch payment state. Three observers fire on saved, one of them calls an external pricing API inside the model lifecycle and swallows the exception. The Laravel 9-to-11 upgrade PR has been "almost ready" for two quarters. Twelve thousand lines of red and green, blocked on a test suite that takes nine minutes locally and falls over in CI for reasons nobody has time to investigate.

You want to change one tax rule. Fifteen lines. Before you start, you know the diff will brush six unrelated subsystems and need approval from two other teams. Your manager will ask, again, in standup, why a fifteen-line change is taking three days.

The application isn't broken. It serves traffic. It makes money. What it has stopped being is changeable. And a codebase that has stopped being changeable is dying in slow motion.

Framework gravity

Every framework-coupled codebase has a slow, polite, almost invisible pull on every design decision. Call it framework gravity. Each individual yield looks reasonable. Each commit makes sense in isolation.

You add an Eloquent relationship because the docs show how. The tutorial puts a facade in the controller, so a facade goes in the controller. Someone runs php artisan make:model -mfsc because it scaffolds in a second. An observer gets wired in EventServiceProvider because the recipe said so. Each step is a small bow to the framework. Bow enough times and your spine takes the shape of the room.

After four years of bows, the application is the framework. Laravel isn't a thing your code uses. Your code is a thing Laravel happens to contain.

Symfony codebases catch the same illness with different symptoms. Instead of god-trait Eloquent models you get services.yaml files three thousand lines long, autowire annotations leaking into entity classes, and event subscribers doing the bulk of the business reasoning under a name like OrderWorkflowSubscriber. Same illness, slightly different scaffolding.

Sediment layers building under a single business decision

What sediment looks like in PHP 8.3

Here's the model file an engineer I worked with opened on a Monday. The names are changed; the shape is real. This is what year four looks like in production Laravel.

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Services\RecommendationService;
use App\Services\PricingApiClient;

class Order extends Model
{
    use SoftDeletes;
    use HasRoles;
    use Auditable;
    use Searchable;

    protected $casts = [
        'placed_at' => 'datetime',
        'status'    => OrderStatus::class,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('tenant', function ($q) {
            $q->where('tenant_id', auth()->user()?->tenant_id);
        });

        static::saving(function (Order $order) {
            $order->total_cents = $order->recomputeTotal();
            $order->tax_cents = $order->recomputeTax();
        });

        static::saved(function (Order $order) {
            app(PricingApiClient::class)
                ->reportSale($order);
        });
    }

    public function totalWithUpsell(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->total_cents
                + app(RecommendationService::class)
                    ->upsellFor($this)->priceCents,
        );
    }

    public function recomputeTax(): int
    {
        if ($this->customer->country === 'DE') {
            return (int) round($this->subtotal_cents * 0.19);
        }
        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Count the places business logic lives. One global scope hiding rows from queries. Two lifecycle hooks doing math the domain cares about. One hook calling an external API inside the save path. An accessor that reaches into a service container to compute a property. A tax rule hard-coded inside the model.

Five different mechanisms, one file. None of them is a bug. Each was the path of least resistance the day it was written. Together, they are sediment.

You can grep for it. In a Laravel repo:

grep -rn "static::saving\|static::saved\|static::creating" \
  app/Models | wc -l
grep -rn "addGlobalScope" app/Models | wc -l
grep -rn "Attribute::make\|getAttribute\|setAttribute" \
  app/Models | wc -l
Enter fullscreen mode Exit fullscreen mode

Three numbers. Add them. That's your sediment count — a rough rule of thumb, not a benchmark. Under 5, the codebase is young or someone has been quietly disciplined. Between 6 and 20, you have typical mid-stage rot. Above 50, the application has dissolved into the framework whether anyone has said the word out loud or not.

Why the upgrade is the tell

There's one signal that confirms framework gravity has won. The major-version framework upgrade gets discussed in the same breath as a rewrite.

Laravel 9 to 10 should be a Composer bump, a deprecation pass, an afternoon's work. Symfony 5 to 6 is a guided walk through the upgrade tool. Both projects publish careful migration guides. Both maintain backward compatibility within reason.

So why does the upgrade ticket sit in the backlog for a year? Why does the team eventually ask, out loud in a planning meeting, whether they should rewrite the thing in Go?

Because the framework and the application are no longer separable. The team has used Illuminate\Support\Collection as a domain type. They've type-hinted controllers against Illuminate\Http\Request deep inside services. They've extended internal Laravel classes that the framework, very reasonably, refactored between majors. Their tests subclass TestCase from the framework and inherit dozens of behaviors they don't control.

A framework upgrade in a healthy codebase is a Composer change. In a framework-coupled codebase it's a migration project with a steering committee. That gap, between "Composer change" and "steering committee," is the cost of framework gravity, denominated in calendar quarters.

A single line of business code pulled clean of framework rings

The same logic, without the framework

The order pricing rule above doesn't need Eloquent, observers, the service container, or a model at all. It needs a function and the data the function operates on.

<?php

declare(strict_types=1);

namespace App\Domain\Pricing;

final readonly class LineItem
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public int $priceCents,
    ) {}
}

final readonly class OrderTotal
{
    public function __construct(
        public int $subtotalCents,
        public int $taxCents,
        public int $totalCents,
    ) {}
}

final class PriceOrder
{
    public function __construct(
        private TaxRules $taxRules,
    ) {}

    /** @param list<LineItem> $items */
    public function __invoke(
        string $countryCode,
        array $items,
    ): OrderTotal {
        $subtotal = 0;
        foreach ($items as $item) {
            $subtotal += $item->priceCents * $item->quantity;
        }
        $tax = $this->taxRules->taxFor($countryCode, $subtotal);
        return new OrderTotal(
            subtotalCents: $subtotal,
            taxCents:      $tax,
            totalCents:    $subtotal + $tax,
        );
    }
}

interface TaxRules
{
    public function taxFor(string $country, int $cents): int;
}
Enter fullscreen mode Exit fullscreen mode

Four types. No extends Model. No service-container lookups. No global state. PriceOrder is a use case. TaxRules is a port. The Laravel controller that calls it is an adapter. If the team swaps Eloquent for Doctrine, the repository that loads the line items is another adapter. So is the queue job that fires when the order is placed.

The test is six lines because the use case has one dependency.

<?php

declare(strict_types=1);

use App\Domain\Pricing\{LineItem, PriceOrder, TaxRules};

final class FixedTax implements TaxRules
{
    public function __construct(private int $rateBps) {}

    public function taxFor(string $country, int $cents): int
    {
        return (int) round($cents * $this->rateBps / 10_000);
    }
}

it('computes total with tax', function () {
    $price = new PriceOrder(new FixedTax(1900));
    $total = $price(
        'DE',
        [new LineItem('A', 2, 1500)],
    );
    expect($total->subtotalCents)->toBe(3000);
    expect($total->taxCents)->toBe(570);
    expect($total->totalCents)->toBe(3570);
});
Enter fullscreen mode Exit fullscreen mode

The test runs without a database, without HTTP, without RefreshDatabase. It runs in milliseconds. When the tax rule changes, exactly one file changes. When the team swaps Eloquent for Doctrine, this code does not move.

This is the whole thesis. Demote the framework from "the application" to "an adapter." The thing your business pays for lives in pure PHP: the pricing, the policies, the invariants. The framework sits at the edge, where it can be replaced without renovation.

The discipline is one decision, made repeatedly

You don't have to throw away Laravel. You don't have to write everything as pure PHP from day one. You don't have to learn category theory or buy a whiteboard.

The discipline is one decision made every time new code wants to be born: does this belong to the application, or to the adapter? Put it in the right place.

  • The tax rule? Application. It's a policy. It survives every framework migration.
  • The Stripe webhook signature check? Adapter. It belongs at the edge.
  • The order's total calculation? Application. The math is the product.
  • The INSERT INTO orders? Adapter. The storage is replaceable.
  • The "send confirmation email" trigger? The trigger is application (something happened, downstream wants to know). The SMTP call is adapter.

Most engineers reading this already know which side each thing belongs on. The framework didn't trick anyone. The framework offered the quickest path to a green test on a Tuesday afternoon, and Tuesday afternoon won. Repeated for four years, Tuesday afternoon is what built the sediment.

If you have ten minutes today, do this. Open the model file you opened most often last quarter. Look at every method longer than three lines. For each one, ask: would this still be true if the framework went away tomorrow? If the answer is yes, the method is in the wrong file. Move it. Even one move is a small reversal of the pull.

Compounded over four years, that single habit is the difference between a codebase that gets easier to change and one that gets harder.


If this was useful

The pattern above (domain in pure PHP, framework as one of the adapters) is the spine of Decoupled PHP. It walks the same shape from a single use case up to a production service with HTTP, queues, Doctrine, and external APIs, all behind ports. It's the book to read when the Laravel-9-to-11 upgrade PR has been sitting in your backlog for six months.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)