Framework Bridges
Bridges allow you to apply derafu/query expressions to existing third-party query builder instances — without replacing them. You keep your existing query builder and add Derafu filters on top.
All bridges accept a parsed ConditionInterface | CompositeConditionInterface and apply it to the query builder. You parse expressions with CompositeExpressionParser and pass the result to the bridge.
Shared Setup: The Expression Parser
All bridges share the same expression parser setup:
use Derafu\Query\Filter\CompositeExpressionParser;
use Derafu\Query\Filter\ExpressionParser;
use Derafu\Query\Filter\FilterParser;
use Derafu\Query\Filter\PathParser;
use Derafu\Query\Operator\OperatorLoader;
use Derafu\Query\Operator\OperatorManager;
$loader = new OperatorLoader();
$manager = new OperatorManager(
$loader->loadFromFile('vendor/derafu/query/resources/operators.yaml')
);
$parser = new CompositeExpressionParser(
new ExpressionParser(new PathParser(), new FilterParser($manager))
);
Doctrine DBAL Bridge
Class: Derafu\Query\Bridge\DoctrineDBALQueryBuilderConditionApplier
Applies conditions to a Doctrine DBAL QueryBuilder. Supports all operators including date, regex, and bitwise operators.
Methods
public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
Usage
use Derafu\Query\Bridge\DoctrineDBALQueryBuilderConditionApplier;
$applier = new DoctrineDBALQueryBuilderConditionApplier();
// Parse the expression.
$condition = $parser->parse('status?=active&&total?>1000');
// Apply to an existing DBAL QueryBuilder.
$applier->apply($dbalQb, $condition);
// The QB now has: WHERE (status = :param_status_... AND total > :param_total_...)
$rows = $dbalQb->executeQuery()->fetchAllAssociative();
FROM and JOIN Inference
When a condition uses multi-segment paths, the bridge:
- Infers
FROMfrom the first segment of the first multi-segment path, if noFROMhas been set yet. - Generates JOINs from intermediate segments, with deduplication (same alias → skip).
// No from() call needed — it's inferred from the path.
$condition = $parser->parse(
'customers[alias:c]__invoices[on:id=customer_id,alias:i]__total?>1000'
);
$applier->apply($dbalQb, $condition);
// Sets FROM customers AS c, adds INNER JOIN invoices AS i ON c.id = i.customer_id
// WHERE i.total > :p
Driver Detection
The bridge reads the database platform from the DBAL connection (via reflection on the internal connection property) and maps it to the driver name used by SqlBuilderWhere:
| Platform | Driver |
|---|---|
PostgreSQLPlatform |
pgsql |
MySQLPlatform |
mysql |
SQLitePlatform |
sqlite |
SQLServerPlatform |
sqlsrv |
OraclePlatform |
oci |
| Other | pgsql (fallback) |
HAVING
$dbalQb->groupBy('status');
$condition = $parser->parse('COUNT(*)?>1');
$applier->applyHaving($dbalQb, $condition);
Doctrine ORM Bridge
Class: Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier
Applies conditions to a Doctrine ORM QueryBuilder as DQL. This bridge generates DQL-compatible fragments — it does not produce raw SQL.
Methods
public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
Usage
use Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier;
$applier = new DoctrineORMQueryBuilderConditionApplier();
$condition = $parser->parse('status?=active&&total?>1000');
$ormQb = $em->createQueryBuilder()
->select('i')
->from(Invoice::class, 'i');
$applier->apply($ormQb, $condition);
$invoices = $ormQb->getQuery()->getResult();
Single-Segment Path Qualification
Single-segment paths like status are automatically qualified with the root entity alias. The root alias is read from the first FROM clause:
status?=active → i.status = :param_status_... (when root alias is 'i')
Multi-segment paths are left as-is, allowing explicit alias control.
JOIN Generation
The ORM bridge adds JOINs to the QueryBuilder using Doctrine’s association names — the on: option in path segments is ignored. Doctrine derives join conditions from entity mappings.
invoices[alias:i]__payments[alias:p]__status
If Invoice has a payments association, the bridge calls:
$ormQb->innerJoin('i.payments', 'p');
EXISTS Paths in DQL
Subquery paths (___) are translated to DQL:
| Expression | DQL |
|---|---|
___payments?is:empty |
SIZE(i.payments) = 0 |
___payments?isnot:empty |
SIZE(i.payments) > 0 |
___payments__status?=pending |
EXISTS(SELECT 1 FROM App\Entity\Invoice source JOIN source.payments payment WHERE source.id = i.id AND payment.status = :p) |
___payments__AVG(amount)?<1000 |
EXISTS(SELECT AVG(payment.amount) FROM App\Entity\Invoice source JOIN source.payments payment WHERE source.id = i.id HAVING AVG(payment.amount) < :p) |
___payments__COUNT(*)?=0 |
SIZE(i.payments) = 0 (optimized) |
These fragments are schematic: aliases are generated internally, and the correlation uses all mapped identifiers of the root entity rather than assuming an id property. Association mappings determine the JOINs, including inverse, unidirectional and nested relationships.
SIZE() is used for a single collection association and eligible existence/count comparisons. Single-valued or nested associations use correlated EXISTS / NOT EXISTS. Other aggregate comparisons use EXISTS with HAVING; COUNT(*) becomes COUNT(associationAlias) in DQL.
Numeric aggregate operands retain their decimal string representation. In SQLite and PostgreSQL, the ORM numeric binding type emits CAST(parameter AS NUMERIC). Numeric comparisons include list and range operands. MIN / MAX preserve the mapped field type, so text and dates are not indiscriminately cast to numbers.
Case-Sensitive LIKE on MySQL
like:, notlike: and their auto-pattern aliases are supported. The bridge registers DERAFU_MYSQL_BINARY_STRING automatically and uses a DQL template such as:
i.name LIKE DERAFU_MYSQL_BINARY_STRING(:pattern)
Doctrine translates the function to CAST(parameter AS BINARY) in MySQL SQL. Consumers do not need to register it or change their expressions.
DQL-Incompatible Operators
The following operator types throw UnsupportedOperatorException when used with this bridge:
datetype:date:,month:,year:,period:— require SQL functions not available in DQL.binarytype:b&,b|,b^, etc. — bitwise SQL not supported in DQL.regexptype:~,~*,similarto:, etc. — database-specific regex not in DQL.ilike:andnotilike:, including their auto-pattern aliases (^*,~~*,$*and negations) — ILIKE is not DQL.
In the API Platform SmartFilter, these exceptions are caught and the filter is silently skipped.
Illuminate (Laravel) Bridge
Class: Derafu\Query\Bridge\IlluminateQueryBuilderConditionApplier
Applies conditions to Illuminate’s Query\Builder or Eloquent’s Builder. Both are accepted. The bridge modifies Eloquent’s original underlying Query\Builder through getQuery(). Eloquent applies its global scopes when executing the query, so scopes and Derafu filters remain in effect together.
Methods
public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
Usage
use Derafu\Query\Bridge\IlluminateQueryBuilderConditionApplier;
$applier = new IlluminateQueryBuilderConditionApplier();
$condition = $parser->parse('status?=active&&total?>1000');
// With a plain Query\Builder.
$qb = DB::table('invoices');
$applier->apply($qb, $condition);
$rows = $qb->get();
// With an Eloquent Builder.
$builder = Invoice::query();
$applier->apply($builder, $condition);
$invoices = $builder->get();
Parameter Bindings
The shared SQL compiler produces named parameters. The Illuminate bridge converts their occurrences to positional ? placeholders and appends the corresponding values to the appropriate where or having binding group. Repeated parameters receive a binding for every occurrence, in SQL order. Existing bindings, including those on FROM subqueries, are preserved.
FROM and JOIN Inference
Same behavior as the DBAL bridge: infers the FROM table from multi-segment paths and adds join(), leftJoin(), or rightJoin() calls as needed. Duplicate joins (same table reference) are skipped.
Driver Detection
The bridge reads the driver name directly from Connection::getDriverName():
| Driver name | SQL generated |
|---|---|
pgsql |
PostgreSQL SQL |
mysql |
MySQL SQL |
sqlite |
SQLite SQL |
HAVING
$condition = $parser->parse('AVG(price)?>500');
$applier->applyHaving($qb, $condition);
// Adds a compiled condition and appends its positional bindings to HAVING.
API Platform Bridge
Class: Derafu\Query\Bridge\ApiPlatform\SmartFilter
Integrates derafu/query as an API Platform filter via the QueryParameter attribute. When a request comes in, the filter builds a Derafu expression from the parameter’s property name and value, parses it, and applies it to the ORM QueryBuilder.
Registration
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\QueryParameter;
use Derafu\Query\Bridge\ApiPlatform\SmartFilter;
#[ApiResource]
#[QueryParameter(key: 'price', property: 'price', filter: SmartFilter::class)]
#[QueryParameter(key: 'status', property: 'status', filter: SmartFilter::class)]
class Product
{
// …
}
URL Filter Syntax
The URL parameter value is the filter part (operator + value); the property name provides the path segment:
GET /api/products?price=>1000&status=in:paid,issued
The SmartFilter builds expressions internally:
priceproperty +>1000value → expressionprice?>1000statusproperty +in:paid,issuedvalue → expressionstatus?in:paid,issued
Generic SmartFilter Property
For a single parameter that accepts a full composite expression, use the special __derafu_smart_filter property name:
#[QueryParameter(key: 'filter', property: '__derafu_smart_filter', filter: SmartFilter::class)]
GET /api/products?filter=status%3F%3Dactive%26%26price%3F%3E1000
The decoded filter value is status?=active&&price?>1000. Encode the entire parameter value when building the URL; otherwise & separates HTTP query parameters instead of remaining part of the filter. For example, use URLSearchParams in JavaScript or http_build_query() in PHP.
Dependency Injection (Symfony)
Register CompositeExpressionParser and DoctrineORMQueryBuilderConditionApplier as services and inject them into SmartFilter:
# services.yaml
services:
Derafu\Query\Bridge\ApiPlatform\SmartFilter:
arguments:
$compositeParser: '@Derafu\Query\Filter\Contract\CompositeExpressionParserInterface'
$applier: '@Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier'
Error Handling
SmartFilter silently catches two exceptions:
UnsupportedOperatorException: DQL-incompatible operators (date:,b&,ilike:, regex) — the filter is skipped.- Any
Throwable: Malformed expressions — the filter is skipped.
These catches surround expression parsing and condition application. An absent parameter, an empty/non-string property or an empty/non-string value is ignored. A non-null $context['parameter'] of the wrong type is rejected before the catches; it must be an ApiPlatform\Metadata\Parameter instance.
Skipped filters do not replace authorization. Keep mandatory access restrictions in server-controlled query conditions or scopes.
Bridge Comparison
| Feature | Doctrine DBAL | Doctrine ORM | Illuminate |
|---|---|---|---|
| SQL operators | Engine-dependent | Compatible subset† | Engine-dependent |
| Date operators | Engine-dependent | ❌ | Engine-dependent |
| Regex operators | Engine-dependent | ❌ | Engine-dependent |
| Bitwise operators | Engine-dependent | ❌ | Engine-dependent |
ilike: / notilike: |
Engine-dependent | ❌ | Engine-dependent |
| FROM inference from paths | ✅ | ❌‡ | ✅ |
| JOIN from path segments | ✅ | ✅ (ORM assoc) | ✅ |
| EXISTS paths | ✅ | ✅ (SIZE/EXISTS DQL) | ✅ |
| Aggregate subqueries | ✅ | ✅ | ✅ |
SQL support depends on the configured template and functions available in the selected database. A bridge does not make every operator portable across all engines.
† Compatible operators only — DQL-incompatible operators throw UnsupportedOperatorException.
‡ ORM bridge requires an explicit FROM / from() call before applying conditions.