Mapping Dolibarr - React
To expose Dolibarr objects to the React application, SmartMaker uses mapping classes prefixed with dm. A mapper describes which fields are published, under which name and which ones are writable.
Before writing a mapper: check that it does not already exist
SmartAuth ships a library of mappers for the Dolibarr core objects, in smartauth/dolMapping/. Do not rewrite your own for a standard object.
| Domain | Available mappers |
|---|---|
| Commercial documents | dmInvoice, dmOrder, dmProposal, dmContract, dmSupplierInvoice, dmSupplierOrder, dmSupplierProposal, dmShipment, dmReception, dmDeliveryNote |
| Third parties and contacts | dmThirdparty, dmContact, dmSupplier, dmUser |
| Catalogue and stock | dmProduct, dmCategory, dmWarehouse, dmStockMovement |
| Production | dmMo, dmBom |
| Members | dmMember, dmMemberType, dmSubscription, dmDonation |
| Project and planning | dmProject, dmTask, dmAgendaEvent, dmIntervention, dmExpenseReport, dmTicket |
| Accounting | dmBankAccount, dmBank, dmCompanyBankAccount, dmMulticurrency |
| Dictionaries | dmC*: dmCcountry, dmCstate, dmCpaymentterm, dmCunits, etc. (read only) |
Important
A module only writes a mapper for its own business objects. For a core object it consumes the SmartAuth mapper, possibly by inheriting from it. A bug fixed in dolMapping/ benefits every module; a mapper duplicated locally recreates the debt that centralisation removed.
And often, no mapper at all
For the core objects, SmartAuth exposes a generic REST facade built on these mappers. A module then has no CRUD, no search and no pagination to write:
GET objects/{objtype} paginated list (filters, sorting, search)
GET objects/{objtype}/describe field schema (objectDesc)
GET objects/{objtype}/{id} one object
POST objects/{objtype} creation
PATCH objects/{objtype}/{id} update
DELETE objects/{objtype}/{id} deletion
Documents with lines add objects/{objtype}/{id}/lines, workflows objects/{objtype}/{id}/actions/{action}, invoices objects/{objtype}/{id}/payments.
So write a mapper when you have your own Dolibarr class to expose. That is the case covered in the rest of this page.
Declaring a mapper
Minimal structure
<?php
namespace MyModule\Api;
// The target Dolibarr class MUST be loaded here
dol_include_once('/mymodule/class/myobject.class.php');
use SmartAuth\DolibarrMapping\dmBase;
use SmartAuth\DolibarrMapping\dmTrait;
class dmMyObject extends dmBase
{
use dmTrait;
// Mapper type: 'object' or 'dict'
protected $type = 'object';
// Dolibarr class represented: MANDATORY
protected $dolibarrClassName = 'MyObject';
// Element for the extrafields (elementtype column of llx_extrafields)
protected $parentTableElementToUseForExtraFields = 'myobject';
// Mapping: Dolibarr name => API name
protected $listOfPublishedFields = [
'rowid' => 'id',
'ref' => 'ref',
'label' => 'label',
'description' => 'description',
'fk_soc' => 'thirdparty',
'fk_statut' => 'status',
'date_creation' => 'created_at',
'note_public' => 'public_note',
'note_private' => 'private_note',
];
// Write allowlist: DOLIBARR names, never API names
protected $writableFields = [
'label',
'description',
'note_public',
];
public function __construct()
{
global $langs;
$langs->load("mymodule@mymodule");
$this->boot();
}
}
Calling boot() at the end of the constructor is mandatory.
Recognised properties
| Property | Mandatory | Role |
|---|---|---|
$type |
yes | object or dict |
$dolibarrClassName |
yes for object |
exact name of the Dolibarr class represented |
$listOfPublishedFields |
yes | map of Dolibarr name to API name |
$writableFields |
no, defaults to [] |
write allowlist, in Dolibarr names |
$listOfDerivedFields |
no | computed fields with no source column |
$parentTableElementToUseForExtraFields |
no | extrafields attachment |
$parentClassName |
no | only for a sub-object or line mapper |
$parentClassNameForLines |
no | Dolibarr class of the lines |
$listOfPublishedFieldsForLines |
no | map of the line fields |
$parentLabelForLines |
no | API key under which the lines are published |
$parentFieldsOverride |
no | patch of a field definition (type, required...) |
Pitfall: $dolibarrClassName is not derived from the mapper name
Warning
$dolibarrClassName is mandatory on every mapper of type object. It is not derived from the class name, and for good reason: the derivation would be wrong in most cases.
| Mapper | Actual Dolibarr class | What a derivation would give |
|---|---|---|
dmThirdparty |
Societe |
Thirdparty |
dmInvoice |
Facture |
Invoice |
dmOrder |
Commande |
Order |
dmProposal |
Propal |
Proposal |
dmIntervention |
Fichinter |
Intervention |
dmWarehouse |
Entrepot |
Warehouse |
dmMember |
Adherent |
Member |
dmShipment |
Expedition |
Shipment |
The mapper validates its declaration at boot() and raises an explicit LogicException if $dolibarrClassName is missing, or if the announced class does not exist (typically, a forgotten dol_include_once).
Pitfall: never use $parentClassName on a top-level object
$parentClassName is only for a sub-object or line mapper, to reach back to its parent. A header mapper must not declare it.
// WRONG: Product has no parent, and the property is the wrong one
class dmProduct extends dmBase
{
use dmTrait;
protected $parentClassName = 'Product';
}
// CORRECT
class dmProduct extends dmBase
{
use dmTrait;
protected $type = 'object';
protected $dolibarrClassName = 'Product';
}
// CORRECT: line mapper, with a real parent
class dmFichinterLigne extends dmBase
{
use dmTrait;
protected $type = 'object';
protected $dolibarrClassName = 'FichinterLigne';
protected $parentClassName = 'Fichinter';
}
Remember the distinction in one sentence: $dolibarrClassName answers "who am I?", $parentClassName answers "who is my parent?". Declaring both identical raises a LogicException at boot.
Reading: exportMappedData()
exportMappedData() converts a Dolibarr object into a JSON object using the API field names.
$object = new \MyObject($db);
$object->fetch($id);
$object->fetch_optionals(); // extrafields
$object->fetch_lines(); // lines, if the object has any
$mapper = new dmMyObject();
$payload = $mapper->exportMappedData($object);
What the method does along the way:
- it resolves the foreign keys declared in the Dolibarr
$fields, over two levels of depth at most - it includes the extrafields declared as
options_xxx - it includes the associated categories when the object has some (product, third party, contact, member)
- it exposes
nb_linked_files, and the full file list if$mapper->withFiles = true
Describing the schema to the front
objectDesc() returns the structural description of the object: fields, API types, translated labels, display position. This is what lets the front generate forms and lists without hard-coding the fields. The result is computed once at boot and cached.
$mapper = new dmMyObject();
$schema = $mapper->objectDesc();
Writing: writableFields and importMappedData()
importMappedData() is the inverse of exportMappedData(): it takes an API payload and returns a stdClass with Dolibarr names, ready to be applied to the object.
$mapper = new dmMyObject();
try {
$sanitized = $mapper->importMappedData($payload);
} catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) {
return [['errors' => $e->getErrors()], 400];
}
$object = new \MyObject($db);
$object->fetch($id);
foreach (get_object_vars($sanitized) as $field => $value) {
$object->$field = $value;
}
$object->update($user);
The contract
- any field absent from
$writableFieldsis rejected, not ignored - all the rejections are collected and reported in a single exception, not on the first one met
- API names are automatically mapped back to Dolibarr names
- each value is cast according to the type declared in the Dolibarr
$fields:integertoint,priceordoubletofloat,bool*to 0 or 1,dateto a timestamp - the
lineskey always raises an exception: lines do not go throughimportMappedData()
A mapper that does not declare $writableFields is entirely read only. That is the default behaviour, and it is deliberate.
Pitfall: writableFields holds Dolibarr names
Warning
Each entry of $writableFields must be a key of $listOfPublishedFields (the Dolibarr name), never a value (the API name). This is a silent bug: the field is rejected, no error is visible on the client side, and the object is never updated.
protected $listOfPublishedFields = [
'nom' => 'name',
'email' => 'email',
];
// CORRECT
protected $writableFields = ['nom', 'email'];
// WRONG: 'name' is the API name, not the Dolibarr name
protected $writableFields = ['name'];
This pitfall was hit three times in production before being locked down. The boot now raises a LogicException listing the offending entries.
What importMappedData() does not do
- no business validation: the mapper sanitises and casts, Dolibarr validates on persistence
- no line writing: go through
addline()andupdateline() - best-effort casting: a string
"abc"in an integer field becomes0, with no warning
Transforming a value: fieldFilterValueXxx()
To transform a field before export, declare a public method fieldFilterValue followed by the Dolibarr name of the field in CamelCase. The method name is the contract: no annotation, no registration.
/**
* Returns a signed URL instead of the raw file name.
*/
public function fieldFilterValueLogo($object, $value)
{
return '/upload/societe/' . $object->id . '/' . urlencode($value);
}
/**
* Fetches the associated contacts.
*/
public function fieldFilterValueContacts($object, $value)
{
return $object->liste_contact(-1, 'external');
}
Typical use cases: converting a timestamp, translating a code into a label, computing a value derived from another field.
Derived fields without a Dolibarr column
To publish a key that is not backed by any column, declare it in $listOfDerivedFields and not in $listOfPublishedFields. The fieldFilterValueXxx() method is then called without first checking that the source field is present.
Warning
Do not return base64 images in a list field. A list of 200 third parties with their logo inline saturates the response and the local database of the PWA. The convention is to publish a media URL and, for lists, a logo_mini thumbnail.
Extrafields
Extrafields are published like ordinary fields, by prefixing their name with options_:
protected $listOfPublishedFields = [
'options_mymodule_address' => 'intervention_address',
'options_mymodule_date_inter' => 'date_intervention',
];
The attachment is done through $parentTableElementToUseForExtraFields, which must be exactly the elementtype column of llx_extrafields for that object.
Extrafields configurable by the administrator
The SmartBoot skeleton shows the pattern: two constants list the extrafields to expose, read only and read-write, and the constructor adds them to the mapping.
public function __construct()
{
global $db;
$this->db = $db;
$extRO = getDolGlobalString('MYMODULE_SMARTMAKER_EXTRAFIELDS_RO');
if (!empty($extRO)) {
foreach (explode(',', $extRO) as $field) {
$field = trim($field);
if (!empty($field)) {
$key = 'options_' . $field;
$this->listOfPublishedFields[$key] = $key;
}
}
}
$extRW = getDolGlobalString('MYMODULE_SMARTMAKER_EXTRAFIELDS_RW');
if (!empty($extRW)) {
foreach (explode(',', $extRW) as $field) {
$field = trim($field);
if (!empty($field)) {
$key = 'options_' . $field;
$this->listOfPublishedFields[$key] = $key;
$this->writableFields[] = $key;
}
}
}
$this->boot();
}
Note
The extrafields themselves are never created in SQL. They are declared through $extrafields->addExtraField(...) in the init() of the module descriptor.
Objects with lines
// Dolibarr class of the lines
protected $parentClassNameForLines = 'MyObjectLine';
// Description of the line fields, to generate the form
protected $parentFieldsForLines = [
'id' => ['type' => 'integer', 'label' => 'ID', 'visible' => -1, 'position' => 10],
'date' => ['type' => 'datetime', 'label' => 'Date', 'visible' => 1, 'position' => 50],
'desc' => ['type' => 'html', 'label' => 'Description', 'visible' => 1, 'position' => 105],
'qty' => ['type' => 'integer', 'label' => 'Quantity', 'visible' => 1, 'position' => 110],
];
// Mapping of the line fields
protected $listOfPublishedFieldsForLines = [
'id' => 'id',
'date' => 'date',
'desc' => 'description',
'qty' => 'quantity',
'subprice' => 'unit_price',
'total_ht' => 'total',
];
// API key under which the lines are published
protected $parentLabelForLines = "linesDetail";
Lines are exposed for reading through this mechanism. For writing they go through the native Dolibarr methods addline(), updateline() and deleteline(), or through the facade routes objects/{objtype}/{id}/lines for the core objects.
Note
fetch() does not always load the lines. Call fetch_lines() before the export, otherwise the object comes out without its lines.
Adjusting a field description
$parentFieldsOverride patches the definition of a field coming up from Dolibarr, without touching the upstream class.
protected $parentFieldsOverride = [
'duree' => ['type' => 'duration', 'required' => 'required'],
'contacts' => ['type' => 'array'],
'fk_user' => ['type' => 'select'],
];
Typically: rendering a duration stored in seconds as a duration field on the front side, or making mandatory a field that Dolibarr considers optional.
Naming convention for the API fields
The mapper is where the Dolibarr vocabulary is left behind. Follow the common convention, otherwise two modules will publish the same object in two different shapes.
| Dolibarr | API |
|---|---|
rowid |
id |
ref_client |
customer_ref |
nom |
name |
town |
city |
fk_pays |
country |
fk_departement |
state |
phone_mobile |
mobile |
url |
website |
datec or date_creation |
created_at |
tms |
updated_at |
note_public |
public_note |
note_private |
private_note |
statut or status |
status |
A mapper that publishes a status field also exposes status_label, the localised label, when the client explicitly asks for it.
Warning
On the PWA side, store in the local database only the name published by the API. Keeping the Dolibarr alias "just in case" builds a database where the same data lives under two keys depending on where it came from. Real case: a third-party list displayed "Unnamed" on every row because one screen read nom where the mapper publishes name, while another screen displayed the same third parties correctly.
Usage in a Controller
public function show($payload = null)
{
global $db;
$id = (int) $payload['id'];
$object = new \MyObject($db);
if ($object->fetch($id) <= 0) {
dol_syslog(__METHOD__ . ' fetch failed for id=' . $id, LOG_ERR);
return [['error' => 'not found'], 404];
}
$object->fetch_optionals();
$object->fetch_lines();
$mapper = new dmMyObject();
return [$mapper->exportMappedData($object), 200];
}
public function update($payload = null)
{
global $db, $user;
$mapper = new dmMyObject();
try {
$sanitized = $mapper->importMappedData($payload);
} catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) {
dol_syslog(__METHOD__ . ' rejected fields: ' . $e->getMessage(), LOG_WARNING);
return [['errors' => $e->getErrors()], 400];
}
$object = new \MyObject($db);
if ($object->fetch((int) $payload['id']) <= 0) {
dol_syslog(__METHOD__ . ' fetch failed', LOG_ERR);
return [['error' => 'not found'], 404];
}
foreach (get_object_vars($sanitized) as $field => $value) {
$object->$field = $value;
}
if ($object->update($user) <= 0) {
dol_syslog(__METHOD__ . ' update failed: ' . $object->error, LOG_ERR);
return [['error' => $object->error], 500];
}
return [$mapper->exportMappedData($object), 200];
}
Dictionaries
A dictionary mapper describes one row of a llx_c_* table. Conventions:
protected $type = 'dict';(the termdictionaryis obsolete)$dolibarrClassNamedeclared if Dolibarr provides a dedicated class (Ccountry,Cstate,PaymentTerm,CUnits...), absent otherwise$writableFieldsstays empty: dictionaries are managed from the Dolibarr administration- expose at least
codeandlabel
The mapper and offline synchronization
A point often discovered too late: offline synchronization has no mapping
layer of its own. It goes through your Dm mappers, the very same ones the
synchronous REST facade uses.
The syncable object registry
ObjectRegistry.php, on the SmartAuth side, is the single source of truth. For
each of the 26 built-in types it declares the Dolibarr class, the table, the
required rights and the mapper:
'thirdparty' => [
'class' => 'Societe',
'table' => 'societe',
'mapper' => '\\SmartAuth\\DolibarrMapping\\dmThirdparty',
'rights' => [ /* ... */ ],
],
That same registry serves the REST facade and the sync engine. There is therefore no risk of an object behaving differently depending on the entry door.
What the mapper decides for synchronization
| Step | What applies |
|---|---|
| Pull | The SQL row is reloaded then passed through exportMappedData(). The client receives the published API field names, not the Dolibarr columns |
| Push | The write allowlist is $writableFields. There is no second list specific to synchronization |
| Isolation | For a table without an entity column, multi-company isolation comes from the mapper's isolationWhereSql() |
Three practical consequences:
- A mapper without
$writableFieldsis read-only, therefore not writable through synchronization. The pull will work, the push will reject everything. - A field missing from
$writableFieldsis silently rejected on push, exactly as on the REST facade. The client sees no error and the object is not updated. - If no mapper resolves for a type without an
entitycolumn, the engine pulls nothing and refuses any write. This fail-closed behaviour is deliberate: better to synchronize nothing than to leak another entity's data.
Declaring a custom syncable object
A module adds its own types through the smartmaker_registerSyncableObjects
hook. The mapper is declared exactly as for a built-in type:
public function smartmaker_registerSyncableObjects($parameters, &$object, &$action, $hookmanager)
{
$object['mymodule_product'] = [
'class' => 'Product',
'file' => DOL_DOCUMENT_ROOT.'/product/class/product.class.php',
'table' => 'product',
'element' => 'product',
'module' => 'product',
'mapper' => '\\SmartAuth\\DolibarrMapping\\dmProduct',
'pull_where' => 'tosell = 1',
'rights' => [
'read' => ['produit', 'lire'],
'create' => ['produit', 'creer'],
'update' => ['produit', 'creer'],
'delete' => ['produit', 'supprimer'],
],
];
return 0;
}
The point of a dedicated type rather than the native product is the
pull_where: it narrows the embedded dataset without affecting the other
consumers of product. The server also publishes the exclusions in the delete
list, which saves the client from having to clean up its orphans.
A client discovers the available types, and its effective rights on each of
them, through GET /sync/objects. Hard-coding a synchronization scope on the
PWA side is therefore unnecessary.
SmartAuth's document controller, however, only knows the native types (
product,category,thirdparty,project,intervention). A custom type applies to entities, not to attached file downloads. See Synchronization - reference catalog.
Pitfalls to know about
| Symptom | Cause |
|---|---|
LogicException on the first new dmXxx() |
$dolibarrClassName missing, or class not loaded by dol_include_once |
LogicException mentioning the parent |
$parentClassName declared on a header mapper, or equal to $dolibarrClassName |
| a writable field is ignored with no error | $writableFields holds the API name instead of the Dolibarr name |
| the object comes out without its lines | fetch_lines() not called before the export |
| the extrafields are missing | fetch_optionals() not called, or $parentTableElementToUseForExtraFields incorrect |
| huge response and slow PWA | base64 images inline in a list field |
| empty front list or "Unnamed" | the PWA reads the Dolibarr name instead of the published API name |
| the offline push writes nothing, with no error | $writableFields empty, or holding the API name instead of the Dolibarr name |
| a syncable type returns no data | no mapper resolves for a type without an entity column: the engine is fail-closed |
See also
- Back (PHP) - Routes and Controllers
- API requests - React side
- Offline synchronization - the mapper seen from the client
- SmartAuth - foundation and mappers of the Dolibarr core
- Training - Mappers