Skip to content

Replace the single-file SDK with a layered PSR-4 package - #26

Merged
listopad merged 6 commits into
masterfrom
feature/v3-layered-refactor
Jul 25, 2026
Merged

Replace the single-file SDK with a layered PSR-4 package#26
listopad merged 6 commits into
masterfrom
feature/v3-layered-refactor

Conversation

@listopad

@listopad listopad commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Splits the single-file SDK into a layered PSR-4 package (Unitpay\src/) behind a thin
Unitpay\Unitpay facade. Nothing changed on the wire: same endpoints, same flat request
format, same signature algorithm, same response shapes. What changed is the PHP surface you
call. Still php >=7.4 and ext-json, cURL still optional, no runtime dependencies added.

Architecture

Layers, with dependencies pointing strictly downward:

Layer Contents
Unitpay Facade and composition root: form(), fluent setters, service getters
Api/ AbstractService plus the Payment / Subscription / Payout / Reference services and PendingParams
Http/ TransportInterface port and CurlTransport adapter
Signature/ SignatureBuilder
Webhook/ WebhookVerifier and IpAllowlist
Model/, Model/Enum/ CashItem and the const-classes Nds, PaymentObject, PaymentMethod, Measure, PaymentType
Exception/ Marker interface and five typed exceptions

Model/, Model/Enum/ and Exception/ are leaves and import nothing internal. Services
never construct a transport. They receive TransportInterface from the facade, which owns
the single shared instance (it also serves the webhook IP feed), so a custom HTTP stack is
injected in one place.

Breaking changes

  • UnitPay.php is gone, so installation is Composer-only. The package needs the PSR-4
    autoloader and can no longer be required as a single file.
  • Classes moved. UnitPay becomes Unitpay\Unitpay (note the casing), CashItem becomes
    Unitpay\Model\CashItem, UnitpayIpAllowlist becomes Unitpay\Webhook\IpAllowlist, and
    every exception moves into Unitpay\Exception\. Exception class names and their
    InvalidArgumentException / UnexpectedValueException parents did not change, so
    existing catch blocks keep working once the imports are updated.
  • api('method', [...]) is replaced by four service objects reached from the facade:
    payments(), subscriptions(), payouts() and reference(). Each method takes its
    required parameters as arguments and the rest in a trailing options array. Watch out for
    getBinInfo: it sits on payouts(), next to getSbpBankList, not on reference().
  • Required parameters are enforced by the method signatures now, not by the runtime
    REQUIRED_UNITPAY_METHODS_PARAMS dictionary. A missing one is an ArgumentCountError
    that static analysis catches, rather than a UnitpayValidationException at request time.
    UnitpayUnsupportedMethodException no longer applies to outbound calls, since there is no
    method-name string left to mistype; it covers inbound webhooks only.
  • The fiscal dictionaries moved to const-classes in Model\Enum and lost the redundant
    prefix: CashItem::NDS_20 becomes Nds::VAT20, CashItem::PAYMENT_OBJECT_COMMODITY
    becomes PaymentObject::COMMODITY, CashItem::PAYMENT_METHOD_PAYMENT_FULL becomes
    PaymentMethod::PAYMENT_FULL, CashItem::MEASURE_KG becomes Measure::KG, and
    UnitPay::PAYMENT_TYPE_CARD becomes PaymentType::CARD. One case is irregular:
    CashItem::NDS_NONE becomes Nds::NONE, with no VAT prefix.
  • Webhook verification lives behind $unitpay->webhook(), that is
    Unitpay\Webhook\WebhookVerifier: checkHandlerRequest(), getHandlerMethod(),
    getHandlerParams(), getSuccessHandlerResponse(), getErrorHandlerResponse(),
    setAllowedIps(), addAllowedIps(), getAllowedIps() and refreshAllowedIps().
    getIp() and isAllowedIp() stay protected, so running behind a proxy means
    subclassing the verifier, not the facade.
  • getSignature() is no longer public on the facade. Direct signing lives in
    Unitpay\Signature\SignatureBuilder::build($params, $secretKey, $method).
  • The transport constructor argument changed from a callable to TransportInterface.
    Omit it and you still get the default CurlTransport with unchanged behavior: cURL with
    a file_get_contents() fallback, TLS verified.

Explicit decisions

No compatibility shim ships. A class_alias would restore the old class names without the
old methods and constants, so code would compile and then fail at runtime. A real compat
layer would re-create the god class this release removes, and it would force dropping
final from Unitpay\Model\CashItem. Migration is a mechanical one-time edit instead.

The deprecated payment objects stay for now. excise, gambling_bet, gambling_prize,
lottery_prize and composite were announced in v2.1.0 for removal in 3.0, but pulling
them alongside the namespace break adds migration friction for no functional gain. They are
deferred to 4.0 and remain available as PaymentObject::EXCISE and friends. They should not
be used in new code.

Unchanged, and verified as such

The signature algorithm and the {up} delimiter are untouched, as is the mandatory
PHP_INT_MAX guard against signature forgery in SignatureBuilder::build(), which two
regression tests cover. Also unchanged: constant-time comparison via hash_equals(), the
REMOTE_ADDR-only IP source (never the spoofable X-Forwarded-For), TLS verification on
the IP-feed fetch, the fail-safe allowlist refresh, the flat request format, the response
shapes, PHP >= 7.4 support and the zero-dependency policy.

Tests & QA

  • The suite was ported to the new namespaces and grew from 121 to 150 tests and 318
    assertions. It now mirrors the src/ tree.
  • Tests\Support\FakeTransport replaces the old callable double. It implements
    TransportInterface, records the URL and headers of every call, and replays a queue of
    canned bodies; pass false to simulate a transport failure.
  • New coverage for the new seams: service getters and their memoization, shared transport
    injection, PendingParams draining.
  • Tooling paths follow. composer.json moves from classmap to psr-4, lint and md
    now target src, and phpstan.neon and .php-cs-fixer.dist.php do the same.

