flowti/zabbix-bundle
flowti/zabbix-bundle offers a PHP integration layer for Zabbix, letting your application query hosts, items, triggers, and events through the Zabbix API. It wraps common monitoring calls in a simple, framework-friendly service so you can surface alerts and metrics without hand-writing raw API reques...
The release notes change what this package is. The previous read was "Symfony bundle wrapping a Zabbix trapper sender." v0.0.1 ships an API client: user.login, user.logout, event.acknowledge, host.get, hostgroup.get, event.get, trigger.get. Those are JSON-RPC calls against https://<zabbix>/api_jsonrpc.php, not the TCP/10051 sender protocol. That matters for you because:
app/Support/ — no DI container magic, no kernel events, no bundle wiring. The Symfony coupling is usually one constructor and a config array.config/zabbix.php, no facade. composer require flowti/zabbix-bundle into a Laravel app still installs files nothing loads. You are harvesting a class, deliberately.Two jobs, decide before you plan:
src/ for a sender class before assuming it still exists or works; if it's absent or Symfony-bound, write the ~40-line protocol yourself.src/DependencyInjection/Configuration.php — the config tree is the real docs. You're looking for api_url / username / password / auth / timeout, and whether a trapper host/port pair still exists.src/ — read its constructor and its request method. You need to know: does it user.login per call, per instantiation, or does it accept a pre-obtained auth token? That one detail decides how much glue you write.Resources/config/services.* — the service ID to inject in Symfony, and the class name to lift for Laravel.composer.json — Symfony version constraints and whether symfony/http-client / Guzzle is a hard dependency.composer require flowti/zabbix-bundle
// config/bundles.php
Floti\ZabbixBundle\FlowtiZabbixBundle::class => ['all' => true],
# config/packages/flowti_zabbix.yaml <- confirm root key + keys in Configuration.php
flowti_zabbix:
api_url: '%env(ZABBIX_API_URL)%'
username: '%env(ZABBIX_API_USER)%'
password: '%env(ZABBIX_API_PASSWORD)%'
php bin/console debug:container zabbix
Copy the client class into app/Support/Zabbix/, strip the Symfony-only constructor arguments, bind it:
// app/Providers/ZabbixServiceProvider.php
public function register(): void
{
$this->app->singleton(ZabbixApiClient::class, fn () => new ZabbixApiClient(
apiUrl: config('services.zabbix.url'),
user: config('services.zabbix.user'),
password: config('services.zabbix.password'),
));
}
If the class fights you (event dispatcher, logger, parameter bag), don't fight back. The API is a single POST with a JSON body — this is the entire protocol:
$response = Http::timeout(5)->post(config('services.zabbix.url'), [
'jsonrpc' => '2.0',
'method' => 'trigger.get',
'params' => ['output' => ['triggerid', 'description', 'priority'], 'only_true' => 1, 'min_severity' => 3],
'id' => 1,
'auth' => $token, // or omit and use a pre-issued API token
]);
First real use case: a "current problems" panel — trigger.get with only_true + min_severity, joined to host.get for host names, cached 60s, plus an event.acknowledge action from the admin UI. That's the 80% case for this release and it exercises every listed method except the ones you'll never call.
Authenticate once, cache the token. user.login returns a session auth token with a server-side TTL. Logging in on every client instantiation is a needless round trip and a needless audit-log entry. Log in lazily, stash the token in Cache::remember('zabbix.auth', now()->addMinutes(25), ...), and on an auth error (JSON-RPC error code for "not authorised") invalidate and retry exactly once.
Don't call user.logout per request. In a PHP-FPM/Laravel world, logout on shutdown buys nothing and turns a 3-request page into 4. Let the token TTL expire; only wire logout into a long-running process's shutdown if you're genuinely session-constrained.
Wrap the raw client in a narrow, intention-revealing interface. The package returns untyped nested arrays with string IDs. Never let those reach a controller.
interface Problems
{
/** @return Problem[] */ // your DTO, not the raw array
public function current(int $minSeverity = 3): array;
public function acknowledge(string $eventId, string $comment): void;
}
This is also your seam for tests and for swapping to a hand-rolled HTTP client later, which you may well do.
Poll, cache, serve. trigger.get and event.get hit the Zabbix database directly. A scheduled command every 30–60s writing into cache/Redis, with the web layer reading only cache, is the only pattern that survives real traffic. Never call the API from a page render.
Queue writes. event.acknowledge mutates state on a system other teams depend on. Dispatch a job, return 202, surface the result via the job's own notification path. You do not want a Slack ack button blocked on Zabbix API latency.
Resolve host IDs once and memoise. host.get output is hostid (string), host (technical name), name (visible name), status (0 monitored / 1 not), interfaces only if you asked via selectInterfaces. Build a host → hostid map, cache it for hours, and refresh on a schedule. It almost never changes and it's the join key for everything else.
Batch where you can. JSON-RPC 2.0 accepts an array of request objects in one POST and returns an array of responses — same shape, per-call errors. If the bundle's client doesn't expose that, and you're fetching problems for 30 hosts, you'll feel it.
Keep the two integrations separate in your head. Pull (this package, HTTP 80/443 to the Zabbix frontend, read + acknowledge) and push (trapper sender, TCP 10051 to the server, write metrics) are different network paths, different auth models, different failure modes. Most mature setups use both; they share no code and should share no config key.
Fake the HTTP layer in tests. Http::fake() and assert on the request body — method name, only_true, limit, the action bitmask. Asserting only on stubbed responses hides the payload bugs, which are the ones that actually happen. Also assert that an error response doesn't blow up the caller.
v0.0.1 means zero BC promise. Pre-1.0 versioning, first tagged release, no dependents to sanity-check it. Pin an exact version in composer.json, and plan on forking — the license is MIT, so a maintained copy in app/Support/ is cheap and honest. This is a "vendor it" package, not a "depend on it" package.
user.login is the legacy auth path. Zabbix 6.4 introduced API tokens and deprecated the auth property; modern setups issue a token per integration and skip login entirely. Check whether the client can send a Bearer/pre-issued auth value — if it only knows user.login, you're wiring username+password for a monitoring integration, which is both a security smell and a forward-compatibility risk. Verify against the exact Zabbix version you run (6.0 LTS, 6.4, 7.0 all differ here).
HTTP 200 is not success. Zabbix returns errors as a JSON-RPC error object with code, message, data — status code is 200. Batch requests return an array where some elements are errors. Any client that returns the decoded array without checking error will silently hand you null where you expected problems. Check error on every response, log it, and throw your own exception type.
event.acknowledge uses an action bitmask, not a string. 1 = close problem, 2 = acknowledge, 4 = add message, 8 = change severity, 16 = unacknowledge. So "acknowledge with a comment" is action: 6 (2 + 4). Send action: 2 and your comment vanishes with no error. Send action: 6 without message and you may get an error or an empty comment depending on version. This is the single most common bug in Zabbix integrations.
event.acknowledge can succeed emptily. It returns eventids — an empty array means nothing matched (wrong ID, or already acknowledged and not re-acknowledgeable). No exception, no warning. Treat empty eventids as a failure in your code.
trigger.get defaults are expensive and surprising. No only_true means you get OK triggers too. min_severity defaults to 0. output: extend returns every field. active, monitored, skipDependent all change the result set. Always pass explicit output (field list), only_true, min_severity, and limit.
event.get without time_from and limit will hammer the Zabbix DB. It's a time-series table. Pass time_from as a Unix timestamp — string datetimes are interpreted in the Zabbix server's timezone and will quietly be hours off — plus value: 1 for problems, source: 0 for trigger events, and a hard limit. Watch the API user's timeout; long queries get killed, not paginated.
IDs are strings. hostid, eventid, triggerid all come back as strings. Strict comparisons (===) against ints fail, and using them as array keys silently casts. Normalise at your DTO boundary and never again.
host.get: host vs name. host is the technical name (what the trapper host field must match, what macros use); name is the visible name. Getting these backwards is the classic "my metrics land on the wrong host" bug. status: 0 means monitored — 1 means unmonitored, which reads backwards to almost everyone.
Different port, different firewall rule. The API is HTTPS to the frontend on 80/443. The trapper is outbound 10051 to the server. If someone whitelisted 10051 and the API client times out, that's why. Test from the container: curl -sS $ZABBIX_API_URL -d '...'.
Least privilege on the API user. Create a dedicated user with a role that can read hosts/triggers/events and acknowledge events — not a super-admin. Credentials in config/services.php via env, never committed; the package's YAML config may invite inlining them.
Config is cached, twice over. Symfony compiles the container (cache:clear after YAML edits); Laravel caches config (config:cache). Debugging a config that isn't being read wastes an afternoon in both ecosystems.
Timeout and retry live in your wrapper, not the vendor class. Set an explicit 5s timeout, retry once on connection error only (not on JSON-RPC errors — those are deterministic), and never let a Zabbix outage throw out of a page render.
Instrument the client itself. Log method, request id, duration, and any error.code at debug. When a dashboard goes blank, the difference between "auth expired", "role lacks permission", and "trigger.get returned zero rows" is in that log line.
Extension points if you stay on Symfony: decorate the client service (same class, your wrapper, constructor-injected original) to add logging/retry, or override the service definition ID in your app's services.yaml. Both survive composer update; editing vendor/ does not. In Laravel, the extension point is your provider binding — which is exactly why the provider is worth writing even for a one-class harvest.
How can I help you explore Laravel packages today?