happyr/doctrine-specification
Reusable Doctrine query Specifications for PHP. Replace messy repositories and huge QueryBuilder methods with small, composable, testable spec classes. Reduce duplication, avoid methods with many arguments, and extend queries cleanly as your app grows.
## Getting Started
### Minimal Setup
1. **Installation**:
```bash
composer require happyr/doctrine-specification
Ensure your Laravel app uses Doctrine ORM (e.g., via doctrine/orm or a bridge like laravel-doctrine).
First Use Case:
Replace a bloated repository method with a reusable Specification class.
Example: Filter active users with a Published spec:
// app/Specifications/User/Published.php
namespace App\Specifications\User;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
use Happyr\DoctrineSpecification\Specification\Spec;
class Published extends BaseSpecification
{
public function getSpec()
{
return Spec::eq('isPublished', true);
}
}
Apply in Repository:
// app/Repositories/UserRepository.php
use App\Specifications\User\Published;
use Doctrine\ORM\EntityRepository;
class UserRepository extends EntityRepository
{
public function findPublishedUsers()
{
return $this->match(new Published());
}
}
Usage in Controller:
use App\Repositories\UserRepository;
class UserController extends Controller
{
public function index(UserRepository $repository)
{
$users = $repository->findPublishedUsers();
return view('users.index', compact('users'));
}
}
Composing Specifications:
Combine specs using Spec::andX(), Spec::orX(), or Spec::not() for complex queries.
$spec = Spec::andX(
new Published(),
Spec::gt('createdAt', new \DateTime('-7 days'))
);
Context-Dependent Specs: Pass dynamic data via constructor (e.g., role-based access):
class HasRole extends BaseSpecification
{
private $role;
public function __construct(string $role)
{
$this->role = $role;
}
public function getSpec()
{
return Spec::contains('roles', $this->role);
}
}
Repository Integration:
Extend EntityRepository to add match() method (if not auto-injected):
use Happyr\DoctrineSpecification\Repository\SpecificationRepository;
class UserRepository extends SpecificationRepository
{
// Inherits match() method
}
Collection Filtering:
Use filterCollection() for in-memory arrays (e.g., API responses):
$activeUsers = (new Published())->filterCollection($allUsers);
Service Container Binding: Bind repositories to Laravel’s container for dependency injection:
$this->app->bind(UserRepository::class, function ($app) {
return $app['doctrine.orm.entity_manager']
->getRepository(User::class);
});
Query Scoping: Use specs to scope queries in Eloquent-like services:
class UserService
{
public function getActiveUsers(UserRepository $repo)
{
return $repo->match(Spec::andX(
new Published(),
new HasRole('admin')
));
}
}
API Resource Filtering: Dynamically build specs from request parameters:
$spec = Spec::andX(
new Published(),
...array_map(fn($field) => Spec::eq($field, $request->$field), ['status', 'region'])
);
Context Resolution:
Spec::join() with explicit aliases or extend DQLContextResolver:
Spec::join('user.roles', 'r', 'WITH', 'r.active = true');
Performance:
orX with many conditions) can bloat queries.->getQuery()->getSQL().Doctrine Version Mismatch:
composer.json or use v1.x.Caching:
QueryBuilder hints:
$qb->setMaxResults(100)->setCacheable(true);
Query Dumping:
Enable Doctrine’s SQL logging in config/doctrine.php:
'logging' => true,
'logging_format' => '%%sql%%',
Spec Validation:
Test specs in isolation with isSatisfiedBy():
$spec = new Published();
$this->assertTrue($spec->isSatisfiedBy($publishedUser));
Custom Operators:
Extend Happyr\DoctrineSpecification\Specification\Operands for domain-specific logic:
class CustomOperands extends Operands
{
public static function isActive(): Comparison
{
return new Comparison('isActive', '=', true);
}
}
Repository Decorators: Wrap repositories to add cross-cutting concerns (e.g., logging):
class LoggingRepositoryDecorator implements SpecificationRepository
{
public function match(BaseSpecification $spec)
{
\Log::debug('Executing spec: ' . get_class($spec));
return $this->decorated->match($spec);
}
}
Context Providers:
Inject dynamic context (e.g., tenant ID) via BaseSpecification constructor:
class TenantScoped extends BaseSpecification
{
public function __construct(private string $tenantId)
{
}
public function getSpec()
{
return Spec::eq('tenantId', $this->tenantId);
}
}
where() clauses unless using a bridge like doctrine/orm-elq.UserPublished, PostDraft) for clarity.$mockSpec = $this->createMock(BaseSpecification::class);
$mockSpec->method('getSpec')->willReturn(Spec::eq('active', true));
$this->assertEquals([$activeUser], $repo->match($mockSpec));
```markdown
## Gotchas and Tips (Continued)
### Advanced Patterns
1. **Specification Factories**:
Centralize spec creation for consistency:
```php
class UserSpecFactory
{
public static function published(): Published
{
return new Published();
}
public static function withRole(string $role): HasRole
{
return new HasRole($role);
}
}
Composite Specs: Build reusable composite specs (e.g., "Admin or Editor"):
class IsEditorOrAdmin extends BaseSpecification
{
public function getSpec()
{
return Spec::orX(
new HasRole('admin'),
new HasRole('editor')
);
}
}
Spec Caching: Cache compiled specs for performance-critical paths:
class CachedSpecification
{
private static $cache = [];
public static function get(BaseSpecification $spec, string $key)
{
return self::$cache[$key] ?? self::$cache[$key] = $spec;
}
}
API Resource Filtering: Use specs to filter API responses dynamically:
class UserResource extends JsonResource
{
public function toArray($request)
{
$spec = $this->buildSpecFromRequest($request);
return parent::toArray($request)->filter($spec->filterCollection(...));
}
}
Policy Integration:
Replace authorize() logic with specs:
class PostPolicy
{
public function view(User $user, Post $post)
{
$spec = Spec::andX(
new OwnsPost($user),
new IsPublished()
);
return $spec->isSatisfiedBy($post);
}
}
Event Listeners: Trigger specs on entity events (e.g., soft-deletes):
How can I help you explore Laravel packages today?