PHP collector
Use seamward/php-collector when you are building a framework adapter or need direct control over observation and delivery in a PHP application. Laravel applications should normally use the Laravel collector, which includes this core package.
Status
The PHP collector is in alpha and is available from Packagist as 0.1.0-alpha.1.
Install
Install the framework-neutral package with:
Code example
composer require seamward/php-collector:^0.1@alphaChoose a transport
The core does not depend on Laravel, Symfony, Guzzle, or a specific HTTP client. Implement the small Transport interface with the HTTP client your application already uses:
Code example
use Seamward\Collector\Shipping\ShippingRequest;use Seamward\Collector\Shipping\ShippingResponse;use Seamward\Collector\Transport\Transport;
final class AppTransport implements Transport{ public function send(ShippingRequest $request): ShippingResponse { $response = $this->http->post( $request->endpoint, $request->body, $request->headers, );
return new ShippingResponse($response->status(), $response->body()); }}ShippingRequest already contains the exact signed body and required headers. The transport should return the status and response body without applying its own retry policy.
Record an observation
Code example
use Seamward\Collector\Collector;use Seamward\Collector\Configuration\Connection;use Seamward\Collector\Observation\Observation;use Seamward\Collector\Observation\Operation;
$collector = Collector::connect( connection: Connection::fromCredentials($connectionKey, $ingestToken), transport: new AppTransport($http),);
$collector->record(new Observation( operation: Operation::outboundHttp('POST', '/v1/candidates'), statusCode: 201, durationMs: 48, payload: (object) ['id' => 'local-value'],));Payload values are used only to build a structural shape and fingerprint. The envelope has no field for raw bodies or headers. Correlation identifiers and business object ids are keyed hashes before they enter the buffer.
Every completed PHP envelope is checked against the strict envelope contract before it enters the buffer. The collector emits envelope 0.4 or later, which adds a readable display route template and an explicit status state; 0.1.0-alpha.1 emitted 0.2. Invalid metadata increments buildErrors and never escapes into the host application. The shared conformance corpus verifies the same valid and invalid cases against the Node.js contract schema.
Observe a provider SDK call
When a provider call goes through that provider's SDK, or any client you do not want to hand-time, wrap it with observeCall. Define the operation once, then reuse it at every call site.
Code example
use Seamward\Collector\Observation\CallOperation;
// Your code: one definition per provider operation.$retrievePaymentIntent = CallOperation::http( method: 'GET', routeTemplate: '/v1/payment_intents/{paymentIntentId}', businessObjectType: 'payment_intent',);
// Each call site.$intent = $collector->observeCall( $retrievePaymentIntent, fn () => $stripe->paymentIntents->retrieve($id), reference: $id,);observeCall returns the callback's own result and rethrows its own exception. It records exactly one observation either way. method and routeTemplate are the provider's HTTP method and route pattern for this SDK method; write parameters by name and never use a concrete id or query string. reference is the id of the record involved and leaves your process only as a keyed hash.
In the console the observation reads GET /v1/payment_intents/:paymentIntentId.
The HTTP status is read from the common places on results and exceptions: a status or statusCode value, getStatusCode(), status(), getHttpStatus(), a nested response or getResponse(), getLastResponse()->code, and @metadata.statusCode. An exception's getCode() is not treated as an HTTP status. When your client reports the status elsewhere, or you want the response shape watched for drift, pass extractors:
Code example
$listInvoices = CallOperation::http( method: 'GET', routeTemplate: '/v2/customers/{customerId}/invoices', extract: fn ($result): array => [ 'statusCode' => $result->httpStatus, 'payload' => $result->body, ], extractError: fn (Throwable $exception): ?array => $exception instanceof ProviderTimeout ? ['noResponse' => true] : null,);An extractor returns an array with any of statusCode, noResponse, payload, and accepted, or null to fall back to the built-in reading. payload is used locally for its structural shape; its values never enter the envelope.
When no status can be established, the observation is still recorded with status 0 and status state unknown; a status is never invented. Only a PSR-18 network exception is recorded as no response by default. An extractor that throws never affects your call and increments stats()['extractionErrors']. An SDK that retries internally still produces one observation with one attempt.
Observe a queue
Wrap the broker call and use a stable queue, topic, or subscription label. The callback result and exception remain authoritative:
Code example
$published = $collector->observeQueuePublish( queueName: 'candidate-events', message: $message, publisher: fn ($message) => $queue->publish('candidate-events', $message), eventType: 'candidate.published',);
$handled = $collector->observeQueueConsumer( queueName: 'candidate-events', message: $message, consumer: fn ($message) => $handler->handle($message),);Queue success projects to status 202, an explicit disposition: "rejected" result projects to 422, and a thrown host exception projects to 500 before it is rethrown. These values describe evidence only. The collector never acknowledges, retries, or dead-letters a broker message.
Observe a scheduled feed
Wrap one import or export run. Use inbound for imports and outbound for exports:
Code example
$result = $collector->observeScheduledFeed( feedName: 'nightly-candidate-import', direction: 'inbound', payload: $rows, handler: fn ($rows) => $importer->import($rows),);A completed feed projects to status 200, an explicit rejection to 422, and a thrown host exception to 500.
To have an overdue run reported, declare how often the feed runs:
Code example
$result = $collector->observeScheduledFeed( feedName: 'hourly-candidate-import', direction: 'inbound', payload: $rows, handler: fn ($rows) => $importer->import($rows), expectedIntervalMs: 3_600_000,);Seamward then reports a delivery gap once a run is more than one interval overdue. In the same release, any observation's correlation accepts sequence, the provider's whole-number position for a delivery, so positions that never arrived can be reported:
Code example
$collector->record(new Observation( operation: Operation::inboundWebhook('/webhooks/events'), statusCode: 202, durationMs: 12, payload: $event, correlation: ['sequence' => $event->sequence],));An unusable interval or sequence is left out of the envelope; the observation is still recorded.
Add deployment context
Pass stable release metadata to Collector::connect():
Code example
$collector = Collector::connect( connection: Connection::fromCredentials($connectionKey, $ingestToken), transport: new AppTransport($http), deployment: [ 'service' => 'candidate-api', 'release' => $release, 'commitSha' => $commitSha, ],);Explicit values win. Otherwise, the core recognizes SEAMWARD_SERVICE, SEAMWARD_RELEASE, SEAMWARD_COMMIT_SHA, VERCEL_GIT_COMMIT_SHA, RENDER_GIT_COMMIT, RAILWAY_GIT_COMMIT_SHA, and GITHUB_SHA. Invalid values are ignored. A valid Observation::$deployment value overrides the collector default for that one observation.
Delivery and failure behavior
Call $collector->flush() at a safe lifecycle boundary. The PHP core deliberately has no timer, destructor network call, or stop() method. Delivery failures never throw into the host application. Retryable batches remain in a bounded in-memory queue, non-retryable 4xx results are counted as rejected, mixed 207 results are accounted for per envelope, and overflow drops the oldest evidence.
Use $collector->stats() to read enqueued, shipped, rejected, dropped, failedBatches, queueLength, and buildErrors.
