PHPackages                             simplesquid/saloonphp-odata - 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. simplesquid/saloonphp-odata

ActiveLibrary[API Development](/categories/api)

simplesquid/saloonphp-odata
===========================

A Saloon plugin providing a fluent, version-aware (v3 + v4) OData query builder and server-driven paginator.

v0.1.1(1mo ago)03.6k↑78%1[1 PRs](https://github.com/simplesquid/saloonphp-odata/pulls)MITPHPPHP ^8.4CI passing

Since Apr 13Pushed 1mo agoCompare

[ Source](https://github.com/simplesquid/saloonphp-odata)[ Packagist](https://packagist.org/packages/simplesquid/saloonphp-odata)[ Docs](https://github.com/simplesquid/saloonphp-odata)[ RSS](/packages/simplesquid-saloonphp-odata/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (7)Versions (4)Used By (0)

saloonphp-odata
===============

[](#saloonphp-odata)

[![Latest Version on Packagist](https://camo.githubusercontent.com/d3df56e647ab6eb83063d3ba3c34e29c5cf63d59c9e4683c2b20ffb3db6b4d8c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73696d706c6573717569642f73616c6f6f6e7068702d6f646174612e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/simplesquid/saloonphp-odata)[![Tests](https://camo.githubusercontent.com/d42dcd7166ff29f8bca7faec3a0a03fa898bad5f3ddb78a492e987fdee847580/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73696d706c6573717569642f73616c6f6f6e7068702d6f646174612f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/simplesquid/saloonphp-odata/actions/workflows/run-tests.yml)[![PHPStan](https://camo.githubusercontent.com/e2315c352bc295620320742d46df781cd35dee709fb1d0ad2804af4ccc6f0218/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73696d706c6573717569642f73616c6f6f6e7068702d6f646174612f7068707374616e2e796d6c3f6272616e63683d6d61696e266c6162656c3d7068707374616e267374796c653d666c61742d737175617265)](https://github.com/simplesquid/saloonphp-odata/actions/workflows/phpstan.yml)[![Total Downloads](https://camo.githubusercontent.com/2acea2102434227fb3de9e83c00926578156ac25c7597fe2af4baed1a8b4c12e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73696d706c6573717569642f73616c6f6f6e7068702d6f646174612e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/simplesquid/saloonphp-odata)

A [Saloon](https://github.com/saloonphp/saloon) plugin providing a fluent, version-aware OData query builder and a server-driven paginator. Supports OData v3 and v4. Bring your own Connector — Saloon handles HTTP.

```
use Saloon\Enums\Method;
use Saloon\Http\Request;
use SimpleSquid\SaloonOData\Concerns\HasODataQuery;
use SimpleSquid\SaloonOData\Filter\FilterBuilder;

class GetPeople extends Request
{
    use HasODataQuery;
    protected Method $method = Method::GET;
    public function resolveEndpoint(): string { return '/People'; }
}

$req = (new GetPeople)->odataQuery()
    ->select('FirstName', 'LastName')
    ->filter(fn (FilterBuilder $f) => $f
        ->whereEquals('Status', 'Active')
        ->or()
        ->where('Age', 'gt', 30))
    ->orderBy('LastName')
    ->top(10)
    ->count();

$connector->send($req);
// GET /People?$select=FirstName,LastName&$filter=Status eq 'Active' or Age gt 30&$orderby=LastName asc&$top=10&$count=true
```

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

[](#installation)

```
composer require simplesquid/saloonphp-odata
```

For the paginator, also install Saloon's pagination plugin:

```
composer require saloonphp/pagination-plugin
```

Requires PHP 8.4+ and Saloon v4.

Usage
-----

[](#usage)

### As a Request trait

[](#as-a-request-trait)

```
use Saloon\Enums\Method;
use Saloon\Http\Request;
use SimpleSquid\SaloonOData\Concerns\HasODataQuery;
use SimpleSquid\SaloonOData\Filter\FilterBuilder;

class GetPeople extends Request
{
    use HasODataQuery;

    protected Method $method = Method::GET;

    public function resolveEndpoint(): string
    {
        return '/People';
    }
}

$request = new GetPeople;

$request->odataQuery()
    ->select('FirstName', 'LastName', 'Email')
    ->filter(fn (FilterBuilder $f) => $f
        ->whereEquals('Status', 'Active')
        ->and()
        ->where('Age', 'gt', 30))
    ->orderByDesc('CreatedAt')
    ->top(25)
    ->count();

$response = $connector->send($request);
// GET /People?$select=FirstName,LastName,Email&$filter=Status eq 'Active' and Age gt 30&$orderby=CreatedAt desc&$top=25&$count=true
```

The trait exposes `$request->odataQuery()` returning the underlying `ODataQueryBuilder`. The builder's params are merged into the request's query string immediately before send via Saloon middleware — you never call `->toArray()` yourself. If the builder is never touched and no class-level attributes apply, no middleware runs.

### As a standalone builder (e.g. inside `defaultQuery()`)

[](#as-a-standalone-builder-eg-inside-defaultquery)

```
public function defaultQuery(): array
{
    return ODataQueryBuilder::make()
        ->select('Id', 'Name')
        ->top(50)
        ->toArray();
}
```

Or, if you have a Request/PendingRequest in hand, use `applyTo()`:

```
ODataQueryBuilder::make()->select('Id')->applyTo($request);
```

### Declarative configuration with attributes

[](#declarative-configuration-with-attributes)

```
use SimpleSquid\SaloonOData\Attributes\DefaultODataQuery;
use SimpleSquid\SaloonOData\Attributes\ODataEntity;
use SimpleSquid\SaloonOData\Attributes\UsesODataVersion;
use SimpleSquid\SaloonOData\Enums\ODataVersion;

#[UsesODataVersion(ODataVersion::V3)]
#[ODataEntity('SalesInvoices')]
#[DefaultODataQuery(
    select: ['ID', 'InvoiceDate', 'AmountDC'],
    top: 50,
    count: true,
    filterRaw: "Division eq 12345",
)]
class GetSalesInvoices extends Request
{
    use HasODataQuery;
    protected Method $method = Method::GET;
    public function resolveEndpoint(): string
    {
        return '/'.$this->odataEntity();
    }
}
```

Defaults are applied on first access to `odataQuery()`. Runtime calls layer over them; use `clearSelect()` / `replaceSelect()` / `clearFilter()` / `replaceFilter()` / `clearOrderBy()` / `replaceOrderBy()` / `clearExpand()` / `replaceExpand()` when you need to override rather than append.

The version is resolved in this order:

1. Explicit `ODataQueryBuilder::make($version)` call.
2. `#[UsesODataVersion]` on the Request class (or any parent).
3. `#[UsesODataVersion]` on the Connector class (applied at request boot).
4. Default: v4.

> Filters and nested `$expand` are rendered lazily, so a connector-level version still applies cleanly even after the user has chained `->filter(...)` on the builder. The exception is `filterRaw()` — those strings are version-baked by the caller.

Builder reference
-----------------

[](#builder-reference)

### Selection

[](#selection)

```
$q->select('FirstName', 'LastName', 'Email');
$q->replaceSelect('FirstName');   // discard previous, set anew
$q->clearSelect();                 // discard all
```

### Filtering

[](#filtering)

```
use SimpleSquid\SaloonOData\Filter\FilterBuilder;

$q->filter(fn (FilterBuilder $f) => $f
    ->whereEquals('Status', 'Active')      // shorthand for eq
    ->whereNotEquals('Type', 'Draft')      // shorthand for ne
    ->where('Age', 'gt', 30)               // operators: eq, ne, gt, ge, lt, le, has*, in*
    ->or()                                  // switch the trailing join (default is `and`)
    ->not()                                 // negate the next clause
    ->group(fn (FilterBuilder $g) => ...)   // wrap in parentheses
    ->in('Status', ['A', 'B'])              // v4 only
    ->has('Roles', 'Admin')                 // v4 only
    ->contains('Name', 'foo')               // becomes substringof('foo', Name) on v3
    ->startsWith('Name', 'A')
    ->endsWith('Name', 'Z')
    ->raw('year(Created) eq 2025')          // pre-encoded escape hatch (UNSAFE for user input)
);

$q->filterRaw("Status eq 'Active'");        // bypass the closure entirely (UNSAFE for user input)
$q->clearFilter();                          // wipe all filter fragments
```

Operators accept a `ComparisonOperator` enum or a string; strings are validated. Property names are validated to prevent filter injection.

### Date-only and GUID literals

[](#date-only-and-guid-literals)

```
use SimpleSquid\SaloonOData\Support\DateOnly;
use SimpleSquid\SaloonOData\Support\Literal;

// Some endpoints prefer date-only over full datetime:
$q->filter(fn ($f) => $f->where('Date', 'gt', Literal::dateOnly($dt)));
$q->filter(fn ($f) => $f->where('Date', 'gt', DateOnly::from($dt)));   // equivalent

// GUIDs render unquoted in v4 and as guid'...' in v3 — wrap explicitly:
$q->filter(fn ($f) => $f->whereEquals('Id', Literal::guid('11111111-2222-3333-4444-555555555555')));
```

### Expansion

[](#expansion)

```
$q->expand('Trips');                       // flat (works on v3 and v4)
$q->expand('Trips/Stops');                 // path style (v3 + v4)

$q->expand('Trips', fn (ExpandBuilder $e) => $e
    ->select('Name', 'Budget')
    ->filter(fn (FilterBuilder $f) => $f->where('Status', 'eq', 'Completed'))
    ->orderBy('Name')
    ->orderByDesc('Budget')
    ->top(5)
);                                         // v4 nested options; throws on v3
$q->clearExpand();
```

### Ordering &amp; paging

[](#ordering--paging)

```
$q->orderBy('LastName');                   // asc by default
$q->orderBy('LastName', SortDirection::Desc);
$q->orderByDesc('CreatedAt');
$q->clearOrderBy();

$q->top(50)->skip(100);
$q->skipToken('cursor-from-server');
$q->count();                               // $count=true (v4) or $inlinecount=allpages (v3)
```

### Other system options

[](#other-system-options)

```
$q->search('foo bar');                     // v4 only — throws at render time on v3
$q->format('json');
$q->param('apikey', 'secret');             // arbitrary non-system param ($-prefixed keys rejected)
```

### Output

[](#output)

```
$q->toArray();                             // ['$select' => '...', ...]
$q->toQueryString();                       // RFC 3986 encoded query string
(string) $q;                               // alias for toQueryString()
$q->applyTo($requestOrPendingRequest);
$q->clone();                               // independent fork (no shared state)
$q->fresh();                               // empty builder, same version
```

Pagination
----------

[](#pagination)

```
use SimpleSquid\SaloonOData\Pagination\ODataPaginator;
use Saloon\PaginationPlugin\Contracts\Paginatable;

class GetPeople extends Request implements Paginatable { /* ... */ }

// Version is resolved from #[UsesODataVersion] attributes; pass explicitly only to override.
$paginator = new ODataPaginator($connector, new GetPeople);

foreach ($paginator->items() as $item) {
    // single record from any page
}
```

Reads spec-defined envelope keys only:

VersionNext-link keyItems keyv4`@odata.nextLink``value`v3 JSON-Light`__next``value`v3 JSON-Verbose`d.__next``d.results`The paginator extracts only the `$skiptoken` from the next-link URL and applies it to the original request — it does not follow the full server-supplied URL.

Requests that need custom item extraction can implement Saloon's `MapPaginatedResponseItems` contract.

Literal encoding
----------------

[](#literal-encoding)

All `$filter` literal encoding goes through `Support\Literal::encode($value, $version)`. Supported types:

PHP typev4 outputv3 output`null``null``null``bool``true`/`false``true`/`false``int` / `float``42` / `3.14``42` / `3.14``string``'value'` (single-quote escape: `''`)`'value'``Guid` (via `Literal::guid()`)bare `xxxxxxxx-...``guid'xxxxxxxx-...'``DateTimeInterface``2025-01-15T10:30:00Z``datetime'2025-01-15T10:30:00'``DateOnly` (via `Literal::dateOnly()` or `DateOnly::from()`)`2025-01-15``datetime'2025-01-15'``BackedEnum`encoded `value`encoded `value``UnitEnum`encoded case `name`encoded case `name``array`tuple `(a,b,c)`tupleGUID detection is **opt-in** via `Literal::guid()` to prevent user-supplied strings that happen to look like GUIDs from silently changing semantics.

Security
--------

[](#security)

- Property names passed to `select()`, `where()`, `orderBy()`, `expand()`, etc. are validated against an OData identifier pattern. Anything containing spaces, quotes, parens, or other syntax characters throws `InvalidODataQueryException`.
- Literal values are version-correctly quote-escaped through `Support\Literal`.
- The paginator only extracts `$skiptoken` from server-supplied next-link URLs; it does not follow arbitrary URLs.
- `filterRaw()` and `FilterBuilder::raw()` are documented escape hatches. **Never pass untrusted input to either.**

Testing
-------

[](#testing)

```
composer test
composer analyse
composer format
```

License
-------

[](#license)

The MIT License (MIT). See [LICENSE.md](LICENSE.md).

###  Health Score

44

—

FairBetter than 90% of packages

Maintenance89

Actively maintained with recent releases

Popularity25

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity43

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 90.9% 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

Every ~0 days

Total

2

Last Release

56d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e90efcfce673cabc08f0101ee076b8060508c33e7693c048d58e44e4a53b2300?d=identicon)[simplesquid](/maintainers/simplesquid)

---

Top Contributors

[![mdpoulter](https://avatars.githubusercontent.com/u/1091085?v=4)](https://github.com/mdpoulter "mdpoulter (10 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (1 commits)")

---

Tags

exact-onlineodataodata-v3odata-v4phpquery-buildersaloonsaloonphpsaloonsaloonphpquery builderodatasimplesquidExact Onlineodata-v3odata-v4

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/simplesquid-saloonphp-odata/health.svg)

```
[![Health](https://phpackages.com/badges/simplesquid-saloonphp-odata/health.svg)](https://phpackages.com/packages/simplesquid-saloonphp-odata)
```

###  Alternatives

[saloonphp/laravel-plugin

The official Laravel plugin for Saloon

806.6M184](/packages/saloonphp-laravel-plugin)[saloonphp/barstool

A Laravel package for logging Saloon Requests &amp; Response.

1725.8k1](/packages/saloonphp-barstool)[marceloeatworld/falai-php

\#1 PHP client for the fal.ai serverless AI platform, compatible with Laravel and native PHP, built on Saloon v4

105.9k](/packages/marceloeatworld-falai-php)

PHPackages © 2026

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