vidic / orm-search-param
PHP 8 attribute-based search parameter parser for Doctrine ORM QueryBuilder
Requires
- php: ^8.1
- doctrine/orm: ^3.0
Requires (Dev)
- phpunit/phpunit: ^11.0
- symfony/cache: ^8.0
This package is auto-updated.
Last update: 2026-04-27 10:18:55 UTC
README
PHP 8 attribute-based search parameter parser for Doctrine ORM QueryBuilder. Eliminates repetitive if ($filter->x) { $qb->andWhere(...) } chains from repository search methods.
Inspired by nebkam/odm-search-param, which does the same for MongoDB ODM.
Before & After
Before — 70+ lines of imperative if-chains in every repository:
public function search(UserSearchFilter $filter): array { $qb = $this->createQueryBuilder('u'); if ($filter->name) { $qb->andWhere('u.name = :name')->setParameter('name', $filter->name); } if ($filter->email) { $qb->andWhere('u.email LIKE :email')->setParameter('email', '%' . $filter->email . '%'); } if ($filter->role) { $qb->andWhere('u.role = :role')->setParameter('role', $filter->role->value); } if ($filter->createdFrom) { $qb->andWhere('u.createdAt >= :createdFrom')->setParameter('createdFrom', $filter->createdFrom); } // ... 50 more lines return $qb->getQuery()->getResult(); }
After — declare the filter once, call parse() in the repository:
#[OrmRootAlias('u')] class UserSearchFilter { #[OrmSearchParam(type: OrmSearchParamType::Equals)] public ?string $name = null; #[OrmSearchParam(type: OrmSearchParamType::Like)] public ?string $email = null; #[OrmSearchParam(type: OrmSearchParamType::StringEnum)] public ?UserRole $role = null; #[OrmSearchParam(type: OrmSearchParamType::Range, direction: OrmSearchParamDirection::From, field: 'createdAt')] public ?DateTimeImmutable $createdFrom = null; }
public function search(UserSearchFilter $filter): array { $alias = OrmSearchParamParser::getAlias($filter); $qb = $this->createQueryBuilder($alias); OrmSearchParamParser::parse($filter, $qb); return $qb->getQuery()->getResult(); }
Installation
composer require vidic/orm-search-param
Usage
1. Annotate the filter class
Add #[OrmRootAlias] to declare the QueryBuilder alias, then #[OrmSearchParam] on each property you want to filter by:
use Vidic\OrmSearchParam\OrmRootAlias; use Vidic\OrmSearchParam\OrmSearchParam; use Vidic\OrmSearchParam\OrmSearchParamType; use Vidic\OrmSearchParam\OrmSearchParamDirection; #[OrmRootAlias('u')] class UserSearchFilter { #[OrmSearchParam(type: OrmSearchParamType::Equals)] public ?string $name = null; #[OrmSearchParam(type: OrmSearchParamType::Like)] public ?string $email = null; #[OrmSearchParam(type: OrmSearchParamType::In, field: 'id')] public ?array $ids = null; #[OrmSearchParam(type: OrmSearchParamType::Range, direction: OrmSearchParamDirection::From, field: 'createdAt')] public ?DateTimeImmutable $createdFrom = null; #[OrmSearchParam(type: OrmSearchParamType::Range, direction: OrmSearchParamDirection::To, field: 'createdAt')] public ?DateTimeImmutable $createdTo = null; #[OrmSearchParam(type: OrmSearchParamType::StringEnum)] public ?UserRole $role = null; // backed enum, ->value used automatically // Properties with no #[OrmSearchParam] are silently ignored public ?bool $csv = null; }
2. Parse in the repository
use Vidic\OrmSearchParam\OrmSearchParamParser; public function search(UserSearchFilter $filter): array { $alias = OrmSearchParamParser::getAlias($filter); $qb = $this->createQueryBuilder($alias); OrmSearchParamParser::parse($filter, $qb); return $qb->getQuery()->getResult(); }
parse() only adds andWhere conditions for non-null properties. Null values are skipped entirely.
Parameter Types
| Type | DQL produced | Notes |
|---|---|---|
Equals |
alias.field = :param |
|
Like |
alias.field LIKE :param |
wraps value in %…% |
NotLike |
alias.field NOT LIKE :param |
wraps value in %…% |
In |
alias.field IN(:param) |
value must be an array |
NotIn |
alias.field NOT IN(:param) |
value must be an array |
Range |
alias.field >= :param or alias.field <= :param |
requires direction: |
StringEnum |
alias.field = :param |
uses ->value on a string-backed enum |
IntEnum |
alias.field = :param |
uses ->value on an int-backed enum |
Callable |
custom | calls a static method you provide |
Range direction
#[OrmSearchParam(type: OrmSearchParamType::Range, direction: OrmSearchParamDirection::From, field: 'createdAt')] public ?DateTimeImmutable $createdFrom = null; #[OrmSearchParam(type: OrmSearchParamType::Range, direction: OrmSearchParamDirection::To, field: 'createdAt')] public ?DateTimeImmutable $createdTo = null;
Two properties can target the same field — the parameter key is always the property name (createdFrom, createdTo), so there is no collision.
Custom field name
By default, the field name is the property name. Override with field::
#[OrmSearchParam(type: OrmSearchParamType::Equals, field: 'email')] public ?string $emailExact = null; // generates: alias.email = :emailExact
Callable Type
For complex conditions (joins, OR clauses, subqueries), use Callable with a static method:
#[OrmSearchParam(type: OrmSearchParamType::Callable, callable: [self::class, 'applyExcludeInternal'])] public ?bool $excludeInternal = null; public static function applyExcludeInternal(QueryBuilder $qb, bool $value, string $alias, object $filter): void { if (!$value) { return; } OrmSearchParamParser::ensureJoin($qb, $alias, 'user', 'u'); $qb->andWhere('u.email NOT LIKE :internalEmail') ->setParameter('internalEmail', '%@example.com'); }
The callable signature is always (QueryBuilder $qb, mixed $value, string $alias, object $filter): void.
ensureJoin
When multiple callables need the same join, use ensureJoin to avoid duplicate LEFT JOIN clauses:
OrmSearchParamParser::ensureJoin($qb, $alias, 'invoice', 'invoice');
If the join alias is already present on the QueryBuilder, the call is a no-op.
Parser API
// Read the #[OrmRootAlias] from a filter class OrmSearchParamParser::getAlias(object $filter): string // Apply all non-null #[OrmSearchParam] properties to the QueryBuilder OrmSearchParamParser::parse(object $filter, QueryBuilder $qb): void // Add a LEFT JOIN only if not already present OrmSearchParamParser::ensureJoin(QueryBuilder $qb, string $alias, string $relation, string $joinAlias): void
Running Tests
./docker.sh
Or locally (requires PHP 8.4 and pdo_sqlite):
composer install php vendor/bin/phpunit tests