codebar-ag / laravel-odoo
A simple way to interact with the Odoo API in Laravel
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- saloonphp/laravel-plugin: ^5.0
- saloonphp/saloon: ^4.5
- spatie/laravel-data: ^4.23
- spatie/laravel-package-tools: ^1.93
Requires (Dev)
- larastan/larastan: ^3.12
- laravel/pint: ^1.32
- nunomaduro/collision: ^8.9
- orchestra/testbench: ^11.3
- pestphp/pest: ^5.3
- pestphp/pest-plugin-laravel: ^5.0
- spatie/ray: ^1.48
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-04 14:43:33 UTC
README
This package was developed to give you a quick start to communicate with the Odoo external API from Laravel. It wraps the most common endpoints — sessions, users, employees, projects, tasks, timesheets, contacts and bank accounts — behind a clean, typed connector built on Saloon, plus generic calls for any other Odoo model.
⚠️ This package is not designed as a replacement of the official Odoo external API. See the Odoo documentation if you need further functionality. ⚠️
📑 Table of Contents
- What is Odoo?
- Requirements
- Installation
- Configuration
- Basic Usage
- API Reference
- DTOs
- Testing
- Changelog
- Contributing
- Security Vulnerabilities
- Credits
- License
💡 What is Odoo?
Odoo is an open-source suite of business applications covering CRM, sales, project management, timesheets, accounting, inventory and more. It exposes an external API that lets you read and write records across all of these modules. This package provides a typed, Laravel-friendly client for the most common Odoo endpoints used in day-to-day integrations.
🛠 Requirements
| Package | PHP | Laravel | Saloon | saloonphp/laravel-plugin |
|---|---|---|---|---|
| v1.11.0+ | ^8.4 | ^13.0 | ^4.5 | ^5.0 |
| v1.0.0 | ^8.4 | ^13.0 | ^4.0 | ^4.0 |
⚙️ Installation
You can install the package via composer:
composer require codebar-ag/laravel-odoo
🔧 Configuration
Optionally publish the config file to adjust defaults:
php artisan vendor:publish --provider="CodebarAg\Odoo\OdooServiceProvider" --tag="laravel-odoo-config"
You can generate an API key in your Odoo user profile under Preferences → API Keys.
Environment Variables
Add the following variables to your .env file:
LARAVEL_ODOO_URL=https://your-odoo-instance.com LARAVEL_ODOO_API_KEY=your-api-key LARAVEL_ODOO_DB=your-database LARAVEL_ODOO_TIMEOUT=15 # optional — request timeout in seconds (default 15, 0 = no timeout) LARAVEL_ODOO_MAX_REDIRECTS=5 # optional — max HTTP redirects to follow (default 5)
🚀 Basic Usage
Create an OdooConnector instance with your Odoo URL, API key, and optionally a database name:
use CodebarAg\Odoo\OdooConnector; $connector = new OdooConnector( baseUrl: 'https://your-odoo-instance.com', apiKey: 'your-api-key', db: 'your-database', // optional maxRedirects: 5, // optional — max HTTP redirects to follow (default 5) timeout: 15.0, // optional — request timeout in seconds (default 15, 0 = no timeout) );
Each method returns a typed response object with dedicated methods for accessing the data.
Using the Facade
If you set the environment variables above (or publish and edit the config file), the package binds a pre-configured OdooConnector in the container, so you can resolve it or use the Odoo facade instead of constructing it by hand:
use CodebarAg\Odoo\Facades\Odoo; $response = Odoo::health(); $response->isHealthy(); // bool
The facade reads url, api_key, db, timeout, and max_redirects from config/laravel-odoo.php (configurable via their respective LARAVEL_ODOO_* env vars). Direct instantiation with new OdooConnector(...) remains fully supported — for example when you need to talk to more than one Odoo instance.
The container binding is a singleton, so the facade always talks to the same connector instance (and shares its cached bank-account schema, see below).
Responses
Every typed response extends CodebarAg\Odoo\Responses\OdooResponse and shares these accessors next to its own data methods:
$response->successful(); // bool — HTTP 2xx $response->failed(); // bool — the opposite of successful() $response->status(); // int — HTTP status code $response->error(); // ?string — Odoo error message (error_description, error.data.message or error.message); null when successful $response->errorCode(); // ?string — Odoo error code (error or error.code); null when successful $response->body(); // string — raw response body
The data accessors never throw on a failed response: list accessors return [], id() / count() / dto() return null, and allowed() returns false.
Connector helpers
$connector->resolveBaseUrl(); // string — the Odoo URL passed to the constructor $connector->getApiKey(); // ?string — the API key (sent as "Authorization: Bearer <key>" when not null) $connector->getDb(); // ?string — the database (sent as "X-Odoo-Database" header when not null) // Bank-account schema detection (see Bank Accounts) $connector->usesModernBankAccountSchema(); // bool $connector->withBankAccountSchema(modern: true); // static — force the schema, skips detection
usesModernBankAccountSchema() tells you whether the connected Odoo uses the 19.3+ res.partner.bank
schema (account_number / holder_name, no bank_id / currency_id):
- On the first call it sends
version()(GET /web/version) and reads[major, minor]fromversion_infoviaVersionResponse::majorMinor(). Both the on-premise shape ([19, 3, …]) and the SaaS shape (["saas~19", 3, …]) are understood. - It returns
truewhen the major version is greater than 19, or when it is 19 and the minor version is 3 or higher. Otherwise — including when the version cannot be read — it returnsfalse(the classic ≤ 19.0 schema). - The result is cached on the connector instance, so the version is fetched at most once.
withBankAccountSchema(modern: true|false)sets that cached value directly, which skips the lookup.
getBankAccounts(), readBankAccount() (default fields), createBankAccount() and updateBankAccount() call it internally.
📖 API Reference
Session
// Check if the Odoo instance is reachable $response = $connector->health(); $response->isHealthy(); // bool // Get the Odoo server version $response = $connector->version(); $response->serverVersion(); // ?string e.g. "17" $response->serie(); // ?string e.g. "17.0" $response->majorMinor(); // ?array{0: int, 1: int} e.g. [19, 3] (also parses SaaS "saas~19") // List all available databases $response = $connector->databases(); $response->databases(); // array<string>
User
// Get the currently authenticated user $response = $connector->getUser( fields: ['name', 'email'], // optional — omit to get the default field set domain: [], // optional limit: 1, // optional, default 1 ); $user = $response->dto(); // ?UserDto — first record of the result // Get a user by their Odoo ID $response = $connector->getUserById( uid: 1, fields: ['name', 'email'], // optional — omit to get the default field set limit: 1, // optional, default 1 ); $user = $response->dto(); // ?UserDto // Get the authenticated user's context (res.users/context_get) $response = $connector->getUserContext(); $context = $response->dto(); // ?UserDto — only id (uid), lang and tz are filled
Employees
// Get an employee by their Odoo user ID $response = $connector->getEmployeeByUserId( userId: 1, fields: ['name', 'job_title'], // optional — omit to get the default field set limit: 1, // optional, default 1 ); $response->dto(); // ?EmployeeDto
Fields
// Get fields for a specific model (fields_get) $response = $connector->getFields( model: 'account.move', attributes: ['string', 'type'], // optional — field meta-attributes to return ); $response->fields(); // array<string, FieldDto> keyed by field name // Shortcut: fields of the timesheet model (account.analytic.line) $response = $connector->getAllFields(); $response->fields(); // array<string, FieldDto>
Permissions
// Check permissions for a model and operation $response = $connector->getPermissions( model: 'project.project', operation: 'read', // read, write, create, unlink ); $response->allowed(); // bool
Projects
$response = $connector->getProjects( fields: ['name', 'date_start', 'date'], // optional domain: [['active', '=', true]], // optional Odoo domain filter limit: 100, // optional, default 100 ); /** @var array<ProjectDto> $projects */ $projects = $response->projects(); use CodebarAg\Odoo\Dto\Projects\CreateProjectDto; use CodebarAg\Odoo\Dto\Projects\UpdateProjectDto; // Create a project $response = $connector->createProject(new CreateProjectDto( name: 'Website Relaunch', partnerId: 7, // optional, linked contact userId: 2, // optional, project manager allocatedHours: 40.0, // optional tagIds: [1, 2], // optional many-to-many tags extraValues: [], // optional, custom/studio fields )); $id = $response->id(); // ?int // Update a project (only provided fields are written) $response = $connector->updateProject(new UpdateProjectDto( id: 42, name: 'Website Relaunch 2.0', )); $response->ok(); // bool // Delete a project $response = $connector->deleteProject(id: 42); $response->ok(); // bool
Tasks
// Get all tasks $response = $connector->getAllTasks( fields: ['name', 'project_id', 'stage_id'], // optional domain: [['active', '=', true]], // optional limit: 100, // optional, default 100 ); /** @var array<TaskDto> $tasks */ $tasks = $response->tasks(); // Get tasks for a specific project $response = $connector->getTasksByProject( projectId: 42, fields: ['name', 'stage_id', 'date_deadline'], // optional limit: 100, // optional, default 100 operator: '=', // optional domain operator, default '=' ); /** @var array<TaskDto> $tasks */ $tasks = $response->tasks(); use CodebarAg\Odoo\Dto\Tasks\CreateTaskDto; use CodebarAg\Odoo\Dto\Tasks\UpdateTaskDto; // Create a task $response = $connector->createTask(new CreateTaskDto( name: 'Design homepage', projectId: 42, // optional userIds: [5, 6], // optional assignees (many-to-many) stageId: 1, // optional dateDeadline: '2026-07-01', priority: '1', // optional extraValues: [], // optional, custom/studio fields )); $id = $response->id(); // ?int // Update a task (only provided fields are written) $response = $connector->updateTask(new UpdateTaskDto( id: 42, name: 'Design homepage v2', stageId: 2, )); $response->ok(); // bool // Delete a task $response = $connector->deleteTask(id: 42); $response->ok(); // bool
Timesheets
use CodebarAg\Odoo\Dto\Timesheets\CreateTimesheetDto; use CodebarAg\Odoo\Dto\Timesheets\UpdateTimesheetDto; // Get timesheet entries $response = $connector->getTimesheetEntries( fields: ['name', 'project_id', 'task_id', 'unit_amount', 'date'], // optional domain: [['employee_id', '=', 5]], // optional limit: 100, // optional ); /** @var array<TimesheetEntryDto> $entries */ $entries = $response->entries(); // Get timesheet entries from the last N days $response = $connector->getTimesheetEntriesLastDays( days: 7, fields: ['name', 'date', 'unit_amount'], // optional operator: '>=', // optional domain operator, default '>=' ); $entries = $response->entries(); // array<TimesheetEntryDto> // Read a single timesheet entry $response = $connector->readTimesheet( id: 123, fields: ['name', 'unit_amount'], // optional — omit to get the default field set ); $entry = $response->dto(); // ?TimesheetEntryDto // Create a timesheet entry $response = $connector->createTimesheet(new CreateTimesheetDto( name: 'Fixed bug #456', projectId: 1, taskId: 10, date: '2024-06-11', unitAmount: 1.5, employeeId: 5, // optional extraValues: [], // optional — extra Odoo fields (e.g. custom Studio fields) )); $newId = $response->id(); // ?int // Update a timesheet entry (only provided fields are written) $response = $connector->updateTimesheet(new UpdateTimesheetDto( id: 123, name: 'Updated description', unitAmount: 2.0, extraValues: [], // optional — extra Odoo fields )); $response->ok(); // bool // Delete a timesheet entry $response = $connector->deleteTimesheet(id: 123); $response->ok(); // bool
Contacts
Create, update, delete and search operations for the res.partner model.
use CodebarAg\Odoo\Dto\Contacts\CreateContactDto; use CodebarAg\Odoo\Dto\Contacts\UpdateContactDto; // Search only for matching IDs (search) $response = $connector->searchContacts(domain: [['is_company', '=', true]]); $ids = $response->ids(); // array<int> // Count matching records (search_count) $response = $connector->searchCountContacts( domain: [['is_company', '=', true]], // optional, default [] (all contacts) ); $count = $response->count(); // ?int // Search by name (name_search) $response = $connector->nameSearchContacts( name: 'Acme', domain: [['is_company', '=', true]], // optional, default [] limit: 10, // optional, default 100 ); $results = $response->results(); // array — [id, display_name] tuples as Odoo returns them // Create a contact (create) $response = $connector->createContact(new CreateContactDto( name: 'Acme AG', isCompany: true, // optional street: 'Bahnhofstrasse 1', // optional zip: '8001', // optional city: 'Zürich', // optional countryId: 43, // optional — res.country ID email: 'info@acme.example', // optional phone: '+41 44 000 00 00', // optional extraValues: [], // optional — extra Odoo fields (e.g. custom Studio fields) )); $newId = $response->id(); // ?int // Update a contact (write) — only provided fields are written $response = $connector->updateContact(new UpdateContactDto( id: 42, email: 'billing@acme.example', )); $response->ok(); // bool // Delete a contact (unlink) $response = $connector->deleteContact(id: 42); $response->ok(); // bool
CreateContactDto and UpdateContactDto share the same optional fields. name is required on
create; UpdateContactDto requires id and makes name optional. Fields left null are not sent,
and extraValues is merged into the values at the top level.
| DTO property | Type | Odoo field |
|---|---|---|
name |
string |
name |
isCompany |
?bool |
is_company |
parentId |
?int |
parent_id |
type |
?string |
type |
street |
?string |
street |
street2 |
?string |
street2 |
city |
?string |
city |
zip |
?string |
zip |
stateId |
?int |
state_id |
countryId |
?int |
country_id |
phone |
?string |
phone |
mobile |
?string |
mobile |
email |
?string |
email |
website |
?string |
website |
comment |
?string |
comment |
function |
?string |
function |
lang |
?string |
lang |
titleId |
?int |
title |
userId |
?int |
user_id |
categoryId |
?int |
category_id |
vat |
?string |
vat |
ref |
?string |
ref |
active |
?bool |
active |
extraValues |
array |
merged as-is |
Reading contacts has no connector method. Send ReadContactRequest (one record by ID) or
ReadAllContactRequest (all records, empty domain, no limit) directly and hydrate ContactDto
yourself — see Request reference. Both request the default fields
id, name, email, phone, street, city, zip, country_id, is_company and parent_id
unless you pass fields. ContactDto exposes id, name, email, phone, street, city,
zip, countryId / countryName, isCompany and parentId / parentName.
Bank Accounts
CRUD and search operations for the res.partner.bank model.
Odoo 19.0 vs 19.3: Odoo renamed the bank-account fields in 19.3 (
acc_number→account_number,acc_holder_name→holder_name, and thebank_id/currency_idrelations were dropped). The connector detects the server version once (cached) and transparently uses the right field names for reads and writes, so the API below is the same on both. Read DTOs always expose the classic property names (accNumber,accHolderName, …). To skip the version lookup, force it with$connector->withBankAccountSchema(modern: true).
use CodebarAg\Odoo\Dto\BankAccounts\CreateBankAccountDto; use CodebarAg\Odoo\Dto\BankAccounts\UpdateBankAccountDto; // Search + read fields in one call (search_read) $response = $connector->getBankAccounts( fields: ['id', 'acc_number', 'partner_id', 'bank_id'], // optional (version-aware default) domain: [['partner_id', '=', 7]], // optional limit: 100, // optional ); /** @var array<BankAccountDto> $bankAccounts */ $bankAccounts = $response->bankAccounts(); // Search only for matching IDs (search) $response = $connector->searchBankAccounts(domain: [['partner_id', '=', 7]]); $ids = $response->ids(); // array<int> // Read a record by ID (read) $response = $connector->readBankAccount(id: 5); $bankAccounts = $response->bankAccounts(); // array<BankAccountDto> // Count matching records (search_count) $response = $connector->searchCountBankAccounts(domain: [['partner_id', '=', 7]]); $count = $response->count(); // ?int // Create a bank account (create) $response = $connector->createBankAccount(new CreateBankAccountDto( accNumber: 'CH9300762011623852957', partnerId: 7, accHolderName: 'Jane Doe', // optional bankName: 'UBS', // optional bankBic: 'UBSWCHZH80A', // optional bankId: 3, // optional — Odoo 19.0 only (ignored on 19.3) currencyId: 1, // optional — Odoo 19.0 only (ignored on 19.3) allowOutPayment: true, // optional sequence: 10, // optional extraValues: [], // optional — extra Odoo fields (e.g. custom Studio fields) )); $newId = $response->id(); // ?int // Update a bank account (write) $response = $connector->updateBankAccount(new UpdateBankAccountDto( id: 5, accHolderName: 'John Doe', )); $response->ok(); // bool // Delete a bank account (unlink) $response = $connector->deleteBankAccount(id: 5); $response->ok(); // bool
Generic Model Calls
Model-agnostic JSON-2 calls for any Odoo model and any public model method
(POST /json/2/<model>/<method>). Use them for models the package has no typed
requests for. Records come back as plain arrays exactly as Odoo returns them.
// Search + read with paging and ordering (search_read) $response = $connector->searchRead( model: 'sale.order', domain: [['partner_id', '=', 7]], // optional fields: ['name', 'state'], // optional limit: 20, // optional (default 80) offset: 40, // optional (default 0) order: 'date_order desc', // optional — omitted from the body when null ); $records = $response->records(); // array<int, array<string, mixed>> // Create a record (create) — sent as {"vals_list": [values]} $response = $connector->create('crm.lead', ['name' => 'Portal enquiry', 'partner_id' => 7]); $newId = $response->id(); // ?int // Write the same values to records (write) — sent as {"ids": [...], "vals": {...}} $response = $connector->write('res.partner', [7, 8], ['phone' => '+41 44 000 00 00']); $response->ok(); // bool — true when Odoo answered `true` // Call any public model method — params are sent as the JSON body unchanged $response = $connector->callMethod('sale.order', 'action_confirm', ['ids' => [12]]); $response = $connector->callMethod('sale.order', 'get_portal_url', [ 'ids' => [12], 'report_type' => 'pdf', 'download' => true, ]); $result = $response->result(); // mixed — any decoded JSON value (true, an id, a string, a list, …)
The underlying requests can also be sent directly:
use CodebarAg\Odoo\Requests\Api\Models\CallMethodRequest; use CodebarAg\Odoo\Requests\Api\Models\CreateRequest; use CodebarAg\Odoo\Requests\Api\Models\SearchReadRequest; use CodebarAg\Odoo\Requests\Api\Models\WriteRequest; $connector->send(new SearchReadRequest('sale.order', [['state', '=', 'sale']], ['name'], limit: 10, order: 'id desc')); $connector->send(new CreateRequest('crm.lead', ['name' => 'Portal enquiry'])); $connector->send(new WriteRequest('res.partner', [7], ['phone' => '+41 44 000 00 00'])); $connector->send(new CallMethodRequest('sale.order', 'action_confirm', ['ids' => [12]]));
Sync All
Fetch projects, all tasks, and all timesheet entries in one call:
$results = $connector->syncAll(); $projects = $results['projects']->projects(); // array<ProjectDto> $tasks = $results['tasks']->tasks(); // array<TaskDto> $timesheets = $results['timesheets']->entries(); // array<TimesheetEntryDto>
Request reference
Every Saloon request class shipped by the package. Request classes live under
CodebarAg\Odoo\Requests\… and response classes under CodebarAg\Odoo\Responses\…; the
namespace is given once per area. All /json/2/… endpoints are Odoo's JSON-2 API.
"send directly" means there is no connector method: $connector->send(...) returns a plain
Saloon Response.
| Area | Connector method | Request class | HTTP method + endpoint | Response class |
|---|---|---|---|---|
Session — Requests\Session, Responses\Session |
health() |
Health\HealthRequest |
GET /web/health |
HealthResponse |
version() |
Version\GetOdooVersionRequest |
GET /web/version |
VersionResponse |
|
databases() |
Database\GetDatabasesRequest |
POST /web/database/list (JSON-RPC) |
DatabasesResponse |
|
User — Api\User |
getUser() |
GetUserRequest |
POST /json/2/res.users/search_read |
UserResponse |
getUserById() |
GetUserByIdRequest |
POST /json/2/res.users/search_read |
UserResponse |
|
getUserContext() |
GetUserContextRequest |
POST /json/2/res.users/context_get |
UserContextResponse |
|
Employees — Api\Employees |
getEmployeeByUserId() |
GetEmployeeByUserIdRequest |
POST /json/2/hr.employee/search_read |
EmployeeResponse |
Fields — Api\Fields |
getFields(), getAllFields() |
GetFieldsRequest |
POST /json/2/{model}/fields_get |
FieldsResponse |
Permissions — Api\Permissions |
getPermissions() |
GetPermissionsRequest |
POST /json/2/{model}/has_access |
PermissionsResponse |
Projects — Api\Projects |
getProjects() |
GetProjectsRequest |
POST /json/2/project.project/search_read |
ProjectsResponse |
createProject() |
CreateProjectRequest |
POST /json/2/project.project/create |
CreateProjectResponse |
|
updateProject() |
UpdateProjectRequest |
POST /json/2/project.project/write |
MutateProjectResponse |
|
deleteProject() |
DeleteProjectRequest |
POST /json/2/project.project/unlink |
MutateProjectResponse |
|
Tasks — Api\Tasks |
getAllTasks() |
GetAllTasksRequest |
POST /json/2/project.task/search_read |
TasksResponse |
getTasksByProject() |
GetTasksByProjectRequest |
POST /json/2/project.task/search_read |
TasksResponse |
|
createTask() |
CreateTaskRequest |
POST /json/2/project.task/create |
CreateTaskResponse |
|
updateTask() |
UpdateTaskRequest |
POST /json/2/project.task/write |
MutateTaskResponse |
|
deleteTask() |
DeleteTaskRequest |
POST /json/2/project.task/unlink |
MutateTaskResponse |
|
Timesheets — Api\Timesheets |
getTimesheetEntries() |
GetTimesheetEntriesRequest |
POST /json/2/account.analytic.line/search_read |
TimesheetEntriesResponse |
getTimesheetEntriesLastDays() |
GetTimesheetEntriesLastDaysRequest |
POST /json/2/account.analytic.line/search_read |
TimesheetEntriesResponse |
|
readTimesheet() |
ReadTimesheetRequest |
POST /json/2/account.analytic.line/search_read |
TimesheetResponse |
|
createTimesheet() |
CreateTimesheetRequest |
POST /json/2/account.analytic.line/create |
CreateTimesheetResponse |
|
updateTimesheet() |
UpdateTimesheetRequest |
POST /json/2/account.analytic.line/write |
MutateTimesheetResponse |
|
deleteTimesheet() |
DeleteTimesheetRequest |
POST /json/2/account.analytic.line/unlink |
MutateTimesheetResponse |
|
Contacts — Api\Contacts |
send directly | ReadContactRequest |
POST /json/2/res.partner/read |
Saloon Response |
| send directly | ReadAllContactRequest |
POST /json/2/res.partner/search_read |
Saloon Response |
|
searchContacts() |
SearchContactRequest |
POST /json/2/res.partner/search |
SearchContactResponse |
|
searchCountContacts() |
SearchCountContactRequest |
POST /json/2/res.partner/search_count |
SearchCountContactResponse |
|
nameSearchContacts() |
NameSearchContactRequest |
POST /json/2/res.partner/name_search |
NameSearchContactResponse |
|
createContact() |
CreateContactRequest |
POST /json/2/res.partner/create |
CreateContactResponse |
|
updateContact() |
UpdateContactRequest |
POST /json/2/res.partner/write |
MutateContactResponse |
|
deleteContact() |
DeleteContactRequest |
POST /json/2/res.partner/unlink |
MutateContactResponse |
|
Bank Accounts — Api\BankAccounts |
getBankAccounts() |
GetBankAccountsRequest |
POST /json/2/res.partner.bank/search_read |
BankAccountsResponse |
readBankAccount() |
ReadBankAccountRequest |
POST /json/2/res.partner.bank/read |
BankAccountsResponse |
|
searchBankAccounts() |
SearchBankAccountRequest |
POST /json/2/res.partner.bank/search |
SearchBankAccountResponse |
|
searchCountBankAccounts() |
SearchCountBankAccountRequest |
POST /json/2/res.partner.bank/search_count |
SearchCountBankAccountResponse |
|
createBankAccount() |
CreateBankAccountRequest |
POST /json/2/res.partner.bank/create |
CreateBankAccountResponse |
|
updateBankAccount() |
UpdateBankAccountRequest |
POST /json/2/res.partner.bank/write |
MutateBankAccountResponse |
|
deleteBankAccount() |
DeleteBankAccountRequest |
POST /json/2/res.partner.bank/unlink |
MutateBankAccountResponse |
|
Generic models — Api\Models |
searchRead() |
SearchReadRequest |
POST /json/2/{model}/search_read |
SearchReadResponse |
create() |
CreateRequest |
POST /json/2/{model}/create |
CreateResponse |
|
write() |
WriteRequest |
POST /json/2/{model}/write |
WriteResponse |
|
callMethod() |
CallMethodRequest |
POST /json/2/{model}/{method} |
CallMethodResponse |
Api\… areas use CodebarAg\Odoo\Requests\Api\<Area> for the request and
CodebarAg\Odoo\Responses\Api\<Area> for the response (e.g. CodebarAg\Odoo\Requests\Api\Contacts\ReadContactRequest).
syncAll() sends GetProjectsRequest, GetAllTasksRequest and GetTimesheetEntriesRequest.
Sending a request without a connector method
Requests can always be sent with $connector->send(). Contacts are read this way:
use CodebarAg\Odoo\Dto\Contacts\ContactDto; use CodebarAg\Odoo\Requests\Api\Contacts\ReadAllContactRequest; use CodebarAg\Odoo\Requests\Api\Contacts\ReadContactRequest; // Read one contact by ID: new ReadContactRequest(int $id, array $fields = []) $response = $connector->send(new ReadContactRequest(id: 42)); $contact = ContactDto::fromArray($response->json()[0]); // Read with your own field list $response = $connector->send(new ReadContactRequest(id: 42, fields: ['id', 'name', 'email'])); // Read all contacts (empty domain, no limit): new ReadAllContactRequest(array $fields = []) $response = $connector->send(new ReadAllContactRequest); $contacts = collect($response->json()) ->map(fn (array $record) => ContactDto::fromArray($record)) ->all(); // array<ContactDto>
Requests that do have a connector method can be sent directly too, and every typed response can wrap
the Saloon response with fromResponse(). Note that the bank-account requests default to the classic
(≤ 19.0) field set when sent directly — the connector methods pick the version-aware set for you:
use CodebarAg\Odoo\Requests\Api\BankAccounts\BankAccountFields; use CodebarAg\Odoo\Requests\Api\BankAccounts\ReadBankAccountRequest; use CodebarAg\Odoo\Requests\Session\Health\HealthRequest; use CodebarAg\Odoo\Responses\Api\BankAccounts\BankAccountsResponse; $response = BankAccountsResponse::fromResponse( $connector->send(new ReadBankAccountRequest(id: 5, fields: BankAccountFields::MODERN)), ); $response->bankAccounts(); // array<BankAccountDto> $connector->send(new HealthRequest)->json('status'); // "pass" when healthy
Constructor arguments of the requests without a DTO:
| Request | Constructor |
|---|---|
HealthRequest, GetOdooVersionRequest, GetDatabasesRequest, GetUserContextRequest |
none |
GetUserRequest |
array $fields = [], array $domain = [], int $limit = 1 |
GetUserByIdRequest |
int $uid, array $fields = [], int $limit = 1 |
GetEmployeeByUserIdRequest |
int $userId, array $fields = [], int $limit = 1 |
GetFieldsRequest |
string $model, array $attributes = ['string', 'type', 'required', 'readonly', 'relation'] |
GetPermissionsRequest |
string $model, string $operation |
GetProjectsRequest, GetAllTasksRequest, GetTimesheetEntriesRequest, GetBankAccountsRequest |
array $fields = [], array $domain = [], int $limit = 100 |
GetTasksByProjectRequest |
int $projectId, array $fields = [], int $limit = 100, string $operator = '=' |
GetTimesheetEntriesLastDaysRequest |
int $days, array $fields = [], string $operator = '>=' |
ReadTimesheetRequest, ReadContactRequest, ReadBankAccountRequest |
int $id, array $fields = [] |
ReadAllContactRequest |
array $fields = [] |
SearchContactRequest, SearchBankAccountRequest |
array $domain |
SearchCountContactRequest, SearchCountBankAccountRequest |
array $domain = [] |
NameSearchContactRequest |
string $name, array $domain = [], int $limit = 100 |
DeleteProjectRequest, DeleteTaskRequest, DeleteTimesheetRequest, DeleteContactRequest, DeleteBankAccountRequest |
int $id |
SearchReadRequest |
string $model, array $domain = [], array $fields = [], int $limit = 80, int $offset = 0, ?string $order = null |
CreateRequest |
string $model, array $values |
WriteRequest |
string $model, array $ids, array $values |
CallMethodRequest |
string $model, string $modelMethod, array $params = [] |
The create/update requests take their DTO (new CreateContactRequest(CreateContactDto $dto), …);
CreateBankAccountRequest and UpdateBankAccountRequest additionally take bool $modern = false.
An empty $fields array means the request's default field set (except SearchReadRequest, which sends
the empty list to Odoo as-is).
📦 DTOs
Read DTOs are built on spatie/laravel-data.
Odoo's relation tuples ([id, name]) are flattened onto paired properties
(e.g. projectId / projectName) and its false-means-empty sentinel is
normalised to null. Each DTO keeps a fromArray() factory for backwards
compatibility and is also a full laravel-data Data object (from(), collect(), …).
All DTOs live under CodebarAg\Odoo\Dto\<Area>. Read DTOs extend CodebarAg\Odoo\Data\OdooData;
create/update DTOs are payloads whose toArray() builds the Odoo value map (null fields are
omitted, extraValues is merged at the top level).
| DTO | Namespace | Description |
|---|---|---|
UserDto |
Dto\Users |
Represents an Odoo user (read) |
EmployeeDto |
Dto\Employees |
Represents an Odoo employee (read) |
FieldDto |
Dto\Fields |
Represents a field definition on an Odoo model |
ProjectDto |
Dto\Projects |
Represents an Odoo project (read) |
CreateProjectDto |
Dto\Projects |
Payload for creating a project |
UpdateProjectDto |
Dto\Projects |
Payload for updating a project |
TaskDto |
Dto\Tasks |
Represents an Odoo task (read) |
CreateTaskDto |
Dto\Tasks |
Payload for creating a task |
UpdateTaskDto |
Dto\Tasks |
Payload for updating a task |
TimesheetEntryDto |
Dto\Timesheets |
Represents a timesheet entry (read) |
CreateTimesheetDto |
Dto\Timesheets |
Payload for creating a timesheet entry |
UpdateTimesheetDto |
Dto\Timesheets |
Payload for updating a timesheet entry |
ContactDto |
Dto\Contacts |
Represents an Odoo contact / res.partner (read) |
CreateContactDto |
Dto\Contacts |
Payload for creating a contact |
UpdateContactDto |
Dto\Contacts |
Payload for updating a contact |
BankAccountDto |
Dto\BankAccounts |
Represents a bank account / res.partner.bank (read) |
CreateBankAccountDto |
Dto\BankAccounts |
Payload for creating a bank account |
UpdateBankAccountDto |
Dto\BankAccounts |
Payload for updating a bank account |
🧪 Testing
composer test
For live integration tests against a real Odoo instance, copy phpunit.xml.dist to phpunit.xml, fill in the LARAVEL_ODOO_URL, LARAVEL_ODOO_API_KEY and LARAVEL_ODOO_DB env values, then run:
composer test:live
📝 Changelog
Please see CHANGELOG for more information on what has changed recently.
✏️ Contributing
Please see CONTRIBUTING for details.
🧑💻 Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
🙏 Credits
- Sebastian Bürgin-Fix
- Tobias Brogle
- All Contributors
- Skeleton Repository from Spatie
- Laravel Package Training from Spatie
🎭 License
The MIT License (MIT). Please see License File for more information.