webtopay/libwebtopay
The WebToPay PHP library integrates Paysera’s Checkout API for collecting online payments via many methods and validating transferred/received security parameters. Install via Composer or the all-in-one WebToPay.php file.
Install the version that matches your PHP runtime, and pin it — the package has very little ecosystem adoption, so floating constraints buy you nothing but surprise:
composer require webtopay/libwebtopay:^3.0 # PHP 7.4+, strict_types + type hints
# older apps: ^2.0 (new interface) or ^1.6 (legacy interface)
Verify the global class is autoloadable before writing anything else:
php artisan tinker --execute="var_dump(class_exists(\WebToPay::class));"
If it is false, run composer dump-autoload; the library ships a merged all-in-one WebToPay.php and an src/includes.php, either of which can be required as a fallback — but only once, never alongside the Composer autoload.
Where to look first:
vendor/webtopay/libwebtopay/src/ — the actual WebToPay facade class, WebToPayException, signature/encoding helpers, and (depending on version) WebToPay_Factory / payment-method list classes. The method signatures there are the source of truth for your installed version.developers.paysera.com/en/checkout/basic — that is where the parameter contract lives: projectid, orderid, amount, currency, country, payment, accepturl, cancelurl, callbackurl, test, lang, time_limit.First use case: a one-off payment for an order. Put credentials in config/services.php, then build the redirect URL and hand it to Laravel's redirector:
// config/services.php
'paysera' => [
'project_id' => env('PAYSERA_PROJECT_ID'),
'sign_password' => env('PAYSERA_SIGN_PASSWORD'),
'test' => (bool) env('PAYSERA_TEST', false),
],
// app/Http/Controllers/PayseraController.php
public function pay(Order $order)
{
$url = \WebToPay::getPaymentUrl([
'projectid' => config('services.paysera.project_id'),
'sign_password' => config('services.paysera.sign_password'),
'orderid' => (string) $order->payment_attempt_id,
'amount' => $order->amount_minor, // integer, minor units
'currency' => $order->currency, // 'EUR'
'country' => 'LT',
'accepturl' => route('paysera.accept'),
'cancelurl' => route('paysera.cancel'),
'callbackurl' => route('paysera.callback'),
'test' => config('services.paysera.test') ? 1 : 0,
]);
return redirect()->away($url);
}
Prefer getPaymentUrl() over WebToPay::redirectToPayment() — the latter emits headers and terminates the request itself, stepping outside Laravel's response pipeline (and making feature tests miserable).
Wrap the static class, don't call it everywhere. You need a seam for tests and a place to normalize config:
namespace App\Services;
use WebToPay;
class PayseraGateway
{
public function __construct(private readonly array $config) {}
public function paymentUrl(array $params): string
{
return WebToPay::getPaymentUrl(array_merge([
'projectid' => $this->config['project_id'],
'sign_password' => $this->config['sign_password'],
'test' => $this->config['test'] ? 1 : 0,
], $params));
}
public function parse(array $query): array
{
// v2/v3: checkResponse() takes the query array + options
return WebToPay::checkResponse($query, [
'projectid' => $this->config['project_id'],
'sign_password' => $this->config['sign_password'],
]);
// v1.x/legacy signature, if you are stuck on it:
// return WebToPay::validateAndParseData($query, $this->config['sign_password'], 'ss1', 'ss2');
}
}
Bind it as a singleton in a service provider with config('services.paysera'), and in tests bind a fake behind an interface.
Model the payment attempt, not the order. Paysera's orderid must be your own identifier and it must round-trip unchanged through the callback. Create a payment_attempts row (or use a dedicated column) before redirecting, store the amount/currency you sent, and look the row up by orderid on callback. Never derive fulfillment from the browser's return trip.
Three routes, three jobs. accept and cancel are user-facing: validate the signature, then render "thank you / pending / failed" — but do not mark the order paid there. callback is server-to-server and is the only authoritative confirmation:
public function callback(Request $request)
{
try {
$data = $this->paysera->parse($request->query());
} catch (\WebToPayException $e) {
report($e);
return response('', 400); // invalid signature: do not invite retries
}
DB::transaction(function () use ($data) {
$attempt = PaymentAttempt::where('external_id', $data['orderid'])
->lockForUpdate()
->firstOrFail();
abort_unless((int) $data['amount'] === $attempt->amount_minor, 422);
abort_unless($data['currency'] === $attempt->currency, 422);
match ((int) $data['status']) {
\WebToPay::PAYMENT_STATUS_SUCCESS => $attempt->markPaid($data),
\WebToPay::PAYMENT_STATUS_FAILED => $attempt->markFailed($data),
\WebToPay::PAYMENT_STATUS_WAITING => $attempt->markWaiting($data),
default => $attempt->markWaiting($data),
};
});
return response('OK', 200)->header('Content-Type', 'text/plain');
}
Dispatch an event / queued job from the status transition (PaymentSucceeded) so fulfillment, receipts, and webhooks are idempotent and out of the request cycle. Status transition methods should be no-ops when the target state is already reached.
CSRF and auth. The callback has no session and no token. In Laravel 10 and below, add 'paysera/callback' to VerifyCsrfToken::$except (or apply withoutMiddleware). In Laravel 11+, ->withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['paysera/*'])) in bootstrap/app.php. Put the callback in routes/web.php if you need session/signed URLs elsewhere, in routes/api.php if you want it fully stateless — either way keep the URL stable, because it is baked into what Paysera sends back.
Absolute URLs. route() inside a queued job or console command can inherit the wrong host. Pass accepturl/cancelurl/callbackurl built from an explicit base (config('app.url') or a dedicated services.paysera.base_url) so staging and production never cross wires.
Payment methods. If you let users preselect a bank, store the Paysera method code in a payment_methods mapping seeded from the docs and pass it as payment. If you use the library's payment-method list classes, cache the result heavily — it changes rarely and is a network call inside the checkout path.
10.00 EUR is 1000. Passing a float, a decimal string, or a formatted number will either fail or charge the wrong value. Store amount_minor as an unsigned big integer plus ISO currency and never touch floats.data, ss1 (and ss2) — the signature is computed over the urlencoded payload. Laravel has already URL-decoded once; re-encoding, sorting, or cherry-picking parameters breaks verification. Pass $request->query() (or $request->all() if you must support POST) straight through, never http_build_query() output that you re-parsed.+ in base64. Query-string + becomes a space in PHP. If you see "invalid signature" only for some callbacks, this is usually why — the library's decoder handles the properly encoded form, so make sure nothing upstream (proxy rewrites, WAF, your own middleware) mangles the raw query.redirectToPayment() sends headers and exits. It bypasses middleware, response macros, and test assertions. Always use getPaymentUrl().$_SERVER. Configure TrustProxies correctly and pass explicit absolute URLs so you never depend on that inference.0) may arrive before success (1), and success may be delivered several times. Make the handler idempotent, always re-read status inside a transaction with lockForUpdate(), and return OK/200 for already-processed callbacks so Paysera stops retrying. Return a 5xx only for genuinely transient failures (DB down) where a retry helps.payamount, not just amount. The callback carries the requested amount and the actually paid amount/currency. Validate both against your stored attempt before fulfilling; a mismatch is a fraud/ops signal, not a rounding issue.test=1 marks the payment as a test; the callback echoes test back. Log it, and make sure production fulfillment code refuses test-flagged callbacks (or at least flags them loudly).accepturl fires before the callback sometimes. Users see the "success" page while your DB still says pending. Render "we're confirming your payment" and poll or refresh, rather than promising a completed order.WebToPayException (with subclasses in some versions). Catch it narrowly, log the payload minus secrets, alert on signature failures (they usually mean misconfiguration, not attacks), and never echo the exception message to the payer.sign_password never goes in a URL. It is only an input to signature computation. Keep it in .env, out of logs, out of config:cache-visible dumps, and out of exception messages.lang and country are cosmetic/pre-selection hints on Paysera's page; map them from app()->getLocale() with a small hardcoded table and default gracefully rather than sending an unsupported value.composer.json, keep the library unmodified, and write contract tests around your PayseraGateway (one sandbox integration test for a real signature round-trip; fakes everywhere else). To generate a valid fixture callback in tests without the network, use the library's own sign/encode helpers in src/ rather than hand-rolling base64 + md5.vendor/ you are on the hook to publish those changes. Prefer decorating the library over editing it.projectid a string and correct for the environment, (2) does test match the project's mode, (3) is amount an integer in minor units, (4) do accepturl/cancelurl/callbackurl resolve to publicly reachable HTTPS URLs, (5) did any middleware touch the callback query string. Paysera's redirect page shows errors as human-readable text, not API responses, so log the outgoing parameter array (without sign_password) on every initiation.How can I help you explore Laravel packages today?