PSR-compliant PHP client for the Zammad REST API. PHP 8.1+.
We want your feedback! Report a bug · Start a discussion
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;
}<?php
require_once __DIR__ . '/vendor/autoload.php';
use ZammadAPIClient\ZammadClient;
$client = ZammadClient::withToken('https://zammad.example', getenv('ZAMMAD_TOKEN'));# 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-configThen inject ZammadClient via the container.
Register ZammadAPIClient\Bridge\SymfonyBundle in config/bundles.php.
// 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 |
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.phpThe 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.
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.
use ZammadAPIClient\ZammadClient;
// ZammadClient normalizes the URL — /api/v1 is appended automatically
$client = ZammadClient::withToken('https://zammad.example', 'your-token');// Access via typed accessor — autocomplete, type-safe
$ticket = $client->ticket()->find(1);
$user = $client->user()->find(1);
$group = $client->group()->find(1);$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)$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$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'));$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(); // DELETEUse this for interactive workflows where you read, modify, then write. For single-field changes, prefer patch().
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}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);$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; });$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; });$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,
);$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 = 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 arrayAll 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).
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 |
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.
| 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. |
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.',
));| 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 |
— |
| 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 |
— |
| 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 |
— |
| 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 |
| 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 |
| 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 |
| 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 |
| 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') |
| 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 |
// 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();composer install
make test # Unit tests (<1s, no Docker, no Zammad needed)
make test-integration # Integration tests (requires a running Zammad instance)No environment variables needed. Unit tests mock the HTTP layer and run fully isolated.
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.
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) |
AGPL-3.0 or MIT.