Laravel Services vs Fat Models vs Actions : Mon Choix Architectural
Dans le développement de mon app BeeNote (suivi de ruchers), j’ai exploré différentes approches architecturales. Voici pourquoi j’ai choisi les Services comme solution optimale.
🤔 Le Dilemme Architectural Laravel
Quand on développe avec Laravel, on se retrouve rapidement face à une question cruciale : où placer la logique métier ?
Laravel ne nous impose pas une approche unique, ce qui est à la fois une force et un défi. Plusieurs écoles s’affrontent :
Les 4 Approches Principales
- Fat Models (Approche Laravel traditionnelle)
- Services (Compromis moderne)
- Actions (Pattern émergent)
- Repository Pattern (Architecture enterprise)
🏗️ Analyse Comparative des Approches
Fat Models : La Voie Laravel Classique
Le principe : Toute la logique métier dans les modèles Eloquent.
// Dans app/Models/Rucher.php
class Rucher extends Model
{
public static function createForTeam(array $data, User $user): self
{
$rucher = new self($data);
$rucher->team_id = $user->current_team_id;
$rucher->save();
return $rucher;
}
public function updateForTeam(array $data, User $user): self
{
if ($this->team_id !== $user->current_team_id) {
throw new \Exception('Accès refusé');
}
$this->update($data);
return $this;
}
}
✅ Avantages :
- Simple et direct : Pas de classe supplémentaire
- Laravel way officiel : Recommandé dans la documentation
- Découvrable intuitivement :
Rucher::createForTeam()
est évident - Moins de fichiers : Tout regroupé dans le modèle
❌ Inconvénients :
- Modèles gigantesques : 500+ lignes rapidement atteintes
- Violation du principe SRP : Responsabilités mixtes (données + logique)
- Tests complexes : Couplage fort avec la base de données
- Maintenance difficile : Modifications risquées sur des modèles critiques
Actions : L’Approche Ultra-Modulaire
Le principe : Une classe par action métier.
// app/Actions/CreateRucherAction.php
class CreateRucherAction
{
public function execute(array $data, User $user): Rucher
{
$rucher = new Rucher($data);
$rucher->team_id = $user->current_team_id;
$rucher->save();
return $rucher;
}
}
// app/Actions/UpdateRucherAction.php
class UpdateRucherAction
{
public function execute(Rucher $rucher, array $data, User $user): Rucher
{
// Logique de mise à jour...
}
}
✅ Avantages :
- Single Responsibility parfait : 1 classe = 1 tâche précise
- Testabilité maximale : Facile à mocker et isoler
- Réutilisabilité : Utilisable partout (Controllers, Jobs, Commands)
- Architecture propre : Séparation claire des responsabilités
❌ Inconvénients :
- Explosion de fichiers : Multiplication exponentielle des classes
- Over-engineering : Complexité excessive pour du CRUD simple
- Navigation difficile : Dispersion du code lié
- Courbe d’apprentissage : Conceptuellement plus complexe
Repository Pattern : L’Architecture Enterprise
Le principe : Abstraction de la couche de données.
interface RucherRepositoryInterface
{
public function findByTeam(int $teamId): Collection;
public function create(array $data): Rucher;
}
class RucherRepository implements RucherRepositoryInterface
{
public function findByTeam(int $teamId): Collection
{
return Rucher::where('team_id', $teamId)->get();
}
}
✅ Avantages :
- Abstraction de données : Indépendant de la source (DB, API, Cache)
- Testabilité : Interface mockable facilement
- Architecture enterprise : Pattern reconnu en entreprise
❌ Inconvénients :
- Over-engineering majeur : Complexité inutile pour la plupart des cas
- Verbosité excessive : Interface + Implémentation + Binding
- Anti-pattern Laravel : Eloquent est déjà un Repository
- Maintenance lourde : Synchronisation Interface/Implémentation
🎯 Services : Le Compromis Intelligent
Le principe : Regrouper la logique métier par domaine fonctionnel.
// app/Services/RucherService.php
class RucherService
{
public static function createForTeam(array $validatedData, User $user): Rucher
{
if (!$user->current_team_id) {
throw new \Exception('L\'utilisateur doit appartenir à une équipe.');
}
$rucher = new Rucher($validatedData);
$rucher->team_id = $user->current_team_id;
$rucher->save();
return $rucher;
}
public static function getRuchersForTeam(User $user): Collection
{
if (!$user->current_team_id) {
return collect();
}
return Rucher::where('team_id', $user->current_team_id)->get();
}
public static function updateForTeam(Rucher $rucher, array $validatedData, User $user): Rucher
{
if ($rucher->team_id !== $user->current_team_id) {
throw new \Exception('Vous ne pouvez pas modifier ce rucher.');
}
$rucher->update($validatedData);
return $rucher;
}
}
🏆 Pourquoi les Services Gagnent
Avantages Décisifs
🎯 Équilibre parfait :
- Plus structuré que Fat Models
- Moins complexe que Actions
- Plus pragmatique que Repository
📁 Organisation logique :
- Fonctionnalités regroupées par domaine
- Nombre de fichiers maîtrisé
- Navigation intuitive dans le code
🧪 Testabilité optimale :
// Test simple et direct
public function test_create_rucher_for_team()
{
$user = User::factory()->withPersonalTeam()->create();
$data = ['nom' => 'Test Rucher'];
$rucher = RucherService::createForTeam($data, $user);
$this->assertEquals($user->current_team_id, $rucher->team_id);
}
🔒 Sécurité intégrée : Chaque méthode inclut les vérifications métier appropriées.
🚀 Évolutivité : Facile d’ajouter des méthodes liées ou de refactorer vers Actions si nécessaire.
Architecture Résultante
Controllers (Orchestration)
↓ Délègue la logique
Services (Logique Métier)
↓ Utilise les modèles
Models (Données + Relations)
↓ Interagit avec
Database (Persistance)
💡 Principes Directeurs des Services
1. Single Domain Responsibility
Un service par domaine métier : RucherService
, VisiteService
, RucheService
.
2. Dependency Injection
// ❌ Couplage fort
public static function create(array $data)
{
$user = Auth::user(); // Service dépend de l'auth
}
// ✅ Injection claire
public static function create(array $data, User $user)
{
// Service indépendant, testable
}
3. Validation en Amont
Les services reçoivent des données déjà validées via Form Requests.
4. Gestion d’Erreurs Explicite
if (!$user->current_team_id) {
throw new \Exception('L\'utilisateur doit appartenir à une équipe.');
}
🛠️ Implémentation Pratique
Commande Artisan Personnalisée
// app/Console/Commands/MakeServiceCommand.php
class MakeServiceCommand extends Command
{
protected $signature = 'make:service {name}';
protected $description = 'Créer un nouveau service';
public function handle()
{
$name = $this->argument('name');
if (!str_ends_with($name, 'Service')) {
$name .= 'Service';
}
// Logique de création...
}
}
Usage : php artisan make:service Rucher
Intégration Controller
class RucherController extends Controller
{
public function store(StoreRucherRequest $request)
{
$rucher = RucherService::createForTeam(
$request->validated(),
$request->user()
);
return redirect()->route('ruchers.index')
->with('message', 'Rucher créé avec succès !');
}
}
Le contrôleur devient un simple orchestrateur !
📊 Comparatif Final
Critère | Fat Models | Services | Actions | Repository |
---|---|---|---|---|
Simplicité | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐ |
Maintenabilité | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
Testabilité | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
Lisibilité | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
Performance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
Courbe apprentissage | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐ |
🎯 Conclusion
Pour BeeNote, les Services représentent le choix architectural optimal car ils offrent :
- L’équilibre parfait entre simplicité et structure
- Une maintenabilité excellente sans over-engineering
- Une testabilité maximale avec une complexité maîtrisée
- Une évolutivité permettant une migration future vers Actions si nécessaire
Les Services ne sont pas un pattern Laravel officiel, mais ils constituent une solution pragmatique et élégante pour organiser la logique métier dans des applications de taille moyenne.
L’architecture parfaite n’existe pas, mais les Services s’en rapprochent pour la majorité des projets Laravel.