This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A PHP SDK for the Quickpay API. Library — not an application. Requires PHP >= 8.1. Installed by consumers as setono/quickpay-php-sdk; root namespace Setono\Quickpay\. Scope is deliberately narrow: the payments resource, the /ping health check, the payment-window link flow, and callback (webhook) verification.
Composer scripts (run via composer <script>):
composer phpunit— PHPUnit suite (tests/). Single test:vendor/bin/phpunit --filter <name>.composer analyse— PHPStanlevel: max. Needsphpstan ^2.2becausephpstan.neon.distincludes Valinor's two PHPStan extensions (type inference + pure-error suppression forregisterTransformer).composer check-style/composer fix-style— ECS (sylius-labs standard).vendor/bin/rector(--dry-runin CI) — modernization,UP_TO_PHP_81. It skipsReadOnlyPropertyRectorforsrc/Responseandsrc/Request(see DTO sections).vendor/bin/infection— mutation testing. Gates:minMsi 50,minCoveredMsi 70. The Stryker dashboard upload is branch-gated ininfection.json.dist(stryker.badge) — currently1.x; update it when the working branch changes.composer e2e:smoke/e2e:listen/e2e:create/e2e:operate— the dev-only end-to-end harness (examples/e2e/), documented inexamples/e2e/README.md. It hits the real API, so it loads a gitignored.env.local(QUICKPAY_API_KEY,QUICKPAY_PRIVATE_KEY).e2e:smokeis the quickest real check (ping → create → link → get, charges nothing).
CI (.github/workflows/build.yaml, branch 1.x): coding-standards, dependency-analysis, static-analysis and unit-tests run PHP 8.1–8.5 × lowest/highest; code-coverage (Codecov, codecov/codecov-action@v5) and mutation-tests (Stryker) run on 8.3. .github/workflows/backwards-compatibility-check.yaml runs Roave BC check on every PR against its base branch (PHP 8.4, current tool) — a flagged break must either be reverted or be a deliberate, documented major-version change. When changing dependency constraints, check both lowest and highest still resolve on 8.5.
Client (src/Client/Client.php, ClientInterface) — PSR-18/17 + php-http/discovery, Valinor for (de)serialization. Constructor: (string $apiKey, ?HttpClientInterface, ?RequestFactoryInterface, ?StreamFactoryInterface, ?MapperBuilder, ?NormalizerBuilder, bool $synchronized = false) — only $apiKey is required; the rest are discovered/defaulted. $synchronized (exposed via isSynchronized() on ClientInterface) is the client-wide default for the payment operation methods' $synchronized flag. Immutable (private readonly, no setters). Auth is HTTP Basic with an EMPTY username and the API key as the password (Basic base64(':'.$apiKey)), plus a mandatory Accept-Version: v10 header. Single host https://api.quickpay.net (there is no sandbox host). request() stamps the headers, tracks lastRequest/lastResponse, and routes non-2xx through assertStatusCode() (a match on the status code). Helpers: get(), post(), put(), patch(), delete() (the body-carrying ones take Payload|array $body = [] — a typed DTO, or a plain array sent as given for unmodeled endpoints; never null: an empty body goes out as {}, which the live API accepts even on cancel (verified) while it rejects []; authorize still requires amount; payment update is PATCH, not PUT), and ping(): bool. payments() is lazily memoized. resolveUrl() pins the host: an absolute URL to any other host, a non-default port, or an absolute URL combined with a $query throws InvalidUrlException — the credential-leak guard. configureMapperBuilder() / registerNormalizerTransformers() are the public hooks for consumers wiring a cached Valinor builder.
Endpoint hierarchy (src/Client/Endpoint/) — Endpoint (base: $client + $mapperBuilder; mapItem() runs the source through Valinor Source::camelCaseKeys(), maps to the typed DTO, stamps $raw, and converts Valinor MappingError → MappingException) → ResourceEndpoint (getOne/createOne/updateOne [PATCH]/postOperation [POST {id}/{action}, appends ?synchronized when asked]/putSubResource) → CollectionEndpoint (getPage/paginate). Quickpay list pagination is header-less — ?page=N&page_size=M returns a bare JSON array, so paginate() stops when a page returns fewer items than pageSize. PaymentsEndpoint (final) exposes getById/create/updatePayment/authorize/capture/refund/cancel/createLink; the operation methods take an optional ?bool $synchronized = null — null falls back to the client-wide synchronized constructor flag (Quickpay processes operations async by default and returns a pending op; synchronized: true waits for the completed transaction).
Request DTOs (src/Request/) — Payload is a mutable marker base. Concrete DTOs are final class with plain public promoted properties. Fields the API unconditionally requires are required, non-nullable constructor params — verified against the LIVE API (2026-08-06), not the docs: CreatePaymentRequest::$orderId+$currency, $amount on CreateLinkRequest/CaptureRequest/RefundRequest/AuthorizePaymentRequest, and ALL five BasketItem fields (the API treats a basket item as all-or-nothing — any partial item gets per-field is missing errors; a []-serialized empty item even triggers an HTTP 500 on their side). Address and Shipping are verified lenient (partial accepted, stored with nulls) — but Shipping::$method, when present, is server-validated against a fixed value set (home_delivery ok, pickup rejected). An all-empty nested Payload set as a DIRECT property (shipping/invoiceAddress) is stripped from the body entirely by the parent's []-strip, so it never reaches the wire; only empty items INSIDE a list (basket) could — which required BasketItem fields now make unrepresentable. Everything else is optional/nullable with no construction-time validation — Quickpay enforces conditional requirements and format rules (violations surface as a ValidationException). Before marking a new field required, verify with a live probe — and mind the validation ORDER: body shape → transaction state → params. A [] (JSON array) body fails with the generic body: "is invalid", and state errors mask param validation, so operation params (capture/refund) can only be probed on a payment in the right state. To get one without the payment window: authorize via the API with a test card (card: {number: '1000000000000008', expiration: '2612', cvd: '123'}) — it works (at least on this account), producing a test_mode payment that charges nothing. On serialization the Payload normalizer transformer strips null/[] and converts camelCase → snake_case (Client::camelToSnake). CreatePaymentRequest, UpdatePaymentRequest (PATCH; no order_id/basket — not updatable), AuthorizePaymentRequest, CaptureRequest, RefundRequest, CreateLinkRequest, plus nested Address/BasketItem/Shipping. CollectionRequestOptions (page/pageSize, asserted >= 1, toArray() → page/page_size). Capture/refund/authorize take an extras hash (acquirer-specific) — extras keys pass through verbatim, NOT snake_cased; acquirer is a link param, not an operation param.
Response DTOs (src/Response/) — entry DTOs extend Resource (public array $raw, stamped by the endpoint after mapping). final class (NOT final readonly, so $raw can be set post-construction — hence the rector skip). Type only the stable, commonly-used fields; reach everything else via $raw (original snake_case keys). Payment, Operation, Link, Metadata, and Collection<T> (passive carrier; pagination logic lives on the endpoint). GOTCHA learned the hard way: a single mis-typed nested field fails the WHOLE resource mapping (Valinor is strict; the $raw fallback only protects fields you DON'T type). E.g. Metadata::$is3dSecure is ?bool even though the API docs label it "string" — the live API returns a boolean. Verify nested field types against real responses, not the docs, and keep the typed subset conservative. Dates are ?\DateTimeImmutable (supportDateFormats('Y-m-d\TH:i:sP', 'Y-m-d\TH:i:s.uP')).
Callbacks (src/Callback/) — CallbackValidator verifies hash_hmac('sha256', rawBody, privateKey) against the QuickPay-Checksum-Sha256 header using hash_equals. The private key (Settings → Integration) is NOT the API key, and the HMAC is over the raw, un-re-encoded body. CallbackHandler::handle(ServerRequestInterface) is the primary entry point (reads body + QuickPay-* headers); handleRaw(rawBody, checksum, resourceType) is for raw pieces (superglobals, or a framework that consumed the body). Both return a verified Callback value object (body, type, accountId, apiVersion). The QuickPay-Resource-Type header is required and strictly validated against the ResourceType enum (Payment/Subscription) — unknown/missing throws InvalidCallbackException. A callback is NOT assumed to be a payment: check isPayment() / type, then payment() (guarded) or toArray().
Exceptions (src/Exception/) — QuickpayException (marker interface on every SDK throw) → ResponseAwareException (lazy-parses getMessageText()/getErrorCode()/getValidationErrors() from Quickpay's {message, errors, error_code} body) → ClientErrorException (4xx) / ServerErrorException (5xx) → concrete: Unauthorized (401), Forbidden (402/403), NotFound (404), MethodNotAllowed (405), Conflict (409), Validation (400/422), TooManyRequests (429), InternalServerError (5xx), UnexpectedStatusCode (fallback). MalformedResponseException → MappingException for 2xx bodies that fail to decode/map. Non-response throws: InvalidUrlException (host pinning), InvalidCallbackException (bad callback body / resource type), InvalidChecksumException (bad signature).
Enums (src/Enum/) — PaymentState and OperationType are non-exhaustive: the DTO keeps the value as a string and exposes a state()/type() helper via tryFrom, so a server-added value never throws. ResourceType is the deliberate exception — it is strict (the callback handler rejects anything not in it).
- No sandbox, no test key. Consumers use their real API key; a payment becomes a test payment (
test_mode: true) only when paid with a test card. Test callbacks are real and signed exactly like production. - Valinor wiring:
camelCaseKeyson input,camelToSnake+ null/[]-strip (thePayloadtransformer) on output. We do NOT register Valinor converters (they leak memory);$rawis stamped inline inEndpoint::mapItem(). APayloadwith no set fields normalizes to[], which the API rejects (body: "is invalid") —Client::send()rewrites it to{}. examples/e2e/is committed dev tooling and is intentionally OUT of the phpstan/ecs/rector paths (src+testsonly) — check those scripts withphp -l. Secrets live only in the gitignored.env.local; never commit them.
tests/ autoloads under the same Setono\Quickpay\ namespace (autoload-dev). The HTTP layer is faked with tests/TestDouble/ScriptedHttpClient.php (a URI-keyed fake — no mocking framework); build clients via QuickpayTestCase::client(), and load captured payloads from tests/Fixtures/. New code needs tests (the mutation gate is minCoveredMsi 70). tests/Client/LiveClientTest.php hits the real API and is skipped unless QUICKPAY_LIVE=1 and QUICKPAY_API_KEY are set.