PHP SDK
The official Saloon-based PHP client for the Signals API — typed requests for every endpoint, filters, pagination, throttling, and error handling.
PHP SDK
signals-rental/signals-api-php-sdk is the official PHP client for the Signals API. It is generated from the same OpenAPI 3.1 document this installation publishes at /api/openapi.json, then wrapped in a hand-written core that covers what a generator cannot: q[...] filters, offset pagination, idempotency keys, optimistic concurrency, dry runs, async job polling, and a throttler that keeps you inside the weighted rate limits.
It is built on Saloon v4, so Saloon's mocking, middleware, pools and sendAsync() all work unchanged.
Source and issues: github.com/signals-rental/signals-api-php-sdk.
Requirements
| PHP | 8.4 or newer |
| Saloon | v4 (saloonphp/saloon: ^4.0) |
| Laravel | optional — 13+ for the service provider, facade and cache-backed throttle store |
The SDK is framework-agnostic: everything that touches Laravel lives under src/Laravel, and an architecture test in the package keeps it that way.
Installation
composer require signals-rental/signals-api-php-sdk
Getting started
Signals is self-hosted, so you supply your installation's application URL and a Sanctum bearer token. The SDK appends /api itself.
use SignalsRental\SignalsApiPhpSdk\SignalsConnector;
$signals = SignalsConnector::fromUrl('https://acme.example.com', 'your-api-token');
$rental = $signals->rentals()->show(1042);
$rental->unwrap(); // ['id' => 1042, 'title' => 'Summer Festival', ...]
For anything beyond a URL and a token, build the config yourself. SignalsConfig::make() accepts snake_case or camelCase keys:
use SignalsRental\SignalsApiPhpSdk\SignalsConfig;
use SignalsRental\SignalsApiPhpSdk\SignalsConnector;
$signals = new SignalsConnector(SignalsConfig::make([
'base_url' => 'https://acme.example.com',
'token' => 'your-api-token',
'timeout' => 30,
'connect_timeout' => 10,
'retries' => 2,
'retry_interval' => 250,
'user_agent' => 'acme-crm/2.1',
'options' => [
'throttle' => [
'units_per_minute' => 6_000,
'max_wait_seconds' => 15.0,
],
],
]));
Laravel
The service provider and the Signals facade register through package discovery. Set two environment variables, publish the config if you need more:
SIGNALS_BASE_URL=https://acme.example.com
SIGNALS_API_TOKEN=your-api-token
php artisan vendor:publish --tag=signals-config
use SignalsRental\SignalsApiPhpSdk\Laravel\Facades\Signals;
$rental = Signals::rentals()->show(1042);
$order = Signals::rentals()->convertToOrder(1042);
Injecting SignalsConnector resolves the same shared singleton. That is deliberate: the throttling engine is stateful, and one instance paces every request your application makes against one budget. In Laravel the counters live in your cache store, so the budget is shared across workers, cron and the web tier.
Configuration
config/signals.php maps one-to-one onto these environment variables. Outside Laravel the same throttle settings live under options.throttle.
| Environment variable | Default | What it does |
|---|---|---|
SIGNALS_BASE_URL |
— | Your installation's application URL. Required. |
SIGNALS_API_TOKEN |
— | The Sanctum bearer token. Limits are per token, so issue one per integration. |
SIGNALS_TIMEOUT / SIGNALS_CONNECT_TIMEOUT |
30 / 10 |
Seconds to wait for a response / a connection. |
SIGNALS_RETRIES / SIGNALS_RETRY_INTERVAL |
0 / 0 |
Repeats for transient failures only, and the first back-off in milliseconds (exponential from there). |
SIGNALS_USER_AGENT |
signals-php-sdk/{version} |
Names your application in the Signals request log. |
SIGNALS_THROTTLE_ENABLED |
true |
Pace requests at all. |
SIGNALS_THROTTLE_REQUESTS_PER_MINUTE |
60 |
The request budget. null trusts the response headers alone. |
SIGNALS_THROTTLE_UNITS_PER_MINUTE |
null |
The weighted-unit budget. Quotas are per installation, so set yours. |
SIGNALS_THROTTLE_MAX_WAIT |
30.0 |
Longest the SDK blocks waiting for capacity before throwing. |
SIGNALS_THROTTLE_MAX_RETRIES_429 |
2 |
Extra Retry-After-driven attempts a 429 earns. |
SIGNALS_THROTTLE_CACHE_STORE / SIGNALS_THROTTLE_KEY_PREFIX / SIGNALS_THROTTLE_SCOPE |
app default / signals:throttle / derived |
Where the counters live, and the bucket connectors must share to share a budget. |
Resources and requests
Every OpenAPI tag becomes a resource accessor on the connector, and every operation becomes a method on that resource. Path parameters come first, then the JSON payload for a write or the query parameters for a read; each method's docblock carries the endpoint's verb, path and description.
$signals->rentals()->show(1042);
$signals->rentals()->store(['title' => 'Summer Festival']);
$signals->rentals()->update(1042, ['reference' => 'PO-2026-114']);
$signals->rentals()->convertToOrder(1042);
$signals->catalogueItems()->index();
$signals->accounts()->show(17);
$signals->webhooks()->listWebhooks();
Every endpoint method returns a SignalsResponse; the …Request() twins return the unsent request, and paginate() returns an OffsetPaginator.
When you need to change a request first — an idempotency key, an expected_version guard, a dry run — build the request class and send it through the connector:
use SignalsRental\SignalsApiPhpSdk\Requests\Rentals\ConvertToOrderRequest;
$response = $signals->send(
(new ConvertToOrderRequest(1042))->expectedVersion(7)
);
ConvertToOrderRequest::OPERATION_ID; // 'v1.rentals.convert_to_order'
ConvertToOrderRequest::WEIGHT; // 4
OperationManifest::find('v1.rentals.convert_to_order') maps an operation id from the endpoint catalogue back to the class that sends it, its weight, and its resource method.
Filtering, sorting, includes and sparse fieldsets
Collection endpoints take the ?q[field_predicate]=value filters described in API Conventions. Filter builds them and serialises values the way the API reads them — dates as ISO-8601 in UTC, booleans as 1/0, backed enums as their value. Custom fields are addressed as cf.name.
use SignalsRental\SignalsApiPhpSdk\Query\Filter;
use SignalsRental\SignalsApiPhpSdk\Query\ListOptions;
$options = ListOptions::make()
->filter(fn (Filter $f) => $f
->eq('status', 'confirmed')
->cont('title', 'festival')
->gteq('starts_at', new DateTimeImmutable('2026-06-01 00:00:00', new DateTimeZone('UTC')))
->in('warehouse_id', [3, 7])
->cf('po_ref')->eq('PO-2026-114'))
->sortDesc('created_at')
->include('items', 'costs')
->fields('id', 'title', 'charge_total')
->perPage(50);
$response = $signals->rentals()->index(options: $options);
Only the flag predicates (_null, _not_null, _present, _blank, _true, _false) may be used without a value; eq('venue_id', null) is refused with an InvalidConfigurationException rather than sent as q[venue_id_eq]=1. Filter and ListOptions are immutable, so a part-built one is safe to share.
Filters and sorts outside an endpoint's allow-list are skipped rather than rejected, so always check what the endpoint applied — see ignoredFilters() below.
Pagination
paginate() returns an OffsetPaginator. Nothing is fetched until you iterate, and only one page is held in memory:
foreach ($signals->rentals()->paginate(options: $options)->perPage(100)->items() as $rental) {
// every rental, page by page
}
foreach ($signals->rentals()->paginate()->pages() as $page => $response) {
$response->ignoredFilters();
}
Other helpers: collect() (small result sets only), first(), total(), startingAt($page) and maxPages($n). Any index request can be paginated directly with OffsetPaginator::for($signals, $request).
Reading responses
SignalsResponse extends Saloon's Response and understands the Signals envelope:
$response = $signals->rentals()->index();
$response->key(); // 'rentals'
$response->unwrap(); // the rows, without the envelope
$response->total(); // 128
$response->ignoredFilters(); // ['colour_eq'] — act on this
$response->ignoredSorts(); // ['colour']
$response = $signals->rentals()->show(1042);
$response->ref(); // 'rental:1042' — the same identifier webhooks carry
$response->aggregateVersion(); // 7 — send back as expected_version
$response->rateLimit(); // limit, remaining, retryAfter
A non-empty ignoredFilters() or ignoredSorts() means the rows you got back are broader than the ones you asked for. Log it or fail on it.
Data classes and enums
Readonly data classes are generated from the document's schemas. They are a convenience for reading a response, not a validation layer: a missing field degrades rather than throwing, and undeclared keys are kept in $raw.
use SignalsRental\SignalsApiPhpSdk\Data\RentalData;
$rental = RentalData::fromArray($signals->rentals()->show(1042)->unwrap());
$rental->chargeTotal; // '12500.00' — money stays a string
$rental->startsAt; // '2026-06-01T09:00:00Z' — timestamps stay ISO-8601 strings
$rental->customFields; // ['po_ref' => 'PO-2026-114']
ListOptions::make()->fields(...RentalData::FIELDS);
Money and timestamps are deliberately strings, to avoid float rounding and timezone surprises. String-backed enums cover the closed value sets; data classes decode them through tryFrom, so a value from a newer Signals release reads as null rather than crashing the integration.
Weighted endpoints and automatic throttling
Signals enforces two independent limits per token: a request rate limit (60 requests a minute by default) and the weighted unit budget that meters billing. The SDK paces itself against both — it waits until both budgets can absorb a request, records the spend, reconciles against the rate-limit headers the response carries, and honours a server-issued Retry-After for every send from that connector. Which headers those are depends on the server: with unit throughput metering enabled (the default) an authenticated response carries X-Signals-Units-* and no X-RateLimit-*; with metering disabled the flat per-minute limiter returns X-RateLimit-* instead. Read X-Signals-Units-* when present and fall back to X-RateLimit-* — see API Conventions.
Set your installation's unit quota, because the SDK cannot guess it:
SIGNALS_THROTTLE_UNITS_PER_MINUTE=6000
$usage = $signals->throttle?->usage();
$usage->requestsRemaining(); // 58
$usage->unitsRemaining(); // 5997
If capacity will not free up within throttle.max_wait_seconds, the SDK throws ThrottleTimeoutException rather than blocking forever. Keep the wait well inside your queue worker's timeout, and short for anything serving a web request.
Prefer webhooks over polling: polling a weight-6 availability endpoint every 30 seconds costs over 500,000 units a month before your application does any real work, whereas a webhook subscription is one weight-3 call and its envelope carries the same ref a REST response does, so a delivery tells you exactly which record to fetch.
Error handling
Failed responses are thrown as SDK exceptions mapped from the status. All of them extend SignalsException, which extends Saloon's request exception.
| Status | Exception |
|---|---|
| 401 | AuthenticationException |
| 403 | AuthorizationException |
| 404 | NotFoundException |
| 409 | ConflictException, or VersionConflictException / IdempotencyConflictException when the body says so |
| 422 | ValidationException |
| 425 | IdempotencyInFlightException |
| 428 | IdempotencyKeyRequiredException |
| 429 | RateLimitedException |
| 5xx | ServerException |
| anything else | RequestException |
use SignalsRental\SignalsApiPhpSdk\Exceptions\SignalsException;
try {
$signals->rentals()->store(['title' => 'Summer Festival']);
} catch (SignalsException $e) {
$e->apiMessage(); // 'The given data was invalid.'
$e->errorCode(); // the stable, branchable code, when the API sent one
$e->errors(); // ['starts_at' => ['The starts at field is required.']]
$e->requestId(); // the X-Request-Id correlation id
}
Four exceptions sit outside that hierarchy because no HTTP response exists when they are raised — InvalidConfigurationException, ThrottleTimeoutException, JobTimedOutException and UnexpectedResponseException — so catching SignalsException never swallows a misconfiguration.
Retries are only attempted for transient failures (408, 425, 429, 502, 503, 504). A 500 is not retried: the server may have committed the write before failing, so an unkeyed POST could be duplicated. The same reasoning applies to a request that never comes back — a read timeout on an unkeyed POST/PATCH is surfaced rather than repeated, while safe methods and keyed mutations are retried.
API conventions the SDK carries for you
- Idempotency keys —
->idempotencyKey($key)is available on operations carrying thex-signals-idempotencymarker (autooraccepted), including thePUT,PATCH, andDELETEoperations behind the idempotency middleware; the SDK attaches the key whenever the marker is present. Operations markedautocreate a key unless you supply one (see conventions). The six automatic operations are scan-session store/close/abandon, single scan, bulk scan, and void. Replaying a key returns the stored response; reusing it with a different body is a 409; an in-flight key is a 425; installations that require keys answer 428 without one. An absentx-signals-idempotencyextension does not opt an operation into either behaviour. - Optimistic concurrency —
->expectedVersion($n)sendsexpected_version; a mismatch throwsVersionConflictExceptionwithcurrentVersion()andexpectedVersion(), and nothing was mutated. - Dry runs —
->dryRun()on requests that support it validates without committing. - Async jobs —
JobPollerwaits on long-running operations (exports, imports) and throwsJobTimedOutExceptionif the deadline passes.
Testing your integration
The SDK is plain Saloon, so MockClient fixtures and Saloon's recording tools work as documented at docs.saloon.dev. Against a real installation, issue a dedicated token for tests so its usage and limits are isolated from production integrations.
Keeping up with the API
The package ships the OpenAPI document it was generated from and a regeneration command; when your Signals installation is upgraded, regenerate against its current /api/openapi.json to pick up new endpoints and fields. The versioning page describes how the API itself evolves.
The SDK versions independently from the framework. The current SDK release is
0.3.0, generated from the framework specification;
its Composer extra.signals-spec pin records SHA-256
735d644379cef9b02300df6a084977730b15560a61d0cbf4b63810c121fc4498.
In SDK 0.3.0, regeneration replaces the hand-written Http\BulkScanResult with
the generated Data\ScanBulkResult, backed by ScanBulkItemStatus. It also
generates MeData, removes pagination options from scanContexts()->index(),
types scanning 422 codes as ScanningErrorCode, and reads both
ScanResultData.summary and WorkflowRunData.outcome as object-or-null values.
Related
| Page | Why |
|---|---|
| Authentication | Issuing the Sanctum token the SDK needs |
| Conventions | Filters, idempotency, versioning and envelope rules the SDK implements |
| Usage & Metering | The unit weights the throttler paces against |
| Webhooks | The alternative to polling |
| API Reference | The OpenAPI document the SDK is generated from |