A Saloon PHP plugin providing a fluent, version-aware OData query-string builder (v3 + v4) and a server-driven paginator. Designed to layer on top of any Saloon Connector / Request. The downstream user is targeting Exact Online (OData v3) but the package is generic.
- PHP
^8.4(uses asymmetric visibility,#[\Override], readonly value objects) - Saloon v4
- Pest 3 (with arch plugin)
- Laravel Pint
- PHPStan level 10
src/
ODataQueryBuilder.php main fluent builder (Stringable)
Concerns/HasODataQuery.php Saloon plugin trait
Filter/FilterBuilder.php closure target for ->filter()
Expand/ExpandBuilder.php closure target for ->expand() (v4 only)
Order/OrderByClause.php readonly value object
Enums/ ODataVersion, ComparisonOperator, SortDirection
Attributes/ UsesODataVersion, ODataEntity, DefaultODataQuery
Support/ Literal (version-aware encoder), DateOnly, Guid, PropertyName, SkipToken, AttributeReader
Pagination/ODataPaginator.php Saloon Paginator: walks @odata.nextLink / __next / d.__next
Exceptions/ InvalidODataQueryException, UnsupportedInVersionException
tests/
ArchTest.php
Unit/... per-feature unit tests
Feature/... trait + attribute + paginator feature tests
Fixtures/ TestConnector, TestRequest, V3Request, V3Connector, AttributedRequest
declare(strict_types=1);at the top of every file.- Final classes by default. Builders are
final; readonly value objects usefinal readonly. - Operators always accept
string|Enumat the public boundary; coerce viaEnum::coerce()and validate. Unknown strings throwInvalidODataQueryException. - All literal encoding goes through
Support\Literal::encode($value, $version). Never inline. Version-awareness lives there. - Version resolution order: explicit
make($v)>#[UsesODataVersion]on Request (or parent) >#[UsesODataVersion]on Connector > default V4. The connector fallback works because filters and nested$expandare rendered lazily attoArray()time, sowithVersion()from the trait at boot still produces correct rendering.filterRaw()content is the only version-baked thing — caller's responsibility. - Validation of v3-incompatible operators (
in,has, nested expand closures,$search) defers to render time. This keeps the version-switch story consistent. Trade-off: errors surface at send time, not at definition time. - Property names go through
Support\PropertyName::assert()everywhere they enter the OData expression. Don't bypass — it's a security boundary. Literal::guid()is opt-in. Never auto-detect GUIDs from raw strings — that's a type-confusion vector.HasODataQuery::bootHasODataQueryis a no-op when$this->odataQuery === nulland no class-level attributes apply. Don't call$this->odataQuery()in the boot path until that check has passed.- Use
public private(set)for fluent state that should be readable but only mutated internally (seeODataQueryBuilder::$version). #[\Override]on every method that overrides or implements an interface method.AttributeReadercaches reflection results in static maps keyed by class name. Tests must callAttributeReader::flush()inbeforeEachif they exercise multiple fixtures with overlapping classes.
- No Laravel framework dependencies. The arch test enforces no
Illuminate\Foundation/Laravelimports. - No vendor-specific extensions in core (no Exact division URL helpers, no Microsoft Graph delta tokens, etc.). Only OData v3/v4 spec content. If you find yourself adding logic for a single vendor, push back or carve out a sibling package.
- No AI attribution anywhere — commits, code, docs.
- No emojis in source or docs unless the user explicitly asks.
composer test
composer analyse
composer format- Base
ODataConnector/ODataRequestclasses (users compose their own) - Response DTO mapping /
@odata.contextparsing - OData v2 (one extra
Literalbranch away if needed) $batchendpoint (separate-batchpackage if ever needed)$metadataparsing / codegen (separate-codegenpackage if ever needed)- Auth (Saloon already handles it)