Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Zammad Api Client Php Laravel Package

zammad/zammad-api-client-php

A PHP client for the Zammad helpdesk API, letting you authenticate and work with tickets, users, and other resources from your PHP application. Install via Composer; requires PHP 7.2+ and Zammad 3.4.1 or newer. A v3 release candidate adds typed DTOs and PSR-18 support.

View on GitHub
Deep Wiki
Context7

Getting Started

Decide v2 vs v3 before you type anything. The repo is mid-migration: ^2.3 is the stable line (PHP 7.2+), while ^3.0@RC is a rewrite with typed DTOs, a PSR-18 HTTP client, typed exceptions, and PHP 8.1+. On a modern Laravel app (10/11/12) you want v3 because PSR-18 means you can hand it the same Guzzle client Laravel already wires up — but it's an RC with an open feedback loop. Pin it explicitly and read the v3 migration guide before committing; the error model in particular changes.

Install and configure. There is no Laravel service provider, config file, or facade shipped with the package — you write the glue. That's ~15 lines:

composer require zammad/zammad-api-client-php:^3.0@RC
// config/services.php
'zammad' => [
    'url'      => env('ZAMMAD_URL'),
    'http_token' => env('ZAMMAD_TOKEN'),   // prefer token over username/password
    'timeout'  => env('ZAMMAD_TIMEOUT', 15),
    'debug'    => env('ZAMMAD_DEBUG', false),
],
// app/Providers/ZammadServiceProvider.php
$this->app->singleton(Client::class, fn () => new Client(config('services.zammad')));

Register it and you can inject ZammadAPIClient\Client anywhere. Never construct the client inline in a controller — you'll want the singleton for getLastResponse() debugging and for test-swapping later.

First use case, in priority order: ticket creation from an inbound channel (contact form, webhook, email parser). That's the 80% case for anyone reaching for this package. The 15-line smoke test:

use ZammadAPIClient\Client;
use ZammadAPIClient\ResourceType;

$client = app(Client::class);

$ticket = $client->resource(ResourceType::TICKET);
$ticket->setValue('title', 'Login broken');
$ticket->setValue('group', 'Users');
$ticket->setValue('customer_id', $userId);
$ticket->save();

if ($ticket->hasError()) {
    report(new \RuntimeException($ticket->getError()));
}

Where to look first: the examples/ directory in the repo (more instructive than the README prose), then ResourceType.php for the full list of resource constants, then the Zammad REST docs — the client is a thin transport, so the REST docs are your field reference. A fresh Zammad install has object ID 1 for every resource type; get(1) on each is the fastest way to discover the real field names in your instance.

Implementation Patterns

Wrap it, don't sprinkle it. The Client → Resource API is stateful and returns errors as values, which is hostile to read inside controllers. Put one thin application service between Laravel and the package:

final class ZammadGateway
{
    public function __construct(private Client $client) {}

    public function createTicket(TicketData $data): int
    {
        $ticket = $this->client->resource(ResourceType::TICKET);
        $ticket->setValues($data->toZammadArray());
        $ticket->save();

        if ($ticket->hasError()) {
            throw ZammadException::from($ticket->getError(), $this->client->getLastResponse());
        }

        return (int) $ticket->getValue('id');
    }
}

Everything downstream — queued jobs, Artisan commands, Livewire components — talks to ZammadGateway, returns plain DTOs/ints, and knows nothing about Resource objects. This is the single highest-leverage decision, because it's also what makes the package testable (see below).

Fetch → mutate → save is the update loop. There is no update(34, [...]). You fetch the resource, call setValue(), then save(). save() figures out create-vs-update itself, and isDirty() lets you guard the round trip:

$ticket = $client->resource(ResourceType::TICKET)->get($id);
if ($ticket->hasError()) { /* ... */ }

$ticket->setValue('state', 'closed');
if ($ticket->isDirty()) { $ticket->save(); }

This is a read-modify-write with no optimistic locking — concurrent writers will silently clobber each other. For anything touched by multiple agents or by both your app and Zammad's UI, treat fields as owned by one side.

Articles are a sub-resource, not a field. Adding a reply means creating a TICKET_ARTICLE with ticket_id, body, type (note/email/web), internal, plus sender and content_type. This trips people up: the article is what notifies the customer, and internal => true vs false is the difference between a private note and a sent email. Get that flag wrong in production code and you'll email a customer a note intended for agents.

Search is paginated and capped server-side, so always paginate explicitly.

$page = 1;
do {
    $resources = $client->resource(ResourceType::TICKET)->search('state:open AND group:Users', $page, 100);
    if (!is_array($resources)) { throw ZammadException::from($resources->getError()); }

    foreach ($resources as $ticket) { /* ... */ }
    $page++;
} while (count($resources) === 100);

Use search() for filtered pulls, all() for bulk syncs. Search syntax (title:Foo AND priority_id:1) is Elasticsearch-backed in Zammad, so it's fast and expressive — but the index is the source of truth, so freshly created tickets may not appear immediately. If you write-then-immediately-search, you will get flaky results and lose an afternoon.

Batch the child lookups. $ticket->getTicketArticles() is one HTTP call per ticket. Syncing 500 tickets with articles is 501+ requests against a default 5-second timeout. Either raise timeout, chunk aggressively in a queued job, or accept eventual consistency via a scheduled reconciliation command.

On-behalf-of is your multi-tenant lever. $client->setOnBehalfOfUser('[email protected]') sets the From header for every subsequent call, and unsetOnBehalfOfUser() clears it. Since the singleton client is shared, this is global mutable state — in a queue worker processing jobs for different tenants, you must set it at the start of each job and unset it in a finally. Better: resolve a fresh client per job from a factory rather than mutating the singleton.

Testing: fake the gateway, not the client. Client is a concrete class with a hardcoded transport in v2, so mocking it is painful. Mock your own ZammadGateway interface (or bind a fake implementation) and unit-test your business logic against it. Reserve one integration test, gated behind an env flag, that hits a throwaway Zammad instance and exercises ticket create → article → tag → close. That test is worth more than fifty mocked ones, because the field names are server-defined and only the server can tell you you spelled one wrong.

Gotchas and Tips

Errors are values, not exceptions. This is the defining quirk of the v2 API and the thing everyone hits first:

  • get() returns a Resource with an error set.
  • search() and all() return either an array or the caller object itself on failure. So if (!is_array($result)) is your error check, and a "successful" empty search returns [] — which is falsy but is an array. Use is_array(), never empty().
  • Always hasError() / getError() after any mutating call. Code that omits this fails silently in production and you'll debug it from customer complaints.

v3 replaces this with typed exceptions — genuinely the best reason to upgrade. If you stay on v2, put the is_array/hasError checks in exactly one place (your gateway) so the rest of the codebase never sees them.

You cannot reuse a Resource object that holds data. After a successful get(), calling get() again on the same object is an error. Same for search() and all() on an object that has values. Always start from a fresh $client->resource(ResourceType::TICKET). This forces a client->resource() call at the top of nearly every method — expect it, don't fight it.

The client validates nothing. No field whitelist, no type coercion, no required-field checks. setValue('titel', 'x') is accepted locally and silently ignored or bounced by Zammad. Discover the schema empirically with get(1), and add your own DTO with typed properties so a typo fails in PHP, not at runtime. Also note some resources reject updates to specific fields — e.g. created_at, number, and article content on persisted articles typically can't be changed.

The default 5-second timeout will bite you. Set timeout to 15–30 for anything queued, and 0 (no timeout) only in a CLI sync command where you control the blast radius. A timeout surfaces as an error on the resource, not an exception, so a slow Zammad looks identical to a validation failure until you inspect $client->getLastResponse() — which holds the HTTP status and body of the last request and is your primary debugging tool. Turn on 'debug' => true in local only; it's chatty.

The server-side result limit silently truncates. Zammad caps returned objects (often 500). Requesting 1000 per page just gives you the cap back. Don't infer "no more results" from count($page) < $requestedPageSize unless your page size is below the cap — compare against the cap or loop until an empty page.

Tokens over passwords, and check the API toggle. Prefer http_token (a Zammad API token) or oauth2_token over username/password — tokens are per-integration, revocable, and scoped to specific permissions, which matters when your Laravel app is one of several systems touching Zammad. API access must be enabled in Zammad's settings (usually on by default; if every call 401s, check there first). Also: creating a ticket as customer_id requires that ID to exist — resolve it via a USER search by email rather than hardcoding.

delete() requires a fetched object. You can't delete(34). Fetch it, then delete. The PHP object survives empty and reusable, which is confusing but harmless. There's no soft-delete concept exposed here — deleted tickets go to Zammad's trash, and the client won't tell you that.

Attachments need a loop. The attachments array on an article holds metadata only; fetch bytes per attachment with $article->getAttachmentContent($attachmentId). There's no bulk endpoint. If you're piping attachments to S3, stream and bound the size — getAttachmentContent() returns the whole thing as a string in memory.

License needs a real look. The repo reports NOASSERTION. Before this goes anywhere near a commercial product, confirm the actual license terms with your legal/OSS-review process. This is not a footnote — it's a blocker for some orgs.

Extension points are thin by design. You can't easily inject a PSR-18 client in v2 (that's the v3 headline feature), there's no retry/backoff, no rate limiter, and no middleware hook. Practical consequence: put retry logic in your gateway or in the Laravel queue ($tries, backoff()) rather than expecting the client to handle it. For rate limiting, use RateLimiter::for('zammad', ...) on job dispatch — Zammad itself doesn't advertise a rate limit, but hammering it from a queue worker will degrade the agent UI for real humans, which is a support ticket you don't want.

Upgrade path: the v2 → v3 jump changes the error contract (hasError() → typed exceptions) and the client construction (array options → PSR-18 client + request factory + DTOs). If you took the gateway advice above, that's one file to rewrite plus your test double. If you scattered $client->resource(...) calls across forty controllers, it's a sprint.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log