Repository navigation
Home
Shared PHP library behind Payplug's e-commerce plugins (PrestaShop, WooCommerce, Sylius). It owns
the Payplug protocol — OAuth2, the Unified API, webhooks — so each plugin only has to supply its
CMS-specific parts. Distributed via Composer, but the shipped code is bundled into plugin ZIPs (no
live vendor/ on the merchant's server).
Where to start
| I want to… | Go to |
|---|---|
| Understand how the pieces fit | Architecture |
| Integrate a payment flow step by step | How-to guides |
| Look up a class or method | the reference below |
| Contribute | Contributing · Release process |
Docker is the only local requirement; PHP and Composer run in a dev container.
make install
| Command | Description |
|---|---|
make install |
Install dependencies + git hooks |
make test |
Unit tests |
make test-integration |
Integration tests; needs VPN and UPC_IT_* credentials in a local .env (copy .env.example). Tests skip themselves when unset |
make coverage |
Tests + Clover report at build/logs/clover.xml (feeds SonarCloud) |
make stan |
PHPStan level 8 |
make cs-lint / make cs-fix
|
Code style check / auto-fix |
make quality |
cs-lint + stan + test
|
make shell |
Shell in the dev container |
make verify-71 |
Proves the PHP 7.1 runtime floor holds — see Compatibility |
The runtime target is PHP 7.1+. composer.json's require.php (>=7.4) is the build-tooling
floor, not the runtime.
// 1. Wire: your plugin implements the contracts, then builds the services once.
$service = new UnifiedApiPaymentService($httpClient, $tokenManager, $baseUrl, $clientId, $clientSecret);
// 2. Pay: build DTOs, call the service, show the 3DS page if one is pending.
$output = $service->createPayment(new HostedFieldDto($common, $hfToken, null, $browser, $customer));
// 3. Confirm: the final result arrives at your webhook.
$operation = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);Everything is stateless: UPC never stores a credential, writes an order or redirects the browser. Those are your plugin's job, through the contracts.
Namespace PayplugUnifiedCore\, rooted at src/. Tests mirror the layout under tests/.
Contracts/ holds the 8 interfaces your plugin implements. UPC ships no concrete implementation;
each interface carries a docblock sketching a Sylius and a WooCommerce one.
| Interface | Provides |
|---|---|
ILogger |
Structured logging sink (debug/info/error) |
IConfigurationRepository |
OAuth2 client credentials and Hosted Fields key material, from the CMS's settings |
IPaymentRepository |
Persists OperationData; tracks webhook processing state for idempotency |
IOrderStateMutator |
Applies a PaymentOutcome to the CMS order, identified by order id |
ILock |
Per-operation mutex so a retried webhook isn't processed concurrently with itself |
ITokenCache |
Caches the OAuth2 JWT |
IOAuthHttpClient |
HTTP for OAuth2 token exchange only (POST, form-encoded) |
IUnifiedApiHttpClient |
HTTP for the Unified API: get() and postJson() (bearer token, JSON) |
PaymentRequestPayload also lives here but is internal: the type createPayment() accepts, which
HostedFieldDto and PaymentDto implement through createPayloadBody(): array. You never
implement it.
| Class | Kind | What it is |
|---|---|---|
PaymentOutcome |
constants |
PAID · AUTHORIZED · CAPTURE_REQUIRED · THREE_DS_PENDING · REFUNDED · FAILED; isValid()
|
AuthorizationType |
constants |
PRE_AUTHORIZATION · FINAL_AUTHORIZATION; isValid() is case-insensitive |
OperationData |
validating |
operationId, execCode, outcome, amount, orderId. Throws InvalidOperationDataException on empty ids/code, negative amount or an unknown outcome. Built by WebhookNotificationHelper::parse() and persisted by IPaymentRepository
|
TokenOutput |
validating |
accessToken, expiresIn, tokenType, optional idToken. Throws InvalidTokenException. idToken is the OIDC ID token of an openid-scoped authorization-code exchange (PRE-3631); null for client credentials |
AuthorizationRequestOutput |
plain |
url, state, codeVerifier from buildAuthorizationUrl()
|
PaymentOutput |
plain |
status, body, redirectHtml (3DS page to inject), redirectUrl (raw mode only), aliasId, and for authorizations maxCaptureDate, remainingCapturableAmount
|
CaptureOutput |
plain |
status, body, capturedAmount, requestedAmount, maxCaptureDate, derived remainingCapturableAmount
|
CancellationOutput |
plain |
status, body, cancelledAmount, requestedAmount, derived remainingCancellableAmount
|
"Validating" objects reject bad data in their constructor and must be built only from data that has
already crossed the library boundary. "Plain" ones are produced internally and hold no validation.
The derived remaining… amounts are hints, not authoritative — see
Capturing or cancelling.
Dto/ holds what your plugin builds. Construction does no validation; that is a separate step
(Validators).
| DTO | Purpose |
|---|---|
CommonFieldsDto |
Fields common to every payment method |
HostedFieldDto |
Hosted-fields payment: hfToken, optional recurringMode, paymentMethod
|
PaymentDto |
Payment with an existing alias: aliasId, recurringMode
|
BrowserDto |
ip, referrer, userAgent. All required together; improves the odds of a frictionless 3DS |
CustomerDto |
id, email. Required together |
BillingDto / ShippingDto
|
Optional blocks on CommonFieldsDto; every field optional |
AddressDto · ContactDto · ShippingScheduleDto
|
Building blocks of the two above |
$common = new CommonFieldsDto($accountId, $amountInCents, 'EUR', $orderId);
$common->description = 'Order #456';
$common->notificationUrl = 'https://merchant.example.com/webhook';
$common->successUrl = '…'; // 3DS return URLs
$common->cancelUrl = '…';
$dto = new HostedFieldDto($common, $hfToken, null, $browser, $customer);
$dto = new PaymentDto($common, $aliasId, 'ONE_CLICK', $browser, $customer);CommonFieldsDto rules worth remembering:
-
accountId,amount(integer cents),currencyandorderIdare required. Convert withAmountHelperwith the order's currency, never* 100. -
descriptionis always sent,nullincluded (the API rejects a missing key). Every other optional property is omitted when unset. -
capture = falsemakes an authorization-only hold.partialAuthorizationandauthorizationTypeare only valid with it; the validator rejects them otherwise. -
BrowserDto::toArray()sends0.0.0.0in place of any IPv6 address, because the API capsbrowser.ipat IPv4 length (PRE-3713). The$ipproperty is unchanged.
recurringMode is 'ONE_CLICK' or 'SUBSCRIPTION'. Breaking note: it became the third
HostedFieldDto constructor parameter at PRE-3590, so an old 5-argument call passing $browser
third now passes it as recurringMode and fails validation.
Saving a card as an alias: set paymentMethod['saveFutureUsage'] = true with a recurringMode and
paymentMethod['details']['fullName'] (the API silently rejects an alias creation without it), then
persist PaymentOutput::$aliasId (read from paymentMethod.storedId, falling back to paymentMethod.id). For a payment made with an existing alias the current API no longer echoes it back, so aliasId is null there: keep using the alias you sent. Billing/shipping ContactDto fields are flattened into their
parent, AddressDto is nested under address. Full flows: How to implement Hosted
Fields and alias payments.
createPayment() picks and runs the right validator before any network call, so you rarely call
them yourself. Each wraps CommonFieldsDtoValidator and re-throws as its own type, so a caller
catches one exception per DTO.
| Validator | Throws | Checks beyond the common fields |
|---|---|---|
CommonFieldsDtoValidator |
InvalidCommonFieldsException |
accountId/orderId/currency non-empty, amount not negative, valid authorizationType, no authorizationType/partialAuthorization when capture is true |
HostedFieldDtoValidator |
InvalidHostedFieldException |
hfToken non-empty; no paymentMethod.id, storedId or hfToken (the token is merged in for you); details.fullName non-empty when saveFutureUsage is true (read with FILTER_VALIDATE_BOOLEAN, so "1" counts) |
PaymentDtoValidator |
InvalidPaymentException |
aliasId and recurringMode non-empty; no paymentMethod.id or storedId (both merged in for you); no saveFutureUsage key at all |
Private to the DTOs, listed because they decide a request body's shape. BuildsCommonPayloadBody
builds the skeleton shared by every payment method (account, amount, currency, order, description,
capture, the payment-method fields, optional blocks, redirect, and partialAuthorization /
operation.authorizationType). OmitsNullPropertiesFromArray is the shared toArray() for
AddressDto, ContactDto and ShippingScheduleDto.
Stateless static helpers in Utilities/Helpers/.
| Helper | Purpose |
|---|---|
AmountHelper |
toCents(float $amount, string $currency, int $roundMode) / fromCents(int $cents, string $currency). The currency (ISO 4217 alpha-3) is required: zero-decimal currencies (JPY, XOF, …) convert with a factor of 1, all others with 100. Empty or malformed codes and 3-decimal currencies (BHD, KWD, TND, …) throw InvalidCurrencyException. Rounds before casting, since 19.99 * 100 is 1998.9999999999998. Pass the CMS's rounding mode (e.g. PrestaShop's PS_ROUND_MODE) to resolve half-minor-unit cases |
PhoneHelper |
toE164($number, $country) and isMobile(...), backed by giggsey/libphonenumber-for-php. Country is ISO 3166-1 alpha-2 (GB, not UK). Throws InvalidPhoneNumberException
|
PkceHelper |
generateCodeVerifier(), deriveCodeChallenge() (S256 only), generateState()
|
Assert |
notEmpty / notNegative / positive / paymentMethodIdNotSet; the caller supplies the exception class |
ExecCodeMapper |
toPaymentOutcome($execCode): "0000" → PAID, "0001" → THREE_DS_PENDING, anything else → FAILED. Shared by payment creation and webhooks |
WebhookNotificationHelper |
verifySignature() and parse(), see Webhooks
|
$client = new OAuth2Client($httpClient, $baseUrl, $callbackUrl, 'payments', $identityUrl);
// Interactive merchant connection (never redirects for you)
$request = $client->buildAuthorizationUrl($clientId); // AuthorizationRequestOutput
$token = $client->exchangeAuthorizationCode($clientId, $code, $codeVerifier); // TokenOutput
// Background calls
$token = $client->getClientCredentialsToken($clientId, $clientSecret);
$tokenManager = new TokenManager($tokenCache, $client);
$jwt = $tokenManager->getValidToken($clientId, $clientSecret); // cached, ready for a header
$jwt = $tokenManager->refreshToken($clientId, $clientSecret); // after the API rejected a tokenOAuth2Client has no cache of its own. TokenManager caches through ITokenCache and
refreshToken() drops the cached entry before minting a replacement.
Every service takes the same five arguments:
IUnifiedApiHttpClient $httpClient, TokenManager $tokenManager, string $baseUrl, string $clientId, string $clientSecret.
AbstractUnifiedApiService supplies the JWT, the one-time 401 retry and response normalization.
UnifiedApiPaymentService is where every payment concern lives:
| Method | Does | Returns |
|---|---|---|
getPayment($id) |
Fetch a payment. Currently 404s on staging since the API contract change (surfaces as PaymentNotFoundException); until the API team confirms, poll getOperation() with operationIds[0] from the creation response |
['status', 'body'] |
getOperation($id) |
Fetch an operation from the public endpoint. Webhook-shaped, so useful as a polling fallback for a lost webhook | ['status', 'body'] |
createPayment($dto) |
Create a payment or authorization from a HostedFieldDto or PaymentDto
|
PaymentOutput |
createRefund(...) |
Full or partial refund | ['status', 'body'] |
capturePayment(...) |
Capture an authorization, in full or part | CaptureOutput |
cancelPayment(...) |
Void an authorization, in full or part | CancellationOutput |
createPayment() validates, builds the body from $dto->createPayloadBody() and POSTs it. An
unrecognized PaymentRequestPayload throws \LogicException rather than skipping validation.
accountId lives on CommonFieldsDto, not the service, because it describes one payment, not the
connection.
$service->createRefund($operationId, $accountId, $orderId, $description,
null, $amountInCents, $currency);$operationId is the payment's id. orderId and description are required and checked
locally (InvalidRefundRequestException); a non-positive amount throws RefundAmountException;
omitting $amountInCents refunds everything remaining; an amount above what was captured is left
for the API to reject. currency has been available on this endpoint since 2026-09-04. Evidence and checklist: How to implement refunds.
An authorization is createPayment() with CommonFieldsDto::$capture = false. You then settle or
release it:
$capture = $service->capturePayment($paymentId, $accountId, $orderId, $description, $amount, $extraData, $currency);
$cancel = $service->cancelPayment($paymentId, $accountId, $orderId, $description, $amount, $extraData, $currency);A null amount captures or cancels everything remaining. Several partial captures are possible
only where the account/processor allows it. Partial cancellation needs to be enabled on the
contract. $currency is required whenever a capture has an $amount. Full flow:
How to implement captures and cancellations.
Do not treat the derived
remaining…amounts as authoritative. The cancellation figure ignores earlier captures, and the capture figure rests on two assumptions never confirmed against a real success response. Keep your own running balance.
The response is classified in this order: 404 → PaymentNotFoundException; 409 →
OperationConflictException; message contains "duplicate" → MultipleCaptureNotAllowedException
(capture) or OperationConflictException (cancel); execCode starting with 4 →
CardOperationException (issuer refusal); then message keywords ("expired", "not voidable", "not
capturable", "already captured", "already cancelled/voided", "exceed") → the matching exception;
partial cancel with errorCategory = INVALID_REQUEST → PartialCancellationNotAllowedException;
anything else → ApiException. UPC cannot make these calls idempotent (it holds no state), but the
distinct types let your own idempotency layer no-op a replay.
Known gap: a 2xx with non-terminal
execCode0002/0003falls through toApiException. Retrying after it risks a double operation once the pending one resolves.
Fetches an operation from the internal endpoint (/processing-operations/operations/{id}),
throwing OperationNotFoundException on a 404. Treat it as unverified: that path returns HTTP
403 for a merchant's own client credentials in staging. UnifiedApiPaymentService::getOperation()
is the path confirmed working.
Every exception extends PayplugException and carries the HTTP status as its code (0 only when
the response shape was unusable). Catch PayplugException for anything UPC raises.
Validation — raised before any network call, so catching one means nothing was sent:
| Exception | Raised by |
|---|---|
InvalidCommonFieldsException / InvalidHostedFieldException / InvalidPaymentException
|
the three DTO validators |
InvalidRefundRequestException / RefundAmountException
|
createRefund(): empty orderId/description; non-positive amount |
InvalidCaptureRequestException / CaptureAmountException
|
capturePayment(): same, plus an amount without a currency |
InvalidCancellationRequestException / CancellationAmountException
|
cancelPayment(): empty orderId/description; non-positive amount |
InvalidOperationDataException / InvalidTokenException / InvalidPhoneNumberException / InvalidNotificationException
|
OperationData, TokenOutput, PhoneHelper, WebhookNotificationHelper
|
InvalidCurrencyException |
AmountHelper: empty or non-ISO currency code, or a 3-decimal currency |
API outcomes:
| Exception | Meaning |
|---|---|
PaymentNotFoundException |
404 on getPayment / createRefund / capturePayment / cancelPayment
|
OperationNotFoundException |
404 on UnifiedApiOperationService::getOperation()
|
CardOperationException |
Issuer refused |
OperationConflictException |
409, or a duplicate on cancel: a concurrent or repeated request |
MultipleCaptureNotAllowedException |
A later capture on an authorization that allows only one |
AuthorizationExpiredException |
Captured after maxCaptureDate
|
PaymentAlreadyCapturedException / PaymentAlreadyCancelledException
|
Operation already applied |
PaymentNotCapturableException / PaymentNotVoidableException
|
Payment's state doesn't allow it |
AmountExceedsAvailableException |
Amount above what remains |
PartialCancellationNotAllowedException |
Partial cancel on a contract without it |
ApiException |
Any other non-2xx, or a malformed HTTP-client response |
PaymentNotFoundException and OperationNotFoundException are siblings of ApiException, not
subclasses, so catching ApiException alone won't catch them. A 401 is retried once with a
fresh JWT; only a second 401 throws.
try {
$response = $service->getPayment($paymentId);
} catch (PaymentNotFoundException $e) {
// $e->getCode() === 404
} catch (ApiException $e) {
// 503, 500, … or 0 for an unusable response
}$expected = $configurationRepository->get('payplug_webhook_authorization_header');
$operation = WebhookNotificationHelper::parse($headers, $rawBody, $expected);
if ($operation->outcome !== PaymentOutcome::THREE_DS_PENDING) {
$paymentRepository->save($operation);
$orderStateMutator->apply($operation->orderId, $operation->outcome);
}-
Verification.
verifySignature()does a constant-time comparison of theAuthorizationheader against the expected value and throwsInvalidNotificationExceptionon a mismatch. The platform has no HMAC over the body, only a shared secret. When the expected value is empty, verification is skipped and the notification accepted: a deliberate, known-temporary trade-off, since no merchant can currently configure a webhook secret. Revisit once that lands. -
THREE_DS_PENDINGis "no new information". Payplug has been seen sending anexecCode "0001"notification before the final one for the same operation. Do not callIPaymentRepository::markTreated()for it, or the final notification is blocked for good. A later webhook resolves it when you callparse()again.
Full handling guide, including idempotency: How to handle webhooks.
src/ and tests/ must not use PHP syntax newer than 7.1 (no typed properties, arrow
functions, constructor promotion, match, enum), because the shipped code runs on older hosts. A
CI job lints every file with php -l across PHP 7.1–8.2, and make verify-71 boots a real
--no-dev vendor tree under a PHP 7.1 interpreter. Run it after touching any dependency version.
Details, including why platform-check is disabled, are in
Architecture.
MIT
Core foundations shared library for Payplug e-commerce plugins
Guides
How-to guides
- Overview
- How to wire the library
- How to implement Hosted Fields
- How to implement alias payments
- How to handle webhooks
- How to implement refunds
- How to implement captures and cancellations