Modèles de développement Laravel
Modèles d'architecture Laravel de qualité production pour des applications évolutives et maintenables.
Quand l'utiliser
- Construire des applications web Laravel ou des API
- Structurer les contrôleurs, services et logique métier
- Travailler avec les modèles et relations Eloquent
- Concevoir des API avec ressources et pagination
- Ajouter des files d'attente, des événements, de la mise en cache et des tâches d'arrière-plan
Comment ça marche
- Structurez l'application autour de limites claires (contrôleurs -> services/actions -> modèles).
- Utilisez des liaisons explicites et à portée pour garder le routage prévisible ; imposez toujours l'autorisation pour le contrôle d'accès.
- Privilégiez les modèles typés, les casts et les scopes pour maintenir une logique métier cohérente.
- Gardez les travaux lourds en E/S dans les files d'attente et mettez en cache les lectures coûteuses.
- Centralisez la configuration dans
config/*et gardez les environnements explicites.
Exemples
Structure du projet
Utilisez une disposition Laravel conventionnelle avec des limites de couches claires (HTTP, services/actions, modèles).
Disposition recommandée
app/
├── Actions/ # Cas d'utilisation à but unique
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Validation des requêtes Form Request
│ └── Resources/ # Ressources API
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Services de coordination métier
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
Contrôleurs -> Services -> Actions
Gardez les contrôleurs fins. Mettez l'orchestration dans les services et la logique à but unique dans les actions.
final class CreateOrderAction
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrderData $data): Order
{
return $this->orders->create($data);
}
}
final class OrdersController extends Controller
{
public function __construct(private CreateOrderAction $createOrder) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->createOrder->handle($request->toDto());
return response()->json([
'success' => true,
'data' => OrderResource::make($order),
'error' => null,
'meta' => null,
], 201);
}
}
Routage et contrôleurs
Privilégiez la liaison route-modèle et les contrôleurs de ressources pour plus de clarté.
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
Liaison de modèle de route (à portée)
Utilisez des liaisons à portée pour empêcher l'accès inter-tenant.
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
Routes imbriquées et noms de liaison
- Gardez les préfixes et chemins cohérents pour éviter le double imbriquement (par exemple,
conversationvsconversations). - Utilisez un seul nom de paramètre qui correspond au modèle lié (par exemple,
{conversation}pourConversation). - Privilégiez les liaisons à portée lors de l'imbriquement pour appliquer les relations parent-enfant.
use App\Http\Controllers\Api\ConversationController;
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
Route::scopeBindings()->group(function () {
Route::get('/{conversation}', [ConversationController::class, 'show'])
->name('conversations.show');
Route::post('/{conversation}/messages', [MessageController::class, 'store'])
->name('conversation-messages.store');
Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
->name('conversation-messages.show');
});
});
Si vous souhaitez qu'un paramètre corresponde à une classe de modèle différente, définissez une liaison explicite. Pour une logique de liaison personnalisée, utilisez Route::bind() ou implémentez resolveRouteBinding() sur le modèle.
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
Liaisons du conteneur de services
Liez les interfaces aux implémentations dans un fournisseur de services pour un câblage clair des dépendances.
use App\Repositories\EloquentOrderRepository;
use App\Repositories\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
Modèles de conception Eloquent
Configuration du modèle
final class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'owner_id', 'status'];
protected $casts = [
'status' => ProjectStatus::class,
'archived_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
}
Casts personnalisés et objets de valeur
Utilisez des énumérations ou des objets de valeur pour un typage strict.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected $casts = [
'status' => ProjectStatus::class,
];
protected function budgetCents(): Attribute
{
return Attribute::make(
get: fn (int $value) => Money::fromCents($value),
set: fn (Money $money) => $money->toCents(),
);
}
Chargement précoce pour éviter N+1
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
Objets de requête pour les filtres complexes
final class ProjectQuery
{
public function __construct(private Builder $query) {}
public function ownedBy(int $userId): self
{
$query = clone $this->query;
return new self($query->where('owner_id', $userId));
}
public function active(): self
{
$query = clone $this->query;
return new self($query->whereNull('archived_at'));
}
public function builder(): Builder
{
return $this->query;
}
}
Scopes globaux et suppressions logicielles (Soft Deletes)
Utilisez des scopes globaux pour le filtrage par défaut et SoftDeletes pour les enregistrements récupérables.
Utilisez soit un scope global, soit un scope nommé pour le même filtre, pas les deux, sauf si vous souhaitez un comportement en couches.
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder): void {
$builder->whereNull('archived_at');
});
}
}
Scopes de requête pour filtres réutilisables
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('owner_id', $userId);
}
}
// Dans un service, repository, etc.
$projects = Project::ownedBy($user->id)->get();
Transactions pour les mises à jour multi-étapes
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
Migrations
Convention de nommage
- Les noms de fichiers utilisent des horodatages :
YYYY_MM_DD_HHMMSS_create_users_table.php - Les migrations utilisent des classes anonymes (pas de classe nommée) ; le nom du fichier communique l'intention
- Les noms de tables sont en
snake_caseet au pluriel par défaut
Exemple de migration
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->index();
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Requêtes de validation (Form Requests) et validation
Gardez la validation dans les Form Requests et transformez les entrées en DTO.
use App\Models\Order;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function toDto(): CreateOrderData
{
return new CreateOrderData(
customerId: (int) $this->validated('customer_id'),
items: $this->validated('items'),
);
}
}
Ressources API
Gardez les réponses API cohérentes avec les ressources et la pagination.
$projects = Project::query()->active()->paginate(25);
return response()->json([
'success' => true,
'data' => ProjectResource::collection($projects->items()),
'error' => null,
'meta' => [
'page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
]);
Événements, tâches (Jobs) et files d'attente
- Émettez des événements métier pour les effets secondaires (emails, analytics)
- Utilisez des tâches en file d'attente pour les travaux lents (rapports, exports, webhooks)
- Privilégiez les gestionnaires idempotents avec des tentatives et un backoff
Mise en cache
- Mettez en cache les points de terminaison à lecture intensive et les requêtes coûteuses
- Invalidez les caches lors des événements de modèle (créé/mis à jour/supprimé)
- Utilisez des tags lors de la mise en cache de données connexes pour une invalidation facile
Configuration et environnements
- Gardez les secrets dans
.envet la configuration dansconfig/*.php - Utilisez des surcharges de configuration par environnement et
config:cacheen production