Skip to content

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!

  • checkPermissions always defaults to TRUE in 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 get and create actions looked like 'first_name' => 'Bob'). In Api4 all fields go in the appropriate param, e.g. create uses 'values' => ['first_name', 'Bob'] or get uses 'where' => [['first_name', '=', 'Bob']].

  • return is replaced by select: 'return' => 'id,display_name' becomes 'select' => ['id', 'display_name'].

  • The options array is gone: 'options' => ['limit' => 25, 'sort' => 'name ASC', 'offset' => 10] becomes top-level 'limit', 'orderBy', and 'offset' params.

  • create no longer upserts: Passing an id to Api3 create would silently update. In Api4, create inserts only. Use update or save (upsert) instead.

  • NULL means NULL: In Api3, NULL was ignored and the string 'null' was used as a workaround. Api4 correctly saves NULL.

  • Custom fields are referenced by name, not database ID: custom_4 becomes my_group.My_Field (option group name + field name).

  • unique_name aliases are no longer supported: Some fields have a unique_name that differs from their canonical column name (e.g. the Contact.employer_id field has unique_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 :name suffix: '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 the Result object.


Get Actions

Api3Api4
$result = civicrm_api3('contact', 'get', [
  'sequential' => 1,
  'first_name' => 'Bob',
  'return' => 'id,display_name',
  'options' => [
    'limit' => 10,
    'offset' => 5,
    'sort' => 'last_name ASC',
  ],
]);
foreach ($result['values'] as $row) {
  echo $row['display_name'];
}
$result = \Civi\Api4\Contact::get(FALSE)
  ->addWhere('first_name', '=', 'Bob')
  ->addSelect('id', 'display_name')
  ->setLimit(10)
  ->setOffset(5)
  ->addOrderBy('last_name', 'ASC')
  ->execute();
foreach ($result as $row) {
  echo $row['display_name'];
}
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

Api3Api4
// Discover fields on an entity
$fields = civicrm_api3('Contact', 'getfields', [
  'api_action' => 'get',
]);

// Get options for a specific field
$options = civicrm_api3('Contact', 'getoptions', [
  'field' => 'contact_type',
]);
// Discover fields on an entity
$fields = \Civi\Api4\Contact::getFields(FALSE)
  ->setAction('get')
  ->execute();

// Get options for a specific field
$options = \Civi\Api4\Contact::getFields(FALSE)
  ->addWhere('name', '=', 'contact_type')
  ->setLoadOptions(['id', 'name', 'label'])
  ->execute()
  ->single()['options'];
  • Api3 getfields → Api4 getFields: Note the camelCase.
  • Api4 getFields supports filtering: Return specific fields or specific items of metadata with ->addWhere() and ->addSelect().
  • getoptions is merged into getFields: Pass setLoadOptions(TRUE) (for simple key/value pairs) or setLoadOptions(['id', 'name', 'label', 'description', 'icon', 'color', ...]) (rich options).
  • Use getFields to confirm canonical field names. Api4 only accepts a field's canonical name (the key returned by getFields), not its unique_name alias. For example, Contact.employer_id has unique_name = current_employer_id; passing 'current_employer_id' to addSelect() is silently ignored. The API Explorer is the easiest way to browse canonical names.

Create/Update Actions

Api3Api4
// Create
civicrm_api3('contact', 'create', [
  'first_name' => 'Alice',
  'contact_type' => 'Individual',
]);

// Update (create-with-id)
civicrm_api3('contact', 'create', [
  'id' => 42,
  'first_name' => 'Alice',
]);

// Upsert multiple
// (not possible in Api3)
// Create
$contact = \Civi\Api4\Individual::create(FALSE)
  ->addValue('first_name', 'Alice')
  ->addValue('contact_type', 'Individual')
  ->execute()->first();

// Update one record
$contact = \Civi\Api4\Individual::update(FALSE)
  ->addWhere('id', '=', 42)
  ->addValue('first_name', 'Alice')
  ->execute()->first();

// Upsert multiple
$contacts = \Civi\Api4\Contact::save(FALSE)
  ->addRecord(['first_name' => 'New', 'last_name' => 'Contact'])
  ->addRecord(['id' => 42, 'first_name' => 'Existing'])
  ->execute();
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

Api3Api4
// Delete by id
civicrm_api3('Contact', 'delete', ['id' => 42]);

// No bulk delete — required a loop
foreach ($ids as $id) {
  civicrm_api3('Contact', 'delete', ['id' => $id]);
}
// Delete by id
\Civi\Api4\Contact::delete(FALSE)
  ->addWhere('id', '=', 42)
  ->execute();

// Delete multiple records matching a WHERE clause
\Civi\Api4\Contact::delete(FALSE)
  ->addWhere('source', '=', 'spam')
  ->execute();
  • Multiple records can be deleted in one call using a where clause — v3 required looping.
  • No error if zero records match. v3 would throw an error; v4 returns an empty result.
  • Soft delete (Contact): delete moves to trash by default. Pass ->setUseTrash(FALSE) to permanently delete.

Results

Api3Api4
$result = civicrm_api3('Contact', 'get', [...]);
// ['is_error' => 0, 'count' => 2, 'values' => [...]]
$first = $result['values'][0]; // (assuming `sequential`)
$count = $result['count'];
foreach ($result['values'] as $row) { ... }
$result = \Civi\Api4\Contact::get(FALSE)->...->execute();
// \Civi\Api4\Result (ArrayObject)
$first = $result->first();
$count = $result->countFetched();
$byId = $result->indexBy('id');
foreach ($result as $row) { ... }
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

Api3Api4
// Implicit join (dot notation) — supported
civicrm_api3('Contact', 'get', [
  'return' => 'id,display_name,email.email',
  'first_name' => 'Bob',
]);

// Explicit joins — not supported
// (requires a separate API call)
$contact = civicrm_api3('Contact', 'getsingle',
  ['id' => 42]);
$emails = civicrm_api3('Email', 'get',
  ['contact_id' => $contact['id']]);

// Writing using :name — not supported
// (integer id required)
civicrm_api3('Activity', 'create',
  ['activity_type_id' => 1]);

// Option match — implicit, no suffix
civicrm_api3('OptionValue', 'get',
  ['option_group_id' => 'activity_type']);
// Implicit join via dot notation
$result = \Civi\Api4\Contact::get(FALSE)
  ->addWhere('first_name', '=', 'Bob')
  ->addSelect('id', 'display_name', 'email.email')
  ->execute();

// Explicit join — filter/sort/aggregate
// across entities (not possible in Api3)
$result = \Civi\Api4\Contact::get(FALSE)
  ->addSelect('display_name',
    'GROUP_CONCAT(emails.email) AS all_emails')
  ->addJoin('Email AS emails', 'LEFT', NULL,
    ['id', '=', 'emails.contact_id'])
  ->addGroupBy('id')
  ->execute();

// Write using :name suffix instead of id
\Civi\Api4\Activity::create(FALSE)
  ->addValue('activity_type_id:name', 'Meeting')
  ->execute();

// Explicit :name suffix required for FK match
\Civi\Api4\OptionValue::get(FALSE)
  ->addWhere('option_group_id:name', '=',
    'activity_type')
  ->execute();
  • 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 a fk_entity. See implicit joins.
  • Explicit joins (addJoin) are Api4-only. They support LEFT/INNER/EXCLUDE join types, custom ON clauses, 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

Api3Api4
civicrm_api3('Contact', 'get', [
  'return' => 'custom_4,custom_7',
  'custom_4' => 'some value',
]);
\Civi\Api4\Contact::get(FALSE)
  ->addSelect(
    'constituent_information.Most_Important_Issue',
    'constituent_information.Region'
  )
  ->addWhere(
    'constituent_information.Most_Important_Issue',
    '=',
    'some value'
  )
  ->execute();
  • Format: group_name.Field_Name where group_name is the machine name of the custom group and Field_Name is the machine name of the custom field.
  • No more numeric IDs. custom_4 identifiers 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

Api3Api4
$result = civicrm_api3('Contact', 'get', [
  'id' => 42,
  'api.Email.get' => ['contact_id' => '$value.id'],
  'api.Phone.get' => ['contact_id' => '$value.id'],
]);
$emails = $result['values'][0]['api.Email.get']['values'];
$result = \Civi\Api4\Contact::get(FALSE)
  ->addWhere('id', '=', 42)
  ->addChain('emails', \Civi\Api4\Email::get(FALSE)
    ->addWhere('contact_id', '=', '$id')
  )
  ->addChain('phones', \Civi\Api4\Phone::get(FALSE)
    ->addWhere('contact_id', '=', '$id')
  )
  ->execute()->first();
$emails = $result['emails'];
  • 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 under api.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

Api3Api4
// The civicrm_api wrapper never throws exceptions it always returns 'is_error'
$result = civicrm_api('Relationship', 'create', [...]);
if ($result['is_error']) {
  // handle $result['error_message']
}

// The civicrm_api3 wrapper throws exceptions instead of returning 'is_error':
try {
  $result = civicrm_api3('Relationship', 'create', [...]);
}
catch (CiviCRM_API3_Exception $e) {
  if ($e->getErrorCode() === 'duplicate') {
    echo "Cannot save relationship: already exists!";
  }
  if ($e->getErrorCode() === 'invalid_relationship') {
    echo "Cannot save relationship: wrong contact type!";
  }}
try {
  $result = \Civi\Api4\Relationship::save(FALSE)
    ->setRecords([...])
    ->execute();
}
// Catch major errors
catch (\CRM_Core_Exception $e) {
  $message = $e->getMessage();
  $errorData = $e->getErrorData();
}
// Result will contain successfully saved relationships
$saved = $result->count();
// Errors saving individual records will be in the Result object
$unsaved = $result->getErrors();
foreach ($unsaved as $error) {
  if ($error->getCode() === 'duplicate') {
    echo "Cannot save relationship: already exists!";
  }
  if ($error->getCode() === 'invalid_relationship') {
    echo "Cannot save relationship: wrong contact type!";
  }
}
  • 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 the Result object.
  • Exception class: \CRM_Core_Exception (replaces CiviCRM_API3_Exception). Subclasses exist for specific cases (e.g. \Civi\API\Exception\UnauthorizedException).
  • ->first() on an empty result returns NULL rather than throwing; use ->single() to throw an exception when ->countFetched() !== 1.