zendesk/zendesk_api_client_php
A PHP client for the Zendesk API that simplifies authentication and CRUD operations for tickets, users, organizations, and other support resources. It offers an object-oriented wrapper with pagination support for integrating Zendesk workflows into PHP applications.
Install
composer require zendesk/zendesk_api_client_php
First contact — the client is a thin, resource-oriented wrapper over https://{subdomain}.zendesk.com/api/v2/. You construct one client and then call resource methods on it:
use Zendesk\API\HttpClient as ZendeskAPI; // alias: collides with Guzzle's HttpClient
$client = new ZendeskAPI('your-subdomain');
// API token (email + token) — the library appends the required "/token" suffix for you
$client->setAuth('basic', [
'username' => config('services.zendesk.email'),
'token' => config('services.zendesk.token'),
]);
// or OAuth
$client->setAuth('oauth', ['token' => $accessToken]);
Prove it works with one read:
$tickets = $client->tickets()->findAll(['per_page' => 5]);
foreach ($tickets as $ticket) {
dump($ticket->id, $ticket->subject, $ticket->status);
}
Where to look first
src/Zendesk/API/Resources/Core/ — one class per endpoint family (Tickets, Users, Organizations, Attachments, Search, Views, Macros, Triggers…). This is the API surface; read it before reading docs.src/Zendesk/API/Traits/Resource/ — Find, FindAll, Create, Update, Delete, CreateMany, UpdateMany, DeleteMany. Knowing which traits a resource uses tells you exactly which verbs and payload shapes it supports.src/Zendesk/API/HttpClient.php — how the base URL, auth headers and Guzzle client are assembled.First real use case: inbound support form → $client->tickets()->create([...]), then attach the ticket ID to your local record. That single flow exercises auth, error handling, custom fields and comments, which is 80% of what you'll ever do with this package.
There is no service provider — you write it. Bind a singleton so the Guzzle connection and auth setup are reused per worker:
// config/services.php
'zendesk' => [
'subdomain' => env('ZENDESK_SUBDOMAIN'),
'email' => env('ZENDESK_EMAIL'),
'token' => env('ZENDESK_TOKEN'),
],
// AppServiceProvider::register()
$this->app->singleton(\Zendesk\API\HttpClient::class, function () {
$c = new \Zendesk\API\HttpClient(config('services.zendesk.subdomain'));
$c->setAuth('basic', [
'username' => config('services.zendesk.email'),
'token' => config('services.zendesk.token'),
]);
return $c;
});
Then inject the interface you actually depend on in your own code (ZendeskGateway), not the vendor client directly — see testing below.
$ticket = $client->tickets()->create([
'subject' => $subject,
'requester' => ['name' => $name, 'email' => $email], // or 'requester_id'
'priority' => 'normal',
'type' => 'question',
'tags' => ['web-form', 'billing'],
'custom_fields' => [
['id' => 12345678, 'value' => 'enterprise'],
],
'comment' => [
'body' => $body,
'public' => true, // false => internal note
'uploads' => [$token], // see attachments below
],
]);
Resolve custom-field keys to IDs once via $client->ticketFields()->findAll() and cache them — Zendesk will silently ignore unknown keys and you'll spend an afternoon debugging "why isn't my field set".
$upload = $client->attachments()->upload([
'file' => storage_path("app/{$path}"), // path or SplFileInfo
'filename' => 'invoice.pdf',
]);
$client->tickets()->update($ticketId, [
'ticket' => [
'comment' => [
'body' => 'Invoice attached.',
'uploads' => [$upload->upload->token],
],
],
]);
Uploads are a separate host (*.zendesk.com/api/v2/uploads.json), return a short-lived single-use token, and must be attached to a comment or they evaporate. Upload first, then create the ticket/comment in the same job — don't persist tokens for later.
$client->tickets()->createMany([ /* up to 100 ticket arrays */ ]);
$client->tickets()->deleteMany(['ids' => [1, 2, 3]]);
createMany is capped at 100 objects per request and is not atomic — a partial success returns a job status. For anything larger, chunk in your own code and inspect per-item results. Check Traits/Resource/UpdateMany.php for the exact ids + attribute payload shape before shipping; it differs subtly from create/update.
$tickets = $client->tickets()->findAll([
'include' => 'users,organizations', // avoids N+1 lookups
'per_page' => 100, // 100 is the hard max
]);
foreach ($tickets as $ticket) { /* ... */ }
For sync jobs and reports, use cursor pagination instead of page — page-based pagination degrades badly and the offset caps out. Pass raw query params and follow the cursor yourself:
$params = ['page[size]' => 100, 'sort_by' => 'id', 'sort_order' => 'asc'];
$params['page[after]'] = $cursor; // from the previous response's meta/links
Store the highest seen id / updated_at in your DB so the next run is incremental. For very large accounts, prefer the export endpoints over tickets()->findAll().
$client->search()->find('type:user email:[email protected]');
$client->search()->find('type:ticket status<solved tags:billing');
Search caps at ~1000 results per query — it's for lookup, not for export. Also remember search indexing lags writes by seconds: don't create-then-immediately-search to confirm.
$client->helpCenter()->articles()->findAll(), ->create([...]) — note the extra helpCenter() namespace; Help Center models require permission_group_id and a locale.$client->views()->findAll() / $client->macros()->findAll() to enumerate automation rather than hard-coding IDs./macros/{id}/apply — if the resource method isn't exposed, see custom resources below.The resource classes are small and composable. When the client lacks a method, don't drop to raw curl:
use Zendesk\API\Resources\ResourceAbstract;
use Zendesk\API\Traits\Resource\FindAll;
class MyReports extends ResourceAbstract
{
use FindAll;
protected $resourceName = 'reports';
protected $objectName = 'reports';
}
Instantiate with the client and reuse the built-in _get/_post/_put helpers so auth, JSON encoding and error handling stay consistent.
Zendesk rate-limits per user/bucket and returns 429 with a Retry-After header. The package does not retry for you. Wrap calls in a gateway with backoff, and in Laravel put a Redis::throttle('zendesk') middleware (or RateLimited job middleware) in front of the queue worker:
try {
$client->tickets()->create($payload);
} catch (\Zendesk\API\Exceptions\ApiResponseException $e) {
if ($e->getCode() === 429) {
$this->release($retryAfter ?? 60);
return;
}
throw $e;
}
Make jobs idempotent by storing the Zendesk ticket ID locally as soon as it's returned — retries after a timeout can otherwise duplicate tickets.
The client builds its own Guzzle instance, so the cleanest seam is your own ZendeskGateway interface, faked in tests. For a genuine integration test, point the client at a Guzzle MockHandler if your version exposes the handler stack; otherwise hit a sandbox subdomain from a dedicated test suite, never from unit tests.
Auth
password instead of token to setAuth('basic', [...]) — the library then base64-encodes email:password and you get a 401 with a cryptic body. Use the token key.setAuth) when you rotate.Errors
Zendesk\API\Exceptions\ApiResponseException. $e->getCode() carries the HTTP status; $e->getErrorDetails() carries Zendesk's structured error / description / details — log that, not just getMessage(), or you'll be missing the field-level reasons.422 bodies contain details keyed by field ({"requester": [{"description": "..."}]}). Surface those to users or you'll get "something went wrong" bug reports.details.Data shape
[]. Always guard $response->tickets ?? [] or go through toArray().count() reflects the dataset — Zendesk's count is the total across pages, not the page size.via, created_at, updated_at, and id on a create are server-set; sending them is ignored at best.null — omitting it means "unchanged".tags is an array in the API but a comma string in some responses/webhooks; normalise on ingest.Business rules that bite
comment (or at least a comment.body) is effectively required on ticket create; attachments-only comments are finicky — include a body.via_followup_source_id, or an update that sets status before it locks.users()->delete() is a soft delete; hard deletion needs the permanent-delete path and fails if the user has tickets. Same for organizations.422 you'll only see if you log details.Operational
X-Zendesk-Request-Id from responses/errors; Zendesk support will ask for it.ticketFields(), groups(), organizations() lookups — they're static-ish and easy to hammer.include= is your cheapest performance win; N+1 users()->find() calls are the #1 cause of hitting rate limits.Zendesk\API\HttpClient, which shadows GuzzleHttp\Client in autocompletion — always alias on import.Zendesk\API\Client vs HttpClient) and setAuth signatures differ. Pin the version and read the CHANGELOG before upgrading across majors — this library has made breaking changes in minor releases historically.How can I help you explore Laravel packages today?