andydefer/laravel-repository
Laravel Repository
Une implémentation légère et typée du pattern Repository pour Laravel avec intégration Records, Eloquent et Enum Casting.
Table des matières
- Installation
- Concepts fondamentaux
- Créer votre premier Repository
- Enum Cast - Cast automatique des enums
- Référence de l'API
- Méthodes à surcharger
- Proxies - AttributeProxy et TransformableProxy
- 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'
Enum Cast - Cast automatique des enums
Le package fournit un cast Eloquent générique EnumCast qui permet de mapper automatiquement des colonnes de base de données vers des enums personnalisés.
Configuration
// config/repository.php
return [
/*
|--------------------------------------------------------------------------
| Enum Casts
|--------------------------------------------------------------------------
|
| Define enum casts for specific tables and columns.
| Each entry maps a table name and column to an enum class.
|
| The enum class must implement EnumerableInterface.
|
| Example:
| 'enum_casts' => [
| 'likes' => [
| 'type' => AndyDefer\LaravelLikes\Enums\LikeType::class,
| ],
| 'signalements' => [
| 'type' => App\Enums\SignalementType::class,
| 'status' => App\Enums\SignalementStatus::class,
| ],
| ],
|
*/
'enum_casts' => [],
];
Interface EnumerableInterface
Tous les enums utilisés avec EnumCast doivent implémenter EnumerableInterface :
use AndyDefer\Repository\Contracts\EnumerableInterface;
enum ProductStatus: string implements EnumerableInterface
{
case DRAFT = 'draft';
case PUBLISHED = 'published';
case ARCHIVED = 'archived';
case OUT_OF_STOCK = 'out_of_stock';
public function getValue(): string
{
return $this->value;
}
public function getLabel(): string
{
return match ($this) {
self::DRAFT => 'Brouillon',
self::PUBLISHED => 'Publié',
self::ARCHIVED => 'Archivé',
self::OUT_OF_STOCK => 'Rupture de stock',
};
}
}
Utilisation dans le modèle
use AndyDefer\Repository\Casts\EnumCast;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $casts = [
'status' => EnumCast::class,
];
}
Exemple complet
// 1. Configurer
// config/repository.php
'enum_casts' => [
'products' => [
'status' => ProductStatus::class,
],
],
// 2. Créer l'enum
enum ProductStatus: string implements EnumerableInterface
{
case DRAFT = 'draft';
case PUBLISHED = 'published';
case ARCHIVED = 'archived';
public function getValue(): string
{
return $this->value;
}
public function getLabel(): string
{
return match ($this) {
self::DRAFT => 'Brouillon',
self::PUBLISHED => 'Publié',
self::ARCHIVED => 'Archivé',
};
}
}
// 3. Modèle
class Product extends Model
{
protected $casts = [
'status' => EnumCast::class,
];
}
// 4. Utilisation
$product = Product::create([
'name' => 'Laptop',
'status' => 'published',
]);
echo $product->status->getLabel(); // 'Publié'
$product->status = ProductStatus::ARCHIVED;
$product->save();
echo $product->status->getValue(); // 'archived'
Fonctionnement du cast
| Opération | Comportement |
|---|---|
| Lecture (get) | Convertit la valeur string/int de la base de données en instance de l'enum configuré via tryFrom() |
| Écriture (set) | Accepte une instance de EnumerableInterface ou une string/int et la convertit en valeur de base de données |
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 |
InvalidArgumentException |
Valeur enum non valide dans EnumCast |
Proxies - AttributeProxy et TransformableProxy
Qu'est-ce que c'est ?
Les Proxies sont des classes qui permettent de caster automatiquement les attributs Eloquent vers des Value Objects, Records ou Collections typées de manière transparente.
Pourquoi les utiliser ?
| Sans Proxies | Avec Proxies |
|---|---|
Cast manuel dans $casts |
Cast automatique via AttributeProxy |
| Logique de transformation répétée | Logique centralisée |
| Code boilerplate | Code propre et lisible |
| Pas de typage fort | Typage fort (VO, Record, Collection) |
TransformableProxy
TransformableProxy est le cœur du système. Il hydrate un objet Transformable depuis n'importe quelle source (string, array, JSON).
use AndyDefer\Repository\Proxies\TransformableProxy;
// Depuis une string
$slug = TransformableProxy::make(SlugVO::class, 'my-article');
// Depuis un tableau
$user = TransformableProxy::make(UserRecord::class, [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
// Depuis du JSON
$coordinates = TransformableProxy::make(
CoordinatesVO::class,
'{"latitude":48.8566,"longitude":2.3522}'
);
Signature :
public static function make(
string $class, // class-string<T> - La classe cible (doit implémenter Transformable)
mixed $value, // La source de données (string, array, JSON, etc.)
bool $nullable = false // Si true, retourne null quand $value est null
): mixed
AttributeProxy
AttributeProxy est un helper pour créer des attributs Eloquent typés. Il utilise TransformableProxy en interne.
use AndyDefer\Repository\Proxies\AttributeProxy;
use Illuminate\Database\Eloquent\Casts\Attribute;
// Attribut nullable (retourne null si la colonne est null)
protected function slug(): Attribute
{
return AttributeProxy::nullable(SlugVO::class, column: 'slug');
}
// Attribut required (lance une exception si la colonne est null)
protected function coordinates(): Attribute
{
return AttributeProxy::required(CoordinatesVO::class, column: 'coordinates');
}
Méthodes disponibles :
| Méthode | Description | Utilisation |
|---|---|---|
required(string $class, ?string $column = null) |
Attribut requis | AttributeProxy::required(SlugVO::class, column: 'slug') |
nullable(string $class, ?string $column = null) |
Attribut nullable | AttributeProxy::nullable(SlugVO::class, column: 'slug') |
make(string $class, bool $nullable = false, ?string $column = null) |
Déprécié | Utiliser required() ou nullable() à la place |
Exemple complet d'utilisation
<?php
declare(strict_types=1);
namespace App\Models;
use AndyDefer\PhpVo\ValueObjects\SlugVO;
use AndyDefer\PhpVo\ValueObjects\CoordinatesVO;
use AndyDefer\Repository\Proxies\AttributeProxy;
use AndyDefer\Repository\Tests\Fixtures\Records\TestUserRecord;
use AndyDefer\Repository\Tests\Fixtures\Collections\TestLanguageCollection;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
final class Article extends Model
{
protected $table = 'articles';
protected $fillable = [
'title',
'slug',
'content',
'coordinates',
'metadata',
'languages',
];
protected $casts = [
'metadata' => 'array',
'languages' => 'array',
];
/**
* Cast le slug en SlugVO.
*/
protected function slug(): Attribute
{
return AttributeProxy::nullable(SlugVO::class, column: 'slug');
}
/**
* Cast les coordonnées en CoordinatesVO.
*/
protected function coordinates(): Attribute
{
return AttributeProxy::required(CoordinatesVO::class, column: 'coordinates');
}
/**
* Cast les métadonnées en UserRecord.
*/
protected function userRecord(): Attribute
{
return AttributeProxy::nullable(TestUserRecord::class, column: 'metadata');
}
/**
* Cast les langues en LanguageCollection.
*/
protected function languages(): Attribute
{
return AttributeProxy::nullable(TestLanguageCollection::class, column: 'languages');
}
}
// Utilisation
$article = Article::create([
'title' => 'Mon article',
'slug' => 'mon-article',
'coordinates' => ['latitude' => 48.8566, 'longitude' => 2.3522],
'languages' => ['fr', 'en'],
]);
// Les attributs sont automatiquement hydratés
echo $article->slug->getValue(); // 'mon-article'
echo $article->coordinates->getLatitude(); // 48.8566
echo $article->languages->count(); // 2
// Mise à jour avec des objets
$article->slug = new SlugVO('nouveau-slug');
$article->save();
Classes compatibles
AttributeProxy fonctionne avec :
| Type | Exemple | Condition |
|---|---|---|
| Value Object | SlugVO, EmailVO, Money |
Doit implémenter Transformable |
| Record | UserRecord, OrderRecord |
Étend AbstractRecord (qui implémente Transformable) |
| Collection | LanguageCollection |
Étend TypedCollection et implémente Transformable |
| Enum | UserStatus, UserRole |
Enum PHP (n'implémente pas Transformable) |
Bonnes pratiques avec les Proxies
1. Utiliser required() ou nullable() selon le besoin
// ✅ BON - Attribut requis
protected function slug(): Attribute
{
return AttributeProxy::required(SlugVO::class, column: 'slug');
}
// ✅ BON - Attribut nullable
protected function slug(): Attribute
{
return AttributeProxy::nullable(SlugVO::class, column: 'slug');
}
2. Spécifier le nom de colonne quand il est différent
// ✅ BON - Colonne explicite
protected function userRecord(): Attribute
{
return AttributeProxy::nullable(UserRecord::class, column: 'metadata');
}
// ✅ BON - Colonne avec le même nom que la méthode
protected function slug(): Attribute
{
return AttributeProxy::nullable(SlugVO::class, column: 'slug');
}
3. Ne pas mélanger avec $casts
// ✅ BON - Un attribut dans le cast ou via AttributeProxy, pas les deux
protected $casts = [
'metadata' => 'array', // Garder pour les données brutes
'languages' => 'array',
];
protected function userRecord(): Attribute
{
// Utilise le cast 'array' pour récupérer les données brutes
return AttributeProxy::nullable(UserRecord::class, column: 'metadata');
}
// ❌ MAUVAIS - Doublon de cast
protected $casts = [
'metadata' => 'array', // Déjà casté
];
protected function metadata(): Attribute
{
// Un autre cast sur la même colonne → conflit !
return AttributeProxy::nullable(UserRecord::class, column: 'metadata');
}
4. Toujours utiliser AttributeProxy dans les modèles
// ✅ BON - Utiliser AttributeProxy
protected function slug(): Attribute
{
return AttributeProxy::nullable(SlugVO::class, column: 'slug');
}
// ❌ MAUVAIS - Logique manuelle dans l'attribut
protected function slug(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? new SlugVO($value) : null,
set: fn ($value) => $value instanceof SlugVO ? $value->getValue() : $value,
);
}
5. Vérifier les types dans les tests
public function test_slug_attribute_returns_vo(): void
{
$user = TestUser::create(['slug' => 'john-doe']);
$this->assertInstanceOf(SlugVO::class, $user->slug);
$this->assertSame('john-doe', $user->slug->getValue());
}
public function test_nullable_attribute_returns_null(): void
{
$user = TestUser::create(['slug' => null]);
$this->assertNull($user->slug);
}
Erreurs fréquentes avec les Proxies
Erreur 1 : Classe non Transformable
❌ Mauvais
protected function custom(): Attribute
{
return AttributeProxy::nullable(\stdClass::class, column: 'data');
}
// InvalidArgumentException: Class stdClass must implement Transformable interface
✅ Bon
protected function custom(): Attribute
{
return AttributeProxy::nullable(MyRecord::class, column: 'data');
}
Erreur 2 : Conflit avec $casts
❌ Mauvais
protected $casts = [
'metadata' => ClusterCast::class,
];
protected function userRecord(): Attribute
{
return AttributeProxy::nullable(UserRecord::class, column: 'metadata');
}
// Conflit : metadata est déjà casté en ClusterVO
✅ Bon
protected $casts = [
'metadata' => 'array', // ← Garder brut
];
protected function userRecord(): Attribute
{
return AttributeProxy::nullable(UserRecord::class, column: 'metadata');
}
Erreur 3 : Valeur null avec required()
❌ Mauvais
protected function coordinates(): Attribute
{
return AttributeProxy::required(CoordinatesVO::class, column: 'coordinates');
}
$model = TestModel::create(['coordinates' => null]);
// InvalidArgumentException: Value cannot be null
✅ Bon
protected function coordinates(): Attribute
{
return AttributeProxy::nullable(CoordinatesVO::class, column: 'coordinates');
}
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
Related Packages
Powerful PHP database abstraction layer (DBAL) with many features for database s...
Laravel Serializable Closure provides an easy and secure way to serialize closur...
Cli error handling for console/command-line PHP applications.
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| v2.17.34 | ^8.1 | ^12.0| | MIT | |
| v2.17.33 | ^8.1 | ^12.0| | MIT | |
| v2.17.32 | ^8.1 | ^12.0| | MIT | |
| v2.17.31 | ^8.1 | ^12.0| | MIT | |
| v2.17.30 | ^8.1 | ^12.0| | MIT | |
| v2.17.29 | ^8.1 | ^12.0| | MIT | |
| v2.17.28 | ^8.1 | ^12.0| | MIT | |
| v2.17.27 | ^8.1 | ^12.0| | MIT | |
| v2.17.26 | ^8.1 | ^12.0| | MIT | |
| v2.17.25 | ^8.1 | ^12.0| | MIT | |
| v2.17.24 | ^8.1 | ^12.0| | MIT | |
| v2.17.23 | ^8.1 | ^12.0| | MIT | |
| v2.17.22 | ^8.1 | ^12.0| | MIT | |
| v2.17.21 | ^8.1 | ^12.0| | MIT | |
| v2.17.20 | ^8.1 | ^12.0| | MIT | |
| v2.17.19 | ^8.1 | ^12.0| | MIT | |
| v2.17.18 | ^8.1 | ^12.0| | MIT | |
| v2.17.17 | ^8.1 | ^12.0| | MIT | |
| v2.17.16 | ^8.1 | ^12.0| | MIT | |
| v2.17.15 | ^8.1 | ^12.0| | MIT | |
| v2.17.14 | ^8.1 | ^12.0| | MIT | |
| v2.17.13 | ^8.1 | ^12.0| | MIT | |
| v2.17.12 | ^8.1 | ^12.0| | MIT | |
| v2.17.11 | ^8.1 | ^12.0| | MIT | |
| v2.17.10 | ^8.1 | ^12.0| | MIT | |
| v2.17.9 | ^8.1 | ^12.0| | MIT | |
| v2.17.8 | ^8.1 | ^12.0| | MIT | |
| v2.17.7 | ^8.1 | ^12.0| | MIT | |
| v2.17.6 | ^8.1 | ^12.0| | MIT | |
| v2.17.5 | ^8.1 | ^12.0| | MIT |
Showing the latest 30 of 106. See every release on Packagist