octoverse-id / octonomy-php
Official PHP SDK for Octonomy — multi-tenant tag management and taxonomy service.
Requires
- php: >=8.2
- ext-json: *
- guzzlehttp/guzzle: ^7.8 || ^8.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-11 11:44:26 UTC
README
The official PHP client for Octonomy — a multi-tenant,
multi-application tag management and taxonomy service. A hand-written, Guzzle-based client for the
Octonomy REST API: /api/v2 by default, with merchant and sub-tenant namespaces, or /api/v1 on
request.
Status: unreleased and early. The package is on Packagist but no version has been tagged yet, so the install command below reports no matching version until the first release ships. The transport, auth, error, and pagination foundation, every resource group and the health probes are implemented.
Install
composer require octoverse-id/octonomy-php
Requires PHP 8.2+. Requests go through Guzzle (7.8 or 8) unless you pass another PSR-18 client.
Quickstart
Two merchants on one tenant each create a tag with the same slug. Both rows live side by side, each merchant reads back only its own, and neither can reach the other's even by id:
<?php use Octoverse\Octonomy\Client; use Octoverse\Octonomy\Exception\NotFoundException; use Octoverse\Octonomy\Model\TagCreate; use Octoverse\Octonomy\Model\TagListParams; use Octoverse\Octonomy\NamespaceScope; use Octoverse\Octonomy\RequestOptions; $client = new Client([ 'base_url' => 'https://octonomy.example.com', // SDK appends /api/v2 'token' => 'svc_live_...', // Authorization: Bearer 'tenant_id' => 'marketplace', // X-Tenant-ID ]); // A namespace is chosen per request, never for the whole client. $acme = new RequestOptions(namespace: new NamespaceScope('merchant', 'acme')); $globex = new RequestOptions(namespace: new NamespaceScope('merchant', 'globex')); // The same slug under both merchants: two rows, no conflict. $featured = new TagCreate(name: 'Featured', slug: 'featured', type: 'label', applicationId: 'storefront'); $acmeTag = $client->tags()->create($featured, $acme); $client->tags()->create($featured, $globex); // Each merchant reads back only its own row. $label = new TagListParams(applicationId: 'storefront', isActive: true, slug: 'featured', type: 'label'); foreach (['acme' => $acme, 'globex' => $globex] as $merchant => $scope) { $row = $client->tags()->list($label, $scope)->data[0]; echo "reading as merchant:{$merchant} -> {$row->slug} ({$row->namespaceId}'s)\n"; } // Not even by id: as far as globex can tell, acme's tag does not exist. try { $client->tags()->get($acmeTag->id, applicationId: 'storefront', options: $globex); } catch (NotFoundException $e) { echo "globex cannot see acme's tag\n"; }
A complete, runnable program lives in examples/quickstart.php. Run against
the dev server below, it prints:
merchant:acme -> tag 'featured' created
merchant:globex -> tag 'featured' created
same slug, both live, no conflict
reading as merchant:acme -> featured (acme's)
reading as merchant:globex -> featured (globex's)
acme's tag by id, as merchant:globex -> NotFoundException (HTTP 404)
Each (…'s) is the namespace the server reports the row in. Run it again against the same server and
each tag is found rather than created twice: the first two lines end already there.
Run it against a local Octonomy
The SDK ships a dev server: a throwaway Octonomy in Docker, booted, checked and seeded for you. Two steps from an empty project to the quickstart's output:
composer require octoverse-id/octonomy-php vendor/bin/octonomy-dev-server run examples/quickstart.php
It needs Docker (a running daemon) and curl, on macOS, Linux or WSL; Windows without WSL is not supported. The first boot pulls the Postgres and Octonomy images, which takes as long as your connection does; after that a boot takes about a minute.
run boots a server unless one is already up, then runs the script with the server's credentials in
OCTONOMY_BASE_URL, OCTONOMY_TOKEN, OCTONOMY_TENANT_ID and OCTONOMY_APPLICATION_ID, and the
Composer autoloader already loaded. Its token may write in any merchant namespace, and the server has
namespaced writes switched on. Any PHP script works the same way, your own included: vendor/bin/octonomy-dev-server run try-octonomy.php. A path is read from the current directory first, then from the SDK's own directory,
which is where examples/quickstart.php comes from in your project.
| Command | Does |
|---|---|
run <script> [args...] |
Boot unless running, then run a PHP script against the server |
up |
Boot a fresh server: an empty database, then a small seeded corpus |
down |
Remove its containers, its Docker network and its env file |
logs |
Print the containers' logs, for a boot that failed |
env |
Print the path of the env file holding its credentials |
The server stays up between runs, keeping what each run wrote; down tears it down, and the next
run or up starts from an empty database. It listens on 127.0.0.1:8000: if that port is taken, as
it is when you already run an Octonomy there, set OCTONOMY_HARNESS_PORT to a free one on the command
that boots it.
There is one dev server per Docker daemon, and the project that booted it holds its credentials. In
another project, run and up refuse to replace it rather than destroy that project's data: run
down where it was booted first, or set OCTONOMY_HARNESS_PREFIX and OCTONOMY_HARNESS_PORT to run
a second one beside it.
Without the dev server, run Octonomy yourself and point the SDK at it, as below. The server's quickstart brings one up with Docker Compose. With no Docker at all, its deployment guide covers running a source checkout on a plain server.
Point at your own Octonomy
The quickstart reads those four variables, so run it with php and set them yourself. It writes: it
creates a featured label tag for your application in the merchant:acme and merchant:globex
namespaces, so give it a tenant you can write to. It also needs two things the dev server sets up for
you:
- A token that reaches both namespaces, with the
tags:readandtags:writescopes, such as one minted on the server withpython manage.py create_service_token --name quickstart --tenant marketplace --application storefront --namespace-wildcard --scope tags:read --scope tags:write. A token with no namespace grant reaches only the global partition, so the first write throwsForbiddenException. - Namespaced writes switched on:
OCTONOMY_NAMESPACE_WRITE_ENABLED=trueon the server. It is off by default, and the first write then throwsFeatureDisabledException, whose message says so.
OCTONOMY_BASE_URL=https://octonomy.example.com \ OCTONOMY_TOKEN=svc_... \ OCTONOMY_TENANT_ID=marketplace \ OCTONOMY_APPLICATION_ID=storefront \ php vendor/octoverse-id/octonomy-php/examples/quickstart.php
Your own code takes the first three as the base_url, token and tenant_id options, and passes the
application to each call that needs one.
Authentication and tenant scope
Every request carries credentials from the constructor options:
| Header | Source | Purpose |
|---|---|---|
Authorization: Bearer <token> |
token |
Service token (scopes: tags:read, tags:write, audit:read) |
X-Tenant-ID |
tenant_id |
Scopes every request to one tenant |
X-Actor-ID (optional) |
actor_id or RequestOptions::withActor(...) |
Attributes mutations in the audit log |
X-Request-ID (optional) |
RequestOptions::withRequestId(...) |
Correlates one call with what the server records about it |
use Octoverse\Octonomy\Model\TagUpdate; use Octoverse\Octonomy\RequestOptions; $client->tags()->update($id, new TagUpdate(isActive: false), options: RequestOptions::withActor('svc-catalog'));
Request correlation
Pass your own id for a call, such as a UUID or the trace id you already log, and Octonomy records it
in place of one it would mint: in the audit row of a mutation, the outbox event and the webhook
delivered from it, its structured request log, and the error envelope of a failed call, which the SDK
exposes as ApiException::$requestId. That ties your log line for the call to each of them.
$options = new RequestOptions(actorId: 'svc-catalog', requestId: $traceId); $client->tags()->update($id, new TagUpdate(isActive: false), options: $options);
The SDK never generates an id: without one no X-Request-ID is sent and the server mints
req_<uuid>. There is no client-wide id either, since an id names one call. A successful call returns
only its model, so log the id on your side when you send it. The id must be non-blank printable ASCII
with no space at either end, or InvalidArgumentException is thrown when the RequestOptions is
built, because the server would otherwise record a different string. Keep it to 100 characters: the
server stores it in a column that wide, and a longer one fails a mutation with a bare 500.
HTTP client
Without an http_client option the SDK builds a Guzzle client with a 30-second timeout, or the
timeout you give in seconds. Pass any PSR-18 client as
http_client instead, such as a Guzzle client with retry middleware or Symfony's Psr18Client, and
set its timeout on that client: timeout applies only to the client the SDK builds, so passing both
throws ConfigurationException rather than dropping the timeout.
- A Guzzle client gets each request through
send(). Its transport options and middleware apply, redirects as it configures them included. Its own default headers, query, body andauthdo not, since the SDK builds each request itself; add a header with middleware (Middleware::mapRequest()) instead. Do not give it a defaultform_paramsormultipart: Guzzle would put either in place of the SDK's body, and its option types offer no way for a request to cancel one. - Any other PSR-18 client gets the finished request through
sendRequest(), and what it does on the way out is up to that client. That includes a PSR-18 decorator around a Guzzle client: throughsendRequest(), Guzzle puts the wrapped client's defaultauth, query and body in place of the SDK's (a defaultauthreplaces theAuthorizationheader), adds its default headers, and does not follow redirects. Give a client wrapped that way none of those defaults.
use Symfony\Component\HttpClient\Psr18Client; $client = new Client([ 'base_url' => 'https://octonomy.example.com', 'token' => 'svc_live_...', 'tenant_id' => 'acme', 'http_client' => new Psr18Client(), ]);
Requests are built with Guzzle's PSR-17 factory unless you pass your own as request_factory and
stream_factory.
API version and namespaces
A client calls /api/v2 unless you construct it with 'api_version' => 'v1' (ApiVersion::V1),
which an Octonomy server older than 2.0.0 needs. The version is fixed when the client is built; build
another client to call the other surface. Any value other than v1 or v2 throws
ConfigurationException when the client is built.
On v2 a request can be placed in a merchant or sub-tenant namespace. The namespace is per request only, never a client default, because the server enforces namespace grants and fails closed:
use Octoverse\Octonomy\Model\TagListParams; use Octoverse\Octonomy\NamespaceScope; use Octoverse\Octonomy\RequestOptions; $merchant = new RequestOptions(namespace: new NamespaceScope('merchant', 'm-42')); // X-Namespace-Type: merchant, X-Namespace-ID: m-42. A namespaced request must name its application. $own = $client->tags()->list(new TagListParams(applicationId: 'commerce'), $merchant); // The namespace's rows plus the global ones the token may see. $all = $client->tags()->list(new TagListParams(applicationId: 'commerce', includeGlobal: true), $merchant); // get(), update() and delete() take the application as an argument (the application_id query // parameter); get() also takes includeGlobal, without which a global tag is a 404 here. $tag = $client->tags()->get($id, applicationId: 'commerce', options: $merchant);
Without a namespace a request reads and writes the global (tenant-shared) partition. A
NamespaceScope needs both a type and an id. A tag's usageCount is tenant-wide on v1 and counts only
the assignments the request can see on v2.
A v1 client refuses a namespace or includeGlobal with a LogicException before sending anything:
/api/v1 has no namespace axis, and would reject the headers or silently drop the parameter.
Partial updates
update() sends a PATCH, and the server changes only the fields the body names. A field you leave out
of a TagUpdate or VocabularyUpdate is left alone, and null asks the server to clear one:
use Octoverse\Octonomy\Model\TagUpdate; $client->tags()->update($id, new TagUpdate(name: 'Autumn')); // {"name": "Autumn"} $client->tags()->update($id, new TagUpdate(parentId: null)); // {"parent_id": null}: un-nests it $client->tags()->update($id, new TagUpdate(description: null)); // {"description": null}: clears it $client->tags()->update($id, new TagUpdate(metadata: [])); // {"metadata": {}}: empties it
A string field you leave out defaults to Undefined::VALUE, which omits it. The server decides what a
null does: docs/api.md lists which fields it clears and which it
refuses. metadata and isActive cannot be null on the server, so null omits them.
A model's metadata goes back into an update with the objects and lists it was read with: a nested
JSON object reads as a \stdClass (docs/api.md).
Errors
Non-2xx responses throw a typed exception extending Octoverse\Octonomy\Exception\ApiException, which
exposes $statusCode, $errorCode, $details, $requestId, and $serverMessage:
use Octoverse\Octonomy\Exception\FeatureDisabledException; use Octoverse\Octonomy\Exception\NotFoundException; use Octoverse\Octonomy\Exception\ValidationException; try { $tag = $client->tags()->get($id); } catch (NotFoundException $e) { // 404 } catch (ValidationException $e) { var_dump($e->details); } catch (FeatureDisabledException $e) { // The deployment has namespaced writes (403) or the namespaced v2 API (503) switched off: // a server flag, not a missing grant, which is a ForbiddenException. }
Every ApiException and TransportException also carries $context, a RequestContext naming the
request that failed: its api_version, method, path, query and namespace. Log it beside the message: a
namespaced 404 then records where the read looked and whether it asked for include_global, which a
status alone cannot (the server never reveals whether the id exists in another scope). It never holds
the token, the tenant or actor id, any other header, or the request body.
Every message says what happened and with which request, why when the SDK actually knows, and how to fix
it, and quotes the server's own message (kept as sent in $serverMessage):
octonomy: namespaced writes are disabled on this Octonomy deployment (namespaced_writes_disabled, HTTP 403).
Request: POST /api/v2/tags in namespace merchant:globex. OCTONOMY_NAMESPACE_WRITE_ENABLED is off on the
server, as it is by default. Fix: set OCTONOMY_NAMESPACE_WRITE_ENABLED=true on the server, or omit the
namespace to write globally. Server said: "Namespaced writes are not enabled." Request ID: req_...
No exception the SDK throws carries the token, the Authorization header, the request body, or
X-Actor-ID: not in its message (text quoted from the HTTP client or the server has them replaced
with [redacted]), its context, or the arguments of the SDK's frames in its trace.
A 2xx whose body is missing the envelope the endpoint guarantees, or whose resource lacks a field,
carries one of the wrong type, or has an unparseable timestamp, throws MalformedResponseException
naming the endpoint and the field: the SDK never fills in a zero value or coerces one. A failure the
HTTP client throws (a network failure, a timeout, or middleware that throws), or a 2xx body that is not
JSON from a method that returns data, throws TransportException (delete() ignores any body). Every one of these failures
extends Octoverse\Octonomy\Exception\OctonomyException. A mistake in the call itself, caught before
anything is sent (a v2-only feature on a v1 client, a NamespaceScope with a blank type or id, a
per-request header value with a control character in it, a body that cannot be encoded as JSON),
throws PHP's LogicException or InvalidArgumentException instead: fix the call, there is nothing to
retry. A configured value a header cannot carry throws ConfigurationException.
Pagination
List methods return one page, a ResultList<T> with ->data (a list of models) and ->pagination
(limit, offset, count, next, previous):
use Octoverse\Octonomy\Model\TagListParams; $page = $client->tags()->list(new TagListParams(type: 'label', limit: 50)); printf("%d of %d\n", count($page->data), $page->pagination->count);
Each list method has a walk*() beside it (walk(), walkAliases(), walkTags(), …) that returns a
lazy \Generator over every page, keyed by each item's offset in the list. It is one request per page
in sequence, not a bulk fetch: it asks for 200 a page, the server's maximum, unless the params set a
limit (the server's own default is 50), and sends nothing until you iterate it. A failed page throws
WalkInterruptedException, whose reachedOffset is where that page starts, so the walk can resume
from there:
use Octoverse\Octonomy\Exception\WalkInterruptedException; $params = new TagListParams(type: 'label'); try { foreach ($client->tags()->walk($params) as $offset => $tag) { echo $offset, ' ', $tag->slug, PHP_EOL; } } catch (WalkInterruptedException $e) { $e->getPrevious(); // what failed, such as the ApiException for a 503 $resume = new TagListParams(type: 'label', offset: $e->reachedOffset); }
A walk is not a snapshot. The server pages by offset and has no cursor, so a row created or removed (for tags, vocabularies and aliases, deactivated) at a place before the walk's position while it runs moves every row after it: the walk yields one twice or never yields one, and so can a resume. Audit logs list newest first, so every write made during a walk of them repeats an entry. Narrow the walk with filters, de-duplicate on id, or walk while nothing writes. Against a server older than 3.2.1 the tags list has no order at all, so a tag walk of more than one page can repeat or miss tags even when nothing writes.
Tagging resources
An assignment links a tag to one of your own resources, named by a type and an id, within an application. Name the tag by id, by alias id or by alias slug, each with its own constructor. Assigning a tag the resource already has returns the existing assignment instead of failing:
use Octoverse\Octonomy\Model\AssignmentCreate; use Octoverse\Octonomy\Model\BulkAssign; use Octoverse\Octonomy\Model\ResourceTagsReplace; $client->assignments()->create(AssignmentCreate::forAliasSlug('cms', 'product', 'sku-1', 'promo')); $client->assignments()->bulkAssign(new BulkAssign('cms', 'product', 'sku-1', tagIds: [$saleId, $redId])); foreach ($client->resources()->listTags('product', 'sku-1', 'cms')->data as $row) { echo $row->tag->slug, PHP_EOL; } // Exactly these tags afterwards; [] removes every tag the resource has in the application. $client->resources()->replaceTags('product', 'sku-1', new ResourceTagsReplace('cms', [$saleId]));
tags()->resolve($slug) turns a slug into the tag it names, its own or an alias's, and says which
matched (TagResolution::$matchedType).
Health probes
The server's liveness and readiness probes are unauthenticated and sit outside /api, so a
HealthClient needs only the base URL. A short timeout suits a probe:
use Octoverse\Octonomy\HealthClient; $probe = new HealthClient(['base_url' => 'https://octonomy.example.com', 'timeout' => 2]); $status = $probe->health()->ready(); if (!$status->ok) { // The server answered, but cannot serve traffic ($status->status is "unavailable"): retry later. }
A Client reaches the same probes through $client->health(), sending neither its token nor its
tenant. A not-ready server (/health/ready answering 503) returns a HealthStatus with ok false;
it is not an exception. A server that cannot be reached throws TransportException, and anything else
that answers in its place, such as a proxy's 502, throws ApiException. At server 3.2.1, ready()
can still pass for up to a minute after the server loses its database, since the server checks a
database connection it keeps open for 60 seconds.
Webhooks
An Octonomy server set to deliver its events by webhook (OCTONOMY_OUTBOX_TRANSPORT=webhook; it logs
them by default) signs each delivery's body. Webhook::verify() checks that signature against the raw
body, exactly as received:
use Octoverse\Octonomy\Webhook; // Anything but a 2xx until the delivery verifies: see below. http_response_code(401); $body = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_OCTONOMY_SIGNATURE'] ?? null; // PSR-7: $request->getHeaderLine(Webhook::SIGNATURE_HEADER) if (!Webhook::verify(getenv('OCTONOMY_WEBHOOK_SIGNING_SECRET'), $signature, $body)) { exit; } $event = json_decode($body, true); // read the tenant, event type and id from here, not from headers // ... handle the event, then: http_response_code(204);
Reject a delivery that does not verify, with any status but a 2xx. The server marks a delivery
answered with a 2xx as delivered and never sends it again, so a handler that answers 200 after a failed
check is wrong for good. A non-2xx leaves the event failed, for the server to retry or dead-letter. Plain
PHP answers 200 for an uncaught exception while display_errors is on, as it is in development, so
answer the failure status first, as above, and the success one only once the event is handled.
Frameworks answer an uncaught exception with a 500 either way.
verify() returns false for anything the sender controls: no signature, one that is not sha256= and
64 hex digits, and a digest that is not the body's. It throws InvalidArgumentException only for your
own mistakes:
- a secret that is not a string or is empty, such as
getenv()'s false for an unset variable (under an empty key anyone could sign a delivery); - a header passed as anything but a string or null, such as PSR-7's
getHeader()array; - a body that is not a string, such as a failed read's false or a parsed body.
Only the body is signed, so a captured delivery still verifies with its X-Octonomy-* headers rewritten:
trust what the verified body says. Nothing signed says when a delivery was sent, and the server delivers
at least once, so deduplicate on the body's id. If every delivery fails to verify, something read the
body before you did. The SDK ships no typed events or request handler: verify the bytes, then decode
them as you need. docs/api.md has the details.
Implemented resources
| Resource | Status |
|---|---|
Vocabularies ($client->vocabularies()) |
✅ create / get / list / update / delete |
Tags ($client->tags()) |
✅ create / get / list / update / delete, resolve(), and a tag's listAliases() / listResources() / listAuditLogs() |
Tag aliases ($client->aliases()) |
✅ create / get / list / update / delete |
Tag assignments ($client->assignments()) |
✅ create / remove / bulkAssign / bulkRemove |
Resource tags ($client->resources()) |
✅ listTags / replaceTags / listAuditLogs |
Audit logs ($client->auditLogs()) |
✅ list |
Health ($client->health(), or HealthClient without credentials) |
✅ live / ready |
Webhooks (Webhook::verify()) |
✅ signature verification |
Common commands
make test # phpunit make check # php-cs-fixer + phpstan + phpunit make help # list all targets
Documentation
- Architecture — how the client is layered.
- API mapping — SDK methods ↔ Octonomy endpoints, auth, scopes.
- Development — setup, quality gates, testing.
- Versioning — SemVer policy and which server contract this SDK targets.
- Release — the release runbook.
- Roadmap — the recipe for adding an endpoint.
- CHANGELOG
Contributing & security
See CONTRIBUTING.md, CODE_OF_CONDUCT.md, and SECURITY.md. The repository follows Conventional Branch naming and Semantic Versioning.
License
Apache License 2.0 — see LICENSE.