andydefer / laravel-repository
A lightweight, type-safe repository pattern implementation for Laravel
Requires
- php: ^8.1
- andydefer/laravel-cluster: ^0.12.0
- laravel/framework: ^12.0|^13.0|^14.0|^15.0
Requires (Dev)
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is auto-updated.
Last update: 2026-08-01 09:27:29 UTC
README
Une implémentation légère et typée du pattern Repository pour Laravel avec intégration Records et Eloquent.
Table des matières
- Installation
- Concepts fondamentaux
- Créer votre premier Repository
- Référence de l'API
- Méthodes à surcharger
- Bonnes pratiques
- Exemple complet avec filtres complexes
- Licence
Installation
composer require andydefer/laravel-repository
Prérequis
- PHP 8.1 ou supérieur
- Laravel 12.x, 13.x, 14.x ou 15.x
- Dépendances automatiques :
andydefer/domain-structures(structures typées)andydefer/laravel-cluster(filtres JSON avancés)laravel/framework
Concepts fondamentaux
Le Record
Un Record est un DTO typé qui sert d'interface entre votre code et le Repository.
use AndyDefer\DomainStructures\Abstracts\AbstractRecord; final class UserRecord extends AbstractRecord { public function __construct( public readonly ?string $name = null, public readonly ?string $email = null, public readonly ?UserStatus $status = null, ) {} }
Règles pour les Records :
- ✅ Étendre
AbstractRecord - ✅ Propriétés
public readonly - ✅ Les champs optionnels =
nullpar défaut - ❌ Pas de logique métier
- ❌ Pas de tableaux bruts (utiliser
TypedCollection)
Filtres Cluster sur colonnes JSON
Le package intègre laravel-cluster pour interroger des colonnes JSON avec une syntaxe puissante :
// Conditions simples $users = $repository ->whereCluster('metadata', 'status=active') ->findBy(new FindByRecord(filters: new EmptyRecord())); // Conditions combinées $users = $repository ->whereCluster('metadata', 'status=active & age>25') ->findBy(new FindByRecord(filters: new EmptyRecord())); // Chemins de tableau $users = $repository ->whereCluster('metadata', 'addresses[city=Kinshasa]') ->findBy(new FindByRecord(filters: new EmptyRecord())); // Fonctions d'agrégation $users = $repository ->whereCluster('metadata', 'COUNT(addresses) > 2') ->findBy(new FindByRecord(filters: new EmptyRecord())); // Conditions OR $users = $repository ->whereCluster('metadata', 'status=active | status=suspended') ->findBy(new FindByRecord(filters: new EmptyRecord()));
Multiples colonnes JSON
Appliquez des filtres sur plusieurs colonnes JSON en une seule fois :
// Avec whereClusters() $users = $repository ->whereClusters([ 'metadata' => 'status=active & role=admin', 'preferences' => 'color=blue & size=large', ]) ->findBy(new FindByRecord(filters: new EmptyRecord())); // Avec ClusterQueries dans FindByRecord use AndyDefer\Repository\ValueObjects\ClusterQueries; $queries = new ClusterQueries([ 'metadata' => 'status=active', 'preferences' => 'color=blue', ]); $findBy = new FindByRecord( filters: new EmptyRecord, clusterQueries: $queries, ); $users = $repository->findBy($findBy);
Records de configuration
Le package fournit des Records standardisés pour les opérations :
FindByRecord
use AndyDefer\Repository\Records\FindByRecord; // Tri simple $findBy = new FindByRecord( filters: new UserFiltersRecord(status: UserStatus::ACTIVE), limit: 10, sortBy: new SortColumns('name:asc'), columns: new SelectColumns(['id', 'name', 'email']), ); // Tri multi-colonnes $findBy = new FindByRecord( filters: new UserFiltersRecord(status: UserStatus::ACTIVE), limit: 10, sortBy: new SortColumns('name:asc|created_at:desc|id:asc'), columns: new SelectColumns(['id', 'name', 'email']), ); // Avec ClusterQueries use AndyDefer\Repository\ValueObjects\ClusterQueries; $queries = new ClusterQueries([ 'metadata' => 'status=active', 'preferences' => 'color=blue', ]); $findBy = new FindByRecord( filters: new UserFiltersRecord(status: UserStatus::ACTIVE), limit: 10, sortBy: new SortColumns('name:asc|created_at:desc'), columns: new SelectColumns(['id', 'name', 'email']), clusterQueries: $queries, );
| Propriété | Type | Défaut | Description |
|---|---|---|---|
filters |
AbstractRecord |
EmptyRecord |
Filtres de recherche |
limit |
?int |
null |
Limite de résultats |
sortBy |
?SortColumns |
null |
Colonnes de tri (supporte le multi-colonnes) |
columns |
SelectColumns |
SelectColumns::all() |
Colonnes à sélectionner |
clusterQueries |
?ClusterQueries |
null |
Requêtes cluster sur colonnes JSON |
PaginateRecord
use AndyDefer\Repository\Records\PaginateRecord; $paginate = new PaginateRecord( perPage: 15, page: 1, sortBy: new SortColumns('created_at:desc'), filters: new UserFiltersRecord(status: UserStatus::ACTIVE), columns: new SelectColumns(['id', 'name', 'email']), ); // Tri multi-colonnes sur plusieurs champs $paginate = new PaginateRecord( perPage: 15, page: 1, sortBy: new SortColumns('category_id:asc|price:desc'), filters: new UserFiltersRecord(status: UserStatus::ACTIVE), columns: new SelectColumns(['id', 'name', 'email']), ); // Avec ClusterQueries $queries = new ClusterQueries([ 'metadata' => 'status=active', 'preferences' => 'color=blue', ]); $paginate = new PaginateRecord( perPage: 15, page: 1, sortBy: new SortColumns('created_at:desc'), filters: new UserFiltersRecord(status: UserStatus::ACTIVE), columns: new SelectColumns(['id', 'name', 'email']), clusterQueries: $queries, );
| Propriété | Type | Défaut | Description |
|---|---|---|---|
perPage |
int |
15 |
Éléments par page |
page |
int |
1 |
Numéro de page |
sortBy |
?SortColumns |
null |
Colonnes de tri (supporte le multi-colonnes) |
filters |
AbstractRecord |
EmptyRecord |
Filtres de recherche |
columns |
SelectColumns |
SelectColumns::all() |
Colonnes à sélectionner |
clusterQueries |
?ClusterQueries |
null |
Requêtes cluster sur colonnes JSON |
RepositoryInfoRecord
use AndyDefer\Repository\Records\RepositoryInfoRecord; $info = $repository->info(); // RepositoryInfoRecord { // modelClass: 'App\Models\User', // recordClass: 'App\Records\UserRecord', // }
Objet Valeur SortColumns
Le package fournit un Value Object pour gérer le tri simple ou multi-colonnes :
use AndyDefer\Repository\ValueObjects\SortColumns; // Tri simple $sort = new SortColumns('name:asc'); $sort->toArray(); // ['name' => 'asc'] // Tri multi-colonnes (syntaxe à barre verticale) $sort = new SortColumns('name:asc|created_at:desc|id:asc'); $sort->toArray(); // ['name' => 'asc', 'created_at' => 'desc', 'id' => 'asc'] // Depuis un tableau associatif $sort = SortColumns::fromArray(['name' => 'asc', 'created_at' => 'desc']); // Vérifications $sort->hasColumn('name'); // true $sort->getDirection('name'); // 'asc' $sort->count(); // 3
Format de chaîne : colonne:direction|colonne:direction
directionpeut êtreascoudesc- Les colonnes sont séparées par le caractère
|(pipe)
Objet Valeur SelectColumns
use AndyDefer\Repository\ValueObjects\SelectColumns; // Créer avec des colonnes spécifiques $columns = new SelectColumns(['id', 'name', 'email']); // Sélectionner toutes les colonnes $allColumns = SelectColumns::all(); // Ajouter des colonnes (retourne une nouvelle instance) $extended = $columns->add('created_at', 'updated_at'); // Vérifier si une colonne existe if ($columns->has('email')) { // ... } // Obtenir le nombre $count = $columns->count(); // 3 // Convertir en tableau $array = $columns->toArray(); // ['id', 'name', 'email']
Objet Valeur ClusterQueries
use AndyDefer\Repository\ValueObjects\ClusterQueries; // Créer avec plusieurs colonnes $queries = new ClusterQueries([ 'metadata' => 'status=active & role=admin', 'preferences' => 'color=blue & size=large', 'settings' => 'theme=dark', ]); // Vérifier si une colonne a une requête if ($queries->has('metadata')) { $query = $queries->get('metadata'); // 'status=active & role=admin' } // Obtenir toutes les requêtes $allQueries = $queries->all(); // ['metadata' => '...', 'preferences' => '...'] // Compter les requêtes $count = $queries->count(); // 3 // Vérifier si vide if ($queries->isEmpty()) { // Aucune requête } // Fusionner deux ensembles de requêtes $otherQueries = new ClusterQueries([ 'settings' => 'language=fr', ]); $merged = $queries->merge($otherQueries); // $merged contient metadata, preferences, settings avec 'language=fr'
Créer votre premier Repository
1. Créer le Modèle
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; final class User extends Model { protected $fillable = ['name', 'email', 'status', 'metadata', 'preferences']; protected $casts = [ 'metadata' => 'array', 'preferences' => 'array', ]; }
2. Créer le Record
<?php namespace App\Records; use AndyDefer\DomainStructures\Abstracts\AbstractRecord; use AndyDefer\LaravelCluster\ValueObjects\ClusterVO; final class UserRecord extends AbstractRecord { public function __construct( public readonly ?string $name = null, public readonly ?string $email = null, public readonly ?UserStatus $status = null, public readonly ?ClusterVO $metadata = null, public readonly ?ClusterVO $preferences = null, ) {} }
3. Créer le Record de filtres (Optionnel)
<?php namespace App\Records; use AndyDefer\DomainStructures\Abstracts\AbstractRecord; final class UserFiltersRecord extends AbstractRecord { public function __construct( public readonly ?string $name = null, public readonly ?string $email = null, public readonly ?UserStatus $status = null, public readonly ?string $cluster_query = null, // Pour les filtres JSON ) {} }
4. Créer le Repository
<?php namespace App\Repositories; use AndyDefer\Repository\AbstractRepository; use AndyDefer\DomainStructures\Abstracts\AbstractRecord; use App\Models\User; use App\Records\UserRecord; use App\Records\UserFiltersRecord; use Illuminate\Database\Eloquent\Builder; final class UserRepository extends AbstractRepository { public function __construct() { parent::__construct(User::class, UserRecord::class); } protected function applyFilters(Builder $query, AbstractRecord $filters): void { if (!$filters instanceof UserFiltersRecord) { return; } if ($filters->name !== null) { $query->where('name', 'like', '%' . $filters->name . '%'); } if ($filters->email !== null) { $query->where('email', 'like', '%' . $filters->email . '%'); } if ($filters->status !== null) { $query->where('status', $filters->status); } // Filtre cluster sur la colonne JSON if ($filters->cluster_query !== null) { $query->whereCluster('metadata', $filters->cluster_query); } } }
5. Utiliser le Repository
use App\Repositories\UserRepository; use App\Records\UserRecord; use App\Records\UserFiltersRecord; use AndyDefer\Repository\Records\FindByRecord; use AndyDefer\Repository\Records\PaginateRecord; use AndyDefer\Repository\ValueObjects\SortColumns; use AndyDefer\Repository\ValueObjects\ClusterQueries; class UserService { public function __construct( private readonly UserRepository $repository, ) {} // Créer un utilisateur public function createUser(string $name, string $email): User { return $this->repository->create(new UserRecord( name: $name, email: $email, status: UserStatus::ACTIVE, metadata: ClusterVO::fromArray(['preferences' => ['theme' => 'dark']]), )); } // Trouver un utilisateur public function findUser(int $id): ?User { return $this->repository->find($id); } // Mettre à jour un utilisateur (uniquement les champs non-nuls) public function updateUser(int $id, string $name): User { return $this->repository->update($id, new UserRecord(name: $name)); } // Supprimer un utilisateur public function deleteUser(int $id): bool { return $this->repository->delete($id); } // Lister avec filtres JSON et tri multiple public function listActiveUsersByStatusAndName(): array { $queries = new ClusterQueries([ 'metadata' => 'status=active & role=admin', 'preferences' => 'theme=dark & notifications=true', ]); $filters = new UserFiltersRecord(status: UserStatus::ACTIVE); $findBy = new FindByRecord( filters: $filters, limit: 50, sortBy: new SortColumns('status:asc|name:asc'), clusterQueries: $queries, ); return $this->repository->findBy($findBy)->all(); } // Paginer les résultats avec tri multiple et filtres cluster public function getPaginatedUsers(int $page = 1): LengthAwarePaginator { $queries = new ClusterQueries([ 'metadata' => 'status=active', 'preferences' => 'theme=dark', ]); $paginate = new PaginateRecord( perPage: 15, page: $page, sortBy: new SortColumns('created_at:desc|id:asc'), clusterQueries: $queries, ); return $this->repository->paginate($paginate); } // Compter les enregistrements avec filtres public function countActiveUsers(): int { $filters = new UserFiltersRecord( status: UserStatus::ACTIVE, cluster_query: 'verified=true' ); return $this->repository->count($filters); } // Vérifier l'existence public function userExists(string $email): bool { $filters = new UserFiltersRecord(email: $email); return $this->repository->exists($filters); } // Suppression groupée avec filtres cluster public function deleteInactiveUsers(): int { return $this->repository ->whereCluster('metadata', 'status=inactive') ->deleteBulk(new EmptyRecord()); } // Suppression définitive groupée public function forceDeleteInactiveUsers(): int { return $this->repository ->whereClusters([ 'metadata' => 'status=inactive', 'preferences' => 'notifications=false', ]) ->forceDeleteBulk(new EmptyRecord()); } }
Référence de l'API
AbstractRepository
| Méthode | Paramètres | Retour | Description |
|---|---|---|---|
info() |
- | RepositoryInfoRecord |
Informations du repository |
whereCluster(string $column, string $query) |
$column, $query |
$this |
Appliquer un filtre JSON cluster |
whereClusters(array $queries) |
$queries |
$this |
Appliquer plusieurs filtres cluster |
whereClusterQueries(ClusterQueries $queries) |
$queries |
$this |
Appliquer des filtres depuis un VO |
clearClusterFilters() |
- | $this |
Supprimer tous les filtres cluster |
create(AbstractRecord $record) |
$record |
Model |
Créer un nouvel enregistrement |
createRaw(array $data) |
$data |
Model |
Créer un enregistrement à partir de données brutes |
find(int $id) |
$id |
`Model | null` |
findWithTrashed(int $id) |
$id |
`Model | null` |
findBy(FindByRecord $record) |
$record |
Collection<Model> |
Rechercher avec critères (supporte tri multiple) |
update(int $id, AbstractRecord $record) |
$id, $record |
Model |
Mettre à jour (champs non-nuls uniquement) |
updateRaw(int $id, array $data) |
$id, $data |
Model |
Mettre à jour avec données brutes |
delete(int $id) |
$id |
bool |
Supprimer par ID (soft delete si disponible) |
restore(int $id) |
$id |
bool |
Restaurer un soft deleted |
forceDelete(int $id) |
$id |
bool |
Supprimer définitivement |
count(?AbstractRecord $criteria) |
$criteria |
int |
Compter les enregistrements |
exists(AbstractRecord $criteria) |
$criteria |
bool |
Vérifier l'existence |
paginate(PaginateRecord $record) |
$record |
LengthAwarePaginator |
Résultats paginés (supporte tri multiple) |
deleteBulk(AbstractRecord $criteria) |
$criteria |
int |
Suppression groupée (soft delete si disponible) |
forceDeleteBulk(AbstractRecord $criteria) |
$criteria |
int |
Suppression définitive groupée |
Méthodes à surcharger
| Méthode | Description |
|---|---|
applyFilters(Builder $query, AbstractRecord $filters) |
Appliquer les filtres de recherche (doit être surchargée) |
Exceptions
| Exception | Quand |
|---|---|
ModelNotFoundException |
update() ou updateRaw() sur un ID inexistant |
InvalidArgumentException |
Nom de colonne invalide dans SelectColumns ou SortColumns |
InvalidArgumentException |
Syntaxe de requête cluster invalide |
Bonnes pratiques
1. Un Record par Entité
// ✅ BON final class UserRecord extends AbstractRecord { ... } final class PostRecord extends AbstractRecord { ... } // ❌ MAUVAIS final class UserPostRecord extends AbstractRecord { ... }
2. Record de filtres séparé pour les cas complexes
// ✅ BON - Pour les filtres complexes final class UserFiltersRecord extends AbstractRecord { ... } // ✅ BON - Pour les cas simples, réutiliser le Record principal $filters = new UserRecord(status: UserStatus::ACTIVE);
3. Utiliser des valeurs par défaut pour les champs optionnels
// ✅ BON public function __construct( public readonly ?string $name = null, // Optionnel public readonly string $email, // Requis ) {} // ❌ MAUVAIS public function __construct( public readonly ?string $name, public readonly string $email, ) {}
4. Implémenter applyFilters() proprement
protected function applyFilters(Builder $query, AbstractRecord $filters): void { if (!$filters instanceof UserFiltersRecord) { return; } $query->when($filters->name ?? null, fn($q, $name) => $q->where('name', 'like', '%' . $name . '%') ); $query->when($filters->status ?? null, fn($q, $status) => $q->where('status', $status) ); $query->when($filters->cluster_query ?? null, fn($q, $query) => $q->whereCluster('metadata', $query) ); }
5. Utiliser whereClusters() pour plusieurs colonnes
// ✅ BON - Différentes colonnes $users = $repository ->whereClusters([ 'metadata' => 'status=active', 'preferences' => 'color=blue', 'settings' => 'theme=dark', ]) ->findBy(new FindByRecord(filters: new EmptyRecord())); // ✅ BON - Combiner avec whereCluster() chaîné $users = $repository ->whereCluster('metadata', 'status=active') ->whereCluster('metadata', 'role=admin') ->whereClusters([ 'preferences' => 'color=blue', 'settings' => 'theme=dark', ]) ->findBy(new FindByRecord(filters: new EmptyRecord()));
6. Utiliser ClusterQueries dans les Records
// ✅ BON - Réutilisable et testable $queries = new ClusterQueries([ 'metadata' => 'status=active', 'preferences' => 'color=blue', ]); $findBy = new FindByRecord( filters: $filters, limit: 10, sortBy: new SortColumns('name:asc'), clusterQueries: $queries, ); $users = $repository->findBy($findBy);
7. Nettoyer les filtres cluster
// ✅ BON - Nettoyer entre les requêtes $repository ->whereCluster('metadata', 'status=active') ->count(); // 5 $repository->clearClusterFilters(); $all = $repository->count(); // 10 // ✅ BON - ou créer une nouvelle instance $repository = new UserRepository(); $activeCount = $repository ->whereCluster('metadata', 'status=active') ->count();
8. Tester vos Repositories
final class UserRepositoryTest extends IntegrationTestCase { private UserRepository $repository; protected function setUp(): void { parent::setUp(); $this->repository = new UserRepository(); } public function test_create_persiste_utilisateur(): void { $record = new UserRecord(name: 'John', email: 'john@example.com'); $user = $this->repository->create($record); $this->assertDatabaseHas('users', [ 'id' => $user->id, 'name' => 'John', 'email' => 'john@example.com', ]); } public function test_where_cluster_filters_results(): void { $this->createUser(['metadata' => ['status' => 'active', 'role' => 'admin']]); $this->createUser(['metadata' => ['status' => 'inactive', 'role' => 'doctor']]); $users = $this->repository ->whereCluster('metadata', 'status=active & role=admin') ->findBy(new FindByRecord(filters: new EmptyRecord())); $this->assertCount(1, $users); } public function test_where_clusters_on_multiple_columns(): void { $this->createUser([ 'metadata' => ['status' => 'active', 'role' => 'admin'], 'preferences' => ['color' => 'blue', 'size' => 'large'], ]); $this->createUser([ 'metadata' => ['status' => 'active', 'role' => 'doctor'], 'preferences' => ['color' => 'red', 'size' => 'large'], ]); $users = $this->repository ->whereClusters([ 'metadata' => 'status=active & role=admin', 'preferences' => 'color=blue', ]) ->findBy(new FindByRecord(filters: new EmptyRecord())); $this->assertCount(1, $users); } }
Exemple complet avec filtres complexes et tri multiple
final class OrderRepository extends AbstractRepository { public function __construct() { parent::__construct(Order::class, OrderRecord::class); } protected function applyFilters(Builder $query, AbstractRecord $filters): void { if (!$filters instanceof OrderFiltersRecord) { return; } if ($filters->fromDate !== null) { $query->whereDate('created_at', '>=', $filters->fromDate); } if ($filters->toDate !== null) { $query->whereDate('created_at', '<=', $filters->toDate); } if ($filters->minAmount !== null) { $query->where('total', '>=', $filters->minAmount); } if ($filters->maxAmount !== null) { $query->where('total', '<=', $filters->maxAmount); } if ($filters->status !== null) { $query->where('status', $filters->status); } if ($filters->search !== null) { $query->where(function ($q) use ($filters) { $q->where('order_number', 'like', '%' . $filters->search . '%') ->orWhere('customer_name', 'like', '%' . $filters->search . '%'); }); } if ($filters->cluster_query !== null) { $query->whereCluster('metadata', $filters->cluster_query); } } } // Utilisation avec ClusterQueries, tri multi-colonnes et filtres use AndyDefer\Repository\ValueObjects\ClusterQueries; $clusterQueries = new ClusterQueries([ 'metadata' => 'status=active & priority=high', 'preferences' => 'shipping.express=true', ]); $filters = new OrderFiltersRecord( fromDate: '2024-01-01', toDate: '2024-12-31', minAmount: 100, status: OrderStatus::PAID, search: 'ACME', ); $paginate = new PaginateRecord( perPage: 20, page: 1, sortBy: new SortColumns('status:asc|created_at:desc|total:desc'), filters: $filters, columns: new SelectColumns(['id', 'order_number', 'total', 'status', 'created_at']), clusterQueries: $clusterQueries, ); $orders = $repository->paginate($paginate);
Support Soft Delete
Le repository détecte automatiquement si votre modèle utilise le trait SoftDeletes et adapte son comportement :
| Méthode | Comportement standard | Avec SoftDeletes |
|---|---|---|
delete() |
Suppression définitive | Soft delete (deleted_at rempli) |
find() |
Retourne tous les modèles | Exclut les soft deleted |
findWithTrashed() |
Comportement standard | Inclut les soft deleted |
restore() |
Non disponible | Restaure un soft deleted |
forceDelete() |
Non disponible | Suppression définitive |
deleteBulk() |
Suppression définitive groupée | Soft delete groupé |
forceDeleteBulk() |
Non disponible | Suppression définitive groupée |
// Modèle avec SoftDelete final class Product extends Model { use SoftDeletes; protected $fillable = ['name', 'price', 'quantity', 'metadata', 'preferences']; protected $casts = [ 'metadata' => 'array', 'preferences' => 'array', ]; } // Utilisation $product = $repository->create(new ProductRecord( name: 'Laptop', price: 999.99, metadata: ClusterVO::fromArray(['category' => 'electronics']), )); // Soft delete $repository->delete($product->id); // Le find normal ne le trouve pas $found = $repository->find($product->id); // null // findWithTrashed le trouve $deleted = $repository->findWithTrashed($product->id); // Product instance // Restauration $repository->restore($product->id); // Suppression définitive (hard delete) $repository->forceDelete($product->id); // Nettoyage de masse avec filtres cluster $deletedCount = $repository ->whereCluster('metadata', 'category=discontinued') ->forceDeleteBulk(new ProductFiltersRecord(includeDeleted: true));
Licence
MIT © Andy Defer