Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Odata Client Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • No default HTTP provider. Forgetting 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.
  • Case and schema matter. OData property names are whatever $metadata says (EMailAddress1 vs emailaddress1 on different services). Pull your service's $metadata/$schema once and treat it as your field dictionary.
  • Date/time literals are the sharpest edge. OData v3 expects unquoted ISO 8601 (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.
  • Null and boolean comparisons render inconsistently across versions. Test 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.
  • Server-side paging overrides 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.
  • Debug the query string first. Use Guzzle middleware 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.
  • Extension point: implement 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.
  • Version the base URL yourself. Dynamics 365 uses /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.
  • Errors aren't normalized. On failure you'll often get a Guzzle exception wrapping an OData JSON error body ({"error":{"code","message"}}) — catch and extract that body rather than surfacing the raw Guzzle message to users.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk