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/jsonrpcand/xmlrpcendpoints as deprecated. According to Odoo's documentation, thecommonandobjectservices 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.
ODOO_URL=https://erp.example.comODOO_DB=productionODOO_API_KEY=your-api-key
// 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 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:
namespace App\Services\Odoo; class OdooException extends \RuntimeException {} // Odoo answered with an error: retrying usually won't helpclass 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:
// app/Providers/AppServiceProvider.phppublic 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 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_idon the order stops a second sync once the first one succeeded.- Lookup by
client_order_refcovers the nasty case where Odoo created the record but the response never reached us. ShouldBeUniqueprevents 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:
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 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:
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, a development agency building Laravel, Next.js and Odoo solutions for European businesses.