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.
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.
| Layer | Does | Never does |
|---|---|---|
| FormRequest | Validates, authorizes, builds a DTO | Write to the database |
| Controller | Connects request, Action and Resource | Hold business ifs |
| Action | Runs one use case in a transaction | Read request() or auth() |
| Model | Relations, casts, scopes | Send mail, call an API |
| Resource | Shapes the output JSON | Run queries (use whenLoaded) |
| Policy | Decides permissions | Change data |
| Job | Runs slow work on a queue | Serialize 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.
<?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');
});<?php
namespace App\Http\Requests;
use App\Data\InvoiceData;
use App\Models\Invoice;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Invoice::class);
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', Rule::exists('customers', 'id')],
'due_at' => ['required', 'date', 'after:today'],
'lines' => ['required', 'array', 'min:1'],
'lines.*.label' => ['required', 'string', 'max:120'],
'lines.*.quantity' => ['required', 'integer', 'min:1'],
'lines.*.unit_price' => ['required', 'integer', 'min:0'], // cents
];
}
public function toData(): InvoiceData
{
return InvoiceData::fromArray($this->validated());
}
}<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\Invoice\CreateInvoice;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInvoiceRequest;
use App\Http\Resources\InvoiceResource;
use App\Models\Invoice;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Attributes\Controllers\Authorize;
class InvoiceController extends Controller
{
#[Authorize('viewAny', Invoice::class)]
public function index(): AnonymousResourceCollection
{
$invoices = Invoice::query()
->with('customer')
->latest()
->paginate(20);
return InvoiceResource::collection($invoices);
}
public function store(StoreInvoiceRequest $request, CreateInvoice $createInvoice): InvoiceResource
{
$invoice = $createInvoice->handle($request->user(), $request->toData());
// 201 is automatic: the model was just created
return InvoiceResource::make($invoice);
}
}<?php
namespace App\Actions\Invoice;
use App\Data\InvoiceData;
use App\Enums\InvoiceStatus;
use App\Events\InvoiceCreated;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\DB;
final class CreateInvoice
{
public function handle(User $author, InvoiceData $data): Invoice
{
return DB::transaction(function () use ($author, $data) {
$invoice = Invoice::create([
'customer_id' => $data->customerId,
'author_id' => $author->id,
'due_at' => $data->dueAt,
'status' => InvoiceStatus::Draft,
'total' => $data->total(),
]);
$invoice->lines()->createMany($data->linesToArray());
InvoiceCreated::dispatch($invoice);
return $invoice->load('lines');
});
}
}<?php
namespace App\Data;
use Carbon\CarbonImmutable;
final readonly class InvoiceData
{
/** @param list<array{label: string, quantity: int, unit_price: int}> $lines */
public function __construct(
public int $customerId,
public CarbonImmutable $dueAt,
public array $lines,
) {}
/** @param array<string, mixed> $input */
public static function fromArray(array $input): self
{
return new self(
customerId: (int) $input['customer_id'],
dueAt: CarbonImmutable::parse($input['due_at']),
lines: $input['lines'],
);
}
public function total(): int
{
return array_sum(array_map(
fn (array $line) => $line['quantity'] * $line['unit_price'],
$this->lines,
));
}
/** @return list<array<string, int|string>> */
public function linesToArray(): array
{
return $this->lines;
}
}<?php
namespace App\Models;
use App\Enums\InvoiceStatus;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['customer_id', 'author_id', 'due_at', 'status', 'total'])]
class Invoice extends Model
{
/** @use HasFactory<\Database\Factories\InvoiceFactory> */
use HasFactory;
protected function casts(): array
{
return [
'due_at' => 'immutable_date',
'status' => InvoiceStatus::class,
'total' => 'integer',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function lines(): HasMany
{
return $this->hasMany(InvoiceLine::class);
}
#[Scope]
protected function overdue(Builder $query): void
{
$query->where('status', InvoiceStatus::Sent)->whereDate('due_at', '<', now());
}
}<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Invoice */
class InvoiceResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'total' => $this->total,
'due_at' => $this->due_at->toDateString(),
'customer' => CustomerResource::make($this->whenLoaded('customer')),
'lines' => InvoiceLineResource::collection($this->whenLoaded('lines')),
'can' => [
'update' => $request->user()?->can('update', $this->resource),
],
];
}
}<?php
namespace App\Enums;
enum InvoiceStatus: string
{
case Draft = 'draft';
case Sent = 'sent';
case Paid = 'paid';
public function label(): string
{
return __("invoices.status.{$this->value}");
}
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Draft => $next === self::Sent,
self::Sent => $next === self::Paid,
self::Paid => false,
};
}
}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
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
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
// 1 query for invoices + 1 query per invoice
$invoices = Invoice::all();
foreach ($invoices as $invoice) {
echo $invoice->customer->name;
}Do
// 2 queries total, only needed columns, paginated
$invoices = Invoice::query()
->select(['id', 'customer_id', 'total', 'status'])
->with('customer:id,name')
->paginate(20);<?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
newor 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 theconfig/folder.