PHP collector API
seamward/php-collector is the framework-neutral implementation shared by PHP adapters. It targets PHP 8.3 or later, has no framework dependency, and is available from Packagist as an alpha package.
Connection and policy
Connection::fromCredentials($connectionKey, $ingestToken, $endpoint) validates both Integration-owned values, derives the bound Integration id, and requires HTTPS except for localhost development.
One ingest token for every Integration
Connection::forService($ingestToken, $connectionName, $endpoint) reports an Integration with the workspace ingest token and the Integration's connection name, in place of a Connection key and token per Integration. See One token for every Integration.
Code example
use Seamward\Collector\Collector;use Seamward\Collector\Configuration\Connection;
$paystack = Collector::connect( connection: Connection::forService( getenv('SEAMWARD_INGEST_TOKEN'), 'paystack-webhook', ), transport: $transport,);It throws InvalidArgumentException when the token is malformed or the name is not 2 to 64 lowercase letters, digits and single hyphens. Connection::isServiceToken($token) checks a token's format and checksum without a network call. The token decides the mode, so the same name works in Test and Live.
RedactionPolicy accepts:
| Argument | Default | Purpose |
|---|---|---|
version | shape-only-v1 | Audit label stored with every payload shape |
hashKey | collector-derived | Local key used before identifiers enter an envelope |
hashNamespace | deterministic local derivation | Non-secret generation label for keyed hashes |
dropFields | [] | Fields removed by the optional local payload redaction helper |
hashFields | [] | Fields hashed by the optional local payload redaction helper |
The default hash key is derived locally from the ingest token, and the token itself is never serialized into an envelope. Seamward issues that token and receives it with every request, so a derived key is one Seamward could work out; pass your own hashKey for a key it cannot. See The hash key.
Observations
Create an Observation with an Operation, status code, duration, and JSON-compatible payload. Convenience factories cover inbound webhooks and outbound HTTP APIs:
Code example
Operation::inboundWebhook('/webhooks/candidates');Operation::outboundHttp('POST', '/v1/candidates');Operation::queuePublish('candidate-events');Operation::queueConsume('candidate-events');Operation::scheduledFeed('nightly-candidate-import', 'inbound');HTTP route templates must be bounded absolute paths without a query string. Surrounding whitespace is trimmed, and :id or {id} segments are normalized to {}. Queue and scheduled-feed targets use bounded stable labels. Use placeholders rather than concrete identifiers. JsonValue::decode() preserves the difference between JSON objects and arrays, including empty {}, [], and numeric-string object keys.
Optional observation metadata includes attempt, eventType, declaredContractVersion, deployment, correlation, and outcome. Raw correlation identifiers and business object ids are replaced by keyed hashes.
Outcome amount and state
An outcome may also report two facts about your own record, which reconciliation rules read: amount for amount-match and state for allowed-sequence.
Code example
Observation::fromArray([ // ...operation, status, duration, payload 'eventType' => 'payout.paid', 'outcome' => [ 'accepted' => true, 'businessObjectType' => 'payout', 'businessObjectId' => $payout->id, 'amount' => ['value' => $payout->amount_minor, 'unit' => 'GBP_minor'], 'state' => $payout->status, ],]);amount.value is an int or float between -1e15 and 1e15; a numeric string is not accepted. amount.unit (up to 32 characters) and state (up to 64) are labels of letters, digits, ., _ or -. These are values you choose to send and are stored as you report them, so report a total and a state name, never an identifier or a personal value. One that does not meet the format is left out and the observation is still sent. An observation that reports either is sent as envelope 0.6.
Account reference
Name the customer or account a record belongs to and an incident can tell you how many of your accounts it affects. Set 'accountRef' in an observation's outcome, or pass accountRef: to observeCall:
Code example
$collector->observeCall( $operation, fn () => $client->applications->retrieve($applicationId), reference: $applicationId, accountRef: $employerId,);It is your own identifier, up to 512 characters, hashed in your process with your hash key. Only the hash is sent, as outcome.accountRefHash, in an envelope 0.9. Seamward never learns the identifier or a name.
Deprecation notices
Providers often announce a change in their response headers before it lands. observeCall reads the Deprecation, Sunset and API version headers of the response and sends what they say as dates and one version label, which Seamward turns into an advisory.
It finds the headers where common clients keep them: getHeaders() or headers() on the result (PSR-7 and Laravel responses), a headers property, the same on a response or getResponse(), getHttpHeaders() on an exception, and getLastResponse()->headers on a provider SDK object. For a client that keeps them elsewhere, return them from your extractor:
Code example
$operation = CallOperation::http( method: 'GET', routeTemplate: '/v1/charges/{chargeId}', extract: fn (ProviderResult $result): array => [ 'statusCode' => $result->httpStatus, 'payload' => $result->body, 'headers' => $result->responseHeaders, ],);With record(), pass them as the observation's responseHeaders.
No header is sent. Only those headers are looked at, and a value is kept only when it parses to a date between 2000 and 2100 or to a version label of up to 64 letters, digits, ., _ or -. Anything else is discarded in your process. An observation that carries a notice is sent as envelope 0.7; see the envelope reference for the exact rules, which the PHP and Node.js collectors share.
Rate limits and signature results
observeCall reads a provider's rate-limit headers from the same places it reads deprecation notices and sends three whole numbers: the limit, what remains, and the seconds until it resets. The headers read are RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, the same names prefixed X-RateLimit- and X-Rate-Limit-, the combined RateLimit header, and Retry-After in seconds. A value that is not a whole number in range is discarded. No header is sent.
When you record a webhook yourself, report the result of your own signature check in the outcome:
Code example
$collector->record(Observation::fromArray([ 'direction' => 'inbound', 'protocol' => 'http-webhook', 'method' => 'POST', 'routeTemplate' => '/webhooks/payments', 'payloadLocation' => 'request', 'statusCode' => $valid ? 202 : 401, 'durationMs' => $elapsedMs, 'payload' => $payload, 'outcome' => ['accepted' => $valid, 'signatureValid' => $valid],]));signatureValid is a bool; any other value is left out. Never pass the signature or the secret. An observation that carries a rate limit or a signature result is sent as envelope 0.8. Seamward uses both for connection health.
The completed envelope is validated strictly before enqueue. Unknown fields, invalid nested values, malformed deployment labels, noncanonical keyed hashes, missing operation identity, invalid status combinations, and malformed shapes are rejected locally. record() suppresses that telemetry failure and increments buildErrors.
Collector lifecycle
Collector::connect() accepts a validated connection, a transport, and optional buffer, policy, envelope builder, and batch size.
record(Observation $observation): voidbuilds and buffers an envelope. It never throws.observeQueuePublish(...)wraps an outbound queue publisher and projects success, rejection, or error to202,422, or500.observeQueueConsumer(...)wraps an inbound queue consumer with the same status projection.observeScheduledFeed(...)wraps an inbound import or outbound export and projects completion, rejection, or error to200,422, or500.observeCall(CallOperation $operation, callable $call, ?string $reference = null, mixed $requestPayload = null, int $attempt = 1, array $correlation = []): mixedobserves one outgoing call made through any client and returns the callback's result; see below.flush(): voidships the current queue. It never throws.stats(): arrayreturns delivery and build counters, includingextractionErrors, andprofileErrorsfor value profiles that could not be built.
Collector::connect also takes two value-profile arguments:
| Argument | Default | Purpose |
|---|---|---|
valueProfile | 'structural' | 'off', 'structural', or 'full'; see Value profiles |
valueProfileExcludeFields | [] | With 'full': field names whose values are never hashed |
'full' throws InvalidArgumentException unless the RedactionPolicy carries your own hashKey. An unrecognised level is treated as 'off'. Fields named in the policy's dropFields and hashFields are never described.
The default in-memory buffer holds 1,000 envelopes and drops the oldest entry on overflow. The default batch size is 50.
Collector::connect() also accepts deployment. Explicit valid values win over supported environment variables. A valid deployment attached directly to one Observation wins over the collector default. The core remains manual-flush by design and performs no destructor or timer-based network I/O.
observeCall and CallOperation
CallOperation::http() builds the reusable definition:
| Argument | Type | Default | Notes |
|---|---|---|---|
method | string | required | The provider's HTTP method |
routeTemplate | string | required | Stable route pattern with named parameters; never a concrete URL |
payloadLocation | string | 'response' | 'request' or 'response' |
businessObjectType | ?string | null | Type of record the operation concerns |
eventType | ?string | null | Low-cardinality operation label |
extract | ?Closure | null | Reads evidence from a result |
extractError | ?Closure | null | Reads evidence from a thrown exception |
An extractor returns null to use the built-in reading, or an array with any of these keys:
| Key | Type | Notes |
|---|---|---|
statusCode | int | The actual HTTP status. Values outside 100 to 599 are treated as unknown |
noResponse | bool | true only when no HTTP response was received. Exceptions only |
payload | mixed | Response body for shape-only fingerprinting. Present with null means an inspected JSON null |
accepted | bool | Overrides the acceptance derived from the status |
Observation gains two matching constructor arguments in the same release: statusState ('unknown' or 'no-response' with status 0) and payloadInspected (false when no payload was examined). The envelope then carries payload.schemaInspected: false, so an unexamined body is never mistaken for a JSON null.
The PHP collector guide has the full walkthrough.
Local redaction helpers
Redactor::payload($value, $policy) and Redactor::headers($headers, $policy) mirror the Node.js helpers for separate local logging or storage pipelines. Credential headers are always dropped. These helpers do not provide the envelope privacy boundary; payload values and headers cannot enter an envelope regardless.
Transport contract
Implement Seamward\Collector\Transport\Transport:
Code example
interface Transport{ public function send(ShippingRequest $request): ShippingResponse;}The request contains endpoint, headers, and the exact JSON body covered by X-Seamward-Signature. Return the HTTP status and body as a ShippingResponse. Transport exceptions are caught by the collector.
Failure behavior
| Result | Queue behavior | Counter |
|---|---|---|
2xx except 207 | Remove the batch | shipped |
Valid 207 | Remove the batch and account per result | shipped and rejected |
Non-retryable 4xx | Remove the batch | rejected |
409, 429, 5xx, transport error, malformed 207 | Retain the batch | failedBatches |
| Buffer overflow | Drop oldest | dropped |
| Invalid observation | Do not enqueue | buildErrors |
