Laravel et pattern Repository

Un Repository est une séparation entre un domaine (business) et une couche de persistance. Le Repository fournit une interface de collecte pour accéder aux données stockées dans une base de données, un système de fichiers ou un service externe. Les données sont renvoyées sous forme d'objets.

En Laravel, l'accés aux données se fait via Eloquent, qui est un ORM, mais il est aussi possible d'utiliser Doctrine. Ces deux ORM sont géniaux pour encapsuler les requetes SQL, mais restent limités lorsque l'accès aux données deviens plus complexe ou spécifique. Utiliser directement l'ORM dans le controlleur est un antipattern qui risque vite rendre le code de votre application illisible !

Prennons ce code d'exemple:

class UsersController extends Controller
{
   public function index()
   {
       $users = User::all();

       return view('users.index', [
           'users' => $users
       ]);
   }
} 

Ici tout a bien, le code est précis et conscis. Si maintenant je rajoute une clause à mes Users.

class UsersController extends Controller
{
   public function index()
   {
       $users = User::where('is_active', true)->get();

       return view('users.index', [
           'users' => $users
       ]);
   }
} 

Vous voyez ou je veux en venir?

Pire encore, si autre part dans votre application vous avez besoin de filter par utilisateur actif, alors vous allez dupliquer ce code autre part et créer à terme un joli code spaghetti!

Imaginez ce que ça donne sur des centaines de routes, avec des centaines de models différents?

Le repository pattern

L'idée principale d'utiliser le Repository Pattern dans une application Laravel est de créer un pont entre les modèles et les contrôleurs. Le modèle ne doit pas être responsable de la communication ou de l'extraction des données de la base de données. Un modèle doit être un objet qui représente une table / un document / un objet donné ou tout autre type de notre structure de données et cela doit être de sa seule responsabilité.

L'utilisation de Repository Pattern présente de nombreux avantages, vous trouverez ci-dessous une liste des plus importants:

  • La centralisation de la logique d'accès aux données facilite la maintenance du code
  • La logique commerciale et d'accès aux données peut être testée séparément
  • Réduit la duplication du code
  • Moins de chances de faire des erreurs de programmation

Avec un repository, notre controlleur de l'exemple précédent ressemble du coup a ça:

class UsersController extends Controller
{
   private $userRepository;

   public function __construct(UserRepositoryInterface $userRepository)
   {
       $this->userRepository = $userRepository;
   }

   public function index()
   {
       $users = $this->userRepository->getAll();

       return view('users.index', [
           'users' => $users
       ]);
   }
}

Et du coup, lorsqu'il faudra limiter les utilisateurs a ceux qui sont actifs, ou ceux qui sont visibles par les droits de l'user courant, etc etc, ça sera la responçabilité du repository. Notre controlleur ne fait plus que l'orchestration des différents appels (sont boulot quoi!) et on respecte a nouveau la séparation des responçabilités entre les différentes classes.

Implémentations de base

Pour simplifier la mise en place de ce pattern, voici une petite classe de base pour les Repos:

<?php namespace App\Models\Repositories;

use Illuminate\Database\Eloquent\Model;

abstract class BaseRepository
{
    protected $model;

    public function __construct(Model $model)
    {
        $this->model = $model;
    }

    public function getAll() {
        return $this->model->all();
    }

    public function getById($id) {
        return $this->model->find($id);
    }

    public function getByIds($ids)
    {
        if ($ids == null) {
            return array();
        }
        if (is_array($ids)) {
            return $this->model->whereIn('id', $ids)->get();
        }

        throw new \InvalidArgumentException("Argument must be an array of indexes to get");
    }

    /**
     * @return Model
     */
    public function newInstance() {
        return $this->model->newInstance();
    }

    public function create($data) {
        if (is_array($data)) {
            return $this->model->create($data);
        }

        if (get_class($data) == get_class($this->model)) {
            return $data->save();
        }

        throw new InvalidArgumentException("L'argument de create doit être de type array ou de type ".get_class($this->model));
    }

    public function update($data, $id = 0, $attribute="id") {
        if ($data instanceof Model) {
            return $data->save();
        }
        if (is_array($data)) {
            return $this->model->where($attribute, '=', $id)->update($data);
        }

        throw new \InvalidArgumentException("Arguments must be a model or an array with an ID");
    }

    public function delete($ids) {
        return $this->model->destroy($ids);
    }

    public function getCount() {
        return $this->model->count();
    }

}

Et au niveau de l'usage:

<?php namespace App\Models\Repositories;

use App\Models\Eloquent\Player;

class PlayerRepository extends BaseRepository
{

    /**
     * Users to auto set as admin at creation
     */
    const AUTO_ADMIN = array(
        'firstadmin@example.com',
        'secondadmin@example.com',
    );

    public function __construct(Player $model)
    {
        parent::__construct($model);
    }

    public function create($data) {
        if (is_array($data) && in_array($data['email'], self::AUTO_ADMIN)) {
            $data['admin'] = true;
        } else if (get_class($data) == get_class($this->model) && in_array($data->email, self::AUTO_ADMIN)) {
            $data->admin = true;
        }

        return parent::create($data);
    }

    public function getByEmail($email)
    {
        if ($email == null) {
            return null;
        }   
        return $this->model->where('email', '=', $email)->first();
    }
}

Ici on remarque que les fonctionnalités de recherche par email, et de passage en admin sont encapsulées dans le repository. Ainsi peut importe si l'utilisateur crée son compte via OAuth, un login standard, ou depuis un seeder, il aura correctement les droits admins qui lui sont dus.

Pour finir, quelques conseils sur l'utilisation de ce pattern:

  • Vous devez toujours injecter votre Repository à l'aide de l'injection de dépendances au lieu de créer une instance à l'aide du mot-clé new. Cela facilite les tests, mais conserve une seule responçabilité unique dans votre controlleur.
  • Rendez votre code réutilisable, si une méthode est utilisée par plusieurs Repository, vous devez implémenter cette méthode dans la classe BaseRepository (Don't Repeat Yourself!).
  • Injectez le modèle dans le constructeur de votre Repository, n'utilisez pas de classe statique! Laissez Laravel faire le binding.

Qu'avez vous pensé de cet article?