# Syncing a Laravel App with Odoo via JSON-RPC: A Practical, Tested Walkthrough

By Gleni Isaku
Published: 2026-10-23
Updated: 2026-10-23
Canonical: https://laranepal.com/blog/syncing-a-laravel-app-with-odoo-via-json-rpc-a-practical-tested-walkthrough


Many Laravel projects end up next to an ERP. The shop, portal or internal tool lives in Laravel, while customers, products, invoices and stock live in Odoo. Sooner or later the two have to talk to each other. This tutorial shows a small, testable way to do that: a dedicated Odoo client service, queued sync jobs with retries and idempotency, and tests that never need a running Odoo instance.

**Requirements:** PHP 8.2+, Laravel 11 or 12, a queue driver (database or Redis), and an Odoo instance (version 16 to 19) with a user that has API access. On Odoo Online, external API access depends on your pricing plan, so check that first.

> **A note on Odoo versions.** Odoo 19 introduced a new External JSON-2 API (`/json/2/<model>/<method>` with an API key as a Bearer token) and marked the older `/jsonrpc` and `/xmlrpc` endpoints as deprecated. According to Odoo's documentation, the `common` and `object` services stay available until Odoo 22 (fall 2028). Everything below uses `/jsonrpc`, because it works on all current versions, and we keep the transport in one class so switching to JSON-2 later is a small change. A sketch of that is at the end.

## 1. Call Odoo directly, or go through a queue?

A simple rule we use in projects:

- **Read in the request, write through a queue.** Showing a customer's open invoices on a portal page can be a direct, cached call. Creating a sales order in Odoo after checkout should not block the user's request or fail the checkout because the ERP is slow or in a maintenance window.
- **Anything that must survive an Odoo outage goes through a queue.** Jobs can retry with backoff; HTTP requests from users cannot.
- **Keep Laravel as the source of truth only for what it owns.** Orders are created in Laravel and pushed to Odoo. Products, prices and stock usually come from Odoo and are pulled into Laravel.

## 2. Configuration

Add the credentials to `.env`. Use a dedicated integration user in Odoo with only the access rights it needs, and on Odoo 14+ use an API key generated in that user's preferences instead of the login password.

```dotenv
ODOO_URL=https://erp.example.com
ODOO_DB=production
ODOO_USERNAME=integration@example.com
ODOO_API_KEY=your-api-key
```

```php
// config/services.php
'odoo' => [
    'url' => env('ODOO_URL'),
    'db' => env('ODOO_DB'),
    'username' => env('ODOO_USERNAME'),
    'api_key' => env('ODOO_API_KEY'),
    'timeout' => 15,
],
```

## 3. A small Odoo client service

JSON-RPC calls to Odoo all go to one endpoint, `/jsonrpc`, with a `service`, a `method` and positional `args`. Authentication returns a user ID (uid), which every later `object` call needs.

```php
<?php

namespace App\Services\Odoo;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class OdooClient
{
    public function __construct(
        private string $url,
        private string $db,
        private string $username,
        private string $apiKey,
        private int $timeout = 15,
    ) {}

    public function uid(): int
    {
        return Cache::remember('odoo.uid', now()->addHour(), function () {
            $uid = $this->call('common', 'login', [$this->db, $this->username, $this->apiKey]);

            if (! is_int($uid) || $uid <= 0) {
                throw new OdooException('Odoo authentication failed.');
            }

            return $uid;
        });
    }

    public function execute(string $model, string $method, array $args = [], array $kwargs = []): mixed
    {
        return $this->call('object', 'execute_kw', [
            $this->db, $this->uid(), $this->apiKey, $model, $method, $args, $kwargs,
        ]);
    }

    public function searchRead(string $model, array $domain, array $fields, int $limit = 0): array
    {
        return $this->execute($model, 'search_read', [$domain], [
            'fields' => $fields,
            'limit' => $limit,
        ]);
    }

    public function create(string $model, array $values): int
    {
        return $this->execute($model, 'create', [$values]);
    }

    public function write(string $model, array $ids, array $values): bool
    {
        return $this->execute($model, 'write', [$ids, $values]);
    }

    private function call(string $service, string $method, array $args): mixed
    {
        try {
            $response = Http::timeout($this->timeout)
                ->acceptJson()
                ->post(rtrim($this->url, '/') . '/jsonrpc', [
                    'jsonrpc' => '2.0',
                    'method' => 'call',
                    'params' => compact('service', 'method', 'args'),
                    'id' => (string) str()->uuid(),
                ]);
        } catch (ConnectionException $e) {
            throw new OdooUnavailableException($e->getMessage(), previous: $e);
        }

        if ($response->serverError()) {
            throw new OdooUnavailableException("Odoo returned HTTP {$response->status()}.");
        }

        $payload = $response->json();

        if (isset($payload['error'])) {
            $message = $payload['error']['data']['message'] ?? $payload['error']['message'] ?? 'Unknown Odoo error';
            throw new OdooException($message);
        }

        return $payload['result'] ?? null;
    }
}
```

Two exception classes keep the important distinction clear:

```php
namespace App\Services\Odoo;

class OdooException extends \RuntimeException {}             // Odoo answered with an error: retrying usually won't help
class OdooUnavailableException extends OdooException {}     // timeout, 5xx, network: retry later
```

Note that Odoo reports application errors (access rights, validation errors, missing fields) inside a normal HTTP 200 response with an `error` key, so checking the status code alone is not enough.

Register the client as a singleton:

```php
// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(OdooClient::class, fn () => new OdooClient(
        config('services.odoo.url'),
        config('services.odoo.db'),
        config('services.odoo.username'),
        config('services.odoo.api_key'),
        config('services.odoo.timeout'),
    ));
}
```

## 4. Syncing customers and orders with queued jobs

Here is a job that pushes a Laravel order to Odoo as a quotation (`sale.order`). It creates or finds the customer (`res.partner`) first.

```php
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Services\Odoo\OdooClient;
use App\Services\Odoo\OdooException;
use App\Services\Odoo\OdooUnavailableException;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class PushOrderToOdoo implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 5;

    public function __construct(public Order $order) {}

    public function uniqueId(): string
    {
        return (string) $this->order->id;
    }

    public function backoff(): array
    {
        return [30, 120, 600, 1800];
    }

    public function handle(OdooClient $odoo): void
    {
        // Idempotency, part 1: already synced? Nothing to do.
        if ($this->order->odoo_id) {
            return;
        }

        try {
            $partnerId = $this->findOrCreatePartner($odoo);

            // Idempotency, part 2: a previous attempt may have created the order
            // in Odoo and then crashed before we saved the ID. Look it up by our reference.
            $existing = $odoo->searchRead('sale.order', [['client_order_ref', '=', $this->order->reference]], ['id'], 1);

            $odooId = $existing[0]['id'] ?? $odoo->create('sale.order', [
                'partner_id' => $partnerId,
                'client_order_ref' => $this->order->reference,
                'order_line' => $this->order->items->map(fn ($item) => [0, 0, [
                    'product_id' => $item->odoo_product_id,
                    'product_uom_qty' => $item->quantity,
                    'price_unit' => $item->unit_price,
                ]])->all(),
            ]);

            $this->order->update(['odoo_id' => $odooId]);
        } catch (OdooUnavailableException $e) {
            throw $e; // let the queue retry with backoff
        } catch (OdooException $e) {
            // A real error (e.g. missing product): retrying won't fix it.
            $this->order->update(['odoo_sync_error' => $e->getMessage()]);
            $this->fail($e);
        }
    }

    private function findOrCreatePartner(OdooClient $odoo): int
    {
        $customer = $this->order->customer;

        if ($customer->odoo_partner_id) {
            return $customer->odoo_partner_id;
        }

        $found = $odoo->searchRead('res.partner', [['email', '=ilike', $customer->email]], ['id'], 1);

        $partnerId = $found[0]['id'] ?? $odoo->create('res.partner', [
            'name' => $customer->name,
            'email' => $customer->email,
        ]);

        $customer->update(['odoo_partner_id' => $partnerId]);

        return $partnerId;
    }
}
```

What makes this safe to retry:

- **`odoo_id` on the order** stops a second sync once the first one succeeded.
- **Lookup by `client_order_ref`** covers the nasty case where Odoo created the record but the response never reached us.
- **`ShouldBeUnique`** prevents two copies of the same job running at once (this needs a cache driver that supports atomic locks).
- **Two exception types** decide between "try again later" and "stop and flag it".

The `[0, 0, {...}]` triplets are Odoo's command format for creating one2many lines inside the same `create` call.

Dispatch the job after the order is committed:

```php
PushOrderToOdoo::dispatch($order)->afterCommit();
```

## 5. Testing without a live Odoo instance

Because everything goes through Laravel's HTTP client, `Http::fake()` lets us simulate Odoo completely. The helper below answers based on the JSON-RPC `service` and `method`, so the tests read like a conversation with Odoo.

```php
<?php

use App\Jobs\PushOrderToOdoo;
use App\Models\Order;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

function fakeOdoo(array $answers): void
{
    Http::fake(function (Request $request) use ($answers) {
        $params = $request['params'];
        $key = $params['service'] === 'object'
            ? $params['args'][3] . '.' . $params['args'][4]   // e.g. "sale.order.create"
            : $params['service'] . '.' . $params['method'];  // e.g. "common.login"

        return Http::response(['jsonrpc' => '2.0', 'id' => $request['id'], 'result' => $answers[$key] ?? []]);
    });
}

it('pushes a new order to Odoo and stores the Odoo ID', function () {
    fakeOdoo([
        'common.login' => 7,
        'res.partner.search_read' => [],
        'res.partner.create' => 42,
        'sale.order.search_read' => [],
        'sale.order.create' => 1001,
    ]);

    $order = Order::factory()->hasItems(2)->create();

    PushOrderToOdoo::dispatchSync($order);

    expect($order->fresh()->odoo_id)->toBe(1001);
    Http::assertSent(fn (Request $r) => ($r['params']['args'][4] ?? null) === 'create'
        && $r['params']['args'][3] === 'sale.order');
});

it('does not create a duplicate when the order already exists in Odoo', function () {
    fakeOdoo([
        'common.login' => 7,
        'res.partner.search_read' => [['id' => 42]],
        'sale.order.search_read' => [['id' => 1001]],
    ]);

    $order = Order::factory()->hasItems(1)->create();

    PushOrderToOdoo::dispatchSync($order);

    expect($order->fresh()->odoo_id)->toBe(1001);
    Http::assertNotSent(fn (Request $r) => ($r['params']['args'][3] ?? null) === 'sale.order'
        && ($r['params']['args'][4] ?? null) === 'create');
});
```

Add one more test where the fake returns `Http::response('', 503)` and assert that an `OdooUnavailableException` is thrown, and one with an `error` payload to check that the order gets flagged. With those four tests the sync logic runs in CI in milliseconds.

## 6. Looking ahead: switching the transport to JSON-2

On Odoo 19 and later, the same `execute()` method can call the JSON-2 API instead. Model and method move into the URL, arguments are named, and the API key goes into the `Authorization` header:

```php
public function executeJson2(string $model, string $method, array $namedArgs = [], array $ids = []): mixed
{
    $response = Http::timeout($this->timeout)
        ->withToken($this->apiKey)
        ->withHeaders(['X-Odoo-Database' => $this->db])
        ->post(rtrim($this->url, '/') . "/json/2/{$model}/{$method}", ['ids' => $ids, ...$namedArgs]);

    $response->throw(); // JSON-2 uses real HTTP status codes for errors

    return $response->json();
}
```

Because all Odoo calls in the app go through one client class, the jobs and tests above barely change.

## Wrapping up

The pattern is small but holds up in production: one client class that knows how to talk to Odoo, queued jobs that are safe to retry, a clear split between "retry later" and "this needs a human", and tests that fake Odoo entirely. Start with one direction (for example orders from Laravel to Odoo), get it stable, then add the next sync.

---

*Gleni Isaku is CEO and co-founder of [Metanow](https://www.metanow.com), a development agency building Laravel, Next.js and Odoo solutions for European businesses.*
