-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Core foundations shared library for Payplug e-commerce plugins (e.g. PrestaShop).
This is a PHP library that provides shared building blocks — contracts, exceptions, models, and
helper utilities — used across Payplug's e-commerce plugins. It is distributed via Composer for
plugin development, but the shipped code is bundled directly into plugin ZIPs (no live vendor/
install on the merchant's server).
This page is the API reference — what each class is and what it does. For step-by-step, end-to-end integration guides (Hosted Fields, alias payments, webhooks, refunds), see How-to guides.
- Docker — the only local requirement; PHP and Composer run inside a dev container, no local PHP install needed.
- The library targets PHP 7.1+ at runtime.
composer.json'srequire.php(>=7.4) reflects the build-tooling floor for consuming plugins, not the runtime the shipped code executes on.
make install
Builds the dev Docker image (PHP 7.4-cli + Composer) and runs composer install inside it.
| Command | Description |
|---|---|
make install |
Install dependencies + git hooks |
make test |
Run the unit PHPUnit suite |
make test-integration |
Run the integration PHPUnit suite; requires VPN access plus UPC_IT_* credentials in a local .env (copy .env.example) — tests skip themselves when unset |
make coverage |
Run tests with a Clover coverage report at build/logs/clover.xml (feeds SonarCloud) |
make stan |
PHPStan static analysis (level 8) |
make cs-lint |
Code style check (dry-run) |
make cs-fix |
Code style auto-fix |
make quality |
cs-lint + stan + test
|
make shell |
Interactive shell in the dev container |
make verify-71 |
Proves the PHP 7.1 runtime floor actually holds — see Compatibility |
PSR-4 autoloaded under the PayplugUnifiedCore\ namespace, rooted at src/:
src/
├── Auth/ OAuth2/PKCE + client-credentials authentication
├── Contracts/ interfaces defining the boundary with each consuming CMS plugin
├── DataValues/ durable, direction-agnostic value types (PaymentOutcome, OperationData)
├── Dto/ caller-built input to a payment-creation call
├── Exceptions/ exception types
├── Output/ value objects produced internally by a UPC method
├── Services/ clients against Payplug APIs (e.g. the Unified API)
├── Traits/ shared payload-building behaviour composed into Dto/ classes
├── Validators/ validate a Dto before it's used by a Services/ class
└── Utilities/
└── Helpers/ stateless helper functions
Tests live under tests/ (namespace PayplugUnifiedCore\Tests\), mirroring this same layout.
Models/ no longer exists as a category — it was split into DataValues/ and Output/ once
distinguishing "durable value with its own lifecycle" from "caller-built input" from
"internally-produced output" stopped being a distinction worth blurring. Dto/ and Validators/
were similarly split out of Models//Utilities/Helpers/ once more than one DTO/validator was
expected.
src/Contracts/ holds the 8 interfaces that define the boundary between this library and each
consuming CMS plugin (first real consumer: UHF/Sylius) — designed around what a CMS needs to
provide, not the not-yet-built Unified API's shape. Each ships with a docblock sketching a Sylius
and a WooCommerce implementation; this library itself contains no concrete implementations.
-
ILogger— structured logging sink (debug/info/error), decoupled from any CMS's native logger. -
IConfigurationRepository— OAuth2 client credentials and Hosted Fields public key material, sourced from each CMS's own settings storage. -
IPaymentRepository— persistsOperationDataand tracks webhook processing state for idempotency. -
IOrderStateMutator— applies aPaymentOutcometo the CMS-native order, identified by order ID (not a CMS-native object, since Sylius and WooCommerce orders share no common type). -
ILock— per-operation mutex preventing a retried webhook from being processed concurrently with itself. -
ITokenCache— caches the OAuth2 JWT this library uses against the Unified API. -
IOAuthHttpClient— narrow HTTP contract for OAuth2 token exchange only (not a general-purpose Unified API HTTP client, which is separate). -
IUnifiedApiHttpClient— narrow HTTP contract for the Unified API:get()for reading resources (payment/operation retrieval) andpostJson()for creating them (hosted-fields payment creation). Kept distinct fromIOAuthHttpClientsince token exchange (POST + form-encoded) and Unified API calls (GET/POST + bearer token + JSON) are different enough shapes that sharing one contract would blur both.
PaymentRequestPayload also lives in src/Contracts/ but is a different kind of contract — not a
CMS boundary a plugin implements, but the internal type
UnifiedApiPaymentService::createPayment() depends on instead of a concrete DTO. Both
HostedFieldDto and PaymentDto implement its single createPayloadBody(): array method, which
is what lets one method accept either without a native PHP union type (unsupported at this repo's
PHP 7.1 floor). A CMS plugin never implements it.
PayplugUnifiedCore\Exceptions\PayplugException is the base type for every exception this
library throws — catch it instead of a generic \Exception to handle any error raised by this
package. Thirteen domain-specific subtypes let callers catch more precisely:
-
RefundAmountException— thrown byUnifiedApiPaymentService::createRefund()on a zero/negative refund amount (see Services) -
InvalidRefundRequestException— thrown by the same method on an emptyorderId/description -
PaymentNotFoundException— thrown on a 404 bygetPayment()/createRefund() InvalidPhoneNumberExceptionCardOperationExceptionApiExceptionInvalidOperationDataExceptionInvalidTokenException-
InvalidNotificationException— thrown byWebhookNotificationHelper(see Webhooks) -
InvalidHostedFieldException— thrown byHostedFieldDtoValidator(see Validators) -
InvalidPaymentException— thrown byPaymentDtoValidator(see Validators) -
InvalidCommonFieldsException— thrown byCommonFieldsDtoValidator(see Validators) -
OperationNotFoundException— thrown byUnifiedApiOperationService(see Services; that service is currently unverified in practice, see the caveat there)
Each behaves like a standard PHP exception: new SomeException($message, $code, $previous).
PayplugUnifiedCore\DataValues\ holds durable, direction-agnostic value types — not the input to
one specific call or the output of another, but data with a life of its own.
PayplugUnifiedCore\DataValues\PaymentOutcome expresses this library's payment result intent to
the CMS, decoupled from any CMS's native order-status vocabulary — a set of class constants (a PHP
7.1 stand-in for a PHP 8.1 enum):
use PayplugUnifiedCore\DataValues\PaymentOutcome;
PaymentOutcome::PAID; // 'paid'
PaymentOutcome::AUTHORIZED; // 'authorized'
PaymentOutcome::CAPTURE_REQUIRED; // 'capture_required'
PaymentOutcome::THREE_DS_PENDING; // 'three_ds_pending'
PaymentOutcome::REFUNDED; // 'refunded'
PaymentOutcome::FAILED; // 'failed'
PaymentOutcome::isValid('paid'); // true
PaymentOutcome::isValid('bogus'); // falsePayplugUnifiedCore\DataValues\OperationData is the persistence value object built from a Payplug
API response or webhook payload — its constructor is this library's validation boundary for that
data, throwing InvalidOperationDataException on an empty operationId/execCode/orderId, a
negative amount, or an outcome that isn't a PaymentOutcome constant:
use PayplugUnifiedCore\DataValues\OperationData;
use PayplugUnifiedCore\DataValues\PaymentOutcome;
$operation = new OperationData('op_123', '4001', PaymentOutcome::PAID, 4999, 'order_456');
$operation->operationId; // 'op_123'
$operation->execCode; // '4001'
$operation->outcome; // 'paid'
$operation->amount; // 4999 (cents)
$operation->orderId; // 'order_456'It's produced both by WebhookNotificationHelper::parse() (see Webhooks) and by
whatever IPaymentRepository implementation a CMS plugin persists/re-fetches it with — durable
state with a life beyond any single call is what keeps it in DataValues/ rather than Output/.
PayplugUnifiedCore\Output\ holds value objects produced entirely internally by a UPC method —
the opposite direction from Dto/ below, which the caller builds.
PayplugUnifiedCore\Output\TokenOutput is the validating value object for an OAuth2 token
response, constructed only from data that has already crossed the library's external boundary (an
OAuth2 token-endpoint response) — its constructor throws InvalidTokenException on an empty
accessToken/tokenType or a non-positive expiresIn:
use PayplugUnifiedCore\Output\TokenOutput;
$token = new TokenOutput('jwt-access-token', 3600, 'Bearer');
$token->accessToken; // 'jwt-access-token'
$token->expiresIn; // 3600
$token->tokenType; // 'Bearer'PayplugUnifiedCore\Output\AuthorizationRequestOutput is the output of
OAuth2Client::buildAuthorizationUrl() — the redirect URL plus the state/codeVerifier the
caller must persist (session) to complete the flow on callback. Unlike TokenOutput, its
constructor holds no validation, since it never crosses an external boundary itself:
use PayplugUnifiedCore\Output\AuthorizationRequestOutput;
$request = new AuthorizationRequestOutput($url, $state, $codeVerifier);
$request->url; // redirect the merchant's browser here
$request->state; // persist in session, compare on callback
$request->codeVerifier; // persist in session, needed for the token exchangePayplugUnifiedCore\Output\PaymentOutput is the output of
UnifiedApiPaymentService::createPayment() (see Services) — same
unvalidated-constructor reasoning as AuthorizationRequestOutput, since it's produced entirely
internally from a Unified API response already checked for a 2xx status:
use PayplugUnifiedCore\Output\PaymentOutput;
$output = $paymentService->createPayment($hostedFieldDto); // or $paymentDto
$output->status; // int HTTP status
$output->body; // raw JSON string from the Unified API
$output->redirectUrl; // string|null — set only in "raw" 3DS-pending mode
$output->redirectHtml; // string|null — decoded HTML to inject when 3DS is pending (the common case)
$output->aliasId; // string|null — the alias just created or reused, if anyBoth redirectUrl and redirectHtml are null when the payment was processed synchronously
(no 3DS challenge). redirectHtml is the "recommended for web" shape — a Base64-encoded
redirect.html block, already decoded here into the raw HTML the CMS plugin must inject into its
own page (it contains a form that auto-submits the end user to the bank's challenge page).
redirectUrl only appears when the request set card.threeDSecure.displayMode=raw. Neither field
maps to a PaymentOutcome — that happens later, via WebhookNotificationHelper (see
Webhooks), once the asynchronous confirmation arrives.
aliasId carries the Unified API's paymentMethod.id back to the caller: the alias just created
(a HostedFieldDto with paymentMethod.saveFutureUsage = true) or the one just reused (a
PaymentDto payment). It's null when the payment involved no alias at all. Persist it to offer
the customer a one-click payment later — see Dto.
This class was called
HostedPaymentOutputuntil PRE-3590, whencreateHostedPayment()on the now-removedUnifiedApiHostedPaymentServicebecameUnifiedApiPaymentService::createPayment(). Both the class and the method were renamed because aPaymentDto-based payment involves no hosted field at all.
PayplugUnifiedCore\Dto\ holds objects assembled by the CMS plugin itself as input to a
payment-creation call. Like Output/, construction holds no validation of its own — that's a
separate step, see Validators.
BrowserDto and CustomerDto hold end-user context reusable by any future payment-method DTO,
not just hosted-fields. All fields are required constructor parameters — the Unified API schema
requires them all-or-nothing whenever the parent object is sent at all, so a partial object simply
can't be constructed:
use PayplugUnifiedCore\Dto\BrowserDto;
use PayplugUnifiedCore\Dto\CustomerDto;
$browser = new BrowserDto($ip, $referrer, $userAgent);
$customer = new CustomerDto($customerId, $email);Sending browser is optional but strongly recommended whenever real end-user request data is
available: it's what lets the card network/issuer attempt a frictionless (challenge-free) 3DS flow
instead of always forcing one.
CommonFieldsDto holds the payment-creation fields common to every payment method:
use PayplugUnifiedCore\Dto\CommonFieldsDto;
$common = new CommonFieldsDto($accountId, $amountInCents, $currency, $orderId);
$common->description = 'Order #456';
$common->capture = true; // false for an authorization-only hold
$common->descriptor = 'MYSHOP'; // shown on the customer's bank statement
$common->notificationUrl = 'https://merchant.example.com/webhook';
$common->extraData = 'order_456'; // echoed back verbatim in the webhook
$common->successUrl = 'https://merchant.example.com/checkout/success'; // 3DS/SCA return URL
$common->cancelUrl = 'https://merchant.example.com/checkout/cancel'; // 3DS/SCA cancel URL
$common->billing = $billingDto; // optional, see "Billing and shipping" below
$common->shipping = $shippingDto; // optional, see "Billing and shipping" belowsubmerchantExternalId is the optional fifth constructor parameter
(new CommonFieldsDto($accountId, $amountInCents, $currency, $orderId, $submerchantExternalId)).
It's a routing identifier belonging to the PayPlug UDV/MID configuration for the payment's
currency, not to any payment method: the EUR configurations require one, the configurations
used for other currencies have none at all. Pass it when your MID configuration owns one, omit it
otherwise — sending a submerchant the configuration doesn't own is rejected by the API just as
firmly as omitting one it does. An empty string is treated exactly like null (omitted from the
request body), since a CMS reading an unset value out of its own settings storage yields '' far
more often than a real null.
It was a required constructor parameter until PRE-3645. Existing calls that pass one keep working unchanged; multi-currency callers can now stop passing one.
description is the odd one out among the optional properties: the Unified API rejects a request
missing that key entirely, so it's always sent, null included. Every other optional property
above is omitted from the request body when unset.
HostedFieldDto composes CommonFieldsDto/BrowserDto/CustomerDto plus the hosted-fields-
specific pieces (hfToken, recurringMode, paymentMethod), and builds the exact Unified API
request body via createPayloadBody():
use PayplugUnifiedCore\Dto\HostedFieldDto;
$dto = new HostedFieldDto($common, $hfToken, null, $browser, $customer, [
'details' => ['fullName' => 'Jane Doe', 'selectedBrand' => 'visa'],
]);
$body = $dto->createPayloadBody(); // array — what UnifiedApiPaymentService POSTsThe full signature is
__construct(CommonFieldsDto $common, string $hfToken, ?string $recurringMode = null, ?BrowserDto $browser = null, ?CustomerDto $customer = null, ?array $paymentMethod = null).
recurringMode was inserted as the third parameter at PRE-3590 — an existing 5-argument call
passing $browser third now passes it as recurringMode and will fail validation.
To also save the card as an alias for future payments, set paymentMethod.saveFutureUsage and
pass a recurringMode:
$dto = new HostedFieldDto($common, $hfToken, 'ONE_CLICK', $browser, $customer, [
'saveFutureUsage' => true,
'details' => ['fullName' => 'Jane Doe'], // required whenever saveFutureUsage is true
]);
$output = $paymentService->createPayment($dto);
$aliasId = $output->aliasId; // persist this to offer one-click laterrecurringMode is 'ONE_CLICK' or 'SUBSCRIPTION', and is only meaningful (and only sent)
alongside saveFutureUsage = true. paymentMethod.details.fullName is otherwise optional, but
the Unified API silently rejects an alias-creation request missing it — so
HostedFieldDtoValidator enforces it, but only once saveFutureUsage is true (see
Validators). Don't set paymentMethod['id'] here; that key belongs to
PaymentDto's flow and is rejected by the validator.
PaymentDto is HostedFieldDto's sibling for paying with an already-created alias — no
hfToken, no card data at all. It composes the same CommonFieldsDto/BrowserDto/CustomerDto
(a frictionless 3DS attempt on a saved alias still benefits from browser/customer context) and
implements the same PaymentRequestPayload contract, so createPayment() accepts either:
use PayplugUnifiedCore\Dto\PaymentDto;
$dto = new PaymentDto($common, $aliasId, 'ONE_CLICK', $browser, $customer);
$output = $paymentService->createPayment($dto);aliasId and recurringMode are both required here — an alias-based payment always declares
which mode it's running under. The optional sixth $paymentMethod array carries supplementary
card metadata (e.g. overriding the alias's saved brand); createPayloadBody() merges $aliasId
into paymentMethod.id on your behalf, so don't set that key yourself. saveFutureUsage is
rejected outright — creating an alias while paying with one isn't supported.
CommonFieldsDto's optional $billing/$shipping properties carry the payment's billing and
shipping blocks. Unlike BrowserDto/CustomerDto, none of these fields are required together, so
every constructor parameter defaults to null and each toArray() omits whichever are still
unset:
use PayplugUnifiedCore\Dto\AddressDto;
use PayplugUnifiedCore\Dto\BillingDto;
use PayplugUnifiedCore\Dto\ContactDto;
use PayplugUnifiedCore\Dto\ShippingDto;
use PayplugUnifiedCore\Dto\ShippingScheduleDto;
$address = new AddressDto('1 rue de la Paix', 'Paris', 'FR', null, '75002');
$contact = new ContactDto('Jane', 'Doe', '+33123456789', '+33612345678');
$common->billing = new BillingDto($address, $contact, 'MRS');
$common->shipping = new ShippingDto(
$address,
$contact,
'jane@example.com',
'ACME Ltd',
new ShippingScheduleDto($addressType, $timeFrame, $addressDate)
);-
AddressDto(line,city,country,state,zipCode) is nested under an"address"key by both parents — it's the one sub-object the Unified API really does nest. -
ContactDto(firstName,lastName,phone,mobilePhone) is the field groupBillingDtoandShippingDtoshare. It is flattened into its parent, not nested: the API has nocontactsub-object, these are flat sibling fields ofbilling/shippingon the wire. -
ShippingScheduleDto(addressType,timeFrame,addressDate) groups the delivery-scheduling fields, also flattened intoshipping. -
BillingDtoaddstitle("MR","MRS", or"MISS");ShippingDtoaddsemailandcompanyName.
ContactDto is distinct from CustomerDto despite the overlap in spirit: CustomerDto
(id/email, both required together) identifies the payer for the risk/3DS context sent at the
body's top level, while ContactDto is contact detail nested inside these two optional blocks. A
new payment method needing "who is paying" should compose CustomerDto.
PayplugUnifiedCore\Validators\ validates a Dto/ object before a Services/ class uses it —
validation is a deliberate separate step from construction, not folded into the DTO's constructor.
CommonFieldsDtoValidator::validate(CommonFieldsDto $dto): void checks accountId/orderId/
currency non-empty and amount not negative, throwing InvalidCommonFieldsException on the
first problem found. Reusable by any future payment-method DTO that composes a CommonFieldsDto.
Its validateOrWrap($dto, $exceptionClass) variant runs the same checks but re-throws as
$exceptionClass, preserving the message and the original exception as $previous — that's how
each payment-method validator below keeps callers down to a single exception type.
HostedFieldDtoValidator::validate(HostedFieldDto $dto): void delegates the common fields that
way (wrapping into InvalidHostedFieldException), then checks the hosted-fields specifics:
use PayplugUnifiedCore\Exceptions\InvalidHostedFieldException;
use PayplugUnifiedCore\Validators\HostedFieldDtoValidator;
try {
HostedFieldDtoValidator::validate($dto);
} catch (InvalidHostedFieldException $e) {
// reject the request, log $e->getMessage()
}-
hfTokenmust be non-empty — a hosted-fields payment can't exist without one. -
paymentMethodmust not set anidkey; that identifier belongs toPaymentDto. -
paymentMethod.details.fullNamemust be a non-empty string wheneverpaymentMethod.saveFutureUsageis true. The field is optional for a plain payment, but the Unified API silently rejects an alias-creation request missing it (confirmed against a real staging failure), so this is the onepaymentMethodsub-field the validator enforces.saveFutureUsageis read throughfilter_var(..., FILTER_VALIDATE_BOOLEAN)rather than a strict=== true, so a CMS handing back"1"or1from form data still triggers the check instead of silently skipping it.
PaymentDtoValidator::validate(PaymentDto $dto): void is the sibling for the alias-payment flow,
wrapping the common fields into InvalidPaymentException and then checking:
-
aliasIdandrecurringModeboth non-empty. -
paymentMethodmust not set anidkey —createPayloadBody()mergesaliasIdin itself. -
paymentMethodmust not set asaveFutureUsagekey at all; creating an alias while paying with one isn't supported. The key is rejected regardless of its value, not just when truthy.
The old browser/customer "all sub-fields present together" checks are gone from both — now
that those are typed BrowserDto/CustomerDto objects rather than loose arrays, a partial one
can't be constructed in the first place.
In practice you don't need to call either validator yourself —
UnifiedApiPaymentService::createPayment() picks the right one for the DTO it was handed and runs
it before any network call.
PayplugUnifiedCore\Traits\ holds behaviour composed into the Dto/ classes rather than
duplicated across them. You never use these directly — they're private methods on whichever DTO
uses them — but they're where a payment request body's exact shape is decided.
BuildsCommonPayloadBody builds the request body skeleton shared by every payment method:
account/submerchantExternalId/amount/currency/orderId/description/capture, then the
calling DTO's own payment-method-specific fields (hfToken; paymentMethod/recurringMode),
then the optional browser/customer/descriptor/notificationUrl/extraData/billing/
shipping fields and a redirect object carrying whichever of successUrl/cancelUrl was set.
Both HostedFieldDto::createPayloadBody() and PaymentDto::createPayloadBody() are thin wrappers
over it. Two deliberate asymmetries live here: description is sent unconditionally, null
included (the Unified API rejects a request missing the key, despite its own docs calling the field
optional), while submerchantExternalId is omitted entirely when null or ''.
OmitsNullPropertiesFromArray is the shared toArray() body for the small value objects whose
fields are plain public scalars keyed by their own property name — AddressDto, ContactDto,
ShippingScheduleDto. It dumps the class's declared public properties in declaration order and
drops the nulls. BillingDto/ShippingDto can't use it: they rename and nest keys (address)
and flatten composed DTOs into themselves, so a straight property dump doesn't apply.
It's a trait rather than a shared abstract base class in both cases because the using classes are
otherwise unrelated final classes with no other reason to share a type hierarchy.
PayplugUnifiedCore\Utilities\Helpers\AmountHelper converts amounts between a major-unit float
(e.g. a plugin's cart or order total) and the integer number of cents the Payplug API expects:
use PayplugUnifiedCore\Utilities\Helpers\AmountHelper;
AmountHelper::toCents(49.99); // 4999
AmountHelper::fromCents(4999); // 49.99toCents() corrects the classic floating-point imprecision (19.99 * 100 evaluates to
1998.9999999999998 in raw PHP) by rounding before casting to int. For CMS platforms where the
merchant can configure their own rounding algorithm (e.g. PrestaShop's PS_ROUND_MODE), pass the
resolved mode explicitly — it only changes the result for amounts landing exactly on a half-cent
boundary:
AmountHelper::toCents(19.995, PHP_ROUND_HALF_EVEN); // 2000
AmountHelper::toCents(19.995, PHP_ROUND_HALF_DOWN); // 1999PayplugUnifiedCore\Utilities\Helpers\PhoneHelper normalizes a customer-entered phone number to
E.164 (the format the Payplug API expects) and determines whether it's a mobile line, backed by
giggsey/libphonenumber-for-php:
use PayplugUnifiedCore\Utilities\Helpers\PhoneHelper;
PhoneHelper::toE164('06 12 34 56 78', 'FR'); // "+33612345678"
PhoneHelper::isMobile('06 12 34 56 78', 'FR'); // true$countryCode is a 2-letter ISO 3166-1 alpha-2 region code (the UK's is GB, not UK). Invalid
or unparseable input throws InvalidPhoneNumberException from both methods.
PayplugUnifiedCore\Utilities\Helpers\PkceHelper generates the PKCE material for the
authorization-code flow:
use PayplugUnifiedCore\Utilities\Helpers\PkceHelper;
$codeVerifier = PkceHelper::generateCodeVerifier();
$codeChallenge = PkceHelper::deriveCodeChallenge($codeVerifier); // S256 only
$state = PkceHelper::generateState();PayplugUnifiedCore\Utilities\Helpers\Assert holds the shared "field must not be empty" / "must
not be negative" / "must be positive" checks used internally by CommonFieldsDtoValidator,
OperationData, and TokenOutput — each caller supplies its own exception class so the thrown
type still matches its existing contract:
use PayplugUnifiedCore\Utilities\Helpers\Assert;
Assert::notEmpty($value, 'fieldName', SomeException::class); // throws if $value === ''
Assert::notNegative($value, 'fieldName', SomeException::class); // throws if $value < 0
Assert::positive($value, 'fieldName', SomeException::class); // throws if $value <= 0
// the same idea for a disallowed array key rather than a scalar field:
Assert::paymentMethodIdNotSet($paymentMethod, 'PaymentDto', SomeException::class);paymentMethodIdNotSet() is the shared runtime guard behind the "paymentMethod must not set
id directly" rule both HostedFieldDtoValidator and PaymentDtoValidator enforce; the third
argument is the hint naming what to use instead.
PayplugUnifiedCore\Auth\OAuth2Client implements the OAuth2/PKCE and client-credentials flows
against the identity provider. It has no caching of its own and never calls header() — the
caller performs the actual redirect:
use PayplugUnifiedCore\Auth\OAuth2Client;
$client = new OAuth2Client($httpClient, 'https://api.payplug.com', 'https://merchant.example.com/callback', 'payments', 'https://www.payplug.com');
// Interactive merchant connection:
$authorizationRequest = $client->buildAuthorizationUrl($clientId); // AuthorizationRequestOutput
// redirect to $authorizationRequest->url; persist ->state and ->codeVerifier in session
// On the callback, after checking the returned state matches:
$token = $client->exchangeAuthorizationCode($clientId, $code, $codeVerifier); // TokenOutput
// Background API calls:
$token = $client->getClientCredentialsToken($clientId, $clientSecret); // TokenOutputPayplugUnifiedCore\Auth\TokenManager wraps the client-credentials flow with caching, for
background API calls that shouldn't hit the identity provider on every request:
use PayplugUnifiedCore\Auth\TokenManager;
$tokenManager = new TokenManager($tokenCache, $client);
$accessToken = $tokenManager->getValidToken($clientId, $clientSecret); // string JWT, ready for an Authorization headerrefreshToken() is the escape hatch for a caller holding a token the API just rejected — it drops
the cached entry and mints a replacement, so a token invalidated before its cache TTL expires
(rotated secret, revoked grant, clock skew) doesn't keep failing every call:
$accessToken = $tokenManager->refreshToken($clientId, $clientSecret); // bypasses the cachePayplugUnifiedCore\Services\AbstractUnifiedApiService holds the mechanics shared by every
concrete service below: resolving a client-credentials JWT via TokenManager, retrying a request
exactly once on a 401 with a freshly minted token, and normalizing IUnifiedApiHttpClient's
response shape. Every service takes the same five constructor arguments
(IUnifiedApiHttpClient $httpClient, TokenManager $tokenManager, string $baseUrl, string $clientId, string $clientSecret)
and shares its error-handling conventions — you don't use this class directly, but its behavior
applies to both services below.
PayplugUnifiedCore\Services\UnifiedApiPaymentService is where every payment concern lives:
reading a payment or operation, creating a payment, and refunding one.
use PayplugUnifiedCore\Services\UnifiedApiPaymentService;
$service = new UnifiedApiPaymentService($httpClient, $tokenManager, 'https://api.payplug.com', $clientId, $clientSecret);
UnifiedApiHostedPaymentServiceno longer exists. It was removed at PRE-3590 and itscreateHostedPayment()becamecreatePayment()on this service — aPaymentDto-based payment involves no hosted field at all, which made both the separate service and the old method name misleading for that flow.
getPayment() and getOperation() return the raw HTTP response — a parsed payment data model is
separate, future scope:
$response = $service->getPayment('5298ff38-883a-465f-b759-aec78cee203e');
$response['status']; // 200
$response['body']; // raw JSON string from the Unified API
$response = $service->getOperation($operationId); // the public operation endpointgetOperation() hits the public operation endpoint (/processing-operations/operations/public/{id}).
Its response is a flat, webhook-shaped payload (id/execCode/orderId/amount) — the same
shape WebhookNotificationHelper::parse() turns into an OperationData (see
Webhooks) — which makes it useful as a polling fallback for a delayed or lost webhook,
e.g. to resolve a stuck PaymentOutcome::THREE_DS_PENDING.
createPayment(PaymentRequestPayload $dto): PaymentOutput POSTs to
/api/payment-gateway/payments. It accepts either DTO that implements the contract — a
HostedFieldDto (hfToken-driven, optionally also creating an alias) or a PaymentDto (paying with
an existing alias, no card data at all):
$output = $service->createPayment($hostedFieldDto); // or $paymentDto — PaymentOutput, see Output above
$output->redirectHtml; // inject into your page when 3DS is pending
$output->aliasId; // persist when you asked for an aliasBoth DTOs hit the same endpoint with the same request/response shape, so one method covers both.
It validates first, before any network call — HostedFieldDtoValidator or PaymentDtoValidator
depending on the DTO handed in (see Validators), throwing
InvalidHostedFieldException or InvalidPaymentException respectively. A
PaymentRequestPayload implementation the method doesn't recognize throws \LogicException: every
implementation must be validated before use, so an unwired one is a programming error rather than
a recoverable condition.
The request body is built entirely by $dto->createPayloadBody() — every field it needs already
lives on the DTOs, so the service constructs nothing itself. That includes accountId, which
lives on CommonFieldsDto rather than the service's constructor: it's data about this specific
payment request, not shared connection configuration, and has no relationship to the OAuth2
clientId/clientSecret pair.
createRefund() creates a full or partial refund. Omit $amount to refund the payment's full
remaining amount:
$response = $service->createRefund(
$operationId, // the payment's own id
$accountId,
$orderId, // required
$description, // required
$submerchantExternalId, // optional — pass null when the MID configuration owns none
$amountInCents, // optional — null refunds everything remaining
$currency // optional
);
$response['status']; // 200
$response['body']; // raw JSON string from the Unified APIThe refund is keyed by the payment's own id — the same value this library's OperationData/webhook
vocabulary already calls operationId, which is why that's the parameter name here rather than a
second name for the same thing.
orderId and description are both genuinely required, and are checked locally before any HTTP
call — ApiException carries only the HTTP status, not the API's own response body naming which
field was missing, so a local check gives a far more useful InvalidRefundRequestException
message. Both were confirmed required by probing each field's absence individually against the real
staging API (2026-08-27), not merely by reading the GitBook doc.
submerchantExternalId and currency are optional, and each is sent only when non-null and
non-empty:
-
submerchantExternalIdmust mirror the payment being refunded. It belongs to the MID configuration for the payment's currency: refunding a EUR payment (whose configuration owns a submerchant) without it fails with 400The parameter "subMerchantExternalId" is missing., while refunding a non-EUR payment with it fails with 400Invalid parameter.and succeeds when it's omitted (staging, 2026-09-04). When sent, the API validates the lower-casesubmerchantExternalIdkey despite its own error text capitalizing it. -
currencyonly became available on this endpoint on 2026-09-04. Before that an$amounttravelled bare and the platform inferred what those minor units meant — harmless while every payment was EUR, ambiguous for a multi-currency merchant. Passingnullor''falls back to that same inference mode rather than putting""on the wire for the API to reject.
Caveat on the evidence: the 2026-09-04 staging run that first succeeded changed both
submerchantExternalIdandcurrencyat once, so which of the two the earlierInvalid parameter.referred to was never isolated.
A $amount that is zero or negative throws RefundAmountException. An amount exceeding what was
captured is not checked here — the Unified API rejects that itself, so the check isn't duplicated.
PayplugUnifiedCore\Services\UnifiedApiOperationService fetches a single operation from the
internal operation endpoint (/processing-operations/operations/{id}), throwing
OperationNotFoundException on a 404:
use PayplugUnifiedCore\Services\UnifiedApiOperationService;
$service = new UnifiedApiOperationService($httpClient, $tokenManager, 'https://api.payplug.com', $clientId, $clientSecret);
$response = $service->getOperation($operationId);Caveat: this private-endpoint path has been observed returning HTTP 403 for a merchant's own client credentials in staging — i.e. it does not currently work for the only credential type this library uses.
UnifiedApiPaymentService::getOperation()above, against the public endpoint, is the path actually confirmed working end-to-end. TreatUnifiedApiOperationServiceas unverified/likely non-functional until a follow-up resolves this.
Shared across both services:
-
404 on
getPayment()andcreateRefund()throwsPaymentNotFoundException; onUnifiedApiOperationService::getOperation()it throwsOperationNotFoundException. Both are siblings ofApiException, not subclasses, so catchingApiExceptionalone will not catch them.UnifiedApiPaymentService::getOperation()'s 404 is not special-cased — it throws the genericApiException, since a caller polling it as a webhook fallback treats every failure the same way. - 401 is retried once with a freshly minted JWT (the cached one is discarded first); only a second 401 throws.
- Any other non-2xx status, or a malformed
IUnifiedApiHttpClientresponse, throwsApiException. - Validation failures (
InvalidHostedFieldException,InvalidPaymentException,InvalidRefundRequestException,RefundAmountException) are all raised before any network call, so catching them tells you nothing was sent.
Every exception type here carries the HTTP status as its exception code, so you can branch without
parsing the message. The code is 0 only when the response shape was unusable and no status was
received:
try {
$response = $service->getPayment($paymentId);
} catch (PaymentNotFoundException $e) {
// $e->getCode() === 404
} catch (ApiException $e) {
// $e->getCode() === 503, 500, … or 0 if the HTTP client returned an unusable shape
}PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper parses and validates an
asynchronous "Payment Operation" notification (webhook/3DS confirmation), independently of the CMS
that receives the HTTP request:
use PayplugUnifiedCore\DataValues\PaymentOutcome;
use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper;
$expectedHeader = $configurationRepository->get('payplug_webhook_authorization_header');
$operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);
if ($operationData->outcome !== PaymentOutcome::THREE_DS_PENDING) {
$paymentRepository->save($operationData);
$orderStateMutator->apply($operationData->orderId, $operationData->outcome);
}verifySignature() does a constant-time comparison of the notification's Authorization header
against $expectedAuthorizationHeader, throwing InvalidNotificationException when it's absent
or doesn't match — the platform has no HMAC/signature-over-body scheme, only a shared secret
configured at webhook-creation time. When $expectedAuthorizationHeader is empty, verification is
skipped and the notification is accepted unverified rather than rejected — a deliberate, known
temporary trade-off, since no merchant/account currently has a way to configure a webhook secret
at all. Revisit once that product decision lands; don't treat it as settled behavior.
parse() can return an OperationData with outcome === PaymentOutcome::THREE_DS_PENDING:
PayPlug's notifier has been observed firing a notification carrying execCode "0001" (3DS
authentication required) before the real, final notification for the same operation. Treat a
THREE_DS_PENDING result as "no new information yet" — do not call
IPaymentRepository::markTreated() for it, or the later, final notification will be permanently
blocked from ever applying. A CMS controller resolves a previously-pending OperationData to its
final state simply by calling parse() again on a later webhook.
PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper::toPaymentOutcome(string $execCode): string
is what WebhookNotificationHelper::parse() (and the synchronous hosted-payment creation flow) use
to translate a Payplug execCode into the PaymentOutcome vocabulary above — "0000" → PAID,
"0001" → THREE_DS_PENDING, everything else → FAILED:
use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper;
ExecCodeMapper::toPaymentOutcome('0000'); // PaymentOutcome::PAID
ExecCodeMapper::toPaymentOutcome('0001'); // PaymentOutcome::THREE_DS_PENDING
ExecCodeMapper::toPaymentOutcome('5NN3'); // PaymentOutcome::FAILEDCode under src/ and tests/ must not use PHP syntax newer than 7.1 (no typed properties,
arrow functions, constructor property promotion, match, enum, etc.), since the shipped code
must run on older PHP hosts. This is enforced two ways: a CI job lints every file directly with
php -l across PHP 7.1–8.2, and make verify-71 goes further by booting a real --no-dev
vendor tree under an actual PHP 7.1 interpreter and smoke-testing PhoneHelper/AmountHelper end
to end — run it after touching any dependency version. See Architecture for
details, including why composer.json's own platform-check is disabled.
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