Laravel10 min de lecture
Architecture Laravel propre
Chaque classe a un seul rôle. Le contrôleur reçoit, l'Action décide, la Resource répond. Résultat : du code lisible, réutilisable et simple à tester.
Le trajet d'une requête
Exemple fil rouge : créer une facture avec POST /api/v1/invoices. Clique sur une étape pour voir le fichier concerné et ce qu'il ne doit jamais faire.
Route
1 / 7
routes/api.php
Associe l'URL et le verbe HTTP à une méthode de contrôleur. Versionne l'API (v1) et applique l'authentification Sanctum au groupe.
Jamais : de closure avec de la logique dedans.
L'arborescence standard
On garde la structure Laravel par type de classe, avec un sous-dossier par domaine métier. Survole un fichier pour savoir pourquoi il est là.
Qui fait quoi
Garde ce tableau ouvert pendant tes premières PR. La plupart des remarques en revue viennent d'une responsabilité mal placée.
| Couche | Fait | Ne fait jamais |
|---|---|---|
| FormRequest | Valide, autorise, produit un DTO | Écrire en base |
| Contrôleur | Relie la requête, l'Action et la Resource | Contenir des if métier |
| Action | Exécute un cas d'usage en transaction | Lire request() ou auth() |
| Modèle | Relations, casts, scopes | Envoyer des mails, appeler une API |
| Resource | Formate le JSON de sortie | Lancer des requêtes (utilise whenLoaded) |
| Policy | Décide des droits | Modifier des données |
| Job | Exécute un travail lent en file | Recevoir un modèle entier sérialisé inutilement |
Une fonctionnalité complète
Tout le code de la création de facture. Les lignes surlignées sont celles qu'on regarde en premier en revue.
<?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,
};
}
}Contrôleur fin
Le premier réflexe d'un stagiaire est souvent de tout écrire dans le contrôleur. Ça fonctionne, mais rien n'est réutilisable ni testable isolément.
À éviter
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
}À faire
public function store(
StoreInvoiceRequest $request,
CreateInvoice $createInvoice,
): InvoiceResource {
$invoice = $createInvoice->handle(
$request->user(),
$request->toData(),
);
return InvoiceResource::make($invoice);
}Eloquent sans surprise
Le problème N+1 est la première cause de lenteur. On active le mode strict en développement pour qu'il lève une exception au lieu de ralentir en silence.
À éviter
// 1 query for invoices + 1 query per invoice
$invoices = Invoice::all();
foreach ($invoices as $invoice) {
echo $invoice->customer->name;
}À faire
// 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);
}
}Principes à retenir
- Typage partout : types de retour, propriétés typées, génériques en PHPDoc pour Larastan.
- Injection de dépendances plutôt que
newou les façades dans les Actions : c'est ce qui rend les tests simples. - Classes finales et readonly par défaut, héritage seulement quand il simplifie vraiment.
- Pas de logique dans les migrations ni dans les seeders de production.
- Configuration via config(), jamais
env()en dehors du dossierconfig/.