Skip to content
All articles
Laravel8 min read·

The Laravel app that survives year two

Most Laravel projects are pleasant for six months and painful by month eighteen. The difference is a handful of structural decisions made early — and they are not the ones people argue about.

LaravelPHPArchitectureQueues

Laravel is exceptionally good at getting you to a working product fast. That is its strength and, eighteen months later, its trap. The patterns that made month one quick — logic in controllers, business rules in models, everything synchronous — are the same patterns that make month eighteen slow.

I have inherited enough of these to have opinions. Here is what consistently matters, and what does not.

Controllers should be boring

A controller has exactly three jobs: accept a validated request, hand it to something that does the work, and return a response. When a controller method is 120 lines long, none of that logic is testable without booting HTTP, and none of it is reusable from a queue job, a console command or an API endpoint.

php
class BookingController extends Controller
{
    public function store(StoreBookingRequest $request, CreateBooking $createBooking)
    {
        $booking = $createBooking->handle(
            BookingData::fromRequest($request)
        );

        return new BookingResource($booking);
    }
}

CreateBooking is a plain class with one public method. It can be called from the controller, from an Artisan command that imports a CSV, from a queued job, and from a test — without a single HTTP request. That is the entire benefit, and it is enormous.

Models are for data, not decisions

Eloquent makes it tempting to put everything on the model. Relationships and casts belong there. A 400-line User model that also sends emails, charges cards and calculates commission does not.

The practical test: if a method on your model has side effects outside the database, it probably belongs in an action or a service. Keep models describing shape and relationships; keep behaviour somewhere you can inject and mock.

Queue anything a user should not wait for

This is the change with the largest visible payoff. Emails, PDF generation, webhook delivery, image processing, third-party API calls, report building — none of these belong in the request cycle.

php
// Not this — the user waits for a third-party API
Mail::to($user)->send(new InvoicePaid($invoice));
$accounting->sync($invoice);

// This — the response returns immediately
SendInvoicePaidMail::dispatch($invoice);
SyncInvoiceToAccounting::dispatch($invoice)->onQueue('integrations');

Put slow, unreliable integrations on their own queue. When a payment provider has a bad afternoon, a backed-up integrations queue should not stop welcome emails going out.

N+1 queries are not a micro-optimisation

A list page that lazy-loads a relationship inside a loop issues one query per row. With 20 rows in development it is invisible. With 5,000 rows in production it is an outage.

Turn on strict mode in development and Laravel will throw the moment you lazy-load. It is the cheapest insurance in the framework:

php
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());

Migrations are permanent, seeders are not

Never edit a migration that has run in production. Write a new one. And keep a seeder that produces a realistic dataset — not three rows, but enough volume that pagination, search and N+1 problems show up locally instead of on launch day.

What does not matter as much as people think

  • Repository pattern over Eloquent. Usually adds a layer of indirection to swap a database you will never swap.
  • Perfect directory structure. Actions, Services, Domain — pick one and be consistent. Consistency beats correctness here.
  • 100% test coverage. Feature tests over the routes that make money will catch more real bugs than exhaustive unit tests over getters.
The goal is not architectural purity. It is that a developer who has never seen the codebase can find where a thing happens, change it, and know whether they broke something.

Everything above serves that. Thin controllers make behaviour findable. Actions make it testable. Queues keep the request cycle honest. Strict mode makes performance bugs loud. None of it is clever, and that is rather the point.

Building something like this?

I design and ship these systems for clients — retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading