zammad / zammad-api-client-php
PSR-compliant Zammad API client for PHP 8.1+
Requires
- php: >=8.1
- guzzlehttp/guzzle: ^7.10
- guzzlehttp/psr7: ^2.7
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/log: ^3.0
Requires (Dev)
- league/openapi-psr7-validator: ^0.22
- mockery/mockery: ^1.6
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^4.0
Suggests
- illuminate/support: Laravel ServiceProvider integration
- symfony/cache: PSR-6/16 cache for response caching
- symfony/dependency-injection: Symfony DI container integration
- symfony/http-client: Alternative PSR-18 transport
- symfony/http-kernel: Symfony Bundle integration
- symfony/serializer: Alternative DTO hydration (replaces manual fromArray)
Provides
None
Conflicts
None
Replaces
None
README
PSR-compliant PHP client for the Zammad REST API. PHP 8.1+.
We want your feedback! Report a bug · Start a discussion
Quick Start
use ZammadAPIClient\Endpoints\Tickets\TicketDTO; use ZammadAPIClient\ZammadClient; $client = ZammadClient::withToken('https://zammad.example', 'your-token'); // Fetch $ticket = $client->ticket()->find(1); echo $ticket->title; // typed property, IDE autocomplete // Create (customer_id is required on creation; article is optional) // For production code, resolve priority_id/state_id by name via // TicketPriorityRepository / TicketStateRepository. See examples/cookbook/. $created = $client->ticket()->create(new TicketDTO( title: 'Hello from v3', customer_id: 1, group_id: 1, priority_id: 2, state_id: 1, article: [ 'subject' => 'Hello', 'body' => 'Message body', 'type' => 'note', ], )); // Partial update $client->ticket()->patch($created->id, ['title' => 'Updated']); // Search foreach ($client->ticket()->search('error') as $ticket) { echo $ticket->title; }
Getting started
Standalone PHP app
<?php require_once __DIR__ . '/vendor/autoload.php'; use ZammadAPIClient\ZammadClient; $client = ZammadClient::withToken('https://zammad.example', getenv('ZAMMAD_TOKEN'));
Laravel
# 1. Register the provider in config/app.php (skip if using auto-discovery, Laravel 5.5+)
Add ZammadAPIClient\Bridge\LaravelServiceProvider::class to config/app.php.
# 2. Publish the default config to config/zammad.php
php artisan vendor:publish --tag=zammad-config
Then inject ZammadClient via the container.
Symfony
Register ZammadAPIClient\Bridge\SymfonyBundle in config/bundles.php.
Authentication
// Token — sends Authorization: Token token=your-token (Zammad personal access token) ZammadClient::withToken($url, 'your-token'); // OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token) ZammadClient::withOAuth2($url, 'your-oauth-token'); // Basic Auth — sends Authorization: Basic base64(user:pass) ZammadClient::withBasicAuth($url, 'admin@example.com', 'test'); // Options ZammadClient::withToken($url, 'your-token', new ConnectionConfig(verifySsl: false, maxRetries: 5), ); // Pass a PSR-3 Logger to log HTTP requests and retries ZammadClient::withToken($url, 'your-token', new ConnectionConfig(logger: $myLogger), );
| ConnectionConfig property | Type | Default | Description |
|---|---|---|---|
maxRetries |
int |
3 |
Number of retries on HTTP 429 before throwing RateLimitException |
verifySsl |
bool |
true |
Verify SSL certificate of the Zammad server |
timeout |
int |
30 |
Total request timeout in seconds |
connectTimeout |
int |
10 |
Connection timeout in seconds |
logger |
?LoggerInterface |
null |
PSR-3 Logger for HTTP request/retry logging |
Examples
The primary example is the examples/cookbook/ directory — runnable recipes covering tickets, stateful resources, pagination, error handling, impersonation, and search. Run them against any Zammad instance:
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://your-zammad:3000 \ ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=your-token \ php examples/cookbook/01-quick-start.php
The env vars are named
...UNIT_TESTS...for historical reasons. They are used by integration tests and the cookbook example. Unit tests (make test) need no env vars.
For user and organization CRUD examples, refer to the integration tests in test/Integration/ (UserIntegrationTest.php, OrganizationIntegrationTest.php). Side-by-side v2→v3 migration examples are in docs/migration-v3-examples.md.
How to use
This library offers three interaction styles. Choose based on your use case:
| Style | API | Best for |
|---|---|---|
| Repository + DTOs (recommended) | $client->ticket()->find(), create(), patch(), delete() |
Type-safe CRUD, IDE autocomplete, explicit intent. Use this by default. |
| Stateful Resource | $client->ticket()->resource($id)->save() / destroy() |
Interactive editing — mutate properties step by step, only changes are sent. |
| Raw HTTP | $client->getHandler()->get(), delete(), etc. |
Calling endpoints that have no dedicated repository. Escape hatch. |
Repositories are accessed via typed accessors: $client->ticket(), $client->user(), $client->organization(), $client->group(), $client->ticketArticle(), $client->ticketState(), $client->ticketPriority(), $client->tag(), $client->textModule(), $client->link(). The underlying repo() method is internal.
Connecting
use ZammadAPIClient\ZammadClient; // ZammadClient normalizes the URL — /api/v1 is appended automatically $client = ZammadClient::withToken('https://zammad.example', 'your-token');
Fetching
// Access via typed accessor — autocomplete, type-safe $ticket = $client->ticket()->find(1); $user = $client->user()->find(1); $group = $client->group()->find(1);
Accessing values
$ticket = $client->ticket()->find(1); echo $ticket->title; // Typed property, IDE autocomplete echo $ticket->state_id; // ?int echo $ticket->created_at; // ?DateTimeImmutable $data = $ticket->toArray(); // All values as array $id = $ticket->id; // Server-assigned ID (null before create)
Creating
$ticket = $client->ticket()->create(new TicketDTO( title: 'My ticket', customer_id: 1, group_id: 1, priority_id: 2, state_id: 1, article: [ 'subject' => 'My ticket', 'body' => 'First message', 'type' => 'note', ], )); echo $ticket->id; // Server-assigned after creation
Updating
$repo = $client->ticket(); // Send a DTO — only non-null fields are transmitted $repo->patch(1, new TicketDTO(title: 'New title', group_id: 1)); // Partial update via array — only supplied fields change $repo->patch(1, ['title' => 'New title', 'state_id' => 3]); // Partial update via TicketUpdateDTO — only non-null fields sent $repo->patch(1, new TicketUpdateDTO(title: 'New title'));
Stateful resource
$repo->resource($id) returns a Resource wrapper — not a DTO. Properties are accessed and mutated via __get/__set magic (not typed properties), and changes are automatically tracked. save() sends only modified fields; destroy() sends DELETE.
$repo = $client->ticket(); $r = $repo->resource(1); // Returns Resource, fetches ticket #1 echo $r->title; // Reads current title $r->title = 'Changed'; // Tracks old → new $r->state_id = 3; // Tracks old → new $r->save(); // PUT {title, state_id} only $r->destroy(); // DELETE
Use this for interactive workflows where you read, modify, then write. For single-field changes, prefer patch().
Choosing the right update method
patch() is the only update method. It accepts arrays, TicketUpdateDTO, or any DTO (via toArray()). Zammad uses HTTP PUT for all updates and merges the payload with the existing resource. Null values are excluded from all request bodies, so absent fields are never overwritten.
| Signature | What it sends | Use case |
|---|---|---|
patch($id, $array) |
Only the explicit array keys | Change one or two known fields. Safest. |
patch($id, $updateDto) |
Only the non-null DTO fields | IDE autocomplete on the mutable fields. |
patch($id, $dto) |
All non-null properties of the DTO (toArray()) |
Replace multiple fields using a full DTO. |
resource($id)->save() |
Only actually changed fields (tracked) | Interactive editing with change tracking. |
$repo = $client->ticket(); // Array — simplest for ad-hoc changes $repo->patch(1, ['title' => 'New title', 'state_id' => 3]); // TicketUpdateDTO — type-safe, IDE-friendly $repo->patch(1, new TicketUpdateDTO(title: 'New title')); // Full DTO — send a modified TicketDTO $ticket = $repo->find(1); $repo->patch(1, new TicketDTO( title: $ticket->title, group_id: 2, // changed from original )); // Stateful resource — tracks property changes $r = $repo->resource(1); $r->title = 'Changed'; $r->state_id = 3; $r->save(); // Sends only {title, state_id}
Deleting
All repositories expose a delete() method. Repositories implementing DeletableInterface perform the actual API call. Other repositories throw a BadMethodCallException — catchable, unlike a fatal error.
| Repository | delete() |
Notes |
|---|---|---|
TicketRepository |
✓ | |
UserRepository |
✓ | |
GroupRepository |
✓ | |
OrganizationRepository |
✓ | |
TextModuleRepository |
✓ | |
TagRepository |
exception | Throws BadMethodCallException; use add() / remove() |
LinkRepository |
exception | Throws BadMethodCallException; use add() / remove() |
TicketArticleRepository |
exception | Zammad API does not allow article deletion |
TicketStateRepository |
exception | System resource, read-only |
TicketPriorityRepository |
exception | System resource, read-only |
$client->ticket()->delete(1); $client->user()->delete(1);
Searching
$repo = $client->ticket(); // Full-text search — returns a lazy Generator (page by page) // Use foreach directly; count()/array access requires iterator_to_array() foreach ($repo->search('some text') as $ticket) { echo $ticket->title; } // Field-specific search foreach ($repo->search('title:Error AND priority_id:1') as $ticket) { echo $ticket->number; } // PaginatedList — count(), totalCount(), page navigation $list = $repo->searchList('error', ['per_page' => 25]); echo $list->totalCount(); $list->page(2); $list->each(function ($t) { echo $t->title; });
Listing all
$repo = $client->ticket(); // Lazy Generator — pages fetched on demand, memory-efficient // Use in foreach; need count? Use list() for PaginatedList instead. foreach ($repo->all() as $ticket) { echo $ticket->title; } // PaginatedList — count(), page navigation, each() callback $list = $repo->list(); echo $list->count(); // Items on current page $list->page(2); // Jump to page 2 $list->pageNext(); // Next page $list->each(function ($t) { echo $t->title; });
Ticket articles
$repo = $client->ticketArticle(); // All articles for a ticket (paginated) foreach ($repo->getForTicket(1) as $article) { echo $article->body; } // Download raw attachment content $binary = $repo->getAttachmentContent( ticketId: 1, articleId: 5, attachmentId: 23, );
Tags
$repo = $client->tag(); $repo->add('Ticket', $ticketId, 'urgent'); $repo->remove('Ticket', $ticketId, 'urgent'); foreach ($repo->all(['object' => 'Ticket', 'o_id' => $ticketId]) as $tag) { echo $tag->value; } $results = $repo->tagSearch('urg'); // Autocomplete
CSV import
$csv = file_get_contents('users.csv'); $result = $client->user()->import($csv); // Returns import summary array $result = $client->organization()->import($csv); // Returns import summary array $client->textModule()->import($csv); // Returns import summary array
All import() methods return an array — the Zammad API response containing import statistics (rows processed, skipped, errors).
CSV format follows Zammad's import specification (header row with field names matching API field names).
Error Handling
All errors are typed exceptions:
use ZammadAPIClient\Exceptions\{ AuthenticationException, ForbiddenException, NotFoundException, ValidationException, RateLimitException, ServerErrorException, NetworkException, }; try { $client->ticket()->find(999999); } catch (NotFoundException $e) { echo $e->getMessage(); // "Resource not found: tickets/999999" } catch (ValidationException $e) { print_r($e->errors); // Per-field validation messages } catch (AuthenticationException $e) { // Invalid credentials (401) } catch (ForbiddenException $e) { // Valid credentials but insufficient permissions (403) } catch (RateLimitException $e) { echo $e->retryAfterSeconds; // Auto-retried, thrown on exhaustion (429) } catch (ServerErrorException $e) { // Server error (5xx) } catch (NetworkException $e) { // DNS, timeout, connection refused }
| Exception | HTTP | Auto-retry | Properties |
|---|---|---|---|
AuthenticationException |
401 | no | $e->getMessage() |
ForbiddenException |
403 | no | $e->getMessage() |
NotFoundException |
404 | no | $e->getMessage() |
ValidationException |
422 | no | $e->errors (array, per-field) |
RateLimitException |
429 | yes | $e->retryAfterSeconds |
ServerErrorException |
5xx | no | $e->getMessage() |
NetworkException |
— | no | DNS, timeout, connection refused |
Data Transfer Objects (DTOs)
Each repository returns typed DTOs. Below are the fields for each DTO. Fields marked yes have no default and are required in the constructor. Fields marked creation are nullable in the type signature but required by the Zammad API when creating a new resource — omitting them will result in a ValidationException (422).
The id field is available both as a property ($dto->id) and a convenience method ($dto->id()). Both return the same server-assigned ID; prefer the property for readability.
TicketDTO
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string |
yes | Subject line of the ticket |
group_id |
?int |
— | Group responsible for the ticket |
priority_id |
?int |
— | References TicketPriority; resolve by name via TicketPriorityRepository |
state_id |
?int |
— | References TicketState; resolve by name via TicketStateRepository |
organization_id |
?int |
— | Derived from customer's organization |
customer_id |
?int |
creation | End-user who submitted the ticket (Zammad requires this on create) |
owner_id |
?int |
— | Agent assigned to the ticket |
number |
?string |
— | Human-readable ticket number (read-only) |
id |
?int |
— | Server-assigned (null before creation) |
pending_time |
?DateTimeImmutable |
— | ISO 8601 datetime for pending states |
article |
?array |
— | Optional initial article. Array shape: { subject: string, body: string, type: string, internal?: bool, content_type?: string, ... }. Use TicketArticleType enum constants (e.g. TicketArticleType::Note->value) for type-safe type values. |
created_at |
?DateTimeImmutable |
— | Server-assigned (read-only) |
updated_at |
?DateTimeImmutable |
— | Server-assigned (read-only) |
customFields |
array |
— | Zammad custom fields (string => mixed). Named camelCase per Zammad API convention. |
TicketUpdateDTO
Used with patch() for partial ticket updates. Only non-null fields are sent to the API.
| Field | Type | Notes |
|---|---|---|
title |
?string |
|
state_id |
?int |
|
priority_id |
?int |
|
group_id |
?int |
|
owner_id |
?int |
|
customer_id |
?int |
|
note |
?string |
Adds an internal note (article type 'note') on update |
pending_time |
?DateTimeImmutable |
ISO 8601 datetime for pending states |
// Example: reassign ticket and leave an internal note $client->ticket()->patch(42, new TicketUpdateDTO( owner_id: 7, note: 'Reassigned from support queue.', ));
UserDTO
| Field | Type | Required | Notes |
|---|---|---|---|
login |
?string |
— | Unique username |
email |
?string |
— | Primary email address |
firstname |
?string |
— | |
lastname |
?string |
— | |
phone |
?string |
— | |
organization_id |
?int |
— | Primary organization |
organization_ids |
?array |
— | Array of secondary organization IDs |
role_ids |
?array |
— | Array of role IDs (e.g. [2] for Agent) |
active |
?bool |
— | Whether the user account is active |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
customFields |
array |
— |
OrganizationDTO
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
yes | Display name |
note |
?string |
— | |
active |
?bool |
— | |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
customFields |
array |
— |
GroupDTO
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
yes | Display name |
note |
?string |
— | |
active |
?bool |
— | |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
customFields |
array |
— |
TicketArticleDTO
| Field | Type | Notes |
|---|---|---|
ticket_id |
?int |
Parent ticket |
type |
?string |
Channel type: TicketArticleType::Note->value ('note'), Email ('email'), Phone ('phone'), Sms ('sms'), Web ('web') |
body |
?string |
Message content |
content_type |
?string |
MIME type: 'text/plain' or 'text/html' |
subject |
?string |
Subject line for email-type articles |
from |
?string |
Sender address/name |
to |
?string |
Recipient address |
cc |
?string |
CC address |
internal |
?bool |
Whether it's an internal note (hidden from customer) |
in_reply_to |
?string |
Message-ID for threading |
reply_to |
?string |
Reply-To address |
message_id |
?string |
Message-ID of this article |
origin_by_id |
?int |
User who created the article (for impersonation) |
sender |
?string |
Read-only: 'Customer', 'Agent', etc. |
type_id |
?int |
Read-only |
sender_id |
?int |
Read-only |
created_by_id |
?int |
Read-only |
updated_by_id |
?int |
Read-only |
created_by |
?string |
Read-only |
updated_by |
?string |
Read-only |
time_unit |
?float |
Time accounting (minutes) |
attachments |
?array |
Array of {filename, data (base64), mime-type?} |
id |
?int |
Server-assigned |
created_at |
?DateTimeImmutable |
Read-only |
updated_at |
?DateTimeImmutable |
Read-only |
TicketStateDTO
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
yes | Display label (e.g. 'open', 'closed') |
state_type_id |
?int |
— | Determines Zammad's automation behaviour |
note |
?string |
— | |
active |
?bool |
— | |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
TicketPriorityDTO
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
yes | Display label (e.g. '2 normal', '3 high') |
note |
?string |
— | |
active |
?bool |
— | |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
TextModuleDTO
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
yes | Display name |
keywords |
?string |
— | Space-separated search keywords |
content |
?string |
— | Template body with optional #{...} variables |
note |
?string |
— | |
active |
?bool |
— | |
id |
?int |
— | Server-assigned |
created_at |
?DateTimeImmutable |
— | Read-only |
updated_at |
?DateTimeImmutable |
— | Read-only |
TagDTO
| Field | Type | Notes |
|---|---|---|
id |
?int |
Tag-assignment ID |
object |
?string |
Object class name (e.g. 'Ticket') |
o_id |
?int |
Numeric ID of the tagged object |
value |
?string |
Tag string (e.g. 'urgent', 'bug') |
LinkDTO
| Field | Type | Notes |
|---|---|---|
id |
?int |
Server-assigned |
link_type_id |
?int |
|
link_type |
?string |
'normal', 'parent', or 'child' |
link_object_source |
?string |
Source object type |
link_object_source_value |
?int |
Source object ID |
link_object_target |
?string |
Target object type |
link_object_target_value |
?int |
Target object ID |
created_at |
?DateTimeImmutable |
Read-only |
updated_at |
?DateTimeImmutable |
Read-only |
Impersonation
// Temporary — auto-cleanup via finally // Accepts user ID (int), login, or email (string) $client->performOnBehalfOf(1, fn() => $client->ticket()->find(42)); $client->performOnBehalfOf('agent@example.com', fn() => $client->ticket()->find(42)); // Persistent — same parameter types $client->setOnBehalfOfUser(1); // ... all subsequent requests act as user #1 ... $client->unsetOnBehalfOfUser();
Development
composer install make test # Unit tests (<1s, no Docker, no Zammad needed) make test-integration # Integration tests (requires a running Zammad instance)
Unit tests
No environment variables needed. Unit tests mock the HTTP layer and run fully isolated.
Integration tests
These require a running Zammad instance and authentication credentials:
| Variable | Required | Default | Description |
|---|---|---|---|
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL |
Yes | http://localhost:3000 |
Zammad server URL (without /api/v1) |
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN |
No | — | Token authentication (preferred) |
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME |
No* | — | Username for basic auth |
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD |
No* | — | Password for basic auth |
* Either ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN or USERNAME+PASSWORD must be set.
Migration from v2
See docs/migration-v3-examples.md for side-by-side code examples. v2 reference documentation is preserved in docs/v2-reference.md.
| v2 | v3 |
|---|---|
new Client(['url' => ..., 'http_token' => ...]) |
ZammadClient::withToken($url, ...) |
$client->resource(TICKET)->get(1) |
$client->ticket()->find(1) |
$ticket->getValue('title') |
$ticket->title |
$ticket->getValues() |
$ticket->toArray() |
$ticket->setValue('title', 'x'); $ticket->save() |
$client->ticket()->patch(1, ['title' => 'x']) |
if ($ticket->hasError()) { $ticket->getError(); } |
catch (NotFoundException $e) { $e->getMessage(); } |
$client->resource(TICKET)->search('term') |
$client->ticket()->search('term') |
$client->resource(TICKET)->all() |
$client->ticket()->all() |
$ticket->delete() |
$client->ticket()->delete($id) |
$client->resource(TAG)->add($ticketId, 'tag', 'Ticket') |
$client->tag()->add('Ticket', $ticketId, 'tag') (order changed) |
License
AGPL-3.0 or MIT.