yoomoney/yookassa-sdk-php
PHP SDK for integrating YooKassa (YooMoney) payments into your app. Create payments, process refunds, manage receipts, handle webhooks, and call API methods through a convenient client. Simplifies checkout and payment workflows in PHP and Laravel projects.
What you're installing. yoomoney/yookassa-sdk-php is the official PHP SDK for ЮKassa (YooKassa / Yandex.Checkout). It wraps the Payments REST API: creating payments, capturing, cancelling, refunds, and 54‑ФЗ receipts. It is a thin, largely DTO + request-builder layer over Guzzle/PSR‑18, so it does not bring Laravel integration, config, migrations, or a fake — you wire all of that yourself.
Reality check before you start. The package is marked archived, last release 2022‑03‑22. It still works on modern PHP and the API it talks to is stable, but nothing is being patched — no PHP 8.2/8.3 fixes, no security releases, no new API features. Treat it as a transport you own, not a dependency you trust blindly. Pin an exact version, wrap every call behind your own interface, and keep the migration option open.
Minimal setup.
composer require yoomoney/yookassa-sdk-php:^3.0
Config — keep credentials out of code and out of git:
// config/services.php
'yookassa' => [
'shop_id' => env('YOOKASSA_SHOP_ID'),
'secret_key' => env('YOOKASSA_SECRET_KEY'),
'return_url' => env('YOOKASSA_RETURN_URL'),
],
// config/yookassa.php
return [
'shop_id' => env('YOOKASSA_SHOP_ID'),
'secret_key' => env('YOOKASSA_SECRET_KEY'),
'return_url' => env('YOOKASSA_RETURN_URL'),
// YooKassa publishes webhook source IPs; put them here, not inline
'webhook_ips' => array_filter(explode(',', env('YOOKASSA_WEBHOOK_IPS', ''))),
];
Where to look first. The YooKassa\Client class is the whole surface area: createPayment(), getPaymentInfo(), capturePayment(), cancelPayment(), createRefund(), setAuth(), setLogger(), setHttpClient(). Everything else (YooKassa\Request\Payments\*, YooKassa\Model\*) exists to build arguments and read responses. Note that namespaces shifted between v2 and v3 (YooKassa\Model\Payment vs YooKassa\Model\Payment\Payment, PaymentInterface locations, etc.) — most "class not found" reports online are a v2/v3 mismatch. Check composer show yoomoney/yookassa-sdk-php before copying snippets.
First use case — the redirect payment. This is 90% of day-one work: the user clicks "Pay", they leave your site for the YooKassa confirmation page, they come back, and a webhook fires.
use YooKassa\Client;
use YooKassa\Request\Payments\CreatePaymentRequest;
$client = new Client();
$client->setAuth(config('yookassa.shop_id'), config('yookassa.secret_key'));
$request = CreatePaymentRequest::builder()
->setAmount(1490.00) // float or ['value' => '1490.00', 'currency' => 'RUB']
->setCapture(true) // auto-capture; false => you must capture manually
->setConfirmation([
'type' => 'redirect',
'return_url' => route('checkout.return', $order),
])
->setDescription("Order #{$order->id}")
->setMetadata(['order_id' => $order->id]) // comes back on webhooks — use it as your join key
->build();
$payment = $client->createPayment($request, (string) Str::uuid());
$order->update(['yookassa_payment_id' => $payment->getId()]);
return redirect()->away($payment->getConfirmation()->getConfirmationUrl());
That's the whole starting loop: persist the payment id, redirect, handle the webhook, handle the return URL. Everything below is refinement.
new it in controllersClient is stateless but re-authenticates and re-builds a Guzzle stack on construction. One instance per request, injected:
// app/Providers/YooKassaServiceProvider.php
public function register(): void
{
$this->app->singleton(Client::class, function () {
$client = new Client();
$client->setAuth(config('yookassa.shop_id'), config('yookassa.secret_key'));
$client->setLogger(app(LoggerInterface::class)); // PSR-3, ships with the SDK
return $client;
});
$this->app->alias(Client::class, 'yookassa');
}
Better still, wrap it in your own PaymentGateway interface and bind that. You will want to swap it for a maintained SDK or a raw HTTP client eventually, and the archived status makes that a "when", not "if".
Don't derive order status from payment->getStatus() on every read — persist it and transition explicitly. YooKassa statuses: pending → (waiting_for_capture →) succeeded, or canceled. Write a small PaymentStatus backed enum and a single applyPaymentState(Order $order, PaymentInterface $payment) method that is idempotent. Both the return-URL controller and the webhook controller call it. This kills the "user refreshed the return URL and got charged twice / fulfilled twice" class of bug.
Every POST on the API requires an Idempotence-Key header. The SDK takes it as the second argument:
$idempotenceKey = $order->yookassa_idempotence_key ??= (string) Str::uuid();
$payment = $client->createPayment($request, $idempotenceKey);
Store it on the order (unique index). If a job retries, the same key means the API returns the original payment instead of creating a second charge. Never generate the key inside a retried closure — that's how double charges happen.
If you use setCapture(false) (two-step, common for physical goods), funds are held and you have a window (~7 days) to capture:
$client->capturePayment(
(new CapturePaymentRequest())->setAmount(1490.00),
$paymentId,
(string) Str::uuid()
);
// or
$client->cancelPayment($paymentId, (string) Str::uuid());
Always guard: capture only from waiting_for_capture, cancel only from waiting_for_capture or pending. An InvalidRequestException here usually means your local state drifted — log the API body and re-fetch via getPaymentInfo().
YooKassa webhooks are not signed. There is no HMAC header. The two things you can do:
$request->ip() against config('yookassa.webhook_ips').use YooKassa\Model\Notification\NotificationFactory;
public function __invoke(Request $request, Client $client)
{
abort_unless(in_array($request->ip(), config('yookassa.webhook_ips'), true), 403);
$notification = (new NotificationFactory())->factory($request->json()->all());
$paymentId = $notification->getObject()->getId();
// Authoritative read — the webhook body is a hint, not a fact
$payment = $client->getPaymentInfo($paymentId);
if ($payment->getStatus() === 'succeeded') {
FulfillOrder::dispatch($payment->getMetadata()['order_id'])->onQueue('fulfillment');
}
return response()->noContent();
}
Return 200 fast and do the work in a queued job. YooKassa retries non-2xx responses, and slow handlers cause duplicate deliveries. Make the job idempotent (unique payment id / ShouldBeUnique).
The return URL is not a reliable signal — users close tabs, and the webhook may arrive before or after the redirect. The return controller should render a "processing" page and poll, or call applyPaymentState again and let the state machine dedupe.
If you're legally the merchant of record, the payment request needs a receipt or the API rejects it. Build Receipt with ReceiptItems — this is fiddly (tax system, VAT code per item, payment_subject/payment_mode) and belongs in a dedicated factory class, not inline:
$receipt = (new Receipt())
->setCustomer(['email' => $order->email])
->setTaxSystemCode(1)
->addItem(
(new ReceiptItem())
->setDescription(mb_substr($item->name, 0, 128))
->setQuantity($item->qty)
->setAmount($item->lineTotal)
->setVatCode(1)
);
$request->setReceipt($receipt);
Test this path in the sandbox before production — receipt validation errors are opaque (400 invalid_request with a parameter field buried in the body).
Refunds are their own resource and are asynchronous:
$refund = $client->createRefund(
(new CreateRefundRequest())->setPaymentId($paymentId)->setAmount(500.00),
(string) Str::uuid()
);
Partial refunds are allowed up to the captured amount. Track cumulative refunds yourself — the API will let you attempt a second partial that exceeds the total and reject it, but you want to catch that before the user sees an error.
The SDK accepts a PSR‑18 client via setHttpClient(), which is your seam for tests. Two workable approaches:
Client in your gateway wrapper and unit-test your state machine / controllers.There is no Laravel HTTP fake support natively; don't try to Http::fake() the SDK — it doesn't use the Laravel HTTP client. Use the separate test shop (shop_id + test_ secret key) in a sandbox environment to get real end-to-end behaviour without moving money.
Money is a string, and floats will bite you. The SDK serialises amounts as "1490.00". Passing floats and doing arithmetic on them produces 1489.9999 and invalid_amount errors. Store money as integer kopecks in your DB, divide once at the boundary, and format with number_format($kopecks / 100, 2, '.', ''). Do not round-trip through the SDK's getAmount()->getValue() for your own accounting.
Currency is effectively RUB. currency defaults to RUB and most shops can only accept it. If you're multi-currency, verify with YooKassa support before assuming it works — the SDK won't warn you.
idempotenceKey must be ≤ 64 chars and unique per operation. Reuse for retries of the same operation, new key for a new payment/refund/capture. A single global key per order breaks partial refunds.
Never assume the webhook ordering. payment.succeeded and payment.canceled can arrive out of order or duplicated. Your state machine must be monotonic: succeeded is terminal, canceled from succeeded should be logged and ignored.
The archived-package tax is real. Expect:
$client->getApiClient() or drop to cURL.composer audit and accept that findings will never be fixed upstream.Mitigation: keep the SDK behind your own PaymentGateway interface with 4–5 methods (createRedirectPayment, fetch, capture, cancel, refund). That's a day of work and it's the difference between a one-file migration and a two-week one.
Connection vs. business errors are different. Catch YooKassa\Common\Exceptions\ApiConnectionException (and 5xx) and retry with backoff — safe because of idempotence keys. Do not retry BadApiRequestException / InvalidRequestException (4xx): the request is wrong, retrying just burns rate limit. Log the full response body; the SDK's exception messages are terse and the useful parameter / code is in the JSON.
Timeouts default generously. Set an explicit HTTP timeout (10–15s) on your custom PSR‑18 client. A hung YooKassa call inside a synchronous checkout request is a bad user experience and a worse queue worker.
confirmation_url expires. Redirect immediately; don't email it or cache it. If the user abandons and returns hours later, create a new payment (new idempotence key) rather than reusing the URL.
Metadata round-trips. setMetadata(['order_id' => ...]) is the cleanest way to correlate webhooks to your domain without a lookup table on payment id. Metadata is limited (roughly 32 keys, string values) — cast to string.
Configuration quirks.
setAuth() can also take a second arg for OAuth, but stick to shopId + secret key.test_; production keys don't. Add a boot-time assertion so a test key never ships to prod (and vice versa).config('yookassa.shop_id') fails with a confusing 401 rather than a clear config error. Validate in a service provider's boot().Extension points worth knowing. setLogger(Psr\Log\LoggerInterface) gives you request/response visibility when debugging 400s — turn it to debug in staging and warning in prod. setApiEndpoint() lets you point at a mock server for integration tests. setHttpClient() is your path to custom middleware (tracing, retries).
Migration horizon. Given the archive date, budget for replacing this within a year or two — either the maintained upstream (if YooKassa resumes releases under a new name) or a thin in-house HTTP client against the well-documented REST API. The API itself is the stable contract; this SDK is just a convenience that's stopped being convenient.
How can I help you explore Laravel packages today?