PHPackages                             enricodelazzari/tempest-query-builder - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. enricodelazzari/tempest-query-builder

ActiveLibrary[API Development](/categories/api)

enricodelazzari/tempest-query-builder
=====================================

Filter, sort and include Tempest models straight from the HTTP request

0.1.0(yesterday)101MITPHPPHP ^8.5CI passing

Since Jul 27Pushed todayCompare

[ Source](https://github.com/enricodelazzari/tempest-query-builder)[ Packagist](https://packagist.org/packages/enricodelazzari/tempest-query-builder)[ Docs](https://github.com/enricodelazzari/tempest-query-builder)[ GitHub Sponsors](https://github.com/enricodelazzari)[ RSS](/packages/enricodelazzari-tempest-query-builder/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (11)Versions (3)Used By (0)

Tempest query builder
=====================

[](#tempest-query-builder)

[![Latest Version on Packagist](https://camo.githubusercontent.com/9cfeaed48d611430a6c45772c4ff0bda0887670e3754158eb12aeca0b6fb625c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f656e7269636f64656c617a7a6172692f74656d706573742d71756572792d6275696c6465722e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/enricodelazzari/tempest-query-builder)[![Tests](https://camo.githubusercontent.com/76076ff650b96300310d3f54ea4ba527f7c4f1281cf068e7a0013d60f9a32dd4/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656e7269636f64656c617a7a6172692f74656d706573742d71756572792d6275696c6465722f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/enricodelazzari/tempest-query-builder/actions/workflows/run-tests.yml)[![Total Downloads](https://camo.githubusercontent.com/33ab46f922dfc14f1ddc67501e54e8081dab4c04c8419b622b3f15ff3e0bc220/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f656e7269636f64656c617a7a6172692f74656d706573742d71756572792d6275696c6465722e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/enricodelazzari/tempest-query-builder)

Filter, sort, eager load and narrow [Tempest](https://tempestphp.com) models straight from the HTTP request, in the spirit of [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder).

Everything a query accepts is declared with attributes, so a query builder is a small, readable class that doubles as the documentation of your endpoint. What comes out is a plain Tempest `SelectQueryBuilder`, so every method you already know stays available.

```
use App\Models\Book;
use EnricoDeLazzari\QueryBuilder\Attributes\AllowedFilter;
use EnricoDeLazzari\QueryBuilder\Attributes\AllowedInclude;
use EnricoDeLazzari\QueryBuilder\Attributes\AllowedSort;
use EnricoDeLazzari\QueryBuilder\Attributes\DefaultSort;
use EnricoDeLazzari\QueryBuilder\Attributes\Model;
use EnricoDeLazzari\QueryBuilder\Filters\PartialFilter;
use EnricoDeLazzari\QueryBuilder\HasQueryBuilder;
use Tempest\Database\Direction;

#[Model(Book::class)]
#[AllowedFilter('title', new PartialFilter)]
#[AllowedFilter('author_id')]
#[AllowedInclude('author')]
#[AllowedSort('title')]
#[AllowedSort('id')]
#[DefaultSort('id', Direction::DESC)]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

Type-hint it in a controller and the request is applied before you get it:

```
use Tempest\Http\Response;
use Tempest\Http\Responses\Json;
use Tempest\Router\Get;

final readonly class BookController
{
    #[Get('/books')]
    public function __invoke(BookQueryBuilder $books): Response
    {
        return new Json(['data' => $books->all()]);
    }
}
```

```
GET /books?filter[title]=tempest&sort=-title&include=author
```

Requirements
------------

[](#requirements)

- PHP 8.5+
- Tempest 3.17+

Installation
------------

[](#installation)

```
composer require enricodelazzari/tempest-query-builder
```

Filtering
---------

[](#filtering)

`?filter[title]=tempest` adds a `WHERE` clause for every filter the query builder allows. Values separated by a comma become a set:

```
#[Model(Book::class)]
#[AllowedFilter('id')]                            // ?filter[id]=1        → books.id = ?
#[AllowedFilter('id')]                            // ?filter[id]=1,2      → books.id IN (?, ?)
#[AllowedFilter('title', new PartialFilter)]      // ?filter[title]=temp  → books.title LIKE '%temp%'
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

These filters ship with the package:

FilterResult`ExactFilter``column = ?`, or `column IN (?, …)` for several values. The default.`PartialFilter``column LIKE '%value%'`. Several values are combined with `OR` inside a group.`BeginsWithFilter``column LIKE 'value%'``EndsWithFilter``column LIKE '%value'``OperatorFilter`Any Tempest `WhereOperator`, e.g. `new OperatorFilter(WhereOperator::GREATER_THAN)``RelationFilter``WHERE EXISTS` against a related model`ScopeFilter`Hands over to one of Tempest's `QueryScope` implementations`CallbackFilter`Hands over to a closure declared in the attribute itself`AllowedFilter` also accepts:

```
#[AllowedFilter(
    name: 'author',          // name exposed in the query string
    filter: new ExactFilter,
    alias: 'author_id',      // column to filter on, when it differs from the name
    delimiter: '|',          // how to split several values, defaults to a comma
    default: '1',            // applied when the request has no such filter
    ignore: ['all'],         // values the filter refuses to act on
    nullable: true,          // let an empty value match `NULL`
)]
```

Empty values are ignored, so `?filter[title]=` leaves the query untouched — unless the filter is `nullable`, in which case an empty value selects the rows whose column is null. That is applied as `IS NULL` whatever the filter strategy is, since a `LIKE` against nothing has no useful meaning.

Values listed in `ignore` are dropped from what the request asked for, so `?filter[title]=all,tempest` with `ignore: ['all']` filters on `tempest` alone. When every value asked for is ignored, the filter is not applied at all.

### Filtering on a relation

[](#filtering-on-a-relation)

`RelationFilter` moves the filter into a subquery on the related model. The column it filters on is the one the attribute resolves, so use `alias` to name it:

```
#[Model(Book::class)]
#[AllowedFilter('author', new RelationFilter('author'), alias: 'name')]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?filter[author]=tolkien
```

### Filtering a relation by its key

[](#filtering-a-relation-by-its-key)

`BelongsToFilter` matches a `belongsTo` relation by the key of the record it points at, reading the foreign key off the relation instead of asking you to name it:

```
#[Model(Book::class)]
#[AllowedFilter('author', new BelongsToFilter)]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?filter[author]=1,2
```

```
SELECT … FROM `books` WHERE `books`.`author_id` IN (?,?)
```

A model that names its foreign key something other than the convention filters correctly all the same, and a relation the model does not define as a `belongsTo` is reported rather than reaching the database as an unknown column.

### Filter groups

[](#filter-groups)

A group fans one query parameter out over several filters, so `?filter[q]=John` can search more than one column at once:

```
#[Model(Book::class)]
#[AllowedFilterGroup('q', [
    new AllowedFilter('title', new PartialFilter),
    new AllowedFilter('subtitle', new PartialFilter),
])]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?filter[q]=John
```

```
WHERE (books.title LIKE ? OR books.subtitle LIKE ?)
```

Members are joined with `OR` by default; pass `Conjunction::AND` for the other one. Groups are joined to each other, and to plain filters, with `AND`:

```
#[AllowedFilter('author_id')]
#[AllowedFilterGroup('q', [...])]
#[AllowedFilterGroup('loc', [...], Conjunction::AND)]
```

```
WHERE books.author_id = ? AND (…) AND (…)
```

A member contributes its column and its filter, and splits the group's value with its own `delimiter` and `ignore` — a member that refuses every value it is given drops out of the group, and a group whose members all drop out leaves the query alone. A member's `default` and `nullable` are not used: they describe whether a parameter was asked for, which is the group's business rather than the member's.

Two things are checked while the attribute is read, rather than when a request eventually reaches it: a group needs at least one member, and every member has to be an `AllowedFilter`. A `ScopeFilter` cannot be a member, because Tempest applies a scope to a whole query rather than to a group of conditions — allow it as a filter of its own instead.

### Filtering through a query scope

[](#filtering-through-a-query-scope)

`ScopeFilter` hands the request over to one of Tempest's query scopes. A scope carries its values on its constructor rather than receiving them when it runs, so that is where the request value goes:

```
use Tempest\Database\Builder\QueryBuilders\QueryScope;
use Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements;
use Tempest\Database\Builder\WhereOperator;

final readonly class PublishedBetween implements QueryScope
{
    public function __construct(
        private string $from,
        private string $to,
    ) {}

    public function apply(SupportsWhereStatements $builder): void
    {
        $builder->whereField('published_at', [$this->from, $this->to], WhereOperator::BETWEEN);
    }
}
```

```
#[Model(Book::class)]
#[AllowedFilter('published_between', new ScopeFilter(PublishedBetween::class))]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?filter[published_between]=2024-01-01,2024-12-31
```

Several values are spread over the constructor, so the scope above receives both dates. The scope decides which column it touches, which means the name the filter is exposed under — and any `alias` on it — has no bearing here.

### Writing your own filter

[](#writing-your-own-filter)

```
use EnricoDeLazzari\QueryBuilder\Filters\Filter;
use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder;

final class PublishedFilter implements Filter
{
    public function apply(SelectQueryBuilder $query, string $column, string|array $value): void
    {
        $query->whereNotNull($column);
    }
}
```

`$value` is a string when the request held a single value and a list when it held several, so a filter decides for itself what several values mean.

For something that does not earn a class, PHP 8.5 lets a closure live in an attribute, so `CallbackFilter`, `CallbackSort` and `CallbackInclude` take one directly:

```
use EnricoDeLazzari\QueryBuilder\Filters\CallbackFilter;
use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder;
use Tempest\Database\Builder\QueryBuilders\WhereGroupBuilder;
use Tempest\Database\Builder\WhereOperator;

#[Model(Book::class)]
#[AllowedFilter('pages', new CallbackFilter(
    static function (SelectQueryBuilder|WhereGroupBuilder $query, string $column, string|array $value): void {
        $query->whereField($column, $value, WhereOperator::GREATER_THAN);
    },
))]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

A closure in an attribute has to be **static and capture nothing** — the constant expression it lives in cannot hold a captured scope. So:

`static function (…) { … }`works`Callbacks::pages(...)` — a first class callableworks`fn (…) => …`**rejected**: an arrow function captures its scope implicitly`function (…) { … }` without `static`**rejected**`static function (…) use ($x) { … }`**rejected**The first two are what you want; the arrow function is the one that catches people out, since it is otherwise the natural thing to reach for.

For `LIKE` filters, extend `LikeFilter` instead and only describe the pattern — combining several values with `OR` is already handled:

```
use EnricoDeLazzari\QueryBuilder\Filters\LikeFilter;

final class ContainsWordFilter extends LikeFilter
{
    protected function pattern(string $value): string
    {
        return "% {$value} %";
    }
}
```

Sorting
-------

[](#sorting)

`?sort=title` orders ascending, `?sort=-title` descending. Several sorts are applied in the order they were requested:

```
#[Model(Book::class)]
#[AllowedSort('title')]
#[AllowedSort('published', alias: 'published_at')]
#[DefaultSort('id', Direction::DESC)]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?sort=-title,published
```

`DefaultSort` is repeatable and only applies when the request does not sort. Columns are qualified with the model's table, so ordering keeps working next to an include that joins a table sharing a column name.

Custom sorts implement `EnricoDeLazzari\QueryBuilder\Sorts\Sort`.

Including
---------

[](#including)

`?include=author` eager loads a relation on the model. Nested relations use the dot notation Tempest already understands:

```
#[Model(Book::class)]
#[AllowedInclude('author')]
#[AllowedInclude('author.publisher')]
#[AllowedInclude('writer', alias: 'author')]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?include=author,author.publisher
```

Custom includes implement `EnricoDeLazzari\QueryBuilder\Includes\Inclusion`.

### Counting and aggregating a relation

[](#counting-and-aggregating-a-relation)

Instead of loading a relation, an include can select an aggregate over it:

```
use EnricoDeLazzari\QueryBuilder\Includes\AggregateInclude;
use EnricoDeLazzari\QueryBuilder\Includes\CountInclude;
use EnricoDeLazzari\QueryBuilder\Includes\ExistsInclude;
use Tempest\Database\AggregateFunction;

#[Model(Author::class)]
#[AllowedInclude('booksCount', new CountInclude, alias: 'books')]
#[AllowedInclude('booksExists', new ExistsInclude, alias: 'books')]
#[AllowedInclude('pagesSum', new AggregateInclude(AggregateFunction::SUM, 'pages'), alias: 'books')]
final class AuthorQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /authors?include=booksCount,pagesSum
```

An aggregate is selected as a column, so the model needs a property to receive it, named exactly as the include is exposed. Mark it `#[Virtual]` so Tempest does not also look for a column of that name on the table:

```
use Tempest\Database\Virtual;

final class Author
{
    use IsDatabaseModel;

    public string $name;

    #[Virtual]
    public int $booksCount = 0;

    #[Virtual]
    public ?int $pagesSum = null;
}
```

`CountInclude` and `ExistsInclude` answer `0` for a relation with no records — `ExistsInclude` answers `1` or `0` rather than a boolean, so that every database agrees. `AggregateInclude` answers `null`, which is what SQL gives for an aggregate over no rows, so type that property nullable.

A relation that points a table at itself is not supported here: the aggregate joins the queried table to the related one, and Tempest does not alias the two apart.

Sparse fieldsets
----------------

[](#sparse-fieldsets)

`?fields[books]=title` narrows the columns the query selects, keyed by the model's table name:

```
#[Model(Book::class)]
#[AllowedField('id')]
#[AllowedField('title')]
final class BookQueryBuilder
{
    use HasQueryBuilder;
}
```

```
GET /books?fields[books]=title,id
```

```
SELECT books.title AS `books.title`, books.id AS `books.id` FROM `books`
```

Columns are selected in the order the request asked for them. A field is named after the model's column and cannot be exposed under a different name, because the column is what the result is keyed by when the model is hydrated.

Two things worth knowing:

- **Only the model's own table can be narrowed.** A fieldset for another table, including one loaded through `?include=`, is ignored: Tempest builds the columns of an eager loaded relation itself, and the package has no say in it. So `?fields[books]=title&include=author` returns the author in full.
- **The primary key is not selected unless you ask for it.** That is what a sparse fieldset means, but it has a consequence: reading `$book->id` on a model whose key was not selected throws Tempest's `RelationWasMissing`. Serializing the model is fine — the key is simply absent from the output.

Rejecting unknown parameters
----------------------------

[](#rejecting-unknown-parameters)

By default, asking for a filter, sort, include or field that was not allowed throws — `InvalidFilterQuery`, `InvalidSortQuery`, `InvalidIncludeQuery` or `InvalidFieldQuery`. They convert to a `400 Bad Request` JSON response listing what was requested and what is allowed, so clients get a usable error instead of silently different results.

Turn this off to ignore unknown parameters instead:

```
// query-builder.config.php
use EnricoDeLazzari\QueryBuilder\QueryBuilderConfig;

return new QueryBuilderConfig(
    strict: false,
);
```

Configuration
-------------

[](#configuration)

Publish a `query-builder.config.php` anywhere Tempest discovers configuration to rename the query parameters:

```
use EnricoDeLazzari\QueryBuilder\QueryBuilderConfig;

return new QueryBuilderConfig(
    filterParameter: 'filter',
    sortParameter: 'sort',
    includeParameter: 'include',
    fieldsParameter: 'fields',
    delimiter: ',',
    splitFilterValues: true,
    strict: true,
);
```

`delimiter` splits a parameter into several values. Set it to an empty string to keep every value whole, for a query string whose values contain the delimiter themselves. `splitFilterValues: false` does the same for filters alone, leaving sorts, includes and fields splitting as usual — and an individual filter can still override both with its own `delimiter`.

`strict` decides whether a parameter that was not allowed is rejected or ignored. Each kind can override it:

```
return new QueryBuilderConfig(
    strict: true,
    strictSorts: false, // an unknown sort is ignored, everything else still throws
);
```

Working with the query
----------------------

[](#working-with-the-query)

A query builder forwards every unknown method to the underlying Tempest `SelectQueryBuilder`, so you can keep building on top of what the request asked for:

```
$books = $query
    ->whereField('published', true)
    ->limit(10)
    ->all();
```

`$query->query` gives you the `SelectQueryBuilder` itself, and `$query->bindings` the values bound so far.

Testing
-------

[](#testing)

```
composer qa              # everything below, in order
composer fmt             # Pint
composer analyse         # PHPStan, at max level
composer refactor:check  # Rector, reporting without writing
composer refactor        # Rector, applying its changes
composer test            # Pest
composer test:coverage   # Pest, with a coverage report
```

Each of these runs in CI on every pull request.

The suite runs against SQLite by default. `DB_DRIVER` points it at MySQL or PostgreSQL instead, with the connection read from the rest of the `DB_*`variables:

```
DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 \
DB_USERNAME=root DB_PASSWORD= DB_DATABASE=tempest_query_builder \
composer test

DB_DRIVER=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 \
DB_USERNAME=postgres DB_PASSWORD=postgres DB_DATABASE=tempest_query_builder \
composer test
```

CI runs all three, so a change that compiles to different SQL on one of the dialects is caught before it lands.

Changelog
---------

[](#changelog)

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.

Contributing
------------

[](#contributing)

Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details.

Security Vulnerabilities
------------------------

[](#security-vulnerabilities)

Please review [our security policy](../../security/policy) on how to report security vulnerabilities.

Credits
-------

[](#credits)

- [Enrico De Lazzari](https://github.com/enricodelazzari)
- [All Contributors](../../contributors)

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community11

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 52.6% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

1d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/10452445?v=4)[Enrico De Lazzari](/maintainers/enricodelazzari)[@enricodelazzari](https://github.com/enricodelazzari)

---

Top Contributors

[![enricodelazzari](https://avatars.githubusercontent.com/u/10452445?v=4)](https://github.com/enricodelazzari "enricodelazzari (20 commits)")[![claude](https://avatars.githubusercontent.com/u/81847?v=4)](https://github.com/claude "claude (14 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (2 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (2 commits)")

---

Tags

apiquery builderfilteringsortingtempestenricodelazzari

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/enricodelazzari-tempest-query-builder/health.svg)

```
[![Health](https://phpackages.com/badges/enricodelazzari-tempest-query-builder/health.svg)](https://phpackages.com/packages/enricodelazzari-tempest-query-builder)
```

###  Alternatives

[tempest/console

The console component provides a way to build commands within the framework or standalone command-line applications.

7249.5k21](/packages/tempest-console)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
