定制 woduda/civicrm-php 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

woduda/civicrm-php

最新稳定版本:0.7.1

Composer 安装命令:

composer require woduda/civicrm-php

包简介

PSR-18 compatible client for CiviCRM REST APIv4

README 文档

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

  • PHP >= 8.3
  • Any PSR-18 HTTP client + PSR-17 factories (discovered automatically via php-http/discovery)
  • The CiviCRM authx extension 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->displayName, 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
$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, Activity API, Tag API, Group API, Note API, Event API, Participant API, Email API, Phone API, Address API, and Relationship 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

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

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

// Typed create — Result<Contact>
$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)

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 Contact|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',
]);

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

$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);

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

Property Type CiviCRM 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<string, mixed> full APIv4 row

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

Property Type CiviCRM 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<string, mixed> 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

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, Phone, and Address are separate CiviCRM entities with their own IDs. Each typed sub-entity API returns Result<DTO> 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

$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

$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

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)

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

Typed entity APIs (contacts(), activities(), notes(), …) return a Result<T> — 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<mixed> — 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<mixed> — returned records
$response->count;   // int         — number of records reported by CiviCRM

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

Property Type API 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

$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

Property Type API 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 is a backed string enum. Values are the CiviCRM name field from the participant_status option group.

Each status belongs to exactly one class:

Class Statuses Method
Positive 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.

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.

统计信息

  • 总下载量: 30
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 1
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-02

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固