ODXProxy
SDKs

PHP SDK

A zero-dependency, PHP 8.1+ client for ODXProxy with first-class multi-tenant support.

odxproxy/client is the official PHP client for ODXProxy. It's zero library dependencies (just ext-curl + ext-json), strictly typed, and built for multi-tenant apps — a global singleton for the common case plus a per-config "context switcher" for iterating over many Odoo instances.

Source: terrakernel/ODXProxyClient-PHP · Packagist odxproxy/client · PHP 8.1+ · PSR-4 namespace OdxProxy\ · v2 API since 0.9.0.

Install

composer require odxproxy/client

Initialize once

Call Odx::init($config) a single time at startup (Laravel AppServiceProvider, or index.php), then call the static helpers anywhere. The config is a flat array carrying both the proxy and the Odoo-instance details:

use OdxProxy\Odx;

Odx::init([
    'gateway_url'     => 'https://your-proxy.example.com', // optional; default https://gateway.odxproxy.io
    'gateway_api_key' => '<proxy x-api-key>',              // the PROXY's key
    'url'             => 'https://erp.example.com',        // Odoo base URL
    'db'              => 'prod',                           // Odoo database
    'user_id'         => 2,                                // Odoo user id
    'api_key'         => '<odoo user api key>',            // the Odoo USER's key
]);

Two different secrets

gateway_api_key is the proxy's x-api-key; api_key is the Odoo user's API key. They sit side by side in the same array but are never the same value — don't conflate them. gateway_url is optional (defaults to https://gateway.odxproxy.io; a trailing slash is trimmed).

Multi-tenant: Odx::with()

Odx::with($config) returns a client bound to a specific config without touching the global singleton — ideal for a cron job iterating over many tenants/users, each with their own Odoo instance:

foreach ($tenants as $tenant) {
    $client = Odx::with($tenant->toOdxConfig()); // same flat-array shape as init()
    $partners = $client->searchRead('res.partner', []);   // [] = empty domain
    // ...process this tenant...
}

This mirrors the proxy's design — one gateway in front of many Odoo instances, with the target instance carried per request.

Fetch records

Helpers return the result directly (already unwrapped from the JSON-RPC envelope) and throw on any error — so there's no ->result to reach through. searchRead is the common one; pass the domain directly (the client handles the execute_kw nesting):

use OdxProxy\Odx;
use OdxProxy\Model\KeywordRequest;

$kw = (new KeywordRequest())
    ->setFields(['id', 'name', 'email'])
    ->setLimit(50)
    ->setContext(['tz' => 'UTC']);

$partners = Odx::searchRead(
    'res.partner',
    [['is_company', '=', true]], // domain (conditions only — no extra nesting)
    $kw
);

foreach ($partners as $p) {
    echo $p['name'];
}

KeywordRequest has fluent setters (setFields, setOrder, setLimit, setOffset, setContext) and is optional (null is fine). For every action except search_read the client strips fields/order/limit/offset automatically — they only apply to a combined search+read.

The 8 helpers + execute()

Each allowed action is a static method on Odx (also available on the instance from Odx::with() / Odx::client()):

ActionMethodReturns
search_countOdx::searchCount(model, domain, kw?)int
searchOdx::search(model, domain, kw?)int[] (IDs)
readOdx::read(model, ids, kw?)array records
search_readOdx::searchRead(model, domain, kw?)array records
createOdx::create(model, values, kw?)new ID
writeOdx::write(model, ids, values, kw?)bool
unlinkOdx::unlink(model, ids)bool
call_methodOdx::call(model, method, args, kw?)mixed
$id      = Odx::create('res.partner', ['name' => 'Acme Inc', 'is_company' => true]);
$ok      = Odx::write('res.partner', [$id], ['name' => 'Acme LLC']);
$deleted = Odx::unlink('res.partner', [$id]);

// call_method — method name first, then its args
$confirmed = Odx::call('sale.order', 'action_confirm', [[42]]);

fields_get has no named helper

The 9th action, fields_get, isn't wrapped as a named method. Reach it through the generic execute() on a client instance: Odx::client()->execute('fields_get', 'res.partner', [], $kw). Anything outside the 9 actions must go through call_method (i.e. Odx::call(...)).

Errors

