APIv3 → APIv4 Migration Guide¶
A reference for upgrading CiviCRM integrations from Api3 to Api4. See usage and actions for full Api4 documentation.
🚨 Key Breaking Changes¶
Read this if nothing else!
-
checkPermissionsalways defaults toTRUEin Api4: PHP calls in Api3 defaulted to no permission checks. -
Entity names must be CamelCase: Api3 was case-insensitive and would accept e.g.
'relationship','membership_type'. Api4 requires'Relationship','MembershipType', etc. -
Fields move out of the top level: In Api3, entity fields were top-level params (both
getandcreateactions looked like'first_name' => 'Bob'). In Api4 all fields go in the appropriate param, e.g.createuses'values' => ['first_name', 'Bob']orgetuses'where' => [['first_name', '=', 'Bob']]. -
returnis replaced byselect:'return' => 'id,display_name'becomes'select' => ['id', 'display_name']. -
The
optionsarray is gone:'options' => ['limit' => 25, 'sort' => 'name ASC', 'offset' => 10]becomes top-level'limit','orderBy', and'offset'params. -
createno longer upserts: Passing anidto Api3createwould silently update. In Api4,createinserts only. Useupdateorsave(upsert) instead. -
NULLmeansNULL: In Api3,NULLwas ignored and the string 'null' was used as a workaround. Api4 correctly savesNULL. -
Custom fields are referenced by name, not database ID:
custom_4becomesmy_group.My_Field(option group name + field name). -
unique_namealiases are no longer supported: Some fields have aunique_namethat differs from their canonical column name (e.g. theContact.employer_idfield hasunique_name = current_employer_id). Api4 only accepts the canonical name. -
Option matching requires explicit suffixes: In Api3, passing a string to an integer field (e.g.
'option_group_id' => 'participant_role') was implicitly resolved. In Api4 you must use the:namesuffix:'option_group_id:name' => 'participant_role'. -
Results are an
ArrayObject, not a plain array: Instead of nested$result['values'], iterate directly:foreach ($result as $row). -
Fine-grained error tracking: Api3 calls were all-or-nothing and would either throw an exception or return
'is_error'if something went wrong. Api4 tracks multiple successes and errors in theResultobject.
Get Actions¶
| Api3 | Api4 |
|---|---|
|
|
| v3 pattern | v4 equivalent |
|---|---|
'first_name' => 'Bob' (top-level filter) |
->addWhere('first_name', '=', 'Bob') |
'id' => ['IN' => [1,2,3]] |
->addWhere('id', 'IN', [1, 2, 3]) |
'return' => 'id,name' |
->addSelect('id', 'name') |
'options' => ['limit' => 25] |
->setLimit(25) |
'options' => ['offset' => 10] |
->setOffset(10) |
'options' => ['sort' => 'name ASC'] |
->addOrderBy('name', 'ASC') |
| Default limit: 25 (silently applied) | Default limit: none — all records returned |
'sequential' => 1 |
Default in v4; always sequential |
getsingle |
->execute()->first() or pass 0 as $index |
getvalue with 'return' => 'field' |
->addSelect('field')->execute()->first()['field'] |
getcount |
->selectRowCount()->execute()->countFetched() |
GetFields & GetOptions¶
| Api3 | Api4 |
|---|---|
|
|
- Api3
getfields→ Api4getFields: Note the camelCase. - Api4
getFieldssupports filtering: Return specific fields or specific items of metadata with->addWhere()and->addSelect(). getoptionsis merged intogetFields: PasssetLoadOptions(TRUE)(for simple key/value pairs) orsetLoadOptions(['id', 'name', 'label', 'description', 'icon', 'color', ...])(rich options).- Use
getFieldsto confirm canonical field names. Api4 only accepts a field's canonicalname(the key returned bygetFields), not itsunique_namealias. For example,Contact.employer_idhasunique_name = current_employer_id; passing'current_employer_id'toaddSelect()is silently ignored. The API Explorer is the easiest way to browse canonical names.
Create/Update Actions¶
| Api3 | Api4 |
|---|---|
|
|
| v3 action | v4 equivalent | Notes |
|---|---|---|
create (no id) |
create or save |
Api4 create supports insert only |
create (with id) |
update or save |
save is the closest v3 equivalent; update is more explicit |
create with options.match |
save with match param |
Match on fields other than id for upsert logic |
replace |
replace |
Api4 replace requires at least one value; use delete if the replacement set is empty |
Delete actions¶
| Api3 | Api4 |
|---|---|
|
|
- Multiple records can be deleted in one call using a
whereclause — v3 required looping. - No error if zero records match. v3 would throw an error; v4 returns an empty result.
- Soft delete (Contact):
deletemoves to trash by default. Pass->setUseTrash(FALSE)to permanently delete.
Results¶
| Api3 | Api4 |
|---|---|
|
|
| Topic | v3 | v4 |
|---|---|---|
| Return type | Plain array with values, count, is_error |
\Civi\Api4\Result (ArrayObject) |
| Iterate | foreach ($result['values'] as $row) |
foreach ($result as $row) |
| First record | $result['values'][0] |
$result->first() or $result[0] |
| Last record | end($result['values']) |
$result->last() |
| Count | $result['count'] |
$result->countFetched() |
| Index by field | (not supported) | $result->indexBy('id') or civicrm_api4(..., 'id') |
| Get nth result | (not supported) | civicrm_api4(..., 0) (first), civicrm_api4(..., -1) (last) |
| Value types | Raw DB strings (e.g. "1" for booleans) |
Properly typed (TRUE/FALSE, int, float) |
| Serialized fields | Raw delimited strings | Automatically unserialized to arrays |
Joins and pseudoconstants¶
| Api3 | Api4 |
|---|---|
|
|
- Implicit joins (dot notation:
email.email,employer_id.display_name) work in both Api3 and Api4. They follow foreign keys automatically on any field with afk_entity. See implicit joins. - Explicit joins (
addJoin) are Api4-only. They support LEFT/INNER/EXCLUDE join types, customONclauses,GROUP BY, aggregation functions, and joining through an EntityBridge. See explicit joins. - Pseudoconstant suffixes (
:name,:label,:icon) work on any field backed by an option list. Use them when reading or writing. See pseudoconstants. - Explicit suffix required for FK string matching. Api3 implicitly resolved
'option_group_id' => 'activity_type'. Api4 requires'option_group_id:name' => 'activity_type'. - Combine joins and suffixes:
contact_id.contact_type:label— traverse a FK then resolve the option label on the joined field.
Custom fields¶
| Api3 | Api4 |
|---|---|
|
|
- Format:
group_name.Field_Namewheregroup_nameis the machine name of the custom group andField_Nameis the machine name of the custom field. - No more numeric IDs.
custom_4identifiers are database-specific and non-portable; the name-based syntax works across any installation. - Discovery: Use
getFields(or the API Explorer) to find the full field names for custom fields on an entity.
Chaining¶
| Api3 | Api4 |
|---|---|
|
|
- Back-references must be explicit. Use
'$fieldName'syntax (e.g.'$id') to reference a value from the parent result. The available back-reference names are discoverable in the API Explorer. - Chain results are inlined. In v4 the chained results appear directly as a key on each parent row (e.g.
$row['emails']), not nested underapi.Email.get.values. - Prefer joins over chains when possible. Chains issue a new query per parent row; implicit joins are more efficient for simple related-field lookups. What can't be done with those can often be achieved with an explicit join.
See chaining for full details.
Error handling¶
| Api3 | Api4 |
|---|---|
|
|
- Fine-grained error tracking: Api3 calls were all-or-nothing and would either throw an exception or return
'is_error'if something went wrong. Api4 tracks multiple successes and errors in theResultobject. - Exception class:
\CRM_Core_Exception(replacesCiviCRM_API3_Exception). Subclasses exist for specific cases (e.g.\Civi\API\Exception\UnauthorizedException). ->first()on an empty result returnsNULLrather than throwing; use->single()to throw an exception when->countFetched() !== 1.