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
68 changes: 50 additions & 18 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,74 @@
# Changelog

## [3.0.0] — unreleased
## [3.2.0] — unreleased

### Added
- `Auth` — credentials as a transport-independent value: `Auth::token()`, `Auth::oauth2()`, `Auth::basic()`. The constructor is private, so the set of valid schemes is closed by construction; empty tokens and usernames are rejected on creation instead of surfacing as a 401 later
- `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport
- `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request
- `ConnectionConfig::$allowInsecureHttp` / `RequestHandler`'s `allowInsecureHttp:` argument — opt out of the cleartext-credential check described below

### Changed
- Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete
- **Breaking:** credentials are no longer sent in cleartext. Constructing a client with an `Auth` and a plain `http://` URL whose host is not loopback now throws an `InvalidArgumentException` instead of putting the token on the wire for anyone on the path to read. Loopback is exempt — `localhost`, any `.localhost` name, the whole `127.0.0.0/8` range and `::1` — so every default this project ships (`http://localhost:3000` in the cookbook, `http://127.0.0.1:8098` in both bridges) is unaffected, as is any `https://` URL. **Who is affected:** anyone pointing the client at a remote Zammad over plain HTTP, e.g. an app server talking to an internal instance or a container reaching `http://zammad-nginx:3000`. Switch the URL to `https://`, or, if the connection is already protected by other means (VPN, service mesh, SSH tunnel), opt out with `new ConnectionConfig(allowInsecureHttp: true)` — or `allowInsecureHttp: true` when building a `RequestHandler` directly. The check keys on the `Auth` it is given, so an `Authorization` header that your own PSR-18 client adds stays your responsibility ([#173](https://github.com/zammad/zammad-api-client-php/pull/173))
- `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected
- The `@deprecated` marker on `DTOInterface::id()`'s trait implementation is withdrawn. It announced a removal in 4.0 that was never planned, was never mentioned in the 3.0.0 changelog that introduced it, and contradicted both the interface — which requires the method and is not deprecated — and the library's own use of it in `Resource`. Reading `$dto->id` directly remains the more readable form
- Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168))
- **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler`
- **Fixed:** `SymfonyBundle` registered the client with `ContainerBuilder::set()`, which stores a live instance rather than a definition. Instances set that way do not survive container compilation, so any service injecting `ZammadClient` failed the build with *"has a dependency on a non-existent service"* — the bundle never worked in a compiled container. It now registers a lazy `Definition` with `ZammadClient::create()` as its factory and an inlined `Auth` definition, so nothing is instantiated at compile time and the credentials are resolved from `%env(ZAMMAD_TOKEN)%` at runtime instead of being written into `var/cache`
- **Fixed:** the password parameters of `Auth::basic()` and `GuzzleClientFactory::withBasicAuth()` are marked `#[\SensitiveParameter]`. An empty username throws from inside `Auth::basic()`, so with PHP's default `zend.exception_ignore_args=0` the password appeared verbatim in the stack trace. The attribute is honoured from PHP 8.2 on and inert on 8.1
- **Fixed:** the Symfony section of the custom HTTP client guide offered the `create()` definition and a custom PSR-18 stack in one `services:` block, with the custom stack under its own `app.zammad_client` ID. Autowiring resolves a `ZammadClient` type-hint to the `ZammadAPIClient\ZammadClient` ID, so anyone copying that block got the Guzzle client and a dead custom stack. The two are now separate, clearly alternative blocks, both registering the class ID — the same shape `SymfonyBundle` uses
- **Fixed:** the Slim recipe (`examples/cookbook/10-slim.php`) told readers to create a `.env` file and then read `$_ENV` without loading one — and `$_ENV` is empty altogether unless `variables_order` includes `E`. It now uses `getenv()`, like every other recipe
- **Fixed:** the v3 migration guide's `composer require` line allowed `^3.0`, while the client instantiation shown directly below it uses `ZammadClient::create()`, which only exists from 3.2.0
- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory`. The fallback for installations without Flex, in that same section, then declared the factory under its class name while the handler referenced `nyholm.psr7.psr17_factory` — reproducing the very *"has a dependency on a non-existent service"* build error it was meant to avoid. It now declares the ID the handler asks for

`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code.

**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable therefore fails earlier and more clearly than before. Both framework bridges resolve the client lazily, so this surfaces the first time something asks the container for it, not while the container is built: Laravel falls back to an empty token (`env('ZAMMAD_TOKEN', '')`) and throws `InvalidArgumentException`, while the Symfony bundle falls back to `%env(ZAMMAD_TOKEN)%` and throws `EnvNotFoundException` when the variable is unset, or `InvalidArgumentException` when it is set but empty. The Symfony bundle only became lazy in this release — see the fix above; before it, an empty token made `cache:clear` (and with it `composer install`) fail outright. The bundles are still covered by neither tests nor static analysis ([#169](https://github.com/zammad/zammad-api-client-php/issues/169)); the bundle change above was verified by compiling and dumping a container against symfony/dependency-injection 7.4 outside this repository.

## [3.1.0] — 2026-10-02

### Added
- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids`, `group_ids` and `default_at_signup` are writable on create and update ([#167](https://github.com/zammad/zammad-api-client-php/pull/167))

### Changed
- Documentation: README restructured for newcomers (install / Packagist section), cookbook setup instructions and a broken personal access token link fixed

## [3.0.0] — 2026-09-11

### Added
- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids` and `group_ids` are writable on create and update
- PSR-18 / PSR-17 compliant HTTP layer (`RequestHandler`, `RetryAfterMiddleware`)
- Typed DTOs for all 11 resources (`Ticket`, `User`, `Organization`, `Group`, `Role`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`)
- Typed DTOs for all 10 resources (`Ticket`, `User`, `Organization`, `Group`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`)
- Repository pattern with generator-based pagination (`AbstractRepository`)
- `patch()` method for partial updates via `array` or `TicketUpdateDTO`
- Proper exception hierarchy: `AuthenticationException`, `NotFoundException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException`
- `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods
- Proper exception hierarchy: `AuthenticationException`, `ForbiddenException`, `NotFoundException`, `BadRequestException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException`
- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods (namespace `ZammadAPIClient\Factory`)
- PSR-3 Logger injection
- OpenAPI schema validation in integration tests
- Framework bridges for Laravel and Symfony
- `TicketArticleType` enum for article channel types
- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` baut den `RequestHandler`
- `ImpersonationHandler` — stateless decorator fur API-Impersonation
- `ImpersonationHandler` — scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))`
- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` builds the `RequestHandler`
- `ImpersonationHandler` — stateless decorator for API impersonation, scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))`

### Changed
- **Breaking:** PHP >= 8.1 required
- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (Namespace `ZammadAPIClient\Factory`).
- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (namespace `ZammadAPIClient\Factory`).
Non-Guzzle via `new ZammadClient(new RequestHandler($psr18Client, $psr17Factory, $url))`.
- **Breaking:** `ZammadClient::withClient()` entfernt; `ZammadClient` constructor akzeptiert `RequestHandlerInterface|ClientFactoryInterface`
- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` entfernt.
- **Breaking:** `ZammadClient::withClient()` removed; the `ZammadClient` constructor accepts `RequestHandlerInterface|ClientFactoryInterface`
- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` removed.
Impersonation via `new ZammadClient(new ImpersonationHandler($handler, $userId))`.
- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` entfernt.
`RequestHandler` halt keinen Impersonation-State mehr.
- **Breaking:** `RequestHandlerInterface::getRaw()` Signatur um `$headers`-Parameter erweitert
- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` removed.
`RequestHandler` no longer holds any impersonation state.
- **Breaking:** `RequestHandlerInterface::getRaw()` signature extended by a `$headers` parameter
- **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors
- **Breaking:** Array return values replaced by typed DTOs
- **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client
- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath`
- **Breaking:** Guzzle used as default transport; any PSR-18 client can be injected via `RequestHandler`
- `getListKey()` in repository classes is no longer abstract; it defaults to `$this->resourcePath`
- Typed repository accessors (`$client->ticket()`, `$client->user()`, …) are the recommended public API; the underlying `repo()` method is `@internal`.

