woduda / civicrm-php
PSR-18 compatible client for CiviCRM REST APIv4
Requires
- php: >=8.3
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.11
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- pestphp/pest: ^3.0
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.0
Suggests
- guzzlehttp/guzzle: Guzzle PSR-18 HTTP client
- symfony/http-client: Symfony PSR-18 HTTP client
README
A PSR-18 compatible client for the CiviCRM REST APIv4. Framework-agnostic, immutable and fully typed — it mirrors the ergonomics of modern API SDKs and acts as a typed transport over APIv4 (it is not an ORM).
CiviCRM APIv4 REST docs: https://docs.civicrm.org/dev/en/latest/api/v4/rest/
Contents
- Requirements
- Installation
- Quickstart
- Configuration & authentication
- Generic entity API (
CiviCrmClient) - Contact API
- Activity API
- Tag API
- Group API
- Custom fields
- Query builder (
GetQuery) - Write actions (
ActionRequest) - Chained calls (
ChainBuilder) - Responses
- Error handling
Requirements
- PHP >= 8.3
- Any PSR-18 HTTP client + PSR-17 factories (discovered automatically via php-http/discovery)
- The CiviCRM
authxextension for bearer-token auth
Installation
composer require woduda/civicrm-php
No concrete HTTP client is bundled. Install whichever PSR-18 implementation you prefer and it will be discovered automatically:
composer require guzzlehttp/guzzle
# or
composer require symfony/http-client nyholm/psr7
Quickstart
use Woduda\CiviCRM\CiviCrmClient; use Woduda\CiviCRM\Config; use Woduda\CiviCRM\Query\GetQuery; use Woduda\CiviCRM\Query\Operator; $client = CiviCrmClient::create(new Config( baseUrl: 'https://example.org/civicrm/ajax/api4/', apiKey: 'your-api-key', )); $contacts = $client->contacts()->get( GetQuery::new() ->select('id', 'display_name', 'email_primary.email') ->where('contact_type', Operator::Equals, 'Individual') ->orderBy('display_name') ->limit(25), ); foreach ($contacts as $contact) { echo $contact['display_name'], PHP_EOL; }
Configuration & authentication
Config is an immutable value object. The baseUrl must point at the APIv4
endpoint and end with a trailing slash:
use Woduda\CiviCRM\Config; $config = new Config( baseUrl: 'https://example.org/civicrm/ajax/api4/', apiKey: 'your-api-key', );
The client sends Authorization: Bearer {apiKey} together with the required
X-Requested-With: XMLHttpRequest header on every request.
CiviCrmClient::create() auto-discovers the installed PSR-18 client:
use Woduda\CiviCRM\CiviCrmClient; $client = CiviCrmClient::create($config);
Injecting your own HTTP client
Pass any PSR-18 client (e.g. one configured with timeouts, retries, or a mock
in tests) via CiviCrmClient's constructor:
use Woduda\CiviCRM\CiviCrmClient; use Woduda\CiviCRM\Http\Transport; $client = new CiviCrmClient( new Transport(new \Woduda\CiviCRM\Client($config, $myPsr18Client)), );
Generic entity API (CiviCrmClient)
CiviCrmClient is the primary entry point. It wraps a TransportInterface and
exposes a fluent API over any CiviCRM entity. All CRUD methods accept typed
builder objects directly — no .toParams() glue is needed.
Typed entity shortcuts
$client->contacts(); // ContactApi — typed Contact API with upsert, tag/group helpers $client->activities(); // ActivityApi — typed Activity API with logForContact helper $client->tags(); // TagApi — get-or-create tags, tag a contact $client->groups(); // GroupApi — get-or-create groups, manage membership
All four typed APIs expose getFields() and getActions() in addition to their
domain methods. See Contact API, Activity API,
Tag API, and Group API for the full method reference.
Arbitrary entities
Use entity(string) for any entity not covered by a shortcut:
$client->entity('Relationship')->get(GetQuery::new()->limit(10)); $client->entity('OptionValue')->create(['label' => 'VIP', 'option_group_id' => 1]);
CRUD methods
All CRUD methods return the values array (the equivalent of ApiResponse->values):
// Read $contacts = $client->contacts()->get( GetQuery::new()->where('last_name', Operator::Equals, 'Smith')->limit(50), ); // Create — returns the created record(s) $new = $client->contacts()->create([ 'contact_type' => 'Individual', 'first_name' => 'Jane', 'last_name' => 'Doe', ]); // Update — $where accepts a GetQuery or a raw APIv4 where array $client->contacts()->update( ['first_name' => 'Janet'], GetQuery::new()->where('id', Operator::Equals, 42), ); // or: $client->contacts()->update(['first_name' => 'Janet'], [['id', '=', 42]]); // Save (bulk upsert) $client->contacts()->save([ ['id' => 1, 'do_not_email' => true], ['id' => 2, 'do_not_email' => true], ]); // Delete — $where accepts a GetQuery or a raw APIv4 where array $client->contacts()->delete(GetQuery::new()->where('id', Operator::Equals, 42)); // or: $client->contacts()->delete([['id', '=', 42]]); // Metadata $fields = $client->contacts()->getFields(); // array of field definitions $actions = $client->contacts()->getActions(); // array of available actions
Escape hatch (raw)
For any action not exposed by typed methods, call raw() directly:
$result = $client->raw('Contact', 'merge', [ 'main_id' => 1, 'other_id' => 2, ]);
Contact API
$client->contacts() returns a ContactApi with domain-level helpers on top of
basic CRUD:
$contacts = $client->contacts(); // Read $all = $contacts->get(GetQuery::new()->where('contact_type', Operator::Equals, 'Individual')); $one = $contacts->getById(42); // returns array|null // Write $new = $contacts->create(['contact_type' => 'Individual', 'first_name' => 'Jane']); $upd = $contacts->update(42, ['last_name' => 'Doe']);
Email upsert
// Finds by email_primary.email; updates if found, creates (with email merged) if not. // ⚠ Not atomic — see source docblock for details. $contact = $contacts->upsertByEmail('jane@example.org', [ 'first_name' => 'Jane', 'contact_type' => 'Individual', ]);
Tag assignment
// Resolves tag names to IDs (creates missing ones) then saves all at once. // Idempotent — safe to call multiple times. $contacts->withTags(42, ['Donor', 'VIP']);
Group membership
// Resolves group titles to IDs (creates missing ones) then saves memberships. $contacts->addToGroups(42, ['Newsletter', 'Volunteers']);
Custom fields
// Validates each field name via CustomFieldResolver, then runs a single update. // Throws ValidationException if a field doesn't exist. $contacts->setCustomFields(42, 'Wolontariat', [ 'volunteer_status' => 'active', 'start_date' => '2024-01-01', ]);
Activity API
$activities = $client->activities(); // Generic create $activities->create([ 'activity_type_id.name' => 'Meeting', 'subject' => 'Kickoff', ]); // Convenience: link to a contact, default status = Completed $activities->logForContact(42, 'Phone Call', ['subject' => 'Intake call', 'duration' => 30]); // Returns a pre-filtered GetQuery — chain .select(), .limit() etc. as needed $query = $activities->forContact(42)->select('id', 'subject')->limit(20); $results = $activities->get($query);
Tag API
$tags = $client->tags(); // Returns ID of existing tag, or creates it and returns the new ID $tagId = $tags->ensureExists('VIP'); // Ensures the tag exists, then creates an EntityTag (idempotent) $tags->tagContact(42, 'VIP');
Group API
$groups = $client->groups(); // Returns ID of existing group, or creates it and returns the new ID $groupId = $groups->ensureExists('Newsletter'); // Add / remove membership (removeContact updates status → 'Removed' for audit trail) $groups->addContact(42, $groupId); $groups->removeContact(42, $groupId);
Custom fields
In CiviCRM APIv4, custom fields are addressed as "GroupName.field_name" in both
select arrays and values maps. CustomFieldResolver validates that a given
combination exists and caches the result per instance:
use Woduda\CiviCRM\Api\CustomFieldResolver; use Woduda\CiviCRM\Http\Transport; $resolver = new CustomFieldResolver(Transport::createDefault($config)); // Returns 'Wolontariat.volunteer_status' if the field exists $key = $resolver->resolve('Wolontariat', 'volunteer_status'); // Throws ValidationException if the field doesn't exist $resolver->resolve('Wolontariat', 'nonexistent'); // ❌
ContactApi::setCustomFields() uses a CustomFieldResolver internally — you do
not need to instantiate it yourself when going through $client->contacts().
Query builder (GetQuery)
GetQuery is an immutable builder: every method returns a new instance.
Pass a GetQuery directly to any get() or delete() call — there is no need
to call .toParams() when using CiviCrmClient.
use Woduda\CiviCRM\Query\GetQuery; use Woduda\CiviCRM\Query\Operator; $query = GetQuery::new() ->select('id', 'display_name', 'email_primary.email') ->where('contact_type', Operator::Equals, 'Individual') ->whereIn('id', [1, 2, 3]) ->orderBy('display_name', 'DESC') ->limit(50) ->offset(100); $contacts = $client->contacts()->get($query);
toParams() is available when you need the raw APIv4 params array:
$query->toParams(); // [ // 'select' => ['id', 'display_name', 'email_primary.email'], // 'where' => [['contact_type', '=', 'Individual'], ['id', 'IN', [1, 2, 3]]], // 'orderBy' => ['display_name' => 'DESC'], // 'limit' => 50, // 'offset' => 100, // ]
Other helpers: addSelect(), whereNull(), groupBy(), having().
Operators
Operator is a backed enum covering the APIv4 operators:
| Enum case | APIv4 |
|---|---|
Equals / NotEquals |
= / != |
GreaterThan / LessThan |
> / < |
GreaterOrEqual / LessOrEqual |
>= / <= |
Like / NotLike |
LIKE / NOT LIKE |
In / NotIn |
IN / NOT IN |
Between / NotBetween |
BETWEEN / NOT BETWEEN |
IsNull / IsNotNull |
IS NULL / IS NOT NULL |
Contains |
CONTAINS |
Unary operators omit the value automatically:
GetQuery::new()->where('deleted_date', Operator::IsNull)->toParams(); // ['where' => [['deleted_date', 'IS NULL']]]
AND / OR grouping
where() adds an AND condition; orWhere() groups with the previous
clause into an explicit APIv4 OR group (Laravel-style), and consecutive
orWhere() calls extend that group:
GetQuery::new() ->where('first_name', Operator::Equals, 'Jane') ->orWhere('first_name', Operator::Equals, 'John') ->where('is_deleted', Operator::Equals, 0) ->toParams(); // where => [ // ['OR', [['first_name', '=', 'Jane'], ['first_name', '=', 'John']]], // ['is_deleted', '=', 0], // ]
Write actions (ActionRequest)
ActionRequest models a single write action as an immutable value object with
named constructors. You can build it explicitly for complex operations, or pass
its rendered params to raw():
use Woduda\CiviCRM\Query\ActionRequest; // Build and introspect before sending $request = ActionRequest::create('Contact', [ 'contact_type' => 'Individual', 'first_name' => 'Jane', ]); // Send via raw() for full control $client->raw($request->entity, $request->action, $request->toParams());
ActionRequest::update('Contact', ['first_name' => 'Janet'], [['id', '=', 42]]); ActionRequest::save('Contact', [['first_name' => 'A'], ['first_name' => 'B']]); ActionRequest::delete('Contact', [['id', '=', 42]]);
Chained calls (ChainBuilder)
APIv4 chaining runs follow-up calls for each result of the primary call;
sub-calls reference the parent record via $id-style placeholders.
Attach a sub-call directly to an ActionRequest:
use Woduda\CiviCRM\Query\ActionRequest; $request = ActionRequest::create('Contact', ['first_name' => 'Jane']) ->withChain('email', ActionRequest::create('Email', [ 'contact_id' => '$id', 'email' => 'jane@example.org', ])); // chain => ['email' => ['Email', 'create', ['values' => [...]]]]
Or assemble several entries with ChainBuilder and merge them in:
use Woduda\CiviCRM\Query\ChainBuilder; use Woduda\CiviCRM\Query\GetQuery; use Woduda\CiviCRM\Query\Operator; $chain = ChainBuilder::new() ->create('email', 'Email', ['contact_id' => '$id', 'email' => 'jane@example.org']) ->get('activities', 'Activity', GetQuery::new()->where('source_contact_id', Operator::Equals, '$id')); $request = ActionRequest::create('Contact', ['first_name' => 'Jane']) ->withChainBuilder($chain);
A
GetQuerypassed toActionRequest::withChain()is chained as ageton the parent request's entity. To chain ageton a different entity, useChainBuilder::get()/ChainBuilder::add().
Execute a chained request via raw():
$result = $client->raw($request->entity, $request->action, $request->toParams());
Responses
CiviCrmClient methods (get, create, update, save, delete,
getFields, getActions, raw) return the values array directly:
$contacts = $client->contacts()->get(GetQuery::new()->limit(5)); // [['id' => 1, 'display_name' => 'Jane Doe'], ...]
The low-level transport returns an immutable ApiResponse value object when
you need the full response metadata:
use Woduda\CiviCRM\Http\Transport; $transport = Transport::createDefault($config); $response = $transport->send('Contact', 'get', ['limit' => 5]); $response->values; // array — returned records $response->count; // int — number of records reported by CiviCRM $response->version; // int — APIv4 version (4)
Error handling
Every exception thrown by the library implements CivicrmException, so you can
catch the whole library with one type:
use Woduda\CiviCRM\Exception\ApiException; use Woduda\CiviCRM\Exception\CivicrmException; use Woduda\CiviCRM\Exception\ValidationException; try { $client->contacts()->get(GetQuery::new()->limit(10)); } catch (ApiException $e) { // HTTP 4xx/5xx from CiviCRM: $e->getMessage() / $e->getCode() } catch (ValidationException $e) { // Invalid builder input (e.g. a bad orderBy direction) } catch (CivicrmException $e) { // Anything else originating from this library }
Transport-level failures surface as PSR-18 Psr\Http\Client\ClientExceptionInterface.