vidic/orm-search-param

PHP 8 attribute-based search parameter parser for Doctrine ORM QueryBuilder

Maintainers

Package info

github.com/vidic-igor/orm-search-param

pkg:composer/vidic/orm-search-param

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.1.0 2026-04-27 08:48 UTC

This package is auto-updated.

Last update: 2026-04-27 10:18:55 UTC


README

PHP Version License Tests

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