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.
A fluent library for calling OData REST services inspired by and based on the Laravel Query Builder.
For WordPress users, please see our Gravity Forms Dynamics 365 Add-On.
You can install the PHP SDK with Composer.
composer require saintsystems/odata-client
Starting from version 0.10.0, the OData Client requires an HTTP provider to be explicitly set. This allows you to use any HTTP client implementation that suits your needs.
As of version 1.0.0, we will be using semver.
First, install Guzzle:
composer require guzzlehttp/guzzle
Then configure the OData client:
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
You can also use any PSR-17/PSR-18 compatible HTTP client:
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\Psr17HttpProvider;
// Example using Symfony HTTP Client with Nyholm PSR-7
$httpClient = new \Symfony\Component\HttpClient\Psr18Client();
$requestFactory = new \Nyholm\Psr7\Factory\Psr17Factory();
$streamFactory = new \Nyholm\Psr7\Factory\Psr17Factory();
$httpProvider = new Psr17HttpProvider($httpClient, $requestFactory, $streamFactory);
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
The following is an example that shows how to call an OData service.
<?php
require_once __DIR__ . '/vendor/autoload.php';
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
class UsageExample
{
public function __construct()
{
$odataServiceUrl = 'https://services.odata.org/V4/TripPinService';
$httpProvider = new GuzzleHttpProvider();
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
// Retrieve all entities from the "People" Entity Set
$people = $odataClient->from('People')->get();
// Or retrieve a specific entity by the Entity ID/Key
try {
$person = $odataClient->from('People')->find('russellwhyte');
echo "Hello, I am $person->FirstName ";
} catch (Exception $e) {
echo $e->getMessage();
}
// Want to only select a few properties/columns?
$people = $odataClient->from('People')->select('FirstName','LastName')->get();
}
}
$example = new UsageExample();
You can add custom headers to your OData requests for authentication, tracking, or other purposes:
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
// Method 1: Set headers on the client (applies to all requests)
$odataClient->setHeaders([
'Authorization' => 'Bearer your-token-here',
'X-Custom-Header' => 'MyCustomValue',
'X-Client-Version' => '1.0.0'
]);
// Method 2: Add a single header to the client
$odataClient->addHeader('X-Request-ID', uniqid());
// Method 3: Add headers to specific queries using the fluent interface
$people = $odataClient->from('People')
->withHeader('X-Query-Context', 'get-all-people')
->withHeaders([
'X-Debug' => 'true',
'X-Performance-Track' => 'enabled'
])
->get();
// Headers set on the client persist across requests
$person = $odataClient->from('People')->find('russellwhyte');
// Query-specific headers only apply to that request
$airlines = $odataClient->from('Airlines')
->withHeader('X-Data-Source', 'airlines-api')
->get();
You can add custom query parameters to your OData requests that are not part of the standard OData specification. This is useful for passing additional parameters to your OData service:
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
// Method 1: Add custom options using string format
$people = $odataClient->from('People')
->addOption('timeout=30')
->addOption('format=minimal')
->get();
// Results in: /People?timeout=30&format=minimal
// Method 2: Add custom options using array format
$people = $odataClient->from('People')
->addOption(['timeout' => '30', 'debug' => 'true'])
->get();
// Results in: /People?timeout=30&debug=true
// Method 3: Mix with standard OData parameters
$people = $odataClient->from('People')
->select('FirstName', 'LastName')
->where('FirstName', 'Russell')
->addOption('version=2.0')
->get();
// Results in: /People?$select=FirstName,LastName&$filter=FirstName eq 'Russell'&version=2.0
// Method 4: Multiple addOption calls are merged (not overwritten)
$people = $odataClient->from('People')
->addOption('timeout=30')
->addOption('format=minimal')
->addOption(['debug' => 'true']);
// Results in: /People?timeout=30&format=minimal&debug=true
// Custom option keys are validated:
// ✓ Valid: 'timeout', 'custom_param', 'kebab-case', 'camelCase'
// ✗ Invalid: '$reserved' (starts with $), 'invalid key!' (special chars)
Key Features:
addOption() calls merge instead of overwriting'key=value') and array (['key' => 'value']) formatsIf you need to configure custom network timeouts for your OData requests, you can create a subclass of ODataClient and override the createRequest method:
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
class CustomTimeoutODataClient extends ODataClient {
private $customTimeout;
public function __construct($baseUrl, $authProvider = null, $httpProvider = null, $timeout = 30) {
parent::__construct($baseUrl, $authProvider, $httpProvider);
$this->customTimeout = $timeout;
}
protected function createRequest($method, $requestUri) {
$request = parent::createRequest($method, $requestUri);
$request->setTimeout($this->customTimeout);
return $request;
}
}
// Usage with custom timeout
$httpProvider = new GuzzleHttpProvider();
$client = new CustomTimeoutODataClient('https://api.example.com/odata', null, $httpProvider, 60);
$result = $client->from('Products')->get(); // Uses 60-second timeout
This approach allows you to customize request creation without having to override the entire request flow, following the template method pattern.
Key Features:
For a complete working example, see examples/custom_headers_example.php.
The OData Client provides powerful support for accessing nested properties in OData entities, making it easy to work with complex data structures returned by modern OData services.
Access nested properties naturally using object notation:
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);
// Get a person with address information
$person = $client->from('People')->find('russellwhyte');
// Access nested properties directly
$city = $person->AddressInfo[0]->City; // Object-style access
$country = $person->AddressInfo[0]->CountryRegion; // Deep nesting supported
// Complex nested structures work naturally
if ($person->Settings && $person->Settings->Preferences) {
$theme = $person->Settings->Preferences->Theme;
}
Use dot notation for safe navigation through nested properties:
// Safe access with dot notation - returns null if any part doesn't exist
$city = $person->getProperty('AddressInfo.0.City');
$country = $person->getProperty('AddressInfo.0.CountryRegion');
$theme = $person->getProperty('Settings.Preferences.Theme');
// Works with array indices and object properties
$firstFriendName = $person->getProperty('Friends.0.FirstName');
$homeAddress = $person->getProperty('AddressInfo.0.Address');
Check if nested properties exist before accessing them:
// Check existence using hasProperty()
if ($person->hasProperty('AddressInfo.0.City')) {
$city = $person->getProperty('AddressInfo.0.City');
}
// Also works with isset() for object-style access
if (isset($person->AddressInfo[0]->City)) {
$city = $person->AddressInfo[0]->City;
}
// Check for deeply nested paths
if ($person->hasProperty('Settings.Preferences.AutoSave')) {
$autoSave = $person->getProperty('Settings.Preferences.AutoSave');
}
Handle arrays and collections within nested structures:
// Get people with address information
$people = $client->select('UserName,FirstName,LastName,AddressInfo')
->from('People')
->get();
foreach ($people as $person) {
echo "Person: " . $person->FirstName . " " . $person->LastName . "\n";
// Access nested address info - remains as array for easy filtering
$addresses = $person->AddressInfo;
// Filter addresses by type
$homeAddresses = array_filter($addresses, function($address) {
return isset($address['Type']) && $address['Type'] === 'Home';
});
// Access properties within filtered results
foreach ($homeAddresses as $address) {
// Convert to Entity for object-style access
$addrEntity = new \SaintSystems\OData\Entity($address);
echo " Home Address: " . $addrEntity->Address . ", " . $addrEntity->City . "\n";
}
}
Working with ShareFile-style OData responses with Info objects and Children collections:
// Query for folders with nested Info and Children data
$folders = $client->select('Id,Name,CreatorNameShort,Info,Info/IsAHomeFolder,Children/Id,Children/Name')
->from('Items')
->where('HasChildren', true)
->get();
foreach ($folders as $folder) {
echo "Folder: " . $folder->Name . "\n";
echo "Creator: " . $folder->CreatorNameShort . "\n";
// Access nested Info properties
if ($folder->Info) {
echo "Is Home Folder: " . ($folder->Info->IsAHomeFolder ? 'Yes' : 'No') . "\n";
// Safe navigation for optional nested properties
if ($folder->hasProperty('Info.Settings.Theme')) {
echo "Theme: " . $folder->getProperty('Info.Settings.Theme') . "\n";
}
}
// Work with Children collection
if ($folder->Children) {
echo "Children:\n";
// Filter children by type
$subfolders = array_filter($folder->Children, function($child) {
return $child['FileSizeBytes'] == 0; // Folders have 0 file size
});
foreach ($subfolders as $subfolder) {
echo " - " . $subfolder['Name'] . " (ID: " . $subfolder['Id'] . ")\n";
}
}
echo "\n";
}
Nested property access works seamlessly with OData query operations:
// Select specific nested properties
$result = $client->select('Id,Name,Info/IsAHomeFolder,Children/Name,AddressInfo/City')
->from('Items')
->get();
// Use in where clauses (if supported by the OData service)
$homeItems = $client->from('Items')
->where('Info/IsAHomeFolder', true)
->get();
// Expand related data and access nested properties
$peopleWithTrips = $client->from('People')
->expand('Trips')
->get();
foreach ($peopleWithTrips as $person) {
foreach ($person->Trips as $trip) {
// Access nested trip properties
$tripEntity = new \SaintSystems\OData\Entity($trip);
echo $person->FirstName . " has trip: " . $tripEntity->Name . "\n";
}
}
Key Features:
null instead of throwing errorsFor comprehensive examples and advanced usage patterns, see examples/nested_properties_example.php.
The OData Client supports lambda operators any and all for filtering collections within entities. These operators allow you to filter based on conditions within related navigation properties.
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);
// Find people who have any completed trips
$peopleWithCompletedTrips = $client->from('People')
->whereAny('Trips', function($query) {
$query->where('Status', 'Completed');
})
->get();
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed')
// Find people where all their trips are high-budget
$peopleWithAllHighBudgetTrips = $client->from('People')
->whereAll('Trips', function($query) {
$query->where('Budget', '>', 1000);
})
->get();
// Generates: People?$filter=Trips/all(t: t/Budget gt 1000)
whereAny($navigationProperty, $callback) - Returns true if any element matches the conditionwhereAll($navigationProperty, $callback) - Returns true if all elements match the conditionorWhereAny($navigationProperty, $callback) - OR version of whereAnyorWhereAll($navigationProperty, $callback) - OR version of whereAll// Multiple conditions within lambda
$peopleWithQualifiedTrips = $client->from('People')
->whereAny('Trips', function($query) {
$query->where('Status', 'Completed')
->where('Budget', '>', 500);
})
->get();
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed' and t/Budget gt 500)
// Combining with regular conditions
$activePeopleWithTrips = $client->from('People')
->where('Status', 'Active')
->whereAny('Trips', function($query) {
$query->where('Status', 'Pending');
})
->get();
// Generates: People?$filter=Status eq 'Active' and Trips/any(t: t/Status eq 'Pending')
Key Features:
Trips → t)For comprehensive examples and advanced usage patterns, see examples/lambda_operators.php.
The OData Client supports batch operations, which allow you to send multiple HTTP requests in a single batch request. This can significantly improve performance when you need to perform multiple operations.
<?php
use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;
$httpProvider = new GuzzleHttpProvider();
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);
// Simple batch with multiple GET requests
$response = $client->batch()
->get('People', 'get-people')
->get('Airlines', 'get-airlines')
->get('Airports', 'get-airports')
->execute();
Changesets ensure that all operations within the changeset are executed atomically (all succeed or all fail):
// Batch with changeset for atomic operations
$response = $client->batch()
->startChangeset()
->post('People', [
'FirstName' => 'John',
'LastName' => 'Doe',
'UserName' => 'johndoe',
'Emails' => ['[email protected]']
], 'create-person')
->patch('People(\'russellwhyte\')', [
'FirstName' => 'Jane',
'LastName' => 'Smith'
], 'update-person')
->endChangeset()
->execute();
You can combine individual requests and changesets in a single batch:
$response = $client->batch()
// Individual queries (not in changeset)
->get('People?$top=5', 'get-top-people')
// Atomic operations in changeset
->startChangeset()
->post('People', $newPersonData, 'create-person')
->delete('People(\'obsolete-id\')', 'delete-person')
->endChangeset()
// More individual queries
->get('Airlines?$top=3', 'get-airlines')
->execute();
get($uri, $id) - Add a GET request to the batchpost($uri, $data, $id) - Add a POST request to the batchput($uri, $data, $id) - Add a PUT request to the batchpatch($uri, $data, $id) - Add a PATCH request to the batchdelete($uri, $id) - Add a DELETE request to the batchstartChangeset() - Begin a new changeset for atomic operationsendChangeset() - End the current changesetexecute() - Execute the batch requestKey Features:
For comprehensive examples, see examples/batch_operations.php.
Run vendor/bin/phpunit from the base directory.
View or log issues on the Issues tab in the repo.
Copyright (c) Saint Systems, LLC. All Rights Reserved. Licensed under the MIT license.
How can I help you explore Laravel packages today?