Skip to content
Lantorian

Laravel10 min read

Clean Laravel architecture

Every class has one job. The controller receives, the Action decides, the Resource responds. The result: readable, reusable code that is easy to test.

How a request travels

Running example: create an invoice with POST /api/v1/invoices. Click a step to see the file involved and what it must never do.

Route

1 / 7

routes/api.php

Maps the URL and HTTP verb to a controller method. Versions the API (v1) and applies Sanctum authentication to the group.

Never: a closure with logic inside.

Seven steps, seven responsibilities. When a class does its neighbor's job, it's time to refactor.

The standard tree

We keep Laravel's folder-by-type structure, with one sub-folder per business domain. Hover a file to see why it exists.

Who does what

Keep this table open during your first PRs. Most review comments come from a misplaced responsibility.

LayerDoesNever does
FormRequestValidates, authorizes, builds a DTOWrite to the database
ControllerConnects request, Action and ResourceHold business ifs
ActionRuns one use case in a transactionRead request() or auth()
ModelRelations, casts, scopesSend mail, call an API
ResourceShapes the output JSONRun queries (use whenLoaded)
PolicyDecides permissionsChange data
JobRuns slow work on a queueSerialize a whole model for no reason

A complete feature

All the code for creating an invoice. Highlighted lines are the ones reviewers look at first.

routes/api.php
<?php

use App\Http\Controllers\Api\V1\InvoiceController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')
    ->middleware('auth:sanctum')
    ->group(function () {
        Route::apiResource('invoices', InvoiceController::class);
        Route::post('invoices/{invoice}/pay', [InvoiceController::class, 'pay'])
            ->name('invoices.pay');
    });

Thin controllers

An intern's first instinct is often to write everything in the controller. It works, but nothing is reusable or testable in isolation.

Avoid

InvoiceController.php
public function store(Request $request)
{
    // validation inline, no authorization
    $request->validate(['customer_id' => 'required']);

    $invoice = new Invoice();
    $invoice->customer_id = $request->customer_id;
    $invoice->status = 'draft';           // magic string
    $invoice->save();

    foreach ($request->lines as $line) {  // no transaction
        $invoice->lines()->create($line);
    }

    Mail::to($invoice->customer)->send(new InvoiceMail($invoice));

    return $invoice;                      // raw model: leaks columns
}

Do

InvoiceController.php
public function store(
    StoreInvoiceRequest $request,
    CreateInvoice $createInvoice,
): InvoiceResource {
    $invoice = $createInvoice->handle(
        $request->user(),
        $request->toData(),
    );

    return InvoiceResource::make($invoice);
}

Eloquent without surprises

The N+1 problem is the top cause of slowness. We enable strict mode in development so it throws instead of silently slowing down.

Avoid

N+1
// 1 query for invoices + 1 query per invoice
$invoices = Invoice::all();

foreach ($invoices as $invoice) {
    echo $invoice->customer->name;
}

Do

eager loading
// 2 queries total, only needed columns, paginated
$invoices = Invoice::query()
    ->select(['id', 'customer_id', 'total', 'status'])
    ->with('customer:id,name')
    ->paginate(20);
app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use App\Services\Payment\PaymentGateway;
use App\Services\Payment\StripeGateway;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Code depends on the interface, tests swap the implementation
        $this->app->bind(PaymentGateway::class, StripeGateway::class);
    }

    public function boot(): void
    {
        // Lazy loading, silently discarded attributes, missing attributes: throw in dev
        Model::shouldBeStrict(! $this->app->isProduction()); 

        // No migrate:fresh or db:wipe in production
        DB::prohibitDestructiveCommands($this->app->isProduction()); 

        Date::use(CarbonImmutable::class);
    }
}

Principles to remember

  • Types everywhere: return types, typed properties, PHPDoc generics for Larastan.
  • Dependency injection instead of new or facades inside Actions: that's what keeps tests simple.
  • Final and readonly classes by default, inheritance only when it truly simplifies.
  • No logic in migrations or in production seeders.
  • Configuration through config(), never env() outside the config/ folder.