Docs

  • README and the topic pages (docs/getting-started.md, docs/api-methods.md,
    docs/receipts.md, docs/webhooks.md, docs/telemetry.md) were rewritten for the
    service API. docs/api-methods.md now groups methods by service and gives full
    signatures.
  • docs/migration-v3.md is new: 8 sections plus a checklist, with exact 2.x to 3.0 rename
    tables.
  • All 11 examples/*.php now use vendor/autoload.php and the namespaced API.

Verification

The CI matrix was replicated locally in Docker before pushing. PHP 7.4.33, 8.0.30, 8.1.34,
8.2.32, 8.3.32 and 8.4.23 all pass: 150 tests and 318 assertions on each, 55 files linted
clean. The quality job was replayed on 8.3, where php-cs-fixer reports 0 of 55, PHPMD is
clean, PHPStan level 6 says [OK] No errors, and composer audit finds nothing.

One caveat is worth a follow-up. PHPStan exhausts the default 128M memory_limit in a
parallel worker and needs --memory-limit=512M, so the result currently depends on the
environment's php.ini. Pinning the limit in phpstan.neon or in the composer stan
script would make it deterministic. That change is not part of this PR.

Merge note

The branch was rebased onto master, so master is an ancestor and this merges
fast-forward. Please do not squash: the six commits are ordered so that each one builds and
passes tests on its own.

listopad and others added 6 commits July 25, 2026 01:50
Add PSR-4 autoload for the Unitpay\ namespace alongside the existing classmap (strangler bridge) and wire src/ into phpstan, php-cs-fixer and the lint glob. Extract the exception hierarchy into src/Exception/ and CashItem plus the 54-FZ dictionaries (Nds, PaymentObject, PaymentMethod, Measure, PaymentType) into src/Model/ and src/Model/Enum/ as PHP 7.4-safe const-classes. The single-file UnitPay.php still powers the SDK; the new layers only coexist.
Add Unitpay\Http (TransportInterface + CurlTransport with the cURL/file_get_contents fallback and TLS on), Unitpay\Signature\SignatureBuilder (with the PHP_INT_MAX forgery guard and locale-safe float serialization), and Unitpay\Webhook (WebhookVerifier + IpAllowlist) covering signature + IP-allowlist verification, fail-safe refresh, and handler responses. Standalone testable units; the facade wiring and single-file removal come next.
Add Unitpay\Api (AbstractService request pipeline with telemetry fingerprint, PendingParams fluent-param holder, and typed PaymentService/SubscriptionService/PayoutService/ReferenceService whose method names mirror the backend) and the Unitpay\Unitpay facade (composition root: lazy service getters, fluent setters, form(), webhook() accessor). The global UnitPay.php still powers the SDK and stays until the test port so the single-file removal is one green cutover.
Rework tests/ to mirror src/. The callable transport double becomes
Tests\Support\FakeTransport, a TransportInterface implementation that records
the URL and headers of each call and replays a queue of canned bodies. Adds
focused coverage for the new seams: facade service getters, their memoization,
and the fact that one injected transport serves both the API services and the
webhook IP-feed fetch. 121 -> 150 tests, 318 assertions.

Remove the legacy UnitPay.php along with its classmap autoload entry, PHPStan
path, php-cs-fixer append and the lint/md script globs.

Two tests are deliberately gone: api('doesNotExist') is no longer a code path,
so the unsupported-method case is covered only where it still applies, in
WebhookVerifier. Per-service method-name coverage replaces the old allowlist
test, and required-param validation - now enforced by the service method
signatures rather than a runtime dictionary - is pinned via ArgumentCountError.

examples/ still require the removed file and are updated separately.
Every example now autoloads from vendor/ and uses the Unitpay\ namespace:
api('method', $params) becomes the matching service call, the webhook handler
goes through webhook(), and the CashItem dictionaries come from Model\Enum
(Nds, PaymentObject, PaymentMethod, Measure, PaymentType).

Account-level calls read better under the new signatures: the login is the
first argument and only the account secretKey stays in the options array.
Note that getBinInfo lives on the payouts service, which accountInfo.php now
reflects.

Verified past parallel-lint, which only sees syntax: every `use Unitpay\...`
import resolves through the real autoloader, and PHPStan level 0 over
examples/ is clean, so method names and arity are checked too.
README and docs/ now describe the service API: Unitpay\Unitpay as the entry
point, payments()/subscriptions()/payouts()/reference() for calls, webhook()
for inbound verification, and Model\Enum for the fiscal dictionaries. The API
reference is grouped by service with full method signatures instead of one
flat api() table.

Adds docs/migration-v3.md: every class, method and constant rename from 2.x,
with the old names taken from the pre-removal UnitPay.php rather than guessed.
It opens with what did not change - signature algorithm, wire format, webhook
semantics, exception hierarchy - since that is most of the surface.

The v3.0.0 changelog entry lists the breaking changes and records two
decisions explicitly: no compatibility shim ships, and the payment objects
v2.1.0 announced for "removal in 3.0" (excise, gambling_bet, gambling_prize,
lottery_prize, composite) are kept and deferred to 4.0 instead of lapsing
silently.
@listopad listopad self-assigned this Jul 24, 2026
@listopad listopad changed the title Feature/v3 layered refactor Replace the single-file SDK with a layered PSR-4 package Jul 25, 2026
@listopad
listopad merged commit 8eb5c3b into master Jul 25, 2026
7 checks passed
@listopad listopad mentioned this pull request Jul 29, 2026
@listopad
listopad deleted the feature/v3-layered-refactor branch July 29, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant