obuchmann/odoo-jsonrpc
A Laravel package for communicating with Odoo ERP over its JSON-RPC API. It provides a clean, fluent PHP interface to authenticate and query, create, update, and delete Odoo records, making backend integration simple within Laravel applications.
composer require obuchmann/odoo-jsonrpc
php artisan vendor:publish --provider="Obuchmann\OdooJsonRpc\OdooServiceProvider"
Set the connection in .env (ODOO_HOST, ODOO_PORT, ODOO_DB, ODOO_USERNAME, ODOO_PASSWORD) and confirm the values in config/odoo.php. The client authenticates lazily on first call and caches the uid for the process lifetime.
Look first at the package's model layer — that is where the value is. Rather than hand-rolling execute_kw payloads, you declare a class per Odoo model and let the package hydrate/dehydrate between PHP properties and Odoo's dict format:
use Obuchmann\OdooJsonRpc\Odoo\Model;
class Partner extends OdooModel
{
protected $model = 'res.partner';
public string $name;
public ?string $email = null;
}
A first use case that pays for itself immediately: replace a CSV/cron export. Pull the records you care about, work with them as objects, and write changes back:
$partners = Partner::query()->where('is_company', '=', true)->limit(50)->get();
foreach ($partners as $partner) {
$partner->email = strtolower($partner->email ?? '');
$partner->save(); // write() under the hood
}
$new = new Partner();
$new->name = 'ACME Ltd';
$new->save(); // create() under the hood, sets ->id
If you only need one-off calls, the low-level client (Odoo::connect()->model('res.partner')->...) is available and skips model definitions entirely — good for smoke-testing credentials before you invest in classes.
Model-per-Odoo-model, not per-table. Mirror Odoo's own model names (res.partner, sale.order, product.product) so the mapping stays obvious to anyone who knows Odoo. Declare relations as typed properties so hydration returns objects instead of raw ID arrays; this is the main reason to use the package over a bare HTTP client.
Repository/service wrapper. Don't scatter static model calls through controllers. Wrap them:
class OdooPartnerRepository
{
public function findByVat(string $vat): ?Partner
{
return Partner::query()->where('vat', '=', $vat)->first();
}
}
This gives you one place to add caching, logging, retries, and one place to fake in tests.
Chunked sync, not get() on everything. Odoo has no server-side cursor. Use limit/offset in a queued job and process in batches of a few hundred — large result sets are slow to hydrate and will blow memory in a worker.
Queue anything that writes. Every save() is a synchronous HTTP roundtrip; a loop of 500 writes in a web request will time out. Push to a job, and consider Odoo's batch create/write with arrays of dicts where the model supports it.
Cache reference data aggressively. Countries, currencies, UoM, pricelists and categories change rarely and are read constantly. Cache::remember('odoo.countries', 3600, fn () => Country::query()->get()) removes most of your latency.
Fake the client in tests. Bind a stub of the client/model into the container in your TestCase and never let the test suite hit a real Odoo instance — integration tests belong in a separate suite against a disposable database.
Custom behaviour via overrides. hydrate()/dehydrate() on your model classes are the intended extension points for computed fields, cast normalisation, and guarding against sending values Odoo will reject.
Upgrade to v1.9.1 if you round-trip models. The fix prevents foreign-key (Many2one) properties carrying array data from producing an invalid payload. This bites in two common situations: re-saving a model you loaded from Odoo where a relation was hydrated as an array, and assigning relation IDs in bulk ($order->partner_id = $ids[0] vs $order->partner_id = $ids). Before the fix, Odoo answered with an opaque traceback rather than a validation message; after it, the dehydration step normalises or rejects the value before it reaches the wire. If you have defensive is_array() checks or dehydrate() overrides working around this, re-test them — they may now be redundant or actively wrong.
Odoo errors are buried. Failures come back as a JSON-RPC error object containing Odoo's Python traceback. The exception message is the only diagnostic you get, so log it verbatim; the useful line ("Invalid field 'x' on model 'y'", "Access Denied") is usually in the middle, not the start.
null vs false vs ''. Odoo uses false for "unset" on many fields, and treats them inconsistently per field type. Defaulting a nullable boolean to null can silently write nothing; defaulting to false can clear a value you meant to leave alone. Be explicit about which fields you send on update, and prefer partial writes over re-saving a fully hydrated model.
Dehydration sends what the object holds. Saving a model hydrated from a narrow query can overwrite fields you never loaded with defaults. Load full records before mutating, or write through explicit arrays rather than objects for partial updates.
Datetimes are Odoo's timezone, not PHP's. Odoo stores naive datetimes in the user's timezone configured on the Odoo side. Convert with an explicit timezone and don't assume UTC.
Config caching and long-lived workers. php artisan config:cache after any credential change. Under Octane/Horizon the authenticated session persists across requests — if credentials rotate or the Odoo session expires, the worker keeps a dead uid until restarted. Wrap long-running jobs with a reconnect check.
Access rights are the usual culprit. RPC runs as the configured user, so record rules and model ACLs in Odoo apply. A call that works for an admin and fails in production is nearly always a permission, not a package bug.
Field names drift between Odoo versions. product.product in particular has renamed and removed fields across 15/16/17. Pin your Odoo version in CI, and let a failing integration test catch schema drift rather than a production job.
How can I help you explore Laravel packages today?