saintsystems/odata-client
A fluent PHP client for calling OData REST services, inspired by Laravel’s Query Builder. Install with Composer and use Guzzle or any PSR-17/PSR-18 HTTP provider for flexible integration.
Install both the client and an HTTP provider (Guzzle is the recommended path):
composer require saintsystems/odata-client guzzlehttp/guzzle
Since 0.10.0 an HTTP provider is mandatory — the client no longer ships with one wired up by default. Wire it explicitly:
use GuzzleHttp\Client as GuzzleClient;
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$guzzle = new GuzzleClient([
'base_uri' => 'https://your-org.crm.dynamics.com/api/data/v9.2/',
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
$odata = new ODataClient(
'https://your-org.crm.dynamics.com/api/data/v9.2/',
new GuzzleHttpProvider($guzzle)
);
Look here first: the src/ODataClient.php and src/Query/ tree (mirrors Laravel's query builder), plus src/Query/QueryOptions where $filter, $select, $expand, etc. are assembled. Most day-to-day surprises live in how your where() clauses are stringified into $filter.
First use case — read a page of contacts:
$results = $odata->entitySet('Contacts')
->select(['contactid', 'fullname', 'emailaddress1'])
->where('statecode', '=', 0)
->orderBy('fullname')
->top(25)
->get();
foreach ($results as $contact) {
echo $contact->fullname;
}
entitySet('Contacts') (or the magic $odata->Contacts) returns a Query builder; get() returns an ODataResponse you can foreach directly.
Wrap each entity set in a small repository. Because the client is a thin fluent wrapper, don't sprinkle entitySet('…') through controllers. One class per domain entity keeps $select lists and field-name constants in one place — OData field names rarely match your app's naming and are usually case-sensitive against the service's $metadata.
class ContactRepository
{
public function __construct(private ODataClient $odata) {}
public function active(): ODataResponse
{
return $this->odata->entitySet('Contacts')
->select(['contactid', 'fullname', 'emailaddress1'])
->where('statecode', '=', 0)
->get();
}
}
Auth belongs in Guzzle middleware, not the client. The OData client forwards to your provider; put OAuth token refresh, retry-on-401, and logging on the Guzzle handler stack. This is the single cleanest integration point — you never need to touch ODataClient to add Azure AD/Entra tokens.
Pagination must be driven by the response's next link. OData services cap page size server-side regardless of your top(). Loop until getNextLink() is null:
$query = $odata->entitySet('Contacts')->select(['contactid'])->top(5000);
do {
$page = $query->get();
foreach ($page as $row) { /* ... */ }
// Re-point the query at the server-provided next link
} while ($page->getNextLink() && $query = $odata->fromNextLink($page->getNextLink()));
(Check the version's exact helper — older releases expose the next link but require you to re-instantiate the query against it. Don't assume get() auto-pages.)
CRUD reads like Eloquent, so treat it the same way:
$created = $odata->entitySet('Contacts')->create(['fullname' => 'Jane Doe', 'emailaddress1' => '[email protected]']);
$odata->entitySet('Contacts')->update($id, ['fullname' => 'Jane D.']);
$odata->entitySet('Contacts')->delete($id);
$one = $odata->entitySet('Contacts')->find($id); // by primary key
$first = $odata->entitySet('Contacts')->where('statecode','=',0)->first();
update() sends a PATCH on OData v4 — partial payloads are fine, but you must include only changed fields or you'll null out what you omit on some servers.
Expand navigation properties instead of N+1. Use ->expand('primarycontactid') (or the string form the version supports) whenever you need related records; never loop find() calls for each parent.
Register as a singleton in a service container (Laravel, Symfony). Bind ODataClient once with the configured base URL and Guzzle instance so connection pooling and token caching are shared across requests within a worker.
Integration tip — build queries as your test seam. Because the builder produces a deterministic URL/$filter string, unit-test the query before the HTTP call: assert the generated filter/$select, then integration-test the response mapping separately against a fixture service like the public OData sample endpoint.
new GuzzleHttpProvider(...) is the #1 upgrade break after 0.10.0.select() takes an array, not variadic strings in most versions — ->select('a','b') silently misbehaves.$metadata says (EMailAddress1 vs emailaddress1 on different services). Pull your service's $metadata/$schema once and treat it as your field dictionary.datetime'2017-01-01T00:00:00'), v4 expects quoted strings (2017-01-01T00:00:00Z). Pass raw \DateTime at your peril — inspect the generated $filter and hard-code the format your server accepts. Timezone drift on Edm.DateTimeOffset causes off-by-hours bugs.where('field', '=', null) versus whereNull('field') (if present) rather than trusting docs.first() returns null, find() returns null for missing keys — no exception. Guard your code; don't assume a throwable like Eloquent's findOrFail.top(). Your take(1000) may come back as 100 rows. Always inspect nextLink and account for it in batch jobs.delete() typically returns a boolean derived from HTTP status; confirm the response code when writing audit logging.\GuzzleHttp\Middleware::log) to dump the outgoing URL, or inspect the builder's query object. The generated $filter is what the server actually sees — 90% of "wrong results" bugs are stringification errors there.SaintSystems\OData\Http\IHttpProvider (or the version-appropriate interface) yourself to swap in Symfony HTTP Client, a mock for tests, or an instrumented client — the client only depends on that interface./api/data/v9.2/ (v4) while older SharePoint/Custom endpoints use v3 semantics with different function and date syntax. Pick the matching client behavior and pin it in config.{"error":{"code","message"}}) — catch and extract that body rather than surfacing the raw Guzzle message to users.How can I help you explore Laravel packages today?