Every failure throws OdxProxy\Exception\OdxException (a RuntimeException). It carries the message, optional ->data, ->httpStatus, and a code. Proxy-level failures and Odoo logic errors (HTTP 200 with an error body) both surface here — branch on rpcCode() against the error catalog:

use OdxProxy\Exception\OdxException;

try {
    $partners = Odx::searchRead('res.partner', [], $kw);
} catch (OdxException $e) {
    switch ($e->rpcCode()) {
        case -32000: /* bad x-api-key — reauth */ break;
        case -32003: /* upstream Odoo timeout — retry */ break;
        default:     error_log($e->rpcCode() . ': ' . $e->getMessage());
    }
}

Use rpcCode(), not getCode()

On the v1 helpers, a non-2xx response puts the HTTP status in getCode() (401, not -32000); rpcCode() returns the JSON-RPC code either way. Code 0 is ambiguous on its own — a curl failure, a license error, and every Odoo 19+ v1 error all use it — so use isTransportError() and isLicenseError() (true only on HTTP 403) instead of comparing with 0.

OdxException also has odooStatus() (Odoo's HTTP status for an Odoo-side error — always on v2: 401 Odoo key invalid/expired, 403, 404, 409, 422, 5xx), odooErrorName() (from data['name']), isJson2Unavailable() (-32006), isInvalidRequest() (-32007), and isRetryable() (-32004 or Odoo 409).

Like the Java client, there's a single OdxException (no per-code subclasses) — switch on $e->rpcCode(). A JSON-RPC error can arrive with HTTP 200 (an Odoo logic error such as access-denied); the client already inspects the envelope's error field for you and throws, so you never infer success from the status code.

Connection reuse: the client keeps a cURL handle alive across calls within a request for keep-alive. In a long-running worker you can release it explicitly with OdxProxy\Client\OdxProxyClient::close().

v2 API (Odoo 19+)

From 0.9.0 the client speaks ODXProxy's v2 API (Odoo's JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+). A v2 client comes from the same configuration: Odx::v2() for the global one, or Odx::with($config)->v2() per tenant. user_id is only needed for v1; the optional 'context' config key is merged into every v2 call, and a call's own context: keys win.

use OdxProxy\Odx;

Odx::init([
    'gateway_url'     => 'https://your-proxy.example.com',
    'gateway_api_key' => '<proxy x-api-key>',
    'url'             => 'https://erp.example.com',
    'db'              => 'prod',
    'api_key'         => '<odoo user api key>',   // an Odoo API key, not a password
    'context'         => ['lang' => 'en_US', 'allowed_company_ids' => [1]],
]);

$v2 = Odx::v2();

$partners = $v2->searchRead('res.partner', [['is_company', '=', true]],
    fields: ['name', 'email'], limit: 20, order: 'name asc');
$ids    = $v2->search('res.partner', [['customer_rank', '>', 0]], limit: 10);  // int[]
$count  = $v2->searchCount('res.partner', []);                               // int
$rows   = $v2->read('res.partner', $ids, fields: ['name']);
$schema = $v2->fieldsGet('res.partner', attributes: ['type', 'string']);

$newIds = $v2->create('res.partner', [['name' => 'Acme'], ['name' => 'Globex']]);  // [41, 42]
$one    = $v2->createOne('res.partner', ['name' => 'Initech']);                  // 43
$v2->write('res.partner', $newIds, ['comment' => 'via v2']);
$v2->unlink('res.partner', $newIds);

$v2->call('account.move', 'action_post', ids: [7]);                      // record method
$v2->call('res.partner', 'name_search', ['name' => 'Acm', 'limit' => 5]);
  • Named arguments only — PHP named args map to Odoo's Python parameter names (domain, fields, offset, limit, order, ids, vals, allfields, attributes). In call(), ids: is for record methods only; everything else goes in the $kwargs array.
  • fieldsGet is a named method on v2.
  • null arguments are omitted, so Odoo's defaults apply.
  • create() always returns a list of ids; createOne() returns one int.
  • $v2->isSupported() checks once and caches per URL; $v2->version() returns ['version_info' => [...], 'version' => '...'].
  • On v2, getCode() is always the JSON-RPC code, with the HTTP status in httpStatus.

Looking for another language? See the SDK overview — every client mirrors this same wire protocol.

On this page