PHPackages                             woduda/civicrm-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. woduda/civicrm-php

ActiveLibrary[HTTP &amp; Networking](/categories/http)

woduda/civicrm-php
==================

PSR-18 compatible client for CiviCRM REST APIv4

1.0.1(1mo ago)0951MITPHPPHP &gt;=8.3CI passing

Since Jun 2Pushed 1mo agoCompare

[ Source](https://github.com/woduda/civicrm-php)[ Packagist](https://packagist.org/packages/woduda/civicrm-php)[ RSS](/packages/woduda-civicrm-php/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (7)Dependencies (26)Versions (13)Used By (1)

civicrm-php
===========

[](#civicrm-php)

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:

Contents
--------

[](#contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Quickstart](#quickstart)
- [Configuration &amp; authentication](#configuration--authentication)
- [Generic entity API (`CiviCrmClient`)](#generic-entity-api-civicrmclient)
    - [Typed entity shortcuts](#typed-entity-shortcuts)
    - [Arbitrary entities](#arbitrary-entities)
    - [CRUD methods](#crud-methods)
    - [Escape hatch (`raw`)](#escape-hatch-raw)
- [Contact API](#contact-api)
- [Activity API](#activity-api)
- [Tag API](#tag-api)
- [Group API](#group-api)
- [Email API](#email-api)
- [Phone API](#phone-api)
- [Address API](#address-api)
- [Relationship API](#relationship-api)
- [Note API](#note-api)
- [Event API](#event-api)
- [Participant API](#participant-api)
- [Contribution API](#contribution-api)
- [Custom fields](#custom-fields)
- [Query builder (`GetQuery`)](#query-builder-getquery)
    - [Operators](#operators)
    - [AND / OR grouping](#and--or-grouping)
- [Write actions (`ActionRequest`)](#write-actions-actionrequest)
- [Chained calls (`ChainBuilder`)](#chained-calls-chainbuilder)
- [Responses](#responses)
- [Resilience &amp; logging](#resilience--logging)
- [Error handling](#error-handling)

Requirements
------------

[](#requirements)

- PHP &gt;= 8.3
- Any [PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP client + PSR-17 factories (discovered automatically via [php-http/discovery](https://github.com/php-http/discovery))
- The CiviCRM [`authx`](https://docs.civicrm.org/dev/en/latest/framework/authx/)extension for bearer-token auth

Installation
------------

[](#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
----------

[](#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->displayName, PHP_EOL;
}
```

Configuration &amp; authentication
----------------------------------

[](#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

[](#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`)
------------------------------------

[](#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

[](#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
$client->notes();         // NoteApi             — contact notes (add, list, delete)
$client->events();        // EventApi            — events with capacity and date filtering
$client->participants();  // ParticipantApi      — event attendance lifecycle
$client->emails();        // EmailApi            — typed Email API for contact sub-entity
$client->phones();        // PhoneApi            — typed Phone API for contact sub-entity
$client->addresses();     // AddressApi          — typed Address API for contact sub-entity
$client->relationships();     // RelationshipApi      — contact-to-contact relationships
$client->relationshipTypes(); // RelationshipTypeApi  — relationship type catalog / seeding
$client->contributions();     // ContributionApi      — record and query donations
$client->financialTypes();    // FinancialTypeResolver — resolve type names to IDs
```

All typed APIs expose `getFields()` and `getActions()` in addition to their domain methods. See [Contact API](#contact-api), [Activity API](#activity-api), [Tag API](#tag-api), [Group API](#group-api), [Note API](#note-api), [Event API](#event-api), [Participant API](#participant-api), [Email API](#email-api), [Phone API](#phone-api), [Address API](#address-api), and [Relationship API](#relationship-api) for the full method reference.

### Arbitrary entities

[](#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

[](#crud-methods)

Typed entity APIs (`contacts()`, `activities()`, …) return a `Result` of typed DTOs. `GenericApi` (`entity()`) returns the raw `array` values directly.

```
// Typed read — Result (iterable of Contact DTOs)
$contacts = $client->contacts()->get(
    GetQuery::new()->where('last_name', Operator::Equals, 'Smith')->limit(50),
);

// Typed create — Result
$new = $client->contacts()->create([
    'contact_type' => 'Individual',
    'first_name'   => 'Jane',
    'last_name'    => 'Doe',
]);

// Typed update (ContactApi) — update by ID
$client->contacts()->update(42, ['first_name' => 'Janet']);
```

`GenericApi` (`entity()`) provides `update/save/delete` with flexible `$where` arguments:

```
// Generic update — $where accepts a GetQuery or a raw APIv4 where array
$client->entity('Contact')->update(
    ['first_name' => 'Janet'],
    GetQuery::new()->where('id', Operator::Equals, 42),
);
// or:
$client->entity('Contact')->update(['first_name' => 'Janet'], [['id', '=', 42]]);

// Save (bulk upsert)
$client->entity('Contact')->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->entity('Contact')->delete(GetQuery::new()->where('id', Operator::Equals, 42));
// or:
$client->entity('Contact')->delete([['id', '=', 42]]);
```

```
// Metadata (available on all typed and generic APIs)
$fields  = $client->contacts()->getFields();   // array of field definitions
$actions = $client->contacts()->getActions();  // array of available actions
```

### Escape hatch (`raw`)

[](#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
-----------

[](#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 Contact|null

// Write
$new   = $contacts->create(['contact_type' => 'Individual', 'first_name' => 'Jane']);
$upd   = $contacts->update(42, ['last_name' => 'Doe']);
```

### Email upsert

[](#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

[](#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

[](#group-membership)

```
// Resolves group titles to IDs (creates missing ones) then saves memberships.
$contacts->addToGroups(42, ['Newsletter', 'Volunteers']);
```

### Custom fields

[](#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',
]);
```

### Primary email, phone, and address shortcuts

[](#primary-email-phone-and-address-shortcuts)

Convenience methods that update the primary sub-entity record, or create one with `is_primary = true` when none exists:

```
$email = $contacts->updatePrimaryEmail(42, 'jane@example.org');

$phone = $contacts->updatePrimaryPhone(42, '+48123456789', 'Mobile');

$address = $contacts->updatePrimaryAddress(42, AddressData::fromArray([
    'street_address' => 'Main St 1',
    'city'           => 'Warsaw',
    'postal_code'    => '00-001',
    'country'        => 'PL',
]));
```

These delegate to `EmailApi`, `PhoneApi`, and `AddressApi` — use those directly when you need full control (multiple locations, billing flags, etc.).

Activity API
------------

[](#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
-------

[](#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
---------

[](#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);
```

Note API
--------

[](#note-api)

`$client->notes()` returns a `NoteApi` for the CiviCRM `Note` entity. Notes attach to any entity, but are most commonly linked to contacts (`entity_table = 'civicrm_contact'`).

```
$notes = $client->notes();

// Add a note to a contact (defaults: privacy = 'public', no subject)
$note = $notes->addToContact(42, 'Called to confirm appointment.');

// With subject and custom privacy
$note = $notes->addToContact(42, 'Internal memo.', 'Follow-up', 'private');

// All notes for a contact, most recently modified first
foreach ($notes->forContact(42) as $note) {
    echo $note->modifiedDate->format('Y-m-d'), ' — ', $note->note, PHP_EOL;
}

// Delete by ID
$notes->delete($note->id);

// Arbitrary query — escape hatch for more complex filtering
$results = $notes->get(
    GetQuery::new()->where('subject', Operator::Equals, 'Follow-up')->limit(10),
);
```

### `Note` DTO fields

[](#note-dto-fields)

PropertyTypeCiviCRM field`id``int``id``entityTable``string``entity_table``entityId``int``entity_id``subject``?string``subject``note``string``note``privacy``?string``privacy``modifiedDate``DateTimeImmutable``modified_date``contactIdCreator``?int``contact_id``rawData``array`full APIv4 rowContribution API
----------------

[](#contribution-api)

Record and query donations. `recordOneTime()` resolves the financial type name to an integer ID automatically; `create()` is the low-level escape hatch for full control.

```
$contributions = $client->contributions();

// Record a completed donation
$contribution = $contributions->recordOneTime(
    contactId: 42,
    amount: 500.00,
    currency: 'PLN',
    financialType: 'Donation',
);

// Low-level create (financial_type_id must be an int)
$typeId = $client->financialTypes()->resolve('Donation'); // e.g. 1
$contribution = $contributions->create([
    'contact_id'                  => 42,
    'total_amount'                => 100.00,
    'currency'                    => 'PLN',
    'financial_type_id'           => $typeId,
    'contribution_status_id:name' => 'Completed',
]);

// All contributions for a contact (newest first)
$history = $contributions->forContact(42);

// Aggregated lifetime statistics (2 transport calls)
$totals = $contributions->totalsForContact(42, 'PLN');
echo $totals->lifetimeTotal;       // e.g. 925.0
echo $totals->last12MonthsCount;   // e.g. 2

// Mark a pending contribution completed
$contributions->markCompleted($contribution->id, 'TXN-001');

// Refund with optional reason (creates a Follow Up Activity on the contact)
$contributions->refund($contribution->id, 'Duplicate payment');
```

### `Contribution` DTO fields

[](#contribution-dto-fields)

PropertyTypeCiviCRM field`id``int``id``contactId``int``contact_id``totalAmount``float``total_amount``currency``string``currency``receiveDate``DateTimeImmutable``receive_date``status``ContributionStatus``contribution_status_id:name``financialTypeId``int``financial_type_id``source``?string``source``invoiceNumber``?string``invoice_number``trxnId``?string``trxn_id``paymentInstrumentId``?int``payment_instrument_id``campaignId``?int``campaign_id``rawData``array`full APIv4 row`ContributionStatus` is a backed string enum. Values are the CiviCRM `contribution_status` option\_value names from a default installation. If your site customises these values, read the raw `contribution_status_id:name`from `$contribution->rawData` and use `ContributionStatus::tryFrom()`.

Relationship API
----------------

[](#relationship-api)

CiviCRM relationships are **directional and asymmetric**: one record links contact A to contact B under a single type, and that type reads with two different labels depending on the direction. For the classic "Reports to" / "Manages" type, when employee `#42` reports to manager `#7` (`contact_id_a = 42`, `contact_id_b = 7`): side A's label is "Reports to" and side B's is "Manages". The API keeps both sides explicit (`labelAToB` / `labelBToA`, `nameAToB` / `nameBToA`) rather than hiding the direction.

```
$types = $client->relationshipTypes();

// Idempotent get-or-create — the building block for a schema seeder
$type = $types->ensureExists(
    nameAToB: 'Reports to',
    nameBToA: 'Manages',
    labelAToB: 'Reports to',
    labelBToA: 'Manages',
    contactTypeA: 'Individual', // null = any contact type allowed on side A
    contactTypeB: 'Individual',
);

// byName matches EITHER direction — both return the same type record
$types->byName('Reports to'); // forward (A→B)
$types->byName('Manages');    // reverse (B→A)

$relationships = $client->relationships();

// Create employee(42) → manager(7). Type may be the forward name or the type id —
// both produce an identical Relationship.create request.
$rel = $relationships->create(42, 7, 'Reports to', new DateTimeImmutable('2025-01-01'));

// Every relationship the contact takes part in, on either side (A or B)
foreach ($relationships->forContact(42) as $r) {
    // $r->labelAToB === 'Reports to', $r->labelBToA === 'Manages'
}

// Pre-filtered, refinable query: ofType() returns a GetQuery
$recent = $relationships->get(
    $relationships->ofType('Reports to')->where('start_date', Operator::GreaterThan, '2025-01-01'),
);

// End a relationship: sets end_date and is_active = false
$relationships->terminate($rel->id, new DateTimeImmutable('2026-01-01'));
```

Email API
---------

[](#email-api)

Email, Phone, and Address are separate CiviCRM entities with their own IDs. Each typed sub-entity API returns `Result` and provides contact-scoped helpers.

```
$emails = $client->emails();

// All emails for a contact (primary first)
$all = $emails->forContact(42);

// Primary email, or null
$primary = $emails->primary(42);

// Mark as primary — CiviCRM unsets is_primary on other emails for that contact
$emails->setPrimary(101);

// Add / remove
$email = $emails->add(42, 'jane@example.org', 'Home', isPrimary: true);
$emails->remove(101);
```

Phone API
---------

[](#phone-api)

```
$phones = $client->phones();

$all     = $phones->forContact(42);
$primary = $phones->primary(42);
$phones->setPrimary(201);
$phone   = $phones->add(42, '+48123456789', 'Mobile', 'Home', isPrimary: true);
$phones->remove(201);
```

Address API
-----------

[](#address-api)

```
$addresses = $client->addresses();

$all     = $addresses->forContact(42);
$primary = $addresses->primary(42);
$addresses->setPrimary(301);

// addFromData resolves country (ISO-2 or name) and state/province via Country.get
$address = $addresses->addFromData(42, AddressData::fromArray([
    'street_address' => 'Main St 1',
    'city'           => 'Warsaw',
    'postal_code'    => '00-001',
    'country'        => 'PL',
    'state_province' => 'Mazovia',
]), isPrimary: true);

$addresses->remove(301);
```

Custom fields
-------------

[](#custom-fields-1)

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()`.

The resolver is entity-agnostic: custom groups extending Email, Phone, or Address (if configured in CiviCRM) use the same `"GroupName.field_name"` notation. Validate with `resolve()` and pass the dotted key in `GetQuery::select()` or create/update values.

Query builder (`GetQuery`)
--------------------------

[](#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

[](#operators)

`Operator` is a backed enum covering the APIv4 operators:

Enum caseAPIv4`Equals` / `NotEquals``=` / `!=``GreaterThan` / `LessThan``>` / `=` / `where('deleted_date', Operator::IsNull)->toParams();
// ['where' => [['deleted_date', 'IS NULL']]]
```

### AND / OR grouping

[](#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`)
-------------------------------

[](#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`)
------------------------------

[](#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 `GetQuery` passed to `ActionRequest::withChain()` is chained as a `get` on the parent request's entity. To chain a `get` on a *different* entity, use `ChainBuilder::get()` / `ChainBuilder::add()`.

Execute a chained request via `raw()`:

```
$result = $client->raw($request->entity, $request->action, $request->toParams());
```

Responses
---------

[](#responses)

**Typed entity APIs** (`contacts()`, `activities()`, `notes()`, …) return a `Result` — an immutable, iterable value object wrapping hydrated DTOs:

```
$result = $client->contacts()->get(GetQuery::new()->limit(5));

foreach ($result as $contact) {      // Contact DTOs
    echo $contact->displayName;
}

$result->count;         // int   — count reported by CiviCRM
$result->values;        // array — raw DTO array if needed
$result->first();       // ?T    — first item, or null
```

**`GenericApi`** (`entity()`) and **`raw()`** return `array` — the raw APIv4 values array — without hydration:

```
$rows = $client->entity('Contact')->get(GetQuery::new()->limit(5));
// [['id' => 1, 'display_name' => 'Jane Doe'], ...]

$rows = $client->raw('Contact', 'merge', ['main_id' => 1, 'other_id' => 2]);
```

**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
```

Event API
---------

[](#event-api)

`$client->events()` returns an `EventApi` for the CiviCRM `Event` entity.

```
$events = $client->events();

// Upcoming active events (ordered by start date)
$next = $events->upcoming(5);

// Events within a date range
$q1 = $events->between(new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31'));

// Find by title
$gala = $events->findByTitle('Annual Gala 2026');

// Check capacity
$isFull = $events->isFull(10);

// Count participants (optionally filter by status)
$total      = $events->participantCount(10);
$registered = $events->participantCount(10, ParticipantStatus::Registered);
```

### `Event` DTO fields

[](#event-dto-fields)

PropertyTypeAPI field`id``int``id``title``string``title``summary``?string``summary``description``?string``description``startDate``DateTimeImmutable``start_date``endDate``?DateTimeImmutable``end_date``eventTypeId``int``event_type_id``isActive``bool``is_active``isPublic``bool``is_public``maxParticipants``?int``max_participants``defaultRoleId``?int``default_role_id`---

Participant API
---------------

[](#participant-api)

`$client->participants()` returns a `ParticipantApi` for the CiviCRM `Participant` entity.

```
$participants = $client->participants();

// Register a contact for an event
$p = $participants->register(42, 10, ParticipantStatus::Registered, source: 'Website');

// Lifecycle transitions
$participants->markAttended($p->id);
$participants->cancel($p->id, 'Travel conflict');   // creates a Follow Up activity
$participants->checkIn($p->id, new DateTimeImmutable('2026-09-15 09:30:00')); // creates Check-in activity

// Query
$list  = $participants->forEvent(10, ParticipantStatus::Registered);
$hist  = $participants->forContact(42);           // ordered by register_date DESC
$stats = $participants->countByStatus(10);        // ['Registered' => 45, 'Attended' => 3]
```

### `Participant` DTO fields

[](#participant-dto-fields)

PropertyTypeAPI field`id``int``id``contactId``int``contact_id``eventId``int``event_id``status``ParticipantStatus``status_id:name``roleId``?int``role_id``registerDate``?DateTimeImmutable``register_date``source``?string``source`### `ParticipantStatus` classification

[](#participantstatus-classification)

`ParticipantStatus` is a backed string enum. Values are the CiviCRM `name` field from the `participant_status` option group.

Each status belongs to exactly one class:

ClassStatusesMethodPositive`Registered`, `Attended``isPositive()`Pending`PendingPayLater`, `OnWaitlist`, `AwaitingApproval``isPending()`Negative`NoShow`, `Cancelled`, `Rejected`, `Expired``isNegative()`**Positive** statuses count toward the event's `max_participants` cap. `EventApi::isFull()` compares the count of positive-class participants against this limit.

---

Resilience &amp; logging
------------------------

[](#resilience--logging)

By default the transport makes a single attempt and logs nothing. Opt into retries and PSR-3 logging by passing an `ExponentialBackoff` strategy and/or a logger to `Transport::createDefault()`:

```
use Woduda\CiviCRM\CiviCrmClient;
use Woduda\CiviCRM\Http\Transport;
use Woduda\CiviCRM\Retry\ExponentialBackoff;

$transport = Transport::createDefault(
    $config,
    new ExponentialBackoff(
        maxAttempts: 3,     // total attempts, including the first
        baseDelayMs: 200,   // delay before the second attempt
        multiplier: 2.0,    // growth per attempt
        maxDelayMs: 5000,   // cap on any single delay
        jitter: true,       // full jitter to spread retries
    ),
    $logger,                // any PSR-3 LoggerInterface (optional)
);

$client = new CiviCrmClient($transport);
```

Only transient failures are retried: transport-level (network) errors, `RateLimitException` (HTTP 429 — honoring the `Retry-After` header, capped at `maxDelayMs`), and `ApiErrorException` with a 5xx status. Validation and authentication errors are never retried. The default `NoRetry` strategy preserves single-attempt behavior.

Logging is privacy-aware: a `debug` entry is emitted per request, a `warning` per retry, and an `error` on final failure. The `values` payload is masked as `[REDACTED]`, and credentials live in request headers and never reach the logger.

Error handling
--------------

[](#error-handling)

Every exception thrown by the library implements `CivicrmException`, so you can catch the whole library with one type:

```
use Woduda\CiviCRM\Exception\ApiErrorException;
use Woduda\CiviCRM\Exception\AuthenticationException;
use Woduda\CiviCRM\Exception\CivicrmException;
use Woduda\CiviCRM\Exception\RateLimitException;
use Woduda\CiviCRM\Exception\TransportException;
use Woduda\CiviCRM\Exception\ValidationException;

try {
    $client->contacts()->get(GetQuery::new()->limit(10));
} catch (AuthenticationException $e) {
    // HTTP 401/403 — credentials rejected; $e->httpStatus
} catch (RateLimitException $e) {
    // HTTP 429 — $e->retryAfterSeconds carries the server hint (if any)
} catch (ApiErrorException $e) {
    // Any other HTTP 4xx/5xx from CiviCRM: $e->getMessage() / $e->httpStatus
} catch (ValidationException $e) {
    // Invalid builder input (e.g. a bad orderBy direction)
} catch (TransportException $e) {
    // Transport-level (network) failure; PSR-18 error preserved as $e->getPrevious()
} catch (CivicrmException $e) {
    // Anything else originating from this library
}
```

`RateLimitException` and `AuthenticationException` extend `ApiErrorException`, so an `ApiErrorException` catch still covers them when you don't need the distinction.

###  Health Score

45

—

FairBetter than 91% of packages

Maintenance92

Actively maintained with recent releases

Popularity12

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity56

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

11

Last Release

39d ago

Major Versions

0.8.0 → 1.0.02026-06-15

### Community

Maintainers

![](https://www.gravatar.com/avatar/0b6d932b8d01170831cec6c38053d345bf16083bba2c38e98c3fc460f0bba757?d=identicon)[woduda](/maintainers/woduda)

---

Top Contributors

[![woduda](https://avatars.githubusercontent.com/u/1030651?v=4)](https://github.com/woduda "woduda (52 commits)")

---

Tags

resthttp clientpsr-18api clientrest-clientcivicrmapiv4civicrm-api

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/woduda-civicrm-php/health.svg)

```
[![Health](https://phpackages.com/badges/woduda-civicrm-php/health.svg)](https://phpackages.com/packages/woduda-civicrm-php)
```

###  Alternatives

[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k16](/packages/tempest-framework)[telnyx/telnyx-php

Official Telnyx PHP SDK — APIs for Voice, SMS, MMS, WhatsApp, Fax, SIP Trunking, Wireless IoT, Call Control, and more. Build global communications on Telnyx's private carrier-grade network.

36789.4k2](/packages/telnyx-telnyx-php)[laudis/neo4j-php-client

Neo4j-PHP-Client is the most advanced PHP Client for Neo4j

185702.8k44](/packages/laudis-neo4j-php-client)[phpro/http-tools

HTTP tools for developing more consistent HTTP implementations.

28150.5k](/packages/phpro-http-tools)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

255.2k](/packages/aedart-athenaeum)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
