laravel-barion maintained by szabrob
Barion Smart Gateway integration for Laravel with typed DTOs, callback events and Barion Pixel support.
Requirements
- PHP 8.3+
- Laravel 12 or 13
- spatie/laravel-data 4 (installed automatically)
Installation
composer require szabrob/laravel-barion
Publish the configuration file:
php artisan vendor:publish --tag="laravel-barion-config"
You may also publish the views (the Barion Pixel component) with the laravel-barion-views tag, or everything at once with laravel-barion.
Configuration
BARION_ENVIRONMENT=test # test | production
BARION_TEST_POS_KEY=your-test-shop-pos-key
BARION_TEST_PAYEE=test-shop-wallet@example.com
BARION_POS_KEY=your-live-shop-pos-key
BARION_PAYEE=live-shop-wallet@example.com
BARION_REDIRECT_URL=https://example.com/checkout/barion/return
BARION_PIXEL_ID=BP-0000000000-00
| Environment | API | Gateway |
|---|---|---|
test |
https://api.test.barion.com |
https://secure.test.barion.com/Pay |
production |
https://api.barion.com |
https://secure.barion.com/Pay |
The test shop and the live shop have different POS keys. The package always reads the key of the active environment's section, and it never falls back to the other one: a missing key throws a BarionException before any request is made.
How requests are sent
- Authentication: the POS key goes in the
x-pos-keyheader. It is never put in the request body or the query string. - Payment state: the package uses the v4
GET /v4/Payment/{paymentId}/PaymentStateendpoint. The deprecated v2GetPaymentStateendpoint is not used. - Idempotency: every POST call sends an
Idempotency-Keyheader. If you don't set one, the package derives it from the environment, the endpoint and the request body. Sending the same request again, for example from a re-queued job, reuses the same key. Barion then returns the original result instead of running the operation again. PassidempotencyKey:on any request DTO to use your own key. - Amounts: money fields (
Total,UnitPrice,ItemTotal,AmountToRefund) are rounded to 2 decimals before sending. Other numbers lose their float noise, so0.1 + 0.2is sent as0.3. - Retries: connection errors and 5xx responses are retried with the same idempotency key (
http.retryconfig). 4xx responses are not retried.
Usage
The package and your application talk through spatie/laravel-data DTOs. You build request DTOs with camelCase properties, and the package maps them to the Barion field names. Responses come back as typed DTOs with enums and CarbonImmutable dates.
Starting a payment
use Szabrob\LaravelBarion\Data\ItemData;
use Szabrob\LaravelBarion\Data\Requests\PaymentRequestData;
use Szabrob\LaravelBarion\Data\Requests\PaymentTransactionData;
use Szabrob\LaravelBarion\Enums\Currency;
use Szabrob\LaravelBarion\Facades\Barion;
$attempt = $order->paymentAttempts()->create(); // a new attempt every time the customer clicks "Pay"
$payment = Barion::startPayment(new PaymentRequestData(
paymentRequestId: "order-{$order->id}-{$attempt->id}",
transactions: [
new PaymentTransactionData(
posTransactionId: "order-{$order->id}-{$attempt->id}",
total: 1500,
items: [
new ItemData(name: 'T-shirt', description: 'Black, M', quantity: 1, unit: 'pcs', unitPrice: 1500, itemTotal: 1500, sku: 'TS-BLK-M'),
],
// payee: defaults to the configured payee of the active environment
),
],
currency: Currency::HUF,
redirectUrl: route('checkout.barion.return', $order),
orderNumber: (string) $order->id,
payerHint: $order->email,
// callbackUrl: defaults to the package callback route
));
$order->update(['barion_payment_id' => $payment->paymentId]);
return redirect()->away($payment->gatewayUrl);
Use a new
paymentRequestIdfor every payment attempt. Because the idempotency key comes from the request body, starting the same request again returns the same Barion payment. That protects against duplicate payments on retries. But if the customer canceled the payment or let it expire, a "pay again" with the samepaymentRequestIdand the same data would bring back the old, closed payment. Include an attempt number (as above) or another per-attempt value inpaymentRequestIdandposTransactionId.
PaymentRequestData::from([...]) with a camelCase array works too. Optional 3D Secure data (shippingAddress, billingAddress, payerAccountInformation, purchaseInformation, challengePreference) and recurring payment fields (initiateRecurrence, recurrenceId, recurrenceType) are supported. For PaymentType::Reservation and PaymentType::DelayedCapture, also set reservationPeriod or delayedCapturePeriod.
Callback and events
Barion calls the callback URL with only the paymentId, and the call is not signed. The package registers GET|POST /barion/callback (route name laravel-barion.callback, outside the web group, so it needs no CSRF token). For every call it:
- checks that
paymentIdlooks like a Barion payment id (400otherwise, without calling Barion), - queries the v4 PaymentState API. No other value from the callback request is used,
- dispatches events with the
PaymentStateDatait got back, - answers
200. If Barion does not know the payment (NotExistingPaymentId), it answers404without logging an error. For any other failure (for example a wrong POS key) it answers with an error, so Barion sends the callback again later.
Every callback makes an outgoing API call, so the route is rate limited per IP address: callback.rate_limit requests per minute, 120 by default, null disables it. Throttled requests get a 429, and Barion retries them.
| Event | When |
|---|---|
PaymentStateReceived |
Every callback |
PaymentSucceeded |
Verified status is Succeeded |
PaymentFailed |
Verified status is Canceled, Failed or Expired |
PaymentReserved |
Verified status is Reserved (Reservation payment, call finishReservation()) |
PaymentAuthorized |
Verified status is Authorized (DelayedCapture payment, call capture() or cancelAuthorization()) |
PaymentRefunded |
The payment has succeeded refund transactions. It is dispatched in addition to the status event. |
Every event has a paymentState property (PaymentStateData). A refund does not change the payment status: the payment stays Succeeded, its total drops, and a refund transaction appears. Use $paymentState->refundTransactions() (succeeded Refund, RefundToBankCard and RefundToBankAccount transactions) and $paymentState->refundedAmount(). Every later callback of the same payment dispatches PaymentRefunded again, so deduplicate refunds by their transactionId:
use Szabrob\LaravelBarion\Events\PaymentRefunded;
Event::listen(function (PaymentRefunded $event): void {
foreach ($event->paymentState->refundTransactions() as $refund) {
Refund::firstOrCreate(
['barion_transaction_id' => $refund->transactionId],
['amount' => $refund->total],
);
}
});
use Illuminate\Support\Facades\Event;
use Szabrob\LaravelBarion\Events\PaymentSucceeded;
Event::listen(function (PaymentSucceeded $event): void {
$state = $event->paymentState;
$order = Order::where('barion_payment_id', $state->paymentId)->firstOrFail();
if ($order->isPaid()) {
return; // callbacks can repeat (e.g. after a refund): keep listeners idempotent
}
$order->markAsPaid($state->total, $state->completedAt);
});
Barion may send the callback more than once for the same payment, for example after a refund. Write your listeners so that running them twice is safe.
Set the path and middleware in laravel-barion.callback. To handle callbacks in your own controller instead, set callback.enabled to false and call Barion::handleCallback($paymentId) there. It queries the state and dispatches the same events. On the redirect (return) page, show the state from Barion::paymentState($paymentId). Don't trust query parameters there either.
Payment state
$state = Barion::paymentState($paymentId); // PaymentStateData
$state->status; // PaymentStatus enum (isSuccessful(), isFailed(), isFinal())
$state->total; // float
$state->currency; // Currency enum
$state->completedAt; // ?CarbonImmutable
$state->transactions; // array<TransactionDetailData>
$state->fundingInformation?->bankCard?->maskedPan;
Refund
use Szabrob\LaravelBarion\Data\Requests\RefundRequestData;
use Szabrob\LaravelBarion\Data\Requests\TransactionToRefundData;
$refund = Barion::refund(new RefundRequestData(
paymentId: $paymentId,
transactionsToRefund: [
new TransactionToRefundData(
transactionId: $barionTransactionId,
posTransactionId: "refund-{$refund->id}", // unique per refund
amountToRefund: 500,
comment: 'Returned item',
),
],
));
The default idempotency key is derived from the request body. Use a unique posTransactionId for each refund: two refunds with exactly the same body count as the same request.
Reservation and delayed capture
use Szabrob\LaravelBarion\Data\Requests\CancelAuthorizationRequestData;
use Szabrob\LaravelBarion\Data\Requests\CaptureRequestData;
use Szabrob\LaravelBarion\Data\Requests\FinishReservationRequestData;
use Szabrob\LaravelBarion\Data\Requests\TransactionToFinishData;
// PaymentType::Reservation
Barion::finishReservation(new FinishReservationRequestData($paymentId, [
new TransactionToFinishData(transactionId: $barionTransactionId, total: 1200),
]));
// PaymentType::DelayedCapture
Barion::capture(new CaptureRequestData($paymentId, [
new TransactionToFinishData(transactionId: $barionTransactionId, total: 1500),
]));
Barion::cancelAuthorization(new CancelAuthorizationRequestData($paymentId));
All three return a PaymentOperationResponseData (isSuccessful, status, transactions).
Errors
| Exception | When |
|---|---|
BarionApiException |
Barion answered with an error status or a non-empty Errors list. It has status, errors (ApiErrorData[]) and the raw body. |
BarionConnectionException |
Barion could not be reached, even after the retries. |
BarionResponseException |
Barion answered successfully, but the response does not fit the expected DTO, for example a required field is missing. It has dataClass and the raw body. |
BarionException |
Configuration problem (unknown environment, missing POS key or payee). All the exceptions above extend this class. |
BarionApiException::hasErrorCode('NotExistingPaymentId') checks for a specific Barion error code.
Keeping the POS key out of logs
The POS key is sent in the x-pos-key request header. Tools that record outgoing HTTP requests (Telescope, Nightwatch, custom HTTP client logging) also store the headers, unless you tell them not to. For Telescope:
// App\Providers\TelescopeServiceProvider::hideSensitiveRequestDetails()
Telescope::hideRequestHeaders(['x-pos-key']);
Barion Pixel
Place the component in your layout's <head>. It renders nothing when BARION_PIXEL_ID is not set:
<x-laravel-barion::pixel />
{{-- with a Content Security Policy nonce --}}
<x-laravel-barion::pixel :nonce="Vite::cspNonce()" />
Testing your application
The package uses the Laravel HTTP client, so you can fake Barion with Http::fake():
Http::fake([
'api.test.barion.com/v4/Payment/*/PaymentState' => Http::response([
'PaymentId' => $paymentId,
'Status' => 'Succeeded',
'Total' => 1500,
'Currency' => 'HUF',
]),
]);
$this->post('/barion/callback', ['paymentId' => $paymentId])->assertOk();
Workbench (manual testing)
The repository has a workbench test UI for trying the package against the real Barion test API:
- Fill in the Barion variables in
workbench/.env(copied fromworkbench/.env.example, git-ignored). - Run
composer serveand openhttp://localhost:8000. - Start a payment, pay on the Barion test gateway, then come back to the payment page. There you can query the PaymentState, refund, finish a reservation, capture or cancel an authorization.
Barion cannot reach localhost. For real callbacks, expose the server through a tunnel (for example ngrok http 8000) and set APP_URL to the public URL. The workbench generates the RedirectUrl and CallbackUrl from APP_URL. Without a tunnel, use the Callback szimuláció button. It runs the same handleCallback() flow.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Laravel Barion! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Laravel Barion is open-sourced software licensed under the MIT license.