Modèles de développement Laravel
Modèles d'architecture Laravel de qualité production pour des applications évolutives et maintenables.
Quand utiliser
- Construction d'applications web ou d'API Laravel
- Structuration des contrôleurs, services et logique métier
- Travail avec les modèles Eloquent et les relations
- Conception d'API avec ressources et pagination
- Ajout de files d'attente, événements, mise en cache et tâches en arrière-plan
Comment ça fonctionne
- Structurez l'application autour de limites claires (contrôleurs -> services/actions -> modèles).
- Utilisez des liaisons explicites et des liaisons de portée pour que le routage reste prévisible ; appliquez toujours l'autorisation pour le contrôle d'accès.
- Préférez les modèles typés, les casts et les scopes pour que la logique métier reste cohérente.
- Placez les opérations lourdes 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 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 formulaires
│ └── Resources/ # Ressources API
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Services métier coordinateurs
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
Contrôleurs -> Services -> Actions
Gardez les contrôleurs légers. Placez 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
Préférez la liaison de modèle de route 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 (avec portée)
Utilisez des liaisons de portée pour empêcher l'accès croisé entre tenants.
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 les chemins cohérents pour éviter les doubles imbrications (par exemple,
conversationvsconversations). - Utilisez un seul nom de paramètre qui correspond au modèle lié (par exemple,
{conversation}pourConversation). - Préférez les liaisons de portée lors de l'imbrication 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 soit résolu vers 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 modèle 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 impatient 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
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 les 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 le service, référentiel, etc.
$projects = Project::ownedBy($user->id)->get();
Transactions pour les mises à jour en plusieurs étapes
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
Migrations
Conventions 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 formulaire et validation
Gardez la validation dans les requêtes de formulaire 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, jobs et files d'attente
- Émettez des événements de domaine pour les effets secondaires (e-mails, analyses)
- Utilisez des jobs en file d'attente pour les travaux lents (rapports, exportations, webhooks)
- Préférez les gestionnaires idempotents avec tentatives et backoff
Mise en cache
- Mettez en cache les points de terminaison à forte lecture et les requêtes coûteuses
- Invalidez les caches lors des événements de modèle (created/updated/deleted)
- 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 par environnement et
config:cacheen production