Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## [2.3.0] - 2026-06-10
- Added [#78](https://github.com/zammad/zammad-api-client-php/pull/78) - Ticket linking support (Link resource).
- Added [#59](https://github.com/zammad/zammad-api-client-php/issues/59) - Admin-scoped tag support.
- Added [#126](https://github.com/zammad/zammad-api-client-php/pull/126) - Configurable test timeout via `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT` and `connection_options` support in HTTPClient.
- Fixed [#52](https://github.com/zammad/zammad-api-client-php/issues/52) - `getAttachmentContent()` to return string instead of Stream object.
- Added [#43](https://github.com/zammad/zammad-api-client-php/issues/43) - `sort_by` and `order_by` parameters for `search()`.
- Fixed [#77](https://github.com/zammad/zammad-api-client-php/issues/77) - `getID()` to handle null values and always return a string.

## [2.2.3] - 2026-06-08
- Fixed [#64](https://github.com/zammad/zammad-api-client-php/issues/64) - Use `From` header instead of deprecated `X-On-Behalf-Of`.

## [2.2.2] - 2026-04-27
- Fix PHP deprecation warnings.

Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Additionally you can have a look at the REST interface documentation of Zammad:
* [Ticket priorities](https://docs.zammad.org/en/latest/api/ticket-priority.html)
* [Ticket states](https://docs.zammad.org/en/latest/api/ticket-state.html)
* [Tags](https://docs.zammad.org/en/latest/api/tags.html)
* [Linking Tickets](https://docs.zammad.org/en/latest/api/ticket/links.html)
* [Tag list](https://docs.zammad.org/en/latest/api/ticket/tags.html#administration-scope)
* [Linking Tickets](https://docs.zammad.org/en/latest/api/ticket/links.html)

#### Fetching a ticket's articles
If you already have a ticket object, you can easily fetch its articles:
Expand All @@ -99,6 +100,8 @@ $attachment_content = $ticket_article->getAttachmentContent(23);

In the above example 23 is the ID of the attachment. This ID can be found within the `attachments` array of the ticket article data. Usually you want to loop over this array to fetch the content of all attachments.

`getAttachmentContent()` returns the attachment content as a string, ready to use.

### Updating Resource objects
If you fetched a `Resource` object and changed some values, you have to send your changes to Zammad. You do this with a simple call:
```php
Expand Down Expand Up @@ -294,7 +297,7 @@ If you want Zammad to execute an API call on behalf of another user than the one
```php
$client->setOnBehalfOfUser('myuser');
```
Any API call after above code will use this setting. If you want to return to using the user you used for authentication, call:
This sets the `From` HTTP header. Any API call after above code will use this setting. If you want to return to using the user you used for authentication, call:
```php
$client->unsetOnBehalfOfUser();
```
Expand Down Expand Up @@ -323,9 +326,10 @@ $client->resource( ResourceType::TICKET );
| ORGANIZATION|✔|✔|✔|✔|✔|–|–|✔|
| GROUP|✔|✔|–|✔|✔|–|–|–|
| USER|✔|✔|✔|✔|✔|–|–|✔|
| TAG|✔|–|✔|–|–|✔|✔|–|
| TAG|✔|✔|✔|✔|✔|✔|✔|–|
| LINK|✔|–|–|–|–|✔|✔|–|


## Publishing

1. Add release to [CHANGELOG.md](CHANGELOG.md)
Expand All @@ -335,3 +339,24 @@ $client->resource( ResourceType::TICKET );
## Contributing

Bug reports and pull requests are welcome on [GitHub](https://github.com/zammad/zammad-api-client-php). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.

### Running tests

Tests require a running Zammad instance with API access enabled. Set the following environment variables:

| Variable | Required | Default | Description |
|---|---|---|---|
| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL` | Yes | — | URL to Zammad (e.g. `http://localhost:3000/`) |
| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME` | Yes | — | Username for authentication |
| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD` | Yes | — | Password for authentication |
| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT` | No | `30` | Request timeout in seconds |

**Note:** Only username/password authentication is supported for tests.

```bash
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL="http://localhost:3000/" \
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME="admin@example.com" \
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD="test" \
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT=120 \
vendor/bin/phpunit
```
14 changes: 14 additions & 0 deletions SECURITY.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

mDMEZVsi2RYJKwYBBAHaRw8BAQdAIm/0t+RboVPq5syrc0n9hP3UPH7xok7mNCqM
5R39oZi0JVphbW1hZCBTZWN1cml0eSA8c2VjdXJpdHlAemFtbWFkLmNvbT6ImQQT
FgoAQRYhBARIHz68FJQ7lF5Ox7snHWG50ZiEBQJlWyLZAhsDBQkSzAMABQsJCAcC
AiICBhUKCQgLAgQWAgMBAh4HAheAAAoJELsnHWG50ZiEM+MBAMMdppJHzPNRdgke
bv7+z591+LrQqsKJUBUHjlujsxrbAQCF9RRf2CSTaF2SBD9vrGxdL58Bb/AVs1t6
ZX/Xf/ozDLg4BGVbItkSCisGAQQBl1UBBQEBB0DtyQW5YnpS1MQ+umPKax706r+R
RJZRO63fma5e+rhaKgMBCAeIfgQYFgoAJhYhBARIHz68FJQ7lF5Ox7snHWG50ZiE
BQJlWyLZAhsMBQkSzAMAAAoJELsnHWG50ZiE9w8BAKj36yLaf7do05ObiTjpFR5P
iDa6aRHJSWDpdut8Q19jAQCfH1WZ2M/2VK0E03k6zcfc56m+z1gwdkq78dAunte2
BA==
=GDpl
-----END PGP PUBLIC KEY BLOCK-----
49 changes: 49 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Security Policy

## Supported Versions

Security fixes are provided for the current stable version of this PHP API client only.
Any older version is not supported and needs to be updated first before reporting security issues.

## Reporting a Vulnerability

If you've found a security vulnerability in Zammad,
please report the vulnerability exclusively via email
to [security@zammad.com](mailto:security@zammad.com).

Please do not combine several independent vulnerabilities,
but send a separate mail for each of them instead.

To send us a secure message, please use [our public key](SECURITY.asc).

We will get back to you as soon as possible and inform
you about the next steps. Accepted vulnerabilities will
be disclosed via patch level release with accompanying
security advisory.

### Reporting Process Overview

- Potential security issues can be reported via [security@zammad.com](mailto:security@zammad.com).
- We evaluate them and provide timely feedback to the reporter.
- There may be security releases created if needed, e.g. [Zammad 6.3.1](https://zammad.com/en/releases/6-3-1).
- We publish security advisories for every acknowledged issue via [GitHub Security Advisories](https://github.com/zammad/zammad/security/advisories).
- After their publication, we request CVE identifiers to be assigned to the advisories.

### Rewards

Every first reporter of a vulnerability may be credited
in the related security advisory.

Zammad does not offer financial compensation through a
security bounty program.

## Security Measures in Development Workflow

### Dependency Management

Dependencies are managed via [Composer](https://getcomposer.org/).
You can check for known security vulnerabilities in dependencies by running:

```bash
composer audit
```
68 changes: 68 additions & 0 deletions examples/tag_admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

/**
* Example for Tag administration scope.
*
* Manage tags globally: list, create, rename and delete tags.
* See https://docs.zammad.org/en/latest/api/ticket/tags.html#administration-scope
*/

use ZammadAPIClient\Client;
use ZammadAPIClient\ResourceType;

require __DIR__ . '/../vendor/autoload.php';

$client = new Client([
'url' => 'https://my.zammad.com',
'username' => 'my-username',
'password' => 'my-password',
]);

// List all tags (administration scope)
$tags = $client->resource(ResourceType::TAG)->all();
echo "All tags:\n";
foreach ($tags as $tag) {
echo sprintf(
" ID: %s, Name: %s, Count: %s\n",
$tag->getID(),
$tag->getValue('name'),
$tag->getValue('count')
);
}

// Create a new tag
$tag = $client->resource(ResourceType::TAG);
$tag->setValue('name', 'example-tag');
$tag->save();

echo "\nTag 'example-tag' created.\n";

// Find the created tag to get its ID
$tag_id = null;
$tags = $client->resource(ResourceType::TAG)->all();
foreach ($tags as $t) {
if ($t->getValue('name') === 'example-tag') {
$tag_id = $t->getID();
break;
}
}

if ($tag_id) {
echo "Tag ID: $tag_id\n";

// Rename the tag (update)
// Set the tag's ID so save() will call update() instead of create()
$tag = $client->resource(ResourceType::TAG);
$tag->setRemoteData(['id' => $tag_id]);
$tag->setValue('name', 'example-tag-renamed');
$tag->save();

echo "Tag renamed to 'example-tag-renamed'.\n";

// Delete the tag
$tag = $client->resource(ResourceType::TAG);
$tag->setRemoteData(['id' => $tag_id]);
$tag->delete();

echo "Tag deleted.\n";
}
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<env name="ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL" value="https://...." />
<env name="ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME" value="..." />
<env name="ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD" value="..." />
<env name="ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT" value="30" />
-->
</php>
</phpunit>
4 changes: 2 additions & 2 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ private function request ( $method, $url, array $url_parameters = [], array $opt
$options['headers']['Accept'] = 'application/json';
$options['headers']['Content-Type'] = 'application/json; charset=utf-8';

// Set "on behalf of user" header
// Set "on behalf of user" header (X-On-Behalf-Of was deprecated in Zammad 6.5 in favor of From)
if ( !empty($this->on_behalf_of_user) ) {
$options['headers']['X-On-Behalf-Of'] = $this->on_behalf_of_user;
$options['headers']['From'] = $this->on_behalf_of_user;
}

$http_client_response = $this->http_client->request( $method, $url, $options );
Expand Down
22 changes: 15 additions & 7 deletions src/HTTPClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,22 @@ public function __construct( array $options = [] )
$verifySsl = $options['verify'];
}

// Optional: pass additional Guzzle options (e.g. headers, curl options)
$connection_options = [];
if (is_array($options['connection_options'] ?? null)) {
$connection_options = $options['connection_options'];
}

// Execute constructor of base class
parent::__construct([
'base_uri' => $this->base_url,
'timeout' => $timeout,
'connect_timeout' => $timeout,
'debug' => $debug,
'verify' => $verifySsl,
]);
parent::__construct(
[
'base_uri' => $this->base_url,
'timeout' => $timeout,
'connect_timeout' => $timeout,
'debug' => $debug,
'verify' => $verifySsl,
] + $connection_options
);
}

/**
Expand Down
30 changes: 24 additions & 6 deletions src/Resource/AbstractResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,12 @@ public function clearUnsavedValues()
* Gets the ID of the object.
* This is a convenience method for getValue('id')
*
* @return string ID of this object, if available.
* @return string|null ID of this object as string, or null if not available.
*/
public function getID()
{
return $this->getValue('id');
$value = $this->getValue('id');
return $value === null ? null : (string) $value;
}

/**
Expand Down Expand Up @@ -382,11 +383,13 @@ private function allWithoutPagination()
* @param string $search_term Search term.
* @param integer $page Page of objects, optional, if given, $objects_per_page must also be given.
* @param integer $objects_per_page Number of objects per page, optional, if given, $page must also be given.
* @param string $sort_by Sort by field name, optional (e.g. 'created_at', 'title', 'number').
* @param string $order_by Sort order, optional, must be 'asc' or 'desc'.
*
* @return mixed Returns array of ZammadAPIClient\Resource\... objects
* or this object on failure.
*/
public function search( $search_term, $page = null, $objects_per_page = null )
public function search( $search_term, $page = null, $objects_per_page = null, $sort_by = null, $order_by = null )
{
if ( !empty( $this->getValues() ) ) {
throw new AlreadyFetchedObjectException('Object already contains values, search() not possible, use a new object');
Expand All @@ -405,8 +408,12 @@ public function search( $search_term, $page = null, $objects_per_page = null )
throw new \RuntimeException('Parameters page and objects_per_page must both be given');
}

if ( isset($order_by) && !in_array( mb_strtolower($order_by), [ 'asc', 'desc' ] ) ) {
throw new \RuntimeException('Parameter order_by must be "asc" or "desc"');
}

if ( !isset($page) || !isset($objects_per_page) ) {
return $this->searchWithoutPagination($search_term);
return $this->searchWithoutPagination($search_term, $sort_by, $order_by);
}

$url_parameters = [
Expand All @@ -419,6 +426,13 @@ public function search( $search_term, $page = null, $objects_per_page = null )
$url_parameters['per_page'] = $objects_per_page;
}

if ( isset($sort_by) ) {
$url_parameters['sort_by'] = $sort_by;
}
if ( isset($order_by) ) {
$url_parameters['order_by'] = $order_by;
}

$url = $this->getURL('search');
$response = $this->getClient()->get(
$url,
Expand Down Expand Up @@ -449,18 +463,22 @@ public function search( $search_term, $page = null, $objects_per_page = null )
* This method will be used internally and automatically by search() to automate pagination
* to retrieve all available objects, ignoring the server side limit of fetchable objects.
*
* @param string $search_term Search term.
* @param string $sort_by Sort by field name, optional.
* @param string $order_by Sort order, optional.
*
* @return mixed Returns array of ZammadAPIClient\Resource\... objects
* or this object on failure.
*/
private function searchWithoutPagination($search_term)
private function searchWithoutPagination($search_term, $sort_by = null, $order_by = null)
{
$page = 1;
$objects_per_page = 100;
$objects = [];
$objects_of_page = [];

do {
$objects_of_page = $this->search( $search_term, $page, $objects_per_page );
$objects_of_page = $this->search( $search_term, $page, $objects_per_page, $sort_by, $order_by );
if ( !is_array($objects_of_page) ) {
return $this;
}
Expand Down
8 changes: 6 additions & 2 deletions src/Resource/Tag.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ class Tag extends AbstractResource
'get' => 'tags',
'search' => 'tag_search?term={query}',
'add' => 'tags/add',
'remove' => 'tags/remove'
'remove' => 'tags/remove',
'all' => 'tag_list',
'create' => 'tag_list',
'update' => 'tag_list/{object_id}',
'delete' => 'tag_list/{object_id}',
];

/**
Expand Down Expand Up @@ -101,7 +105,7 @@ public function add($object_id, $tag, $object_type = 'Ticket')
* @return mixed Returns array of ZammadAPIClient\Resource\... objects
* or this object on failure.
*/
public function search($search_term, $page = null, $objects_per_page = null)
public function search($search_term, $page = null, $objects_per_page = null, $sort_by = null, $order_by = null)
{
$this->clearError();

Expand Down
3 changes: 3 additions & 0 deletions src/Resource/TicketArticle.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ public function getAttachmentContent($attachment_id)
}

$content = $response->getBody();
if ( $content instanceof \Psr\Http\Message\StreamInterface ) {
$content = $content->getContents();
}
return $content;
}
}
Loading