This guide explains how to upgrade the centralnic-reseller/php-sdk (namespace CNIC\) across its major versions, step by step, with before/after code.
Semantic versioning applies: only major bumps (X.0.0) can break your code. Minor and patch releases are backward compatible — you can take them freely. The per-release detail (including every fix and feature) lives in HISTORY.md; this document focuses only on the changes that require you to do something when upgrading.
Golden rule: never skip straight to the newest major without reading every intervening major section below. Breaking changes accumulate — a call that was fine in v15 may have moved twice by v19. Upgrade one major at a time, run your test suite between each, and only then move to the next.
| From → To | PHP required | Headline breaking change | Consumer action |
|---|---|---|---|
| → v9.0.0 | 8.1+ | PHP 8.1 minimum | Bump your runtime |
| → v10.0.0 | 8.1+ | cURL handle cached/reused | Call close() in sessionless flows |
| → v11.0.0 | 8.1+ | IBS + Moniker brands added | None (additive) |
| → v12.0.0 | 8.1+ | HEXONET brand removed (EOL) | Migrate off HEXONET |
| → v13.0.0 | 8.1+ | IBS/Moniker switched to JSON API | Re-test IBS/Moniker data handling |
| → v14.0.0 | 8.3+ | Some classes final; getPOSTData() no longer takes a string |
Bump runtime; stop subclassing finals |
| → v15.0.0 | 8.3+ | Logger contract; IBS session methods removed | Retype loggers; guard session calls |
| → v16.0.0 | 8.3+ | ClientFactory::getClient() signature slimmed |
Configure the client yourself |
| → v17.0.0 | 8.3+ | getNextPageNumber() returns null on last page |
Handle the null sentinel |
| → v18.0.0 | 8.3+ | CNR-only response methods moved off ResponseInterface |
Narrow via ExtendedResponseInterface |
| → v19.0.0 | 8.3+ | getClient() removed; setRoleCredentials() moved |
Use cnr()/ibs()/moniker() |
| → v20.0.0 | 8.3+ | IBS/Moniker no longer force IPv4; getColumnKeys() declares its bool parameter |
Set CURLOPT_IPRESOLVE yourself if your host needs it; add the parameter if you implement ResponseInterface |
| → v21.0.0 | 8.3+ | setExtraCurlOptions() now reaches the wire; transport-owned options throw |
Audit what you pass it — options previously ignored now take effect, and seven now raise |
| → v22.0.0 | 8.3+ | Sessions are CNR-only by type; IBS/Moniker SessionClient deleted |
Drop setSession()/getSession() calls on IBS/Moniker; retype to IBS\Client/MONIKER\Client |
| → v23.0.0 | 8.3+ | Connection configuration has one home; getSystem() is nullable |
Handle null from getSystem(); move CURLOPT_TIMEOUT/USERAGENT/PROXY/REFERER to their own setters |
| → v24.0.0 | 8.3+ | CNR IDN command rewriting moved off the shared client into its own module | Nothing, unless you called or overrode autoIDNConvert(), or read/set needsIDNConvert |
| → v25.0.0 | 8.3+ | One shared Record and Column; the brand Record/IBS\Column classes removed |
Retype CNR\Record/IBS\Record/AbstractRecord → CNIC\Record, and IBS\Column → CNIC\Column |
| → v26.0.0 | 8.3+ | Response parsing is an injectable seam; ResponseParser::parse() is no longer static |
Call (new ResponseParser())->parse(…); implement newResponseParser() in a custom Response/TemplateManager |
| → v27.0.0 | 8.3+ | Loggers format() a record and a sink writes it; setDefaultLogger() removed |
Rename your log() body to format() and return the string; extend CNIC\AbstractLogger |
| → v28.0.0 | 8.3+ | IBS/Moniker hash dates keep /; RecordInterface/ColumnInterface gained a date accessor; IBS\Response::getStatus() removed |
Accept / wherever you parsed a getHash()/getPlain()/getListHash() date; add the new method if you implement either interface directly; read getHash()["status"] instead of getStatus() |
| → v29.0.0 | 8.3+ | Public method parameters and six protected properties renamed to be self-describing | Nothing, unless you pass named arguments (getColumn(key: …) → columnName:), implement an SDK interface (match the parameter names), or subclass and read $this->pw/$ua/$curlopts |
| → v30.0.0 | 8.3+ | Transport error is a declared ?string $error parameter, not a "httperror|" prefix on the raw payload; nocurl template gone |
Add the parameter if you override newResponse()/translate(); return ["", $error] (not bytes) on failure if you implement TransportInterface |
| → v31.0.0 | 8.3+ | Response is sealed after construction: the two mutators and the four record-cursor methods are off ResponseInterface |
Replace getNextRecord() loops with foreach ($r as $rec) (it yields the first row too) and getCurrentRecord() with getRecord(0); take populate()'s three new arguments if you subclass |
Two things to respect throughout:
- Runtime floor vs. language ceiling. The current runtime floor is PHP 8.3 (CI tests 8.3 / 8.4 / 8.5). Run on newer PHP freely.
- Type against interfaces, not concretes. The clean upgrade path is to depend on
CNIC\ResponseInterface,CNIC\LoggerInterface, etc. Code that reaches for concrete classes (CNIC\CNR\Response) ormethod_exists()fallbacks is what breaks across majors.
These five majors predate the current PHP floor, so if you are still on one of them you are also on an end-of-life runtime — plan to land on v14+ (PHP 8.3) in the same effort. Four things need action; the rest was additive.
-
v9.0.0 — PHP 8.1 minimum. A runtime bump with no API change: move your runtime and CI matrix to 8.1+ before pulling v9.
-
v10.0.0 — the cURL handle is cached and reused across requests instead of being opened per call. In a sessionless flow you must now release it yourself with
close()when you are done. Session-based flows need no change —logout()closes the connection for you.$cl = ClientFactory::cnr(); $cl->useOTESystem()->setCredentials($user, $password); $r = $cl->request(["COMMAND" => "StatusAccount"]); $cl->close(); // <-- release the cached handle
-
v11.0.0 — Internet.bs (
IBS) and Moniker (MONIKER) added. Purely additive; existing single-brand code is unaffected. -
v12.0.0 — the HEXONET brand was removed following that platform's shutdown, and v12+ cannot talk to it at all. CNR (formerly RRPproxy) is the successor platform. If you genuinely still need a HEXONET connection during a transition window, pin
"centralnic-reseller/php-sdk": "^11"until you have migrated off it. -
v13.0.0 — IBS/Moniker moved to the JSON API, which changed the data structure of their responses. Any code reading specific keys or columns out of an IBS/Moniker response must be re-tested and in places adjusted. CNR is unaffected, so CNR-only integrations can treat this major as a no-op.
What changed:
- Minimum PHP is now 8.3.
- Several classes are now declared
final. getPOSTData()no longer accepts a string input (typed input only).
What to respect:
- Bump your runtime to 8.3+.
- If you subclassed any SDK class, check it is not now
final— extend by composition or type against the relevant interface instead. This is a good moment to stop depending on concrete classes altogether. - If you called
getPOSTData()with a string, pass the typed structure it now expects.
// If you extended an SDK class that became final, prefer composition + interfaces:
final class MyLogger implements \CNIC\LoggerInterface { /* ... */ }Two independent breaking changes land here.
What changed: custom loggers must type their response argument as CNIC\ResponseInterface — not the concrete CNIC\CNR\Response. ResponseInterface now exposes getContext(), so you can read logger context through the shared contract with no method_exists() fallback and no concrete-class dependency.
What to respect: update every custom logger (including WHMCS cnic/ibs loggers) to the new signature.
// BEFORE
public function log(string $post, \CNIC\CNR\Response $r, ?string $error = null): void
// AFTER
public function log(string $post, \CNIC\ResponseInterface $r, ?string $error = null): voidWhat changed: IBS\SessionClient no longer defines login(), logout(), saveSession() or reuseSession(). Previously these existed but threw \Exception("Method not supported").
What to respect: calling one of these on an IBS/Moniker client now raises a fatal Error ("Call to undefined method"), which a try/catch (\Exception) will not catch. If your code supports multiple brands, guard session calls with an instanceof check before calling them.
// BEFORE — relying on a catchable exception:
try {
$cl->login();
} catch (\Exception $e) { /* unsupported on IBS */ }
// AFTER — narrow to the brand that actually has sessions:
if ($cl instanceof \CNIC\CNR\SessionClient) {
$cl->login();
}What changed: ClientFactory::getClient() no longer accepts a $params array or a $logger argument. Its signature became getClient(string $registrar). It also stopped decoding your password internally.
What to respect: you now configure the returned client yourself via its fluent setters, and you must decode credentials yourself before handing them over (the SDK is transport-faithful — it sends exactly what you give it).
// BEFORE (≤ v15)
$cl = ClientFactory::getClient([
"registrar" => "CNR",
"username" => $user,
"password" => $pw, // was html_entity_decode()'d internally
"sandbox" => true,
"proxyserver" => $proxy,
], $logger);
// AFTER (v16)
$cl = ClientFactory::getClient("CNR");
$cl->useOTESystem()
->setCredentials($user, html_entity_decode($pw, ENT_QUOTES)) // decode yourself
->setProxy($proxy)
->setCustomLogger($logger);
getClient(string)is itself removed in v19 — see below. If you are jumping v15 → v19, you can move straight to the typed constructors and skip the string form.
What changed: getNextPageNumber() now returns null when there is no next page, instead of clamping to the current (last) page number.
What to respect: anywhere you consumed the old clamped value, either switch to hasNextPage() or handle the null sentinel explicitly. A naive while loop that trusted the old behaviour could otherwise loop forever or dereference null.
// BEFORE — last page returned its own number (clamped)
$next = $r->getNextPageNumber();
// AFTER — prefer the boolean guard
while ($r->hasNextPage()) {
$next = $r->getNextPageNumber(); // guaranteed non-null inside the guard
$r = $cl->requestNextResponsePage($r);
}
// ...or handle null directly
$next = $r->getNextPageNumber();
if ($next !== null) { /* fetch next page */ }What changed: five methods are no longer part of CNIC\ResponseInterface because they are CNR-specific telemetry/status:
getQueuetime(), getRuntime(), isTmpError(), isPending(), getListHash()
They now live on CNIC\ExtendedResponseInterface (implemented by CNR only). Also, IBS\Response no longer extends CNR\Response, and IBS\Record no longer extends CNR\Record — the brands are now siblings.
What to respect:
- Code holding a concrete
CNR\Client/CNR\Responseis unaffected — the methods are still right there. - Code holding the core
ResponseInterface(e.g. the genericrequest()return type, or brand-agnostic code) must narrow before calling them.
$r = $cl->request(["COMMAND" => "StatusAccount"]); // typed as ResponseInterface
// BEFORE — worked on any response
$queue = $r->getQueuetime();
// AFTER — narrow to the CNR-only capability first
if ($r instanceof \CNIC\ExtendedResponseInterface) {
$queue = $r->getQueuetime();
if ($r->isPending()) { /* ... */ }
}Two related breaking changes.
What changed: the string-dispatch ClientFactory::getClient(string $registrar) is gone, along with the Registrar enum and UnknownRegistrarException. In their place are three typed named constructors, each returning the concrete brand SessionClient:
// BEFORE (v16–v18)
$cl = ClientFactory::getClient("CNR");
$cl = ClientFactory::getClient("IBS");
$cl = ClientFactory::getClient("MONIKER");
// AFTER (v19)
$cl = ClientFactory::cnr(); // -> CNIC\CNR\SessionClient
$cl = ClientFactory::ibs(); // -> CNIC\IBS\SessionClient
$cl = ClientFactory::moniker(); // -> CNIC\MONIKER\SessionClientWhat to respect: the return types are now precise, so brand-specific capabilities are available directly with no narrowing on the normal path. cnr() gives you login()/logout()/saveSession()/setRoleCredentials() straight away; ibs()/moniker() simply don't expose session/role methods (they don't exist on those platforms). If you had a switch on a registrar string or the Registrar enum, replace it with a direct call to the right constructor.
What changed: setRoleCredentials() was removed from the shared AbstractClient and now lives on the CNR-only CNIC\RoleCredentialsInterface. (Inheriting it on IBS/Moniker would have forged an invalid login, since it depends on the CNR role separator.)
What to respect:
- If you build the client via
ClientFactory::cnr(), you get a concreteCNR\SessionClientand can callsetRoleCredentials()directly — no change needed. - If you hold a client through the shared
AbstractClient/generic type, narrow first.
// Normal path — cnr() is fully typed, call it directly:
$cl = ClientFactory::cnr();
$cl->useOTESystem()
->setRoleCredentials($accountId, $roleId, $rolePassword);
// Generic/brand-agnostic code — narrow via the interface:
if ($cl instanceof \CNIC\RoleCredentialsInterface) {
$cl->setRoleCredentials($accountId, $roleId, $rolePassword);
}getSession() / setSession() / useHighPerformanceConnectionSetup() deliberately remain on AbstractClient (they are harmless and brand-agnostic) — they did not move.
This last paragraph is true of v19–v21 only, and v22 reverses half of it.
getSession()/setSession()were not harmless there: on IBS/Moniker they forwarded to null-object stubs, sosetSession("x")looked accepted and was discarded. They moved toCNR\Clientin v22 — see → v22.0.0.useHighPerformanceConnectionSetup()genuinely is brand-agnostic and stays put. The text above is kept as it stood so a v18 → v19 upgrade still reads correctly.
Three changes. If you use IBS or Moniker, read change 1 — it is the only one that alters runtime behaviour, and it is the one that can affect a working integration. Changes 2 and 3 are contract corrections on ResponseInterface that only matter if you implement that interface yourself.
What changed: the IBS and Moniker clients used to seed a brand-default cURL option forcing IPv4 (CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4) on every request. That default is gone; all brands now start with an empty cURL option bag.
It existed as a workaround for a small number of customers whose hosts had trouble reaching the API over IPv6. The library is known to work without it, and hard-coding one network's workaround into every integration is the wrong altitude — choosing a resolution mode belongs to the caller.
What to respect: if your host genuinely needs forced IPv4 — for example you saw IPv6 connect timeouts against the IBS/Moniker API before — set it explicitly after constructing the client:
$client = \CNIC\ClientFactory::ibs(); // or ::moniker()
$client->setExtraCurlOptions([CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4]);setExtraCurlOptions() (added in v19 via RSRMID-2913) is the supported way to do this. Be aware of its actual reach: an option only reaches the wire if the transport does not already set that key itself. CURLOPT_IPRESOLVE is not one it sets, so the snippet above genuinely takes effect — but the transport's own values win on collision, so these are silently ignored when passed this way:
CURLOPT_URL, CURLOPT_POST, CURLOPT_POSTFIELDS, CURLOPT_RETURNTRANSFER, CURLOPT_HEADER, CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYHOST, CURLOPT_TIMEOUT, CURLOPT_CONNECTTIMEOUT, CURLOPT_USERAGENT, CURLOPT_HTTPHEADER.
The first five keep the request envelope intact (overriding them would break response handling) and the next two stop this setter being used to weaken TLS verification. For the user agent, use setUserAgent(). In v20 the request timeout has no public setter at all — it comes from a protected $socketTimeout (300s) and CURLOPT_TIMEOUT passed here is discarded, so on this version it cannot be changed from outside the SDK. That was a gap rather than an intended limit, and v21 closes it.
Everything in this sub-section describes v20 only, and v21 changes it. The silent discard was the defect and was fixed there: your options now reach the wire, seven of the eleven keys above raise instead of being ignored, and the timeout gap is closed with a real
setSocketTimeout(). If you are upgrading past v20, read → v21.0.0 and treat that as current. This text is kept as it stood so a v19 → v20 upgrade still reads correctly.
One subtlety worth knowing: an option you set this way is caller state, so resetCurlOptions() discards it — whereas the old forced IPv4 was a brand default and survived a reset. If you call resetCurlOptions(), re-apply your options afterwards.
How to tell whether this affects you: it only matters if your host resolves the API hostname to an IPv6 address and that path is broken. If your integration works today on a dual-stack or IPv4-only host, expect no change. If in doubt, add the option — it is a no-op on an IPv4-only host.
The remaining two changes are contract corrections on CNIC\ResponseInterface, both consequences of the same underlying problem: the interface had drifted out of step with the class that implements it. Neither affects callers — only code that implements the interface itself.
What changed: CNIC\ResponseInterface::getColumnKeys() was declared without a parameter, while the implementation (AbstractResponse::getColumnKeys(bool $filterPaginationKeys = false)) has always accepted one. The interface now matches the implementation:
// BEFORE (≤ v19) — in ResponseInterface
public function getColumnKeys(): array;
// AFTER (v20)
public function getColumnKeys(bool $filterPaginationKeys = false): array;Nothing changes at runtime. No behaviour was altered — this is a contract correction, and false remains the default, so every existing call site keeps working unchanged.
What to respect — two cases:
1. You only call the SDK (the overwhelmingly common case): no action, and the correction fixes something for you. Before v20, an interface-typed consumer could not call $r->getColumnKeys(true) without PHPStan/Psalm rejecting it (invoked with 1 parameter, 0 required), even though the call ran correctly — so the advice to type against ResponseInterface conflicted with itself. If you worked around that by retyping to the concrete CNR\Response or suppressing the error, drop the workaround and go back to the interface.
2. You implement ResponseInterface yourself — add the parameter. This is the breaking part: a custom implementation or test double still declaring getColumnKeys(): array is no longer compatible with the interface and raises a fatal error at declaration time. Honour the flag (return every column key when false, strip your pagination/metadata columns when true), or extend CNIC\AbstractResponse — as CNR\Response/IBS\Response do — and inherit a correct implementation.
What changed: ResponseInterface used to declare __construct(string $raw, array $cmd, array $ph = []). It is gone from the interface entirely.
What to respect: nothing, unless you reflect on the interface. Removal only relaxes the contract, so every existing implementation still satisfies it, and construction has not moved — responses are built by the brand factory hooks (AbstractClient::newResponse(), AbstractResponseTemplateManager::createResponse()), each naming its own concrete Response. If you build one directly, keep naming the concrete class: new \CNIC\CNR\Response($raw).
The one observable effect is reflective. If you inspect the interface (a DI container autowiring by interface, a doc generator, a contract test), the constructor is now absent:
$ctor = (new ReflectionClass(\CNIC\ResponseInterface::class))->getConstructor();
// ≤ v19: ReflectionMethod
// v20: null <-- ->getParameters() on this now raises a TypeErrorGuard the call (if ($ctor !== null)) or reflect on the concrete CNIC\CNR\Response / CNIC\IBS\Response, which is where the real constructor has always lived. Why the interface is the wrong place to describe construction: see the interface-declaration entry in docs/agents/architecture.md. (Ref: RSRMID-2918.)
Read this if you call setExtraCurlOptions(). If you do not, nothing here affects you: the default transport behaviour is unchanged, and the new timeout setter is purely additive.
What changed: setExtraCurlOptions() used to be a half-truth. The value you passed landed in the client's option bag — so it read back correctly, and looked applied — and was then silently dropped one layer lower, because the transport merged its own defaults over yours. Eleven keys never reached cURL. The order is reversed: your options now win over the transport's defaults.
The clearest case: you asked for a 5s timeout and got 300s, with no exception, no warning, and nothing to inspect that would tell you.
$client->setExtraCurlOptions([CURLOPT_TIMEOUT => 5]);
// ≤ v20: the request still ran with the transport's 300s. Silently.
// v21: the request times out after 5s, as asked.Keys that were ignored before and now apply: CURLOPT_TIMEOUT, CURLOPT_CONNECTTIMEOUT, CURLOPT_USERAGENT, CURLOPT_HTTPHEADER.
What to respect: audit what you pass. This is the risk in this upgrade — an option you set long ago, saw no effect from, and left in place will now start doing what it says. That is especially worth checking for CURLOPT_TIMEOUT (a short value that was harmless while ignored can start aborting slow commands) and CURLOPT_CONNECTTIMEOUT. Options that already worked — CURLOPT_IPRESOLVE, CURLOPT_PROXY, CURLOPT_REFERER, CURLOPT_CAINFO and everything else the transport does not set — behave exactly as before.
What changed: the options the transport genuinely must own are rejected loudly rather than discarded. Passing any of them to setExtraCurlOptions() raises CNIC\Exception\UnsupportedFeatureException on the next request, naming the offending constants:
$client->setExtraCurlOptions([CURLOPT_RETURNTRANSFER => 0]);
$client->request(["COMMAND" => "StatusAccount"]);
// v21: CNIC\Exception\UnsupportedFeatureException
// "cURL option(s) owned by CNIC\HttpTransport cannot be overridden:
// CURLOPT_RETURNTRANSFER. ..."The set is CURLOPT_URL, CURLOPT_POST, CURLOPT_POSTFIELDS, CURLOPT_RETURNTRANSFER, CURLOPT_HEADER, CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYHOST — readable programmatically as array_keys(CNIC\HttpTransport::PROTECTED_OPTIONS) (the constant maps each option to its constant name).
The first five define the request envelope the response parser is written against (CURLOPT_RETURNTRANSFER => 0, for instance, makes curl_exec() return true and leaves the parser with nothing). The last two are TLS verification: disabling certificate checking is a deliberate, security-relevant act and is not something a generic convenience bag should be able to do. Both were already impossible — the difference is that you now find out.
What to respect: if you were passing one of these, remove it. It was never having any effect, so removing it changes nothing about how your integration behaves — it only stops the exception. If you believe you need one, that is a conversation for an issue, not a workaround.
Where the exception surfaces: at request time, not at setExtraCurlOptions() time, and before any connection is attempted. Wrap your first request in a smoke test after upgrading if you set options dynamically.
What changed: custom headers now reach the wire (they were previously ignored), and they are appended to the transport's header list rather than replacing it.
$client->setExtraCurlOptions([
CURLOPT_HTTPHEADER => ["X-Correlation-Id: " . $requestId],
]);
// v21 wire headers: Expect:, Content-Type, Content-Length, Connection, X-Correlation-Id
// ≤ v20: your header was dropped entirely.What to respect: the transport's own four header lines — Expect:, Content-Type, Content-Length, Connection — are its to set, and restating one raises UnsupportedFeatureException (names matched case-insensitively):
$client->setExtraCurlOptions([CURLOPT_HTTPHEADER => ["Content-Type: application/json"]]);
$client->request([/* ... */]);
// v21: CNIC\Exception\UnsupportedFeatureException
// "HTTP header(s) owned by CNIC\HttpTransport cannot be overridden: content-type. ..."This follows the same rule as the protected options above: Content-Type and Content-Length are derived from the POST body, and Connection follows from the reused cURL handle, so overriding one corrupts the request exactly as overriding CURLOPT_POSTFIELDS would. Silently letting a wrong Content-Length through would be the same class of defect this release is fixing, and appending a second Content-Type would just move the decision to the server. Add your own headers freely; leave those four alone.
What changed (additive, nothing breaks): setSocketTimeout() and getSocketTimeout() on the client.
Before v21 the request timeout could not be changed from outside the SDK at all — it came from a protected $socketTimeout (300s) with only a getter, the SocketConfig has no public accessor on the client, and the CURLOPT_TIMEOUT route was the one being discarded. There was no supported way to shorten or lengthen a request.
$client = \CNIC\ClientFactory::cnr();
$client->setSocketTimeout(30); // seconds; 0 means no timeout, per cURL
echo $client->getSocketTimeout(); // 30
$client->setSocketTimeout(-1); // CNIC\Exception\InvalidConfigurationExceptionPrefer this over setExtraCurlOptions([CURLOPT_TIMEOUT => …]) — it states the intent rather than the mechanism. Both work; if you set both, the cURL option bag wins.
A negative value is rejected rather than forwarded: cURL refuses it by returning false from curl_setopt(), which curl_setopt_array() does not surface, so passing it on would drop the setting with no signal — the very thing this release exists to stop. CNIC\Exception\InvalidConfigurationException is new in v21 and extends CNIC\Exception\CnicException, so existing catch (\Exception) code keeps catching it.
Read this if you build an IBS or Moniker client, or if you name either brand's SessionClient type. CNR code is unaffected: every session method is still there, on the same client, with the same behaviour and the same wire format.
What changed: both methods lived on the shared AbstractClient and were therefore inherited by every brand. They now live on CNIC\CNR\Client only.
They were never functional on IBS/Moniker. Those platforms have no API session concept, so the methods forwarded to null-object stubs on AbstractSocketConfig that stored nothing. Because setSession() is fluent, it returned $this and looked accepted:
$cl = \CNIC\ClientFactory::ibs();
// ≤ v21: chainable, no error, no warning — and the value went nowhere.
$cl->setSession("abc123")->request([/* ... */]);
var_dump($cl->getSession()); // NULL, always
// v22: Error — Call to undefined method CNIC\IBS\Client::setSession()That shipped for six majors (v15–v21). The v19 guide called the pair "harmless"; it was not harmless, it was just quiet.
What to respect: the failure mode is a fatal Error, not an exception — try/catch (\Exception) will not catch it, exactly as with the v15 session-method removal. Delete the calls; they were doing nothing. If you have brand-agnostic code that called them speculatively, narrow to the brand that has sessions:
// BEFORE — called on whatever client was to hand
$cl->setSession($storedSessionId);
// AFTER — narrow first (a plain instanceof; there is no session interface)
if ($cl instanceof \CNIC\CNR\Client) {
$cl->setSession($storedSessionId);
}On the normal path you need no check at all: ClientFactory::cnr() returns a fully-typed CNR\SessionClient, so setSession() is simply there.
What changed: both classes were empty subclasses named after a lifecycle their platform does not have — since v15 they added no method at all. They are removed, and the factory returns the plain brand client:
// BEFORE (v19–v21)
$cl = ClientFactory::ibs(); // -> CNIC\IBS\SessionClient
$cl = ClientFactory::moniker(); // -> CNIC\MONIKER\SessionClient
// AFTER (v22)
$cl = ClientFactory::ibs(); // -> CNIC\IBS\Client
$cl = ClientFactory::moniker(); // -> CNIC\MONIKER\ClientCNIC\CNR\SessionClient is untouched and remains what cnr() returns — it is now the only SessionClient in the SDK.
What to respect — two cases:
- You name the type in a property, parameter, return type,
instanceoforusestatement: retype toCNIC\IBS\Client/CNIC\MONIKER\Client. Grep for the two class names rather than waiting for a crash — a property or parameter typed against the deleted class raises aTypeErrorthe moment a real client is assigned to it, butinstanceofagainst a class that no longer exists silently evaluates tofalse, so a brand check written that way just stops matching. PHPStan/Psalm flag both. - You obtain the client from the factory and never spell the type (the common case): no action. The API surface of what you get back is identical apart from the two session methods in change 1.
The brand clients stay extensible. CNIC\MONIKER\Client is the only class in the SDK that nothing internal extends any more, and sealing it would have been easy — but CNR\Client and IBS\Client are open only because something inside the SDK happens to extend them, which is no basis for letting extensibility differ per brand. If you subclass any brand client, that keeps working.
What changed: getSession(), setSession(), getPersistent(), setPersistent() and getRoleSeparator() (plus the $roleSeparator property) are no longer on CNIC\AbstractSocketConfig. They exist only on CNIC\CNR\SocketConfig. Relatedly, the persistent=1 request parameter is now emitted by CNR's own getPOSTDataParams() rather than by the shared getPOSTData().
What to respect: nothing, unless you subclass AbstractSocketConfig yourself — the client never exposes its config object publicly, so there is no call path to these from outside the SDK. If you do subclass it and were overriding or calling one of the five, note that the base no longer declares it: an #[\Override] attribute on such a method is now a compile-time error, and the shared getPOSTData() is purely the encoding step (every parameter, brand-specific ones included, comes from getPOSTDataParams()).
The encoded request body is unchanged. CNR appends persistent=1 last, so the bytes on the wire are byte-identical to v21 — asserted directly in the test suite. Nothing about CNR's session handshake moved.
One note for anyone subclassing CNR\Client: PHP typed properties are invariant, so the inherited $socketConfig stays declared as AbstractSocketConfig, and the session and role-credential methods narrow it through a single new protected accessor, cnrConfig(). Nothing is required of you — newSocketConfig() is typed to return the final CNR\SocketConfig, so an override cannot supply anything else, and the accessor's instanceof guard exists to satisfy static analysis rather than to describe a reachable failure.
cnrConfig()was renamed in v23 to the public, covariantgetSocketConfig(). See → v23.0.0.
This is a deliberate reversal of a v19 sub-decision, and it settles a policy the two preceding majors disagreed on. v19 chose silent no-ops for a capability a brand cannot honour and called them harmless. v21 chose the opposite for cURL options — UnsupportedFeatureException, "rather than being ignored". v22 picks the answer that is better than either where the type system can express it:
- Prefer absence. A method that does not exist is a static-analysis error at the call site — you find out while writing the code, not from a support ticket about a session that was never established.
- Throw where absence is impossible — where the method must exist on a shared surface for other reasons. That is the v21 case.
- Never no-op. A fluent setter that discards its argument is indistinguishable from one that works.
The practical consequence for you: brand capability differences now show up in your editor and in PHPStan/Psalm output, rather than at runtime or not at all.
What changed: connection configuration was split between the client and its SocketConfig, with nothing keeping the two copies in step. It now lives entirely on the SocketConfig, reachable through a new public getSocketConfig(). Three defects went with the split; each is now impossible to express rather than fixed.
Everything you call on the client still exists — setURL(), useOTESystem(), setProxy(), setCredentials() and the rest are unchanged forwarders, and $cl->useOTESystem()->setCredentials($u, $p) is still the idiomatic call. Four narrower things changed, below.
What changed: the client no longer stores which system you selected. It derives it by comparing the configured URL against the brand's OT&E and LIVE endpoints, so the two can no longer disagree. A URL that is neither returns null.
// BEFORE (v22): the flag and the URL parted company, permanently
$cl->useOTESystem()->setURL("https://staging.example/");
$cl->isOTE(); // true <- wrong; requests go to staging.example
$cl->getSystem(); // System::OTE
// AFTER (v23)
$cl->useOTESystem()->setURL("https://staging.example/");
$cl->isOTE(); // false
$cl->getSystem(); // null <- the SDK has no name for this endpointWhat to respect: if you read getSystem(), handle null. A match on it needs a default arm, and passing the result somewhere typed System is now a PHPStan/Psalm error — which is the point: the value was previously wrong rather than absent.
// BEFORE
$label = $cl->getSystem()->value;
// AFTER — one of:
$label = $cl->getSystem()?->value ?? "CUSTOM";
$label = ($cl->getSystem() ?? System::LIVE)->value;isOTE() still returns bool and needs no change, but its answer is now honest after a setURL(). If you were relying on it staying true across a custom URL, that reliance was on the defect.
What changed: useHighPerformanceConnectionSetup() used to rewrite the stored URL to loopback once. It now records the intent and applies the rewrite whenever the URL is read.
Two consequences: your selected system survives it (previously the rewritten URL no longer matched the OT&E endpoint, so isOTE() was lost), and the routing survives a later useOTESystem()/useLIVESystem()/setURL() — routing through a local proxy is a statement about how to reach the endpoint, not which endpoint.
// BEFORE (v22)
$cl->useOTESystem()->useHighPerformanceConnectionSetup();
$cl->getURL(); // http://127.0.0.1/
$cl->useLIVESystem();
$cl->getURL(); // https://api.rrpproxy.net/ <- routing silently undone
// AFTER (v23)
$cl->useOTESystem()->useHighPerformanceConnectionSetup();
$cl->isOTE(); // true (was effectively lost before)
$cl->useLIVESystem();
$cl->getURL(); // http://127.0.0.1/ <- still routedWhat to respect: if you switched systems after enabling high-performance mode and depended on that switch turning it off, switch it on again after — or, better, enable it last. There is no disable method; construct a fresh client if you need one without it. Read the state with $cl->getSocketConfig()->usesHighPerformanceConnectionSetup().
What changed: CURLOPT_TIMEOUT, CURLOPT_USERAGENT, CURLOPT_PROXY and CURLOPT_REFERER each have a setter that owns them, so setExtraCurlOptions() refuses them with UnsupportedFeatureException, naming the setter to use. Previously the bag value quietly beat the setter on the wire while the getter kept reporting what the setter had stored — two answers behind one question.
// BEFORE (v21–v22): worked, and silently outranked setSocketTimeout()/setProxy()
$cl->setExtraCurlOptions([
CURLOPT_TIMEOUT => 5,
CURLOPT_PROXY => "http://proxy.example:3128",
CURLOPT_REFERER => "https://my.app/",
CURLOPT_USERAGENT => "MyPlatform/1.0",
]);
// AFTER (v23) — one setter each
$cl->setSocketTimeout(5)
->setProxy("http://proxy.example:3128")
->setReferer("https://my.app/")
->setUserAgent("MyPlatform", "1.0");What to respect: grep your integration for those four constants. The rejection is eager — it throws from setExtraCurlOptions() itself, not on the next request — so a misconfiguration surfaces at setup. Every other option (CURLOPT_CONNECTTIMEOUT, CURLOPT_IPRESOLVE, CURLOPT_HTTPHEADER, …) is unaffected and still reaches the wire exactly as in v21. The transport's own protected set is unchanged too, and still rejected on the next request rather than at the setter — see → v21.0.0.
If you genuinely want the raw option rather than the intent, drive CNIC\HttpTransport::post() directly; it owns none of the four and accepts all of them.
What changed: the proxy and the referer were stored as cURL bag keys, so resetCurlOptions() — whose job is restoring option defaults — discarded them. They are separate state now.
$cl->setProxy("http://proxy.example:3128")->resetCurlOptions();
$cl->getProxy(); // BEFORE: null AFTER: "http://proxy.example:3128"What to respect: if you were re-applying setProxy()/setReferer() after every resetCurlOptions(), that is now redundant but harmless. If you were relying on the reset to clear them, call setProxy()/setReferer() with no argument — the documented reset for both.
CNR\Client::cnrConfig()(protected, v22) is nowgetSocketConfig()(public). It is the covariant override of the base accessor, still the single point that narrows the invariant$socketConfigproperty toCNR\SocketConfig. Rename your calls; there is no alias, deliberately — two methods narrowing the same property would be two places to keep in step.AbstractClient::getDefaultCurlOpts()moved toAbstractSocketConfig, with the option bag it seeds. If you overrode it on a client subclass, move the override to yourSocketConfigsubclass. (No brand in the SDK overrides it, and the bar for doing so is a protocol-mandatory option — see → v20.0.0.)AbstractClientno longer declares$socketURL,$systemor$curlopts. A subclass reading them gets an undefined-property error; read throughgetSocketConfig()instead.AbstractSocketConfignow has a constructor that seeds the active URL from$liveUrland the option bag fromgetDefaultCurlOpts(). If your config subclass declares one, callparent::__construct()— without it the client starts with an empty URL.getUserAgent()is a pure read. It used to memoise the SDK default into$uaon first call; a subclass that inspected$uato detect "has a UA been set" still works, and now gets the right answer.AbstractClient::executeCurl()lost its third parameter.executeCurl(string $data, array $cfg, array $extraCurlOpts = [])is nowexecuteCurl(string $data, array $cfg). Nothing in the SDK ever passed the third argument, and as an option route that skipped the managed-option check it was a way to put a second answer behindgetProxy(). Configure the option before the request, or driveHttpTransport::post()yourself.- No
getOTEUrl()was added on the client, despitegetLiveUrl()existing. Read it fromgetSocketConfig()->getOTEUrl().
setCredentials() still discards an active CNR session — a session and a password are alternative credentials on the wire and CNR treats the newer one as authoritative. That was true before and undocumented; it is now stated on the method and pinned by a test, because reuseSession() depends on the ordering (credentials first, session second).
What changed: the SDK converts the IDN parameters of an outbound CNR command to punycode before sending it. Those rules used to live on the shared AbstractClient, as a protected autoIDNConvert() switched on by a needsIDNConvert flag on the SocketConfig — a flag only CNR ever set. They now live in CNIC\CNR\IDNCommandRewriter, called from CNR\Client's own buildCommand() hook.
If you just use the SDK, there is nothing to do. The conversion is unchanged and still automatic — same parameters, same results, byte-identical requests on the wire. IDNConvert() is untouched:
// Unchanged in v24: automatic on the request path…
$r = $cl->request(["COMMAND" => "StatusDomain", "OBJECTID" => "dömäin.com", "OBJECTCLASS" => "DOMAIN"]);
$r->getCommand()["OBJECTID"]; // "xn--dmin-moa0i.com"
// …and still available explicitly for a list of names you hold yourself.
$cl->IDNConvert(["münchen.de", "example.com"]);Three things were removed. Each is only reachable from code that reached into the SDK's internals:
| Removed | Was | Now |
|---|---|---|
AbstractClient::autoIDNConvert() |
protected, called by performRequest() |
CNIC\CNR\IDNCommandRewriter::rewrite($cmd) — public static |
AbstractSocketConfig::getNeedsIDNConvert() |
public, answered true on CNR only |
nothing; the brand's buildCommand() decides, so there is no flag to read |
$needsIDNConvert (property, all 3 configs) |
protected, set true on CNR\SocketConfig |
nothing |
What to respect if you did any of the following:
-
You called the rules yourself (via reflection, or from a subclass). Call the module directly — no client needed, which is the point of the change:
use CNIC\CNR\IDNCommandRewriter; // BEFORE (v23): reachable only through a client, and only via reflection $m = new \ReflectionMethod($cl, "autoIDNConvert"); $m->setAccessible(true); $wire = $m->invoke($cl, ["NAMESERVER0" => "ns1.münchen.de"]); // AFTER (v24) $wire = IDNCommandRewriter::rewrite(["NAMESERVER0" => "ns1.münchen.de"]);
-
You overrode
autoIDNConvert()on a client subclass to change or disable the rewriting. The override is now dead code — nothing calls it. OverridebuildCommand()instead and choose there whether to call the rewriter:final class MyClient extends \CNIC\CNR\Client { #[\Override] protected function buildCommand(array $cmd): array { // no IDN rewriting at all — or wrap the call, or add your own rules return \CNIC\CommandFormatter::flattenCommand($cmd); } }
-
You read
getNeedsIDNConvert()to find out whether a client converts. There is no flag to read; the answer is the brand.$cl instanceof \CNIC\CNR\Clientis the honest test, and it is the one the SDK itself now makes structurally. -
You set
$needsIDNConvert = trueon your ownSocketConfigsubclass to get the conversion on IBS/Moniker. It never did anything useful — those platforms convert IDNs server-side, which is why the flag was false for them — and the property no longer exists, so PHP will report the declaration as unused rather than fail. If you have an IBS/Moniker command you believe needs client-side conversion, callIDNCommandRewriter::rewrite()on it yourself before passing it torequest(), but check with support first.
Why this happened: see the IDN entry in docs/agents/architecture.md for the full decision record. (Ref: RSRMID-2922.)
What changed: the record and column layer had one class per brand where the brands did not actually differ.
CNIC\CNR\RecordandCNIC\IBS\Recordwere byte-identical empty subclasses ofCNIC\AbstractRecord. All three are gone, replaced by a single concreteCNIC\Record.CNIC\IBS\ColumnandCNIC\CNR\Columneach implementedColumnInterfaceindependently, duplicating the length field, the constructor,getKey()andgetData(). That body now lives once in a concreteCNIC\Column, which IBS/Moniker use directly —CNIC\IBS\Columnis gone.CNIC\CNR\Columnstays, reduced to the one thing that is genuinely CNR's: it binds the column's value type tostringand narrowsgetDataByIndex()to?string.
If you use the SDK through the interfaces, there is nothing to do. ResponseInterface is unchanged; getRecord()/getRecords() still return RecordInterface, getColumn()/getColumns() still return ColumnInterface, and every method on them behaves identically:
// Unchanged in v25
$rec = $r->getRecord(0); // RecordInterface
$rec->getDataByKey("DOMAIN");
$col = $r->getColumn("DOMAIN"); // ColumnInterface
$col->getDataByIndex(0);
$col->length;| Removed | Replace with |
|---|---|
CNIC\AbstractRecord |
CNIC\Record (concrete — instantiate or extend it directly) |
CNIC\CNR\Record |
CNIC\Record |
CNIC\IBS\Record |
CNIC\Record |
CNIC\IBS\Column |
CNIC\Column |
What to respect if you did any of the following:
-
You named a concrete record or column type — in a type hint, an
instanceof, or anew. Retype to the shared class:// BEFORE (v24) use CNIC\CNR\Record; use CNIC\IBS\Column; function handle(Record $rec): void { /* … */ } if ($col instanceof Column) { /* … */ } // AFTER (v25) use CNIC\Column; use CNIC\Record; function handle(Record $rec): void { /* … */ } if ($col instanceof Column) { /* … */ }
Better still, type against the interfaces —
RecordInterface/ColumnInterface— which have not changed and will not move again. Note thatCNR\Columnnow extendsCNIC\Column, so$col instanceof \CNIC\Columnis true for every brand, CNR included. -
You extended
AbstractRecordto add your own record behaviour. ExtendCNIC\Recordinstead — it is a plain concrete class, so nothing else about your subclass changes:// BEFORE (v24) final class MyRecord extends \CNIC\AbstractRecord {} // AFTER (v25) final class MyRecord extends \CNIC\Record {}
-
You built a Response subclass with its own
newRecord()hook. The hook's declaration on the base is unchanged (abstract protected function newRecord(array $h): RecordInterface); only the class the built-in brands return has changed, so if you returnedCNR\Record/IBS\Record, returnCNIC\Record.Mind which class you extend, because return-type covariance binds you to the parent's declaration — this is not new, but the class it names has moved:
- extending
CNR\ResponseorIBS\Response: theirnewRecord()is declared: Record, so your override must returnCNIC\Recordor a subclass of it. Previously it had to return the brand'sCNR\Record/IBS\Recordor a subclass — the same constraint, pointing at a different class. To ship your own record type from here, extendCNIC\Recordrather than implementingRecordInterfacefrom scratch. - extending
AbstractResponsedirectly (a new brand): the hook is only declared: RecordInterface, so any implementation of that interface is fair game. This is the seam for genuinely different record behaviour.
- extending
-
You relied on the generic type of a column.
CNIC\Columnis templated on its value type (@template TValue). Static analysis will now inferColumn<string>for CNR andColumn<mixed>for IBS/Moniker, so passing a non-string array to a CNR column is a reported error where it previously was not. That is stricter than before, and only affects analysis — no runtime behaviour changed.
No action required — this only relaxes the contract, so any class that satisfied either interface before still satisfies it. It is listed here because it is technically an interface change and you may notice it via reflection.
ColumnInterface declared __construct(string $key, array $data) and RecordInterface declared __construct(array $data). Both are gone, following ResponseInterface in → v20.0.0. Nothing in the SDK constructs through these types — columns are built by each brand's addColumn() and records by its newRecord(), both naming a concrete class — so the declaration constrained implementers without giving callers anything. And it was a real constraint, not a decorative one: PHP fully enforces a constructor declared on an interface (unlike one inherited from a parent class), so it ruled out, say, a column backed by a generator or a database cursor.
If you implement either interface, you may now give your class whatever constructor suits it:
// Still valid, unchanged
final class MyColumn implements \CNIC\ColumnInterface
{
public function __construct(string $key, array $data) { /* … */ }
// …
}
// Now also valid — previously a declaration-time fatal
final class LazyColumn implements \CNIC\ColumnInterface
{
public function __construct(private readonly string $key, private readonly \Closure $producer) {}
// …
}Why this happened: see the record/column entry in docs/agents/architecture.md for the full decision record. (Ref: RSRMID-2923.)
What changed: the step that turns a raw API response into its hash was a hard-wired static call (RP::parse($this->raw)) with a different signature per brand. It is now a contract, CNIC\ResponseParserInterface, with one signature, and the parser a Response uses can be replaced from the outside.
parse()is an instance method.CNIC\CNR\ResponseParserandCNIC\IBS\ResponseParserstill exist and still behave identically — they are now instantiable classes implementing the new interface instead of static utilities.- One signature for both brands:
parse(string $raw, array $cmd = []): array. CNR gained the second parameter and ignores it (its wire format is self-describing); IBS is unchanged. - New optional constructor argument on every Response:
__construct(string $raw, array $cmd = [], array $ph = [], array $context = [], ?ResponseParserInterface $parser = null). Pass nothing and you get the brand's own parser, exactly as before. - New abstract hooks:
AbstractResponse::newResponseParser()andAbstractResponseTemplateManager::newResponseParser(). The latter replacesAbstractResponseTemplateManager::parseResponse(), which is gone. - New:
AbstractResponseTemplateManager::resetTemplates()restores a brand's built-in templates (additive).
If you only use clients, responses and the interfaces, there is nothing to do. Parsed output is byte-for-byte identical for every response the SDK handles — the cassette suite is the proof.
What to respect if you did any of the following:
-
You called
ResponseParser::parse()directly. Instantiate it:// BEFORE (v25) use CNIC\IBS\ResponseParser as RP; $hash = RP::parse($raw, $cmd); // AFTER (v26) use CNIC\IBS\ResponseParser as RP; $hash = (new RP())->parse($raw, $cmd);
Better still, type the variable as
CNIC\ResponseParserInterfaceso either brand's parser — or your own — fits. -
You wrote your own
Responseon top ofAbstractResponse(a new brand, or a customised one). Implement the new hook alongsidenewRecord(), and read the parser from$this->parserinpopulate()rather than naming a class:// AFTER (v26) #[\Override] protected function newResponseParser(): \CNIC\ResponseParserInterface { return new MyResponseParser(); } #[\Override] protected function populate(): void { $this->hash = $this->parser->parse($this->raw, $this->command); // … build columns/records from $this->hash }
Using
$this->parseris what makes the injected substitute take effect; hard-codingnew MyResponseParser()insidepopulate()compiles and passes its tests, and silently closes the seam back up. -
You supply a parser to a
CNR\Response. CNR columns are string-valued (CNIC\CNR\Columnbinds the shared column's value type tostring), and the Response now checks that rather than inferring it from the concrete parser's return type. Each entry of yourPROPERTYblock must be a list of strings: a cell that is not a string, or an entry that is not a list, raisesCNIC\Exception\UnsupportedFeatureExceptionnaming the column, instead of being silently dropped or coerced.// Accepted return ["CODE" => "200", "PROPERTY" => ["DOMAIN" => ["example.com"]]]; // Throws — not a list return ["CODE" => "200", "PROPERTY" => ["DOMAIN" => "example.com"]]; // Throws — cell is not a string return ["CODE" => "200", "PROPERTY" => ["TOTAL" => [42]]];
Omitting
PROPERTYaltogether is fine and unchanged — that is an ordinary CNR response with no columns. The stock CNR parser cannot produce a violating shape, so this only affects your own. -
You extended
AbstractResponseTemplateManager. Replace yourparseResponse()override withnewResponseParser():// BEFORE (v25) #[\Override] protected static function parseResponse(string $plain): array { return MyResponseParser::parse($plain); } // AFTER (v26) #[\Override] protected static function newResponseParser(): \CNIC\ResponseParserInterface { return new MyResponseParser(); }
-
You register response templates in a long-lived process or across test classes.
AbstractResponseTemplateManager::$templatesis public static state with process lifetime, so anything you add stays visible everywhere afterwards.resetTemplates()now gives you the way back to the brand's built-ins:\CNIC\IBS\ResponseTemplateManager::addTemplate("mycase", "FAILURE", "…"); // … later, when the scenario is over \CNIC\IBS\ResponseTemplateManager::resetTemplates();
It is
addTemplate()'s counterpart, not a general undo: it restores what the container held the first time you calledaddTemplate()on that class, it is per brand, and a direct assignment to the public$templatesproperty is outside its reach. Register throughaddTemplate()and it will always take you back.
Why this happened: see the parse-seam entry in docs/agents/architecture.md for the full decision record. (Ref: RSRMID-2924.)
What changed: CNIC\LoggerInterface used to be one method, log(): void, and every implementation ended in echo. The record itself — the only part that actually differs between brands — could not be obtained by anyone. It is now two halves:
format(string $post, ResponseInterface $r, ?string $error = null): string— new onLoggerInterface. Builds the debug record and returns it.CNIC\LogSinkInterface::write(string $message): void— new. Decides where a record goes.CNIC\EchoSinkis the shipped default and writes to standard output, exactly as before.CNIC\AbstractLogger— new base class. Takes a sink (defaulting toEchoSink), implementslog()assink->write(format(…)), and leavesformat()abstract.log()isfinal: a subclass that reintroduced it would silently ignore an injected sink.AbstractClient::setLogSink(LogSinkInterface $sink)— new. Keeps the brand format, changes the destination.AbstractClient::setDefaultLogger()is gone, replaced by the protected factory hooknewLogger(LogSinkInterface $sink): LoggerInterface.
If you never wrote a logger, there is nothing to do. enableDebugMode() emits the same bytes it always has — that is asserted per brand in the test suite.
What to respect if you did any of the following:
-
You wrote a custom logger. Rename the body of
log()toformat(), return the string instead of echoing it, and extendCNIC\AbstractLogger— writing is inherited:// BEFORE (v26) final class MyLogger implements \CNIC\LoggerInterface { public function log(string $post, \CNIC\ResponseInterface $r, ?string $error = null): void { echo "[{$r->getCode()}] {$post}\n"; } } // AFTER (v27) final class MyLogger extends \CNIC\AbstractLogger { #[\Override] public function format(string $post, \CNIC\ResponseInterface $r, ?string $error = null): string { return "[{$r->getCode()}] {$post}\n"; } }
Implementing
LoggerInterfacedirectly still works and is the way to own the destination as well — but then you must supply both methods, and nothing callsformat()for you. -
Your logger existed only to redirect output (a file, a PSR-3 logger, the WHMCS or Blesta module log). Delete it and write a sink instead; you keep the brand's format for free, which is what you previously had to reimplement:
// AFTER (v27) final class FileSink implements \CNIC\LogSinkInterface { public function __construct(private readonly string $path) {} #[\Override] public function write(string $message): void { file_put_contents($this->path, $message . PHP_EOL, FILE_APPEND); } } $cl->enableDebugMode()->setLogSink(new FileSink("/var/log/cnic.log"));
setLogSink()rebuilds the brand logger around your sink, so it also discards anything previously passed tosetCustomLogger(). Pass a freshnew \CNIC\EchoSink()to get back to the stock behaviour. -
You called
setDefaultLogger(). It is gone. UsesetLogSink(new \CNIC\EchoSink())— same effect, and it now says which destination it means. -
You subclassed a brand client and overrode
setDefaultLogger(). OverridenewLogger()instead:// AFTER (v27) #[\Override] protected function newLogger(\CNIC\LogSinkInterface $sink): \CNIC\LoggerInterface { return new MyLogger($sink); }
Honour the
$sinkargument — that is what makessetLogSink()work for your subclass too. -
You subclassed
CNIC\CNR\LoggerorCNIC\IBS\Logger. You could not: both arefinal, and remain so. They now extendAbstractLoggerand declareformat()only. -
You asserted on debug output with
ob_start(). Callformat()and assert on the returned string, or hand the client a collecting sink. Output buffering is no longer needed to see what the SDK logs.
Masking is unaffected and still happens upstream of the formatter: the response masks its own stored command and the client passes an already-secured POST body, so nothing new becomes obtainable except the record you asked for. For why the seam moved, see the logger entry in docs/agents/architecture.md. (Ref: RSRMID-2925.)
→ v28.0.0 — IBS/Moniker date separators survive verbatim; Record/Column gained a date accessor; IBS\Response::getStatus() removed
What changed: CNIC\IBS\ResponseParser used to silently rewrite / to - in any string value whose key matched /(date|paiduntil|expiration)$/i — the only place the SDK mutated raw response data. That rewrite is gone: getPlain(), getHash() and getListHash() now return IBS/Moniker date values exactly as the API sent them, with / (e.g. 2030/07/17), not -.
The rewrite was also over-broad: it matched on key suffix alone, so a non-date string such as n/a under an updatedate key came back corrupted as n-a. That corruption is also gone — every value now survives untouched, date or not.
CNIC\ApiDateTime gained the ability to parse the / form directly instead, so nothing is lost: it now accepts either - or / as the date separator (consistently within one value — 2026-02/20 and 2026/02-20 are both still refused), and $date/$dateTime always come back with - regardless of which one the source used.
Two new opt-in accessors do the narrowing at the point of use, instead of at parse time:
CNIC\Record::getDateTimeByKey(string $key): ?ApiDateTimeCNIC\Column::getDateTimeByIndex(int $idx): ?ApiDateTime
Both are also declared on CNIC\RecordInterface/CNIC\ColumnInterface — which is what makes this breaking even for a consumer who never reads a date.
CNIC\ApiDateTime also gained a new readonly property, string $raw — the original input string exactly as passed to from()/tryFrom(), unmodified (so it is also the only place a discarded fractional-second part, e.g. .813, survives). This is additive on the value object itself (it is final with a private constructor, so nothing you wrote could have broken), but toArray() gains a "raw" key — if you assert on the whole array shape (e.g. assertSame($expected, $dt->toArray())), add that key.
A value with a trailing newline (e.g. read from a file or a form field, rather than from an API response) is now refused by from()/tryFrom() instead of being silently parsed — consistent with how the class already refuses 2026-02-30 rather than coercing it. No API response is affected.
What to respect:
-
If you parse
getPlain()/getHash()/getListHash()dates yourself for IBS or Moniker, they now carry/, not-. Either accept both separators in your own parsing, or switch toApiDateTime, which already does:// BEFORE (v27) — IBS/Moniker date values arrived pre-normalized to "-" $expiry = $rec->getDataByKey("expirationdate"); // "2030-07-17" // AFTER (v28) — the raw API separator survives $expiry = $rec->getDataByKey("expirationdate"); // "2030/07/17" // Parse it instead of hand-rolling the separator handling: $dt = $rec->getDateTimeByKey("expirationdate"); $dt?->date; // "2030-07-17" — always "-", regardless of the source separator
-
If you implement
RecordInterfaceorColumnInterfacedirectly (rather than usingCNIC\Record/CNIC\Column), add the new method:// AFTER (v28) final class MyRecord implements \CNIC\RecordInterface { // ... existing getData()/getDataByKey() ... #[\Override] public function getDateTimeByKey(string $key): ?\CNIC\ApiDateTime { $value = $this->getDataByKey($key); return is_string($value) ? \CNIC\ApiDateTime::tryFrom($value) : null; } }
Why this happened: see the ApiDateTime entry in docs/agents/architecture.md for the full decision record. (Ref: RSRMID-2926.)
What changed: CNIC\IBS\Response::getStatus() is gone. It had zero callers anywhere in src/, and the value it returned was never anything more than getHash()["status"] — already reachable through the universal ResponseInterface, with no narrowing required. A one-method capability interface plus the narrowing ceremony that would come with it was not worth the API surface for a value a caller can already read off the hash.
What to respect:
// before
$status = $r->getStatus();
// after
$status = (string)($r->getHash()["status"] ?? "");isError()/isSuccess() are unaffected — both continue to read the same status hash key internally (via the protected getHashString(), which was never a public replacement path and stays that way); only the public getter that duplicated it is gone.
This is the one field where reading status off the hash directly is genuinely useful, not just a fallback: for Domain/Check, status is how the API reports AVAILABLE/UNAVAILABLE — a 200 response with isError() === false either way (see tests/IBS/cassettes/request-success-dbg.json, which carries "status":"UNAVAILABLE" alongside a 200 code). isError()/isSuccess() tell you whether the command succeeded, not whether the domain is available — for that you still need getHash()["status"].
Why this happened: see the getStatus() entry in docs/agents/architecture.md for the full decision record. (Ref: RSRMID-2927.)
What changed: every public method parameter whose name carried no meaning was renamed. Nothing else — no method was added, removed, renamed or retyped, no default changed, no behaviour changed. Every call that passes arguments positionally is unaffected.
Since PHP 8.0 a parameter name is part of the public API, because a caller may pass it as a named argument — and the runtime floor here is 8.3, so consumers can already do that. There is no parameter alias in PHP and therefore no deprecation path, which is why a rename has to ride a major.
Who is affected — two groups, and only these two:
- Callers using named arguments.
$r->getColumn(key: "DOMAIN")now raisesError: Unknown named parameter $keyat runtime. The message does not mention the SDK, so it is worth grepping for before you upgrade. Typing against an interface does not insulate you: PHP binds a named argument to the implementation's parameter name, so the call breaks whichever type your variable is declared as. - Implementers and subclassers. If you implement
LoggerInterface,TransportInterface,RecordInterface,ColumnInterface,ResponseInterfaceorRoleCredentialsInterface, or extendAbstractClient,AbstractLogger,AbstractResponse,AbstractResponseTemplateManager,AbstractResponseTranslator,AbstractSocketConfig, or a brandClient/Response/SocketConfig/ResponseTemplateManager, your parameter names should match the new ones. PHP itself does not enforce this, so your code keeps running; but Psalm reportsParamNameMismatch, and your own callers bind to your names — so a mismatch quietly leaves your class's contract diverging from the one documented here.
What to respect:
// BEFORE (v28) — named arguments bound to the old names
$r->getColumn(key: "DOMAIN");
$cl->setUserAgent(str: "MyApp", rv: "1.0");
$cl->setCredentials(uid: "test.user", pw: "secret");
// AFTER (v29)
$r->getColumn(columnName: "DOMAIN");
$cl->setUserAgent(label: "MyApp", revision: "1.0");
$cl->setCredentials(login: "test.user", password: "secret");The full set, grouped by the name that replaced them:
| New name | Replaces | Where |
|---|---|---|
$columnName |
$key, $colkey |
ResponseInterface/AbstractResponse/CNR\Response/IBS\Response::addColumn(), getColumn(), getColumnIndex() (1st); RecordInterface/Record::getDataByKey(), getDateTimeByKey(); Column::__construct() (1st) |
$recordIndex |
$idx, $index |
ResponseInterface/AbstractResponse::getRecord(), getColumnIndex() (2nd); ColumnInterface/Column::getDataByIndex(), getDateTimeByIndex(); CNR\Column::getDataByIndex() |
$templateId |
$id |
AbstractResponseTemplateManager::getTemplate(), addTemplate(), hasTemplate(), isTemplateMatchHash() (2nd), isTemplateMatchPlain() (2nd); CNR/IBS\ResponseTemplateManager::getTemplate() |
$description |
$descr |
AbstractResponseTemplateManager::addTemplate() (3rd) |
$response |
$r |
LoggerInterface/AbstractLogger/CNR\Logger/IBS\Logger::format(), log() (2nd) |
$placeholders |
$ph |
AbstractResponse::__construct() (3rd), translate() (3rd, incl. both brand overrides); AbstractResponseTranslator::translate() (3rd), replacePlaceholders() (2nd) |
$row |
$h |
ResponseInterface/AbstractResponse::addRecord(); AbstractResponse/CNR\Response/IBS\Response::newRecord() |
$timeoutSeconds |
$seconds, $timeout, $value |
AbstractClient/AbstractSocketConfig::setSocketTimeout(); TransportInterface/HttpTransport::post() (3rd) |
$maskSecrets |
$secured |
AbstractClient/AbstractSocketConfig::getPOSTData() (2nd), getPOSTDataParams() (2nd, incl. both brand overrides) |
$login |
$value, $uid |
AbstractSocketConfig/CNR\SocketConfig::setLogin(); AbstractClient::setCredentials() (1st) |
$password |
$value, $pw |
AbstractSocketConfig/CNR\SocketConfig::setPassword(); AbstractClient::setCredentials() (2nd); RoleCredentialsInterface/CNR\Client::setRoleCredentials() (3rd) |
$accountId |
$uid |
RoleCredentialsInterface/CNR\Client::setRoleCredentials() (1st) |
$roleId |
$role |
RoleCredentialsInterface/CNR\Client::setRoleCredentials() (2nd) |
$session |
$value |
CNR\Client/CNR\SocketConfig::setSession() |
$url |
$value |
AbstractClient/AbstractSocketConfig::setURL() |
$persistent |
$value |
CNR\SocketConfig::setPersistent() |
$label |
$str |
AbstractClient::setUserAgent() (1st) |
$revision |
$rv |
AbstractClient::setUserAgent() (2nd) |
$currentPage |
$rr |
CNR\Client::requestNextResponsePage() |
$upperCaseKeys |
$toupper |
CommandFormatter::flattenCommand() (2nd) |
$responseHash |
$tpl |
AbstractResponseTemplateManager::isTemplateMatchHash() (1st) |
$tpl → $responseHash is the one rename that fixes a name meaning the opposite of what it said: the argument is the API response hash being tested against a template, not the template. Post-rename the pair reads isTemplateMatchHash(array $responseHash, string $templateId), which is the right way round.
Note $accountId and $login are deliberately different words for what looks like one concept: setCredentials() takes the login as it goes on the wire, while setRoleCredentials() takes the account and role ids separately and composes the login from them. They are not interchangeable.
ApiDateTime::from()/tryFrom() keep $value, mirroring PHP's own BackedEnum::from(), and are not part of this rename.
If you implement RecordInterface, the v28 snippet above needs its parameter renamed:
// AFTER (v29)
#[\Override]
public function getDateTimeByKey(string $columnName): ?\CNIC\ApiDateTime
{
$value = $this->getDataByKey($columnName);
return is_string($value) ? \CNIC\ApiDateTime::tryFrom($value) : null;
}Why this happened: the old names carried no meaning, so every one of them needed a @param line restating it — 48 such annotations are gone with this change, leaving only the ones that say something a name cannot (an array shape, a precondition, or what passing an empty string resets). Follow-up to RSRMID-2934, which did the same for internal parameters without breaking anything. (Ref: RSRMID-2935.)
What changed: the same defect on the property surface. This is a separate break from the parameter renames above, with a different audience: a protected property is reachable only from a subclass, so this affects you only if you extend AbstractSocketConfig, AbstractClient or AbstractResponse (or a brand SocketConfig/Response) and read the property directly.
| Old | New | On |
|---|---|---|
$pw |
$password |
AbstractSocketConfig |
$ua |
$userAgent |
AbstractClient |
$curlopts |
$curlOptions |
AbstractSocketConfig |
$paginationkeys |
$paginationKeys |
AbstractResponse, CNR/IBS\Response |
$columnkeys |
$columnKeys |
AbstractResponse |
$columnindex |
$columnIndex |
AbstractResponse |
The last three were also the repo's only departures from its own camelCase-properties rule.
What to respect:
// BEFORE (v28)
final class MyConfig extends \CNIC\AbstractSocketConfig
{
protected function getPOSTDataParams(array $command, bool $maskSecrets): array
{
return ["u" => $this->login, "p" => $maskSecrets ? "***" : $this->pw];
}
}
// AFTER (v29)
return ["u" => $this->login, "p" => $maskSecrets ? "***" : $this->password];Unlike the parameter renames, this one fails loudly and statically: reading $this->pw is an access to an undefined property, which PHPStan reports at level 9 rather than leaving to runtime. If you run either analyser over your integration you will be told; if you run neither, PHP emits a warning and yields null at runtime.
Public properties are untouched — $templates and ApiDateTime's readonly fields were already accurate, and System::$name/$value are native enum properties.
Why this happened: $pw and $ua are the same meaningless-abbreviation defect as the parameters, and the rest violated the project's own naming standard; batching them into the one major this rename already costs is cheaper than a second breaking release later. (Ref: RSRMID-2935.)
What changed: HttpTransport::post() used to encode a failure twice — once as the tuple's error element [1], and once as a "httperror|" prefix smuggled into the raw payload [0], which AbstractResponseTranslator::translate() then string-split back off. The sentinel is gone: the error now travels only as an explicit, trailing ?string $error parameter, appended last (default null) on every hook in the pipeline. The nocurl template id is also gone — it existed only for a curl_init() === false branch that is unreachable with ext-curl as a hard dependency, and is now an assert() like the rest of that file's defensive guards.
Who is affected — three groups:
- Anyone who subclassed a brand
ClientorResponseto overridenewResponse()ortranslate(). Both hooks gained a trailing?string $error = nullparameter. - Anyone who implements
TransportInterfacedirectly. The contractpost()already declared is now stated explicitly: a non-null element[1]means element[0]is unusable and will be discarded in favour of thehttperrortemplate. A transport that used to return real bytes alongside an advisory error will now have those bytes thrown away. - Anyone calling
getTemplate("nocurl")orhasTemplate("nocurl")onCNR\ResponseTemplateManager/IBS\ResponseTemplateManagerdirectly. This one is silent — nothing throws, the returned value just changes.getTemplate("nocurl")used to returnCODE=423/status=FAILURE("API access error: curl_init failed"); it now falls through to the generic"notfound"template instead —CODE=500/status=FAILURE("Response Template not found").hasTemplate("nocurl")flips fromtruetofalse.
What to respect:
// BEFORE (v29) — the brand hooks took three parameters
abstract protected function newResponse(string $raw, array $cmd, array $cfg): ResponseInterface;
abstract protected function translate(string $raw, array $cmd, array $placeholders): string;
// AFTER (v30) — a trailing $error, appended last so positional callers are unaffected
abstract protected function newResponse(string $raw, array $cmd, array $cfg, ?string $error = null): ResponseInterface;
abstract protected function translate(string $raw, array $cmd, array $placeholders, ?string $error = null): string;// BEFORE (v29) — a transport could return bytes and an error together and both survived,
// because the caller string-split "httperror|" back off $raw
final class MyTransport implements TransportInterface
{
public function post(string $url, string $data, int $timeoutSeconds, string $userAgent, array $options = []): array
{
// ...
return ["httperror|" . $error, $error];
}
}
// AFTER (v30) — $raw is unusable whenever $error is non-null; return it empty
final class MyTransport implements TransportInterface
{
public function post(string $url, string $data, int $timeoutSeconds, string $userAgent, array $options = []): array
{
// ...
return ["", $error];
}
}Behaviour is otherwise unchanged: $error !== null still selects the brand's httperror template, and $error !== "" still gates the {HTTPERROR} injection into it — the same two-level check as before, just reached through a parameter instead of a string split. A cassette or fixture that used to hand-author ["raw" => "httperror|<msg>", "error" => "<msg>"] should now read ["raw" => "", "error" => "<msg>"].
Why this happened: the sentinel was redundant — the same information already existed as the tuple's [1], and string-splitting [0] for it meant a pipe character inside a cURL error message was a real truncation hazard (mitigated, never removed, by an explode(..., 2) limit). Declaring the error as its own parameter removes the encoding round-trip entirely. (Ref: RSRMID-2937.)
What changed: six methods came off ResponseInterface. Two were mutators (addColumn(), addRecord()); four were the record cursor (getCurrentRecord(), getNextRecord(), getPreviousRecord(), rewindRecordList()). A response is now fully assembled by its constructor and read-only afterwards, and its rows are walked with foreach — the interface extends IteratorAggregate.
Who is affected — three groups:
- Anyone stepping through records with the cursor.
while ($rec = $r->getNextRecord())no longer compiles. This is the one change likely to touch real code — see the rewrite below. - Anyone calling
addColumn()/addRecord()on a response. Both are nowprotected. There is no replacement, because there was never a working use: see "Why this happened". - Anyone who subclasses a brand
Response. Thepopulate()hook changed signature — it now receives what it used to read off$this.
What to respect — iterating records:
// BEFORE (v30) — a shared cursor, with a rewind protocol nothing declared
$r->rewindRecordList();
while (($rec = $r->getNextRecord()) !== null) {
echo $rec->getDataByKey("domain");
}
// AFTER (v31) — foreach; the position lives in the loop, not on the response
foreach ($r as $rec) {
echo $rec->getDataByKey("domain");
}Note the getNextRecord() loop above never saw the first record: the cursor started at index 0 and getNextRecord() advanced before returning, so row 0 had to be fetched separately with getCurrentRecord(). If your loop looked like the "before" block and you never noticed a missing first row, you were probably reading a single-record response. foreach yields every row, first one included — so a getNextRecord() loop ported verbatim will now legitimately see one more record than it used to.
Random access is unchanged: getRecord(int $recordIndex) and getRecords() are still there, so getCurrentRecord() becomes getRecord(0).
What to respect — subclassing a brand Response:
// BEFORE (v30) — populate() read three pieces of half-initialised state off $this
protected function populate(): void
{
$this->hash = $this->parser->parse($this->raw, $this->command);
// ...
}
// AFTER (v31) — all three arrive as arguments; the $parser property is gone
protected function populate(string $raw, ResponseParserInterface $parser, array $cmd): void
{
$this->hash = $parser->parse($raw, $cmd);
// ...
}$this->raw, $this->command and $this->hash are all still readable properties; only $this->parser was removed, since nothing after construction has a use for it. Injecting a substitute parser through the constructor is unaffected — the $parser argument still works exactly as in v26+.
Also in this major, both silent unless you subclass:
- A duplicate column name now throws
CNIC\Exception\DuplicateColumnException(new, additive in theCnicExceptionhierarchy). Previously$columns/$columnKeysappended the second column while the name-to-position index kept the first, leavinggetColumns()holding a columngetColumn()could never return. Unreachable from either shipped brand, and from a substitute parser too — both derive their column names fromarray_keys()of the parsed hash, and two distinct PHP array keys cannot stringify to one name. assembleRecords()replaces the record list instead of appending to it, so calling it twice yields the same rows rather than double.
Why this happened: every one of the six was a rule a caller had to know that no type expressed. A column added after construction was absent from every record, because records are assembled from the columns once, at the end of populate() — it appeared in getColumns() and getColumnKeys() and nowhere else. A record added afterwards changed getRecordsCount() and, through it, the four pagination getters IBS derives from it, so a caller could silently repaginate a finished response. And the cursor was hidden mutable state shared by every holder of the object: two consumers iterating one response interfered with each other, the predicates that would have let a caller test the cursor without moving it were protected, and nothing stated that re-iteration needed a rewindRecordList() first. foreach has none of those properties. (Ref: RSRMID-2939.)
Once you have finished upgrading, check your code against the Usage section of README.md — it holds the worked, per-brand example of idiomatic code for the current major (client construction, the $path argument, close(), and which capabilities are CNR-only), kept up to date rather than pinned to any one version.
It lives there rather than here because README.md is the only documentation that ships in the Composer package, so a consumer who installed via composer require can read it without fetching anything else. (Ref: RSRMID-2930.)
- Read every major section between your current version and the target — do not skip.
- Bump one major at a time. After each bump, run your test suite before moving on.
- Match the PHP floor for your target (8.1 for v9–v13, 8.3 for v14+).
- Type against interfaces (
ResponseInterface,ExtendedResponseInterface,RoleCredentialsInterface,LoggerInterface) rather than concrete classes — this is what keeps future majors from breaking you. - Re-test brand data handling if you use IBS/Moniker across the v13 JSON switch.
- Decode your own credentials (v16+) and
close()sessionless clients (v10+). - Consult HISTORY.md for the exhaustive per-release change list.