### Removed
- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt
- Shared mutable Impersonation-State aus `RequestHandler` entfernt
- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, now removed
- Shared mutable impersonation state in `RequestHandler`

---

Expand Down
86 changes: 65 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ resolve `ZammadClient` from the container instead of constructing it by hand.
```php
require __DIR__ . '/vendor/autoload.php'; // skip if your framework autoloads

use ZammadAPIClient\Core\Auth;
use ZammadAPIClient\ZammadClient;

$client = ZammadClient::withToken('https://zammad.example', 'your-token');
$client = ZammadClient::create('https://zammad.example', Auth::token('your-token'));
```

**3. Try it** — fetch, create, update and search tickets:
Expand Down Expand Up @@ -86,10 +87,11 @@ Repositories are accessed via typed accessors: `$client->ticket()`, `$client->us
### Connecting

```php
use ZammadAPIClient\Core\Auth;
use ZammadAPIClient\ZammadClient;

// ZammadClient normalizes the URL — /api/v1 is appended automatically
$client = ZammadClient::withToken('https://zammad.example', 'your-token');
// The URL is normalized — /api/v1 is appended automatically
$client = ZammadClient::create('https://zammad.example', Auth::token('your-token'));
```

### Fetching
Expand Down Expand Up @@ -350,7 +352,7 @@ CSV format follows Zammad's import specification (header row with field names ma
The primary example is the [`examples/cookbook/`](examples/cookbook/README.md) directory — runnable recipes covering tickets, stateful resources, pagination, error handling, impersonation, and search. Run them against any Zammad instance:

```bash
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://your-zammad:3000 \
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=https://your-zammad.example \
ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=your-token \
php examples/cookbook/01-quick-start.php
```
Expand All @@ -361,43 +363,72 @@ For user and organization CRUD examples, refer to the integration tests in [`tes

## Authentication

Credentials are a value of their own. `Auth` has a private constructor, so the
only way to build one is through the three named constructors below — an empty
token or username is rejected right there instead of surfacing as a 401 later.

```php
use ZammadAPIClient\Core\Auth;
use ZammadAPIClient\Core\ConnectionConfig;
use ZammadAPIClient\ZammadClient;

// Token — sends Authorization: Token token=your-token (Zammad personal access token)
ZammadClient::withToken($url, 'your-token');
$client = ZammadClient::create($url, Auth::token('your-token'));

// OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token)
ZammadClient::withOAuth2($url, 'your-oauth-token');
$client = ZammadClient::create($url, Auth::oauth2('your-oauth-token'));

// Basic Auth — sends Authorization: Basic base64(user:pass)
ZammadClient::withBasicAuth($url, 'admin@example.com', 'test');
$client = ZammadClient::create($url, Auth::basic('admin@example.com', 'test'));

// Options
ZammadClient::withToken($url, 'your-token',
$client = ZammadClient::create($url, Auth::token('your-token'),
new ConnectionConfig(verifySsl: false, maxRetries: 5),
);

// Pass a PSR-3 Logger to log HTTP requests and retries
ZammadClient::withToken($url, 'your-token',
$client = ZammadClient::create($url, Auth::token('your-token'),
new ConnectionConfig(logger: $myLogger),
);
```

The client refuses to send a credential in cleartext: a `http://` URL pointing at
anything but the local machine throws an `InvalidArgumentException` instead of
leaking the token to the network. Loopback is exempt, so the local defaults keep
working. If the connection is protected by other means — a VPN, a service mesh, a
tunnel — opt out with `new ConnectionConfig(allowInsecureHttp: true)`.

The credential is applied per request by the transport layer, so it works the
same with any PSR-18 client. Guzzle is only the default — to use another one,
build the handler yourself:

```php
use ZammadAPIClient\Core\Transport\RequestHandler;

$handler = new RequestHandler($psr18Client, $psr17Factory, $url, auth: Auth::token('your-token'));
$client = new ZammadClient($handler);
```

Runnable example: [`examples/cookbook/07-custom-http-client.php`](examples/cookbook/07-custom-http-client.php).
Container wiring and security notes: [Custom HTTP client](docs/custom-http-client.md).

| 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 |
| `allowInsecureHttp` | `bool` | `false` | Permit credentials over plain `http://` to a non-loopback host |

## Framework integration

Prefer to resolve `ZammadClient` from your framework's container instead of
constructing it by hand? Bridges are provided for Laravel and Symfony — configure
credentials once, then inject the same shared client everywhere.

Full runnable setups: [`examples/cookbook/07-laravel.php`](examples/cookbook/07-laravel.php)
and [`examples/cookbook/08-symfony.php`](examples/cookbook/08-symfony.php).
Full runnable setups: [`examples/cookbook/08-laravel.php`](examples/cookbook/08-laravel.php)
and [`examples/cookbook/09-symfony.php`](examples/cookbook/09-symfony.php).

### Laravel

Expand Down Expand Up @@ -705,18 +736,31 @@ $client->ticket()->patch(42, new TicketUpdateDTO(

## Impersonation

Impersonation is scoped to its own client instance — wrap the handler in an
`ImpersonationHandler`. It decorates the handler you hand it, so the credentials
of the original client carry over; the only thing it adds is the `From` header.
That original client keeps acting as the authenticated user, so there is no state
to reset and nothing can leak between calls.

```php
// 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();
use ZammadAPIClient\Core\Auth;
use ZammadAPIClient\Core\Transport\ImpersonationHandler;
use ZammadAPIClient\ZammadClient;

$client = ZammadClient::create('https://zammad.example', Auth::token('your-token'));

// Accepts user ID (int), login, or email (string); no credentials needed here —
// they come from $client's handler
$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1));
$ticket = $asUser->ticket()->find(42);

$asAgent = new ZammadClient(new ImpersonationHandler($client->getHandler(), 'agent@example.com'));

// $client itself is unaffected
```

A runnable recipe is in [`examples/cookbook/05-impersonation.php`](examples/cookbook/05-impersonation.php).

## Development

```bash
Expand Down Expand Up @@ -752,7 +796,7 @@ v2 reference documentation is preserved in [docs/v2-reference.md](docs/v2-refere

| v2 | v3 |
|----|----|
| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, ...)` |
| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token(...))` |
| `$client->resource(TICKET)->get(1)` | `$client->ticket()->find(1)` |
| `$ticket->getValue('title')` | `$ticket->title` |
| `$ticket->getValues()` | `$ticket->toArray()` |
Expand Down
Loading
Loading