You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A developer-experience review of setono/quickpay-php-sdk at v1.0.0, done with a "first-time consumer" lens: the public API surface, README/docblocks, examples, tests, and a cold composer require into a fresh project. Endpoint/parameter facts below were checked against Quickpay's machine-readable spec (https://api.quickpay.net/docs/v10/merchant/api/payments).
Verdict: the fundamentals (typing, safety, honesty about API quirks, tests, docblocks) are excellent. The gaps are mostly around the core — a few "obvious next things" a consumer reaches for, one real inconsistency in the security story, and a README that explains the SDK well but doesn't yet teach the Quickpay mental model or show framework-shaped code. Nothing needs a 2.0; almost everything is additive.
Each item below is fixed in its own PR referencing this issue.
What's already excellent (protect this)
Required-vs-optional on request DTOs verified against the live API, not the docs.
$raw escape hatch + non-exhaustive enums (state()/type() via tryFrom) — the SDK won't break on API evolution.
Async operations modeled honestly (synchronized per call + client default; docblocks spell out what a 202 body does and doesn't tell you).
Callback design: raw-body HMAC, hash_equals, strict resource-type validation, "don't assume it's a payment", the "don't mock the handler — forge signatures with sign()" testing section.
Exception hierarchy under one marker interface, lazy-parsed Quickpay error body, sanitized URLs.
Zero-mock test suite with a URI-keyed fake; e2e harness with a real callback loop.
P1 — fix soon
1. Client::request() bypasses the host-pinning guard.src/Client/Client.phprequest() stamps Authorization on anyRequestInterface and sends it; only get()/send() go through resolveUrl(). Since request() is public on ClientInterface, a consumer using it to reach an unmodeled endpoint can leak the key to another host with no error, contradicting the README/release-notes claim ("credentials only ever go to api.quickpay.net"). Fix: run the same host/port check on $request->getUri() in request(), with a test.
2. Stale docblock:ClientInterface::put() says it is "Used for updating a payment" — update is PATCH now.
P2 — API ergonomics gaps (additive, 1.x-safe)
3. No way to find a payment by order_id. Quickpay rejects duplicate order_id ("has already been taken"), so every integration needs find-or-create — but CollectionRequestOptions only carries page/pageSize, while GET /payments supports order_id, state, accepted, min_time/max_time, acquirer, fraud_suspected, id, sort_by/sort_dir, operations_size. Add PaymentsEndpoint::findByOrderId(string): ?Payment plus a typed filter/query object, and a README recipe "create a payment idempotently".
4. Consumers hand-roll the same result helpers. "Was the capture approved?" = find the operation with type=capture, pending=false, qp_status_code='20000'. Add small, tested helpers: Operation::isApproved(), Payment::operationsOfType(), latestOperation(), operation(int $id), and derived amounts (authorizedAmount(), capturedAmount(), refundedAmount()).
5. Escape hatch is incomplete and undocumented. The SDK is deliberately narrow (no renew, session, DELETE …/link, operations sub-resource, subscriptions), so consumers will hit the boundary. There is no delete() helper, post()/put()/patch() accept only ?Payload (and every concrete Payload is final, so a consumer must subclass the abstract base), and the README never mentions get()/post() as the way to reach unmodeled endpoints. Add delete(), accept Payload|array|null bodies, and document "Calling endpoints the SDK doesn't model".
6. Testing story for consumers is missing.Client/PaymentsEndpoint are final (good) and the intended substitution point is the PSR-18 client — but ScriptedHttpClient lives in tests/ and is export-ignored, and the README's only testing guidance is about callbacks. Ship a Setono\Quickpay\Testing\ fake and add a README section "Testing code that uses the SDK" (also noting response DTOs have public constructors).
7. Cache wiring is a footgun. Passing a plain (new MapperBuilder())->withCache($cache) without wrapping it in Client::configureMapperBuilder() compiles fine and fails at runtime (dates, superfluous keys). Add a cache: constructor argument on Client and CallbackHandler; keep the builder params for power users.
8. handleRaw() drops headers and forces a superglobals dance. It can't populate accountId/apiVersion, and Symfony/Laravel Requests aren't PSR-7 without a bridge — so handleRaw() is what most people will actually use. Accept the optional headers and add handleGlobals() (php://input + $_SERVER), which examples/e2e/listen.php currently reimplements.
9. Transport errors escape the marker interface.catch (QuickpayException $e) misses PSR-18 ClientExceptionInterface (timeouts, DNS), although the QuickpayException docblock promises it nets "all SDK-thrown exceptions". Wrap in TransportException implements QuickpayException, ClientExceptionInterface (BC-safe for existing ClientExceptionInterface catches) and mention it in the README's error section.
10. Smaller additive items: memoize Callback::payment() (re-decodes/re-maps on every call); type Payment::$variables (the README shows setting variables but never reading them back — today that's $raw['variables']), deadlineAt, acquirer; add CreatePaymentRequest::$shopsystem (shopsystem[name|version], useful for a plugin to identify itself); include the SDK version in the User-Agent.
P3 — docs & examples
11. Install text is stale/misleading (README.md Installation). A cold install into a fresh project: with the discovery plugin allowed, composer require auto-installs symfony/http-client + nyholm/psr7; with it disallowed, install succeeds silently and new Client() throws php-http's NotFoundException (not a QuickpayException). Say both things.
12. Teach the mental model, not just the SDK. Add a "Concepts" section (the two keys, amounts as minor units, state + accepted + operations, sync vs async, test mode) and recipes: full checkout flow, Symfony and Laravel callback controllers, find-or-create by order id, testing your code.
13. Promote a critical warning from the e2e README to the main README: the continue_url redirect carries no data and can arrive before the callback — trust only the verified callback or a getById(). Plus callback best practices (respond 2xx fast, expect retries/out-of-order, key idempotency on operations[].id).
14. Docs polish: the README wants a TOC; createLink() returns a Link whose 5 non-url fields are always null (the endpoint returns only {url}) — document that on Link; the four near-identical 8-line paragraphs in PaymentsEndpoint could point to one shared explanation.
15. Packaging polish:composer.json has no keywords/support/homepage; README.md/UPGRADE.md are export-ignored so vendor/setono/quickpay-php-sdk/ ships no docs at all; consider a CHANGELOG.md mirroring the GitHub release notes.
2.0 candidates (don't break 1.x for these)
updatePayment() → update() (create/getById/capture are otherwise noun-free).
Drop Collection::$totalCount/$totalPages — fields documented as "should not be relied upon" shouldn't exist.
Split the response Link (createLink() result vs the nested link on Payment), or return string $url.
A developer-experience review of
setono/quickpay-php-sdkat v1.0.0, done with a "first-time consumer" lens: the public API surface, README/docblocks, examples, tests, and a coldcomposer requireinto a fresh project. Endpoint/parameter facts below were checked against Quickpay's machine-readable spec (https://api.quickpay.net/docs/v10/merchant/api/payments).Verdict: the fundamentals (typing, safety, honesty about API quirks, tests, docblocks) are excellent. The gaps are mostly around the core — a few "obvious next things" a consumer reaches for, one real inconsistency in the security story, and a README that explains the SDK well but doesn't yet teach the Quickpay mental model or show framework-shaped code. Nothing needs a 2.0; almost everything is additive.
Each item below is fixed in its own PR referencing this issue.
What's already excellent (protect this)
$rawescape hatch + non-exhaustive enums (state()/type()viatryFrom) — the SDK won't break on API evolution.synchronizedper call + client default; docblocks spell out what a 202 body does and doesn't tell you).hash_equals, strict resource-type validation, "don't assume it's a payment", the "don't mock the handler — forge signatures withsign()" testing section.P1 — fix soon
Client::request()bypasses the host-pinning guard.src/Client/Client.phprequest()stampsAuthorizationon anyRequestInterfaceand sends it; onlyget()/send()go throughresolveUrl(). Sincerequest()is public onClientInterface, a consumer using it to reach an unmodeled endpoint can leak the key to another host with no error, contradicting the README/release-notes claim ("credentials only ever go toapi.quickpay.net"). Fix: run the same host/port check on$request->getUri()inrequest(), with a test.ClientInterface::put()says it is "Used for updating a payment" — update is PATCH now.P2 — API ergonomics gaps (additive, 1.x-safe)
order_id. Quickpay rejects duplicateorder_id("has already been taken"), so every integration needs find-or-create — butCollectionRequestOptionsonly carriespage/pageSize, whileGET /paymentssupportsorder_id,state,accepted,min_time/max_time,acquirer,fraud_suspected,id,sort_by/sort_dir,operations_size. AddPaymentsEndpoint::findByOrderId(string): ?Paymentplus a typed filter/query object, and a README recipe "create a payment idempotently".type=capture,pending=false,qp_status_code='20000'. Add small, tested helpers:Operation::isApproved(),Payment::operationsOfType(),latestOperation(),operation(int $id), and derived amounts (authorizedAmount(),capturedAmount(),refundedAmount()).renew,session,DELETE …/link, operations sub-resource, subscriptions), so consumers will hit the boundary. There is nodelete()helper,post()/put()/patch()accept only?Payload(and every concretePayloadisfinal, so a consumer must subclass the abstract base), and the README never mentionsget()/post()as the way to reach unmodeled endpoints. Adddelete(), acceptPayload|array|nullbodies, and document "Calling endpoints the SDK doesn't model".Client/PaymentsEndpointarefinal(good) and the intended substitution point is the PSR-18 client — butScriptedHttpClientlives intests/and isexport-ignored, and the README's only testing guidance is about callbacks. Ship aSetono\Quickpay\Testing\fake and add a README section "Testing code that uses the SDK" (also noting response DTOs have public constructors).(new MapperBuilder())->withCache($cache)without wrapping it inClient::configureMapperBuilder()compiles fine and fails at runtime (dates, superfluous keys). Add acache:constructor argument onClientandCallbackHandler; keep the builder params for power users.handleRaw()drops headers and forces a superglobals dance. It can't populateaccountId/apiVersion, and Symfony/LaravelRequests aren't PSR-7 without a bridge — sohandleRaw()is what most people will actually use. Accept the optional headers and addhandleGlobals()(php://input+$_SERVER), whichexamples/e2e/listen.phpcurrently reimplements.catch (QuickpayException $e)misses PSR-18ClientExceptionInterface(timeouts, DNS), although theQuickpayExceptiondocblock promises it nets "all SDK-thrown exceptions". Wrap inTransportException implements QuickpayException, ClientExceptionInterface(BC-safe for existingClientExceptionInterfacecatches) and mention it in the README's error section.Callback::payment()(re-decodes/re-maps on every call); typePayment::$variables(the README shows setting variables but never reading them back — today that's$raw['variables']),deadlineAt,acquirer; addCreatePaymentRequest::$shopsystem(shopsystem[name|version], useful for a plugin to identify itself); include the SDK version in theUser-Agent.P3 — docs & examples
README.mdInstallation). A cold install into a fresh project: with the discovery plugin allowed,composer requireauto-installssymfony/http-client+nyholm/psr7; with it disallowed, install succeeds silently andnew Client()throws php-http'sNotFoundException(not aQuickpayException). Say both things.state+accepted+ operations, sync vs async, test mode) and recipes: full checkout flow, Symfony and Laravel callback controllers, find-or-create by order id, testing your code.continue_urlredirect carries no data and can arrive before the callback — trust only the verified callback or agetById(). Plus callback best practices (respond 2xx fast, expect retries/out-of-order, key idempotency onoperations[].id).createLink()returns aLinkwhose 5 non-urlfields are always null (the endpoint returns only{url}) — document that onLink; the four near-identical 8-line paragraphs inPaymentsEndpointcould point to one shared explanation.composer.jsonhas nokeywords/support/homepage;README.md/UPGRADE.mdareexport-ignored sovendor/setono/quickpay-php-sdk/ships no docs at all; consider aCHANGELOG.mdmirroring the GitHub release notes.2.0 candidates (don't break 1.x for these)
updatePayment()→update()(create/getById/captureare otherwise noun-free).Collection::$totalCount/$totalPages— fields documented as "should not be relied upon" shouldn't exist.Link(createLink()result vs the nested link onPayment), or returnstring $url.