Looking to hire Laravel developers? Try LaraJobs

payline maintained by x-laravel

Description
Laravel payment gateway abstraction layer
Last update
2026/09/29 09:15 (dev-master)
License
Links
Downloads
179

Comments
comments powered by Disqus

Payline

Tests PHP Laravel License

Payline is a reusable payment orchestration layer for Laravel. Gateway packages implement small, operation-specific contracts while applications receive one consistent API for payments, authorization, capture, refund, void, 3DS callbacks, webhooks, idempotency, routing, and reconciliation.

Every recorded operation creates or updates a Payment and Transaction, validates the state transition, and dispatches Laravel events.

Full documentation is in docs/.

Requirements

  • PHP 8.3 or newer
  • Laravel 12 or 13
  • A Payline gateway

Amounts are integers in the currency's minor unit. For example, 10000 represents TRY 100.00.

Installation

composer require x-laravel/payline
php artisan vendor:publish --tag=payline-migrations
php artisan migrate

Payline does not register its migrations from the package directory. Publishing them is required, and it leaves the schema under the application's control.

Publish the configuration when the defaults need changing:

php artisan vendor:publish --tag=payline-config
PAYLINE_GATEWAY=iyzico
PAYLINE_TEST_MODE=true

Verify the installation:

php artisan payline:doctor

Making a model payable

use Illuminate\Database\Eloquent\Model;
use XLaravel\Payline\Contracts\Payable;
use XLaravel\Payline\Traits\HasPayline;

class Order extends Model implements Payable
{
    use HasPayline;

    public function getPayableReference(): string
    {
        return $this->order_number;
    }

    public function getPayableAmount(): int
    {
        return $this->total;
    }
}

HasPayline supplies these optional defaults:

  • getPayableCurrency(): TRY
  • getPayableCustomerEmail(): the model's email attribute
  • getPayableCustomerName(): the model's name attribute
  • getPayableDescription(): null

Creating a payment

use XLaravel\Payline\DTOs\Card;

$card = new Card(
    holderName: 'Jane Doe',
    number: '4111111111111111',
    expiryMonth: '12',
    expiryYear: '2030',
    cvv: '123',
);

$response = $order->pay('iyzico')
    ->card($card)
    ->customerIp($request->ip())
    ->idempotencyKey((string) str()->uuid())
    ->charge();

Reference, amount, currency, customer name, customer email, and description come from the payable. Each one can be overridden on the chain, which also covers charging less than the payable total:

$order->pay('iyzico')->card($card)->amount(5000)->charge();

The facade exposes the same recorded workflow:

use XLaravel\Payline\Facades\Payline;

$response = Payline::for($order)
    ->via('iyzico')
    ->card($card)
    ->charge();

Without a payable, supply the values the request needs:

Payline::via('iyzico')
    ->reference('INV-2026-1')
    ->amount(10000)
    ->card($card)
    ->charge();

The chain accepts reference(), amount(), currency(), card(), cardToken(), saveCard(), method(), installments(), threeDs(), withoutThreeDs(), customerEmail(), customerName(), customerPhone(), customerIp(), description(), callbackUrl(), basketItems(), billingAddress(), shippingAddress(), metadata(), cardProfile(), and idempotencyKey().

charge() and authorize() also accept a PaymentRequest the application builds itself:

use XLaravel\Payline\DTOs\PaymentRequest;

$response = $order->pay('iyzico')->charge(
    PaymentRequest::fromPayable(payable: $order, card: $card),
);

Payline::gateway('iyzico') returns the raw gateway. Raw calls bypass Payline's persistence, validation, idempotency, and events, so application code should normally use via(), for(), or pay().

Handling responses

if ($response->requiresRedirect()) {
    return $response->redirectForm !== null
        ? response($response->redirectForm)
        : redirect()->away($response->redirectUrl);
}

if ($response->isApproved()) {
    $gatewayTransactionId = $response->gatewayTransactionId;
}

if ($response->isFailure()) {
    logger()->warning('Payment failed', [
        'code' => $response->errorCode,
        'message' => $response->errorMessage,
    ]);
}

PaymentResponse provides isSuccessful(), isApproved(), isPending(), isFailure(), and requiresRedirect().

None of them covers unknown, which is neither approved nor failed. Branching on isFailure() alone reports an unknown result as a success, so handle it before the other two:

if ($response->status === TransactionStatus::Unknown) {
    abort(422, 'The bank did not answer. The operation may have gone through, so do not retry it.');
}

A provider Payline could not reach returns Unknown rather than throwing, so this is where an unreachable provider arrives. Reconciliation settles the transaction afterwards. See Payments.

Idempotency

Use a stable key for every retryable operation:

$order->pay('iyzico')
    ->card($card)
    ->idempotencyKey("order:{$order->getKey()}:payment")
    ->charge();

Repeating an operation with the same key and payload returns the recorded result without calling the provider again. Reusing a key with a different fingerprinted payload throws IdempotencyConflictException. Capture, refund, and void operations accept independent idempotency keys.

Authorization and follow-up operations

$response = $order->pay('iyzico')->card($card)->authorize();

Use the recorded Payment for subsequent operations. Payline automatically uses the original gateway and rejects invalid state transitions or excessive amounts:

use XLaravel\Payline\Facades\Payline;

$capture = Payline::payment($payment)->capture(
    amount: 10000,
    idempotencyKey: "payment:{$payment->id}:capture:1",
);

$refund = Payline::payment($payment)->refund(
    amount: 2500,
    reason: 'Customer request',
    idempotencyKey: "payment:{$payment->id}:refund:1",
);

$void = Payline::payment($payment)->void(
    idempotencyKey: "payment:{$payment->id}:void:1",
);

An authorization can be captured in parts. While the captured total stays below the payment amount the payment is partially_captured, and it becomes successful once the captures add up to the full amount:

Payline::payment($payment)->capture(amount: 4000);  // partially_captured
Payline::payment($payment)->capture(amount: 6000);  // successful

Refunds follow the capture that produced them, so a refund cannot exceed the amount that capture collected.

Gateway selection and routing

Select a gateway explicitly:

$order->pay('iyzico')->card($card)->charge();
Payline::via('iyzico')->card($card)->reference('INV-1')->amount(10000)->charge();

Omit the gateway to use commission routing. Provide a card profile directly or resolve it through a registered BIN lookup driver:

use XLaravel\Payline\BinLookupManager;

$card = $card->resolveProfile(app(BinLookupManager::class));

$response = $order->pay()
    ->card($card)
    ->installments(3)
    ->charge();

Commission rates are stored in payline_commission_rates. Exact card family and type matches take precedence over wildcard rows. If no suitable rate exists, Payline uses the default gateway.

Routing also filters gateways through GatewayCapabilities and the classes configured in payline.routing.policies. A policy implements GatewayRoutingPolicy:

use XLaravel\Payline\Contracts\Gateway;
use XLaravel\Payline\Contracts\GatewayRoutingPolicy;
use XLaravel\Payline\DTOs\PaymentRequest;
use XLaravel\Payline\Enums\TransactionType;

class CurrencyPolicy implements GatewayRoutingPolicy
{
    public function allows(
        Gateway $gateway,
        PaymentRequest $request,
        TransactionType $operation,
    ): bool {
        return $request->currency === 'TRY';
    }
}
'routing' => [
    'policies' => [CurrencyPolicy::class],
],

Callbacks and webhooks

Payline registers these routes when payline.routes.enabled is true:

GET|POST /payline/callback/{gateway}
POST     /payline/webhooks/{gateway}

If a payment request has no callback URL, Payline adds its callback route automatically. Redirect destinations can be configured globally or per gateway:

'callback_success_url' => '/payments/success',
'callback_failure_url' => '/payments/failure',

'gateways' => [
    'iyzico' => [
        'callback_success_url' => '/iyzico/success',
        'callback_failure_url' => '/iyzico/failure',
    ],
],

Webhooks are verified before storage or processing. Provider event IDs are deduplicated per gateway; when no event ID is available, Payline uses a fingerprint of the raw request body. Sensitive payload keys are redacted before persistence and event dispatch.

Gateways that sign the raw request must implement HandlesRawWebhooks. Providers that sign a normalized array payload implement HandlesWebhooks instead. A gateway implementing neither cannot receive webhooks. The webhook route is CSRF-exempt and uses throttle:60,1 by default.

Reconciliation

Provider timeouts and ambiguous errors are recorded as unknown instead of being treated as declined payments. A provider Payline could not reach returns an Unknown response rather than throwing, so the caller reads a status; every other exception is still rethrown. Gateways implementing QueriesPayments can reconcile pending and unknown records:

$response = Payline::payment($payment)->reconcile();
php artisan payline:reconcile
php artisan payline:reconcile --gateway=iyzico --limit=50

An abandoned 3D Secure page looks the same to a provider as one the customer is still reading, so a gateway reports both as Pending. Payline records the answer as expired once the transaction is past the deadline the gateway set, or past payline.transactions.pending_ttl when it set none. A provider that later reports the payment as settled still overrides that verdict.

Providers describe the order rather than one operation on it, so reconciliation queries the sale or the authorization and settles any open refund or void from the same answer, through PaymentResponse::$refundedAmount and PaymentResponse::$voided. This is what releases a payment that an unanswered refund left unknown.

Models and events

Payment represents the aggregate state of a checkout attempt. Transaction represents an individual provider operation.

$payment->transactions();
$payment->latestTransaction();
$payment->refunds();
$payment->wasSuccessful();
$payment->hasOutstandingAmount();
$payment->isPending();
$payment->capturedAmount();
$payment->totalRefunded();
$payment->remainingRefundable();

$order->payments();
$order->successfulPayments();
$order->pendingPayments();
$order->amountPaid();
$order->amountRefunded();
$order->amountNet();
$order->lastPayment();

Lifecycle events are dispatched only when the recorded status changes:

  • PaymentInitiated
  • PaymentPending
  • PaymentSucceeded
  • PaymentFailed
  • PaymentErrored
  • PaymentAuthorized
  • PaymentCaptured
  • PaymentRefunded
  • PaymentVoided
  • WebhookReceived
  • CallbackUnmatched

Writing a gateway

Every gateway implements Gateway and only the operation contracts it supports:

use XLaravel\Payline\Contracts\ChargesPayments;
use XLaravel\Payline\Contracts\Gateway;
use XLaravel\Payline\Contracts\ProvidesGatewayCapabilities;
use XLaravel\Payline\DTOs\GatewayCapabilities;
use XLaravel\Payline\DTOs\PaymentRequest;
use XLaravel\Payline\DTOs\PaymentResponse;
use XLaravel\Payline\Enums\PaymentMethod;
use XLaravel\Payline\Enums\TransactionType;

class MyGateway implements Gateway, ChargesPayments, ProvidesGatewayCapabilities
{
    public function __construct(private array $config) {}

    public function getName(): string
    {
        return 'my-gateway';
    }

    public function pay(PaymentRequest $data): PaymentResponse
    {
        return $this->createPayment($data);
    }

    public function capabilities(): GatewayCapabilities
    {
        return new GatewayCapabilities(
            operations: [TransactionType::Payment],
            methods: [PaymentMethod::CreditCard],
            currencies: ['TRY'],
            installments: [1, 2, 3],
            threeDs: true,
            nonThreeDs: false,
        );
    }
}

Available operation contracts:

  • ChargesPayments
  • AuthorizesPayments
  • CapturesPayments
  • RefundsPayments
  • VoidsPayments
  • HandlesCallbacks
  • HandlesWebhooks or HandlesRawWebhooks
  • QueriesPayments
  • ProvidesGatewayCapabilities

Payline dispatches every operation through these contracts. A gateway that defines a matching method without implementing the contract is rejected with a LogicException.

Register the gateway from its service provider:

public function boot(): void
{
    $this->app->make('payline')->extend(
        'my-gateway',
        fn ($app, array $config) => new MyGateway($config),
    );
}

The gateway receives config('payline.gateways.my-gateway') as its configuration array.

Configuration and security

Important config/payline.php options:

return [
    'default' => env('PAYLINE_GATEWAY'),

    'test_mode' => env('PAYLINE_TEST_MODE', false),

    'currency' => env('PAYLINE_CURRENCY', 'TRY'),

    'country' => env('PAYLINE_COUNTRY'),

    'bin_lookup' => [
        'providers' => [],
        'drivers' => [],
    ],

    'routes' => [
        'enabled' => true,
        'prefix' => 'payline',
        'middleware' => ['web'],
        'webhook_middleware' => ['throttle:60,1'],
    ],

    'routing' => [
        'policies' => [],
    ],

    'database' => [
        'connection' => env('PAYLINE_DB_CONNECTION', env('DB_CONNECTION', 'sqlite')),
    ],

    'storage' => [
        'card_details' => true,
        'card_holder_name' => true,
        'card_profile' => true,
        'webhook_payload' => true,
    ],
];

Set PAYLINE_DB_CONNECTION to use a dedicated Laravel database connection. Disable storage fields the application does not need. payline.currency applies only when a charge has neither a payable nor an explicit currency().

Payline never stores the complete card number or CVV. Optional card storage is limited to the BIN, the last four digits, the cardholder name, and the resolved card profile: family, type, scheme, issuer and issuing country. Card masks sensitive fields in debug and JSON output.

storage.card_profile is a separate switch from storage.card_details because the two are turned off for different reasons. A BIN identifies a card; a card family identifies a commission rate, and it is what says why a payment went to the gateway it went to.

Models can be replaced through payline.models, or from AppServiceProvider::boot():

use XLaravel\Payline\Facades\Payline;

Payline::usePaymentModel(MyPayment::class);
Payline::useTransactionModel(MyTransaction::class);
Payline::useWebhookLogModel(MyWebhookLog::class);

These take precedence over the configuration values. Custom models should extend the corresponding Payline model so relationships, casts, and connection handling remain available.

For application-specific callback destinations, bind a custom CallbackRedirectResolver.

Testing

composer test

Documentation

Page Answers
Installation How do I get Payline running?
Architecture What does Payline record, and which class does what?
Payments How do I charge or authorize a card?
Follow-up Operations How do I capture, refund, void or reconcile?
Callbacks and Webhooks How are 3DS returns and provider notifications handled?
Gateway Routing How is a gateway chosen automatically?
Writing a Gateway How do I support a new provider?
Configuration Which configuration keys exist?
Events Which events are dispatched?
Database Which tables, columns and statuses exist?
Decisions Why is the package built this way?

License

Payline is open-sourced software licensed under the MIT license.