Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Paytabs Laravel Package

paytabscom/laravel_paytabs

Laravel PayTabs provides a straightforward integration for the PayTabs payment gateway in Laravel applications. It handles payment requests, redirects, and callback responses through configurable credentials, letting you add online card payments quickly.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package (Composer will pull in the PayTabs PHP SDK it wraps):

composer require paytabscom/laravel_paytabs

Publish the config file and open it first — this is the single most important file in the package:

php artisan vendor:publish --provider="Paytabs\LaravelPaytabs\PaytabsServiceProvider"

config/paytabs.php holds the values you will map to env vars:

Config key What it is
profile_id PayTabs profile ID from the merchant dashboard
server_key Server key (secret — never ship to the browser)
region ARE, SAU, OMN, JOR, EGY, KWT, QAT, MAR, GLOBAL, etc. — must match the profile's country. QAT (Qatar) and MAR (Morocco) were added in v1.10.0
currency Default currency code (SAR, AED, QAR, MAD, USD…)
callback Server-to-server webhook URL (public, no auth)
return Browser redirect URL after the hosted page

Then use the facade for the first, simplest flow — a hosted payment page redirect:

use Paytabs\LaravelPaytabs\Facades\Paytabs;

$payment = Paytabs::setCustomerDetails([
        'name'  => $order->customer_name,
        'email' => $order->customer_email,
        'phone' => $order->customer_phone,
        'street1' => $order->billing_address,
        'city'    => $order->billing_city,
        'state'   => $order->billing_state,
        'country' => 'SAU',
        'zip'     => $order->billing_zip,
    ])
    ->setUrlDetails([
        'return'   => route('checkout.return'),
        'callback' => route('checkout.callback'),
    ])
    ->setCartDetails([
        'id'          => (string) $order->id,
        'currency'    => 'SAR',
        'amount'      => number_format($order->total, 2, '.', ''),
        'description' => "Order #{$order->id}",
    ])
    ->createPaymentPage();

return redirect()->away($payment->getUrl());

Read the facade source (src/ in the package) rather than relying on the README alone — the README is thin, and the setter methods are plain fluent builders, so scrolling Paytabs.php gives you the exact accepted keys in a couple of minutes. The same applies to the region enum: v1.10.0's release note is a one-liner ("Add Morocco & Qatar Endpoints"), so confirm the exact constant names in the SDK before hard-coding 'MAR' / 'QAT' in a migration or seeder.

Implementation Patterns

Split creation from fulfilment. The flow that ages well in production is three endpoints, not one:

  1. POST /checkout/pay — builds the payment page, stores tran_ref + amount + currency on your order, redirects to $payment->getUrl().
  2. POST /checkout/callback — the PayTabs server-to-server webhook. This is the authoritative one. Verify server-side, then mark the order paid.
  3. GET /checkout/return — the user lands here after the hosted page. Treat it as UX only; re-verify and render a status page. Never fulfil on this alone, because the user can hit the return URL directly.

Always verify with a follow-up query, not the incoming payload. The callback body can be spoofed; the pattern is to take the tranRef from the request and ask PayTabs:

public function callback(Request $request)
{
    $result = Paytabs::verifyPayment($request->input('tranRef'));
    $data   = $result->getResponse(); // code, message, payment_result, cart_id, amount, currency...

    if (($data['payment_result']['response_status'] ?? null) !== 'A') {
        return response()->noContent(200); // ack, do not retry-loop
    }

    $order = Order::where('tran_ref', $request->input('tranRef'))->firstOrFail();

    // Guard rails before fulfilment
    abort_unless((string) $data['cart_id'] === (string) $order->id, 422);
    abort_unless($data['currency'] === $order->currency, 422);
    abort_unless(bccomp((string) $data['amount'], (string) $order->total, 2) === 0, 422);

    $order->markPaid();

    return response()->noContent(200);
}

Returning 200 quickly matters: a non-2xx response makes PayTabs retry the webhook, and a 500 from your handler turns into a retry storm on an already-paid order. Do the work inside the request, or dispatch a job and return 200 immediately.

Region is config-global, so model it as data if you sell in more than one country. With QAT and MAR now available alongside the Gulf regions, "one store, several PayTabs profiles" is a realistic setup. The package reads a single region from config, so pick one of:

  • store the region on the order/customer at checkout and resolve it into the container before building the payment (a PaytabsGateway constructed with a region, or a scoped config(['paytabs.region' => $region]) followed by app()->forgetInstance() of the binding), or
  • keep one profile per country and one thin gateway class per profile.

Either way, never let a single request build payments for two different regions through the shared facade instance — see the stateful-singleton gotcha.

Wrap the facade in your own service. The facade is hard to fake in tests. A one-file PaymentGateway interface that you bind to a PaytabsGateway implementation and a FakeGateway in tests pays for itself on day two. It also gives you the seam you will need if the package is ever abandoned (see Gotchas) — you swap one binding instead of grepping for Paytabs:: across the app.

Store the gateway identifiers, not the whole payload. Persist tran_ref, cart_id (your order ID), status, amount and currency on the order. The raw response JSON is useful in a payment_events / audit table, but don't build business logic on parsing it back out. Also store the region the payment was created under — support tickets for cross-border setups get much easier.

Model the gateway state as a transition, not a boolean. pending → authorized/captured → refunded → failed with an idempotency guard (if ($order->paid_at) return;) keeps webhook retries and duplicate callbacks harmless.

Refunds and follow-ups go through the same fluent facade where the version supports it (followUpTransaction, refundTransaction depending on release) — but check the signature against the installed SDK class. When in doubt, issue the refund through the PayTabs REST API directly with Http::withHeaders(['authorization' => config('paytabs.server_key')]); the package gives you little on top of the SDK for those endpoints.

Gotchas and Tips

  • Region support is version-gated. Qatar and Morocco only work from v1.10.0 onwards. If you pin an older tag, a QAT/MAR profile will fail in a way that reads like an auth or validation error, not "unsupported region". Bump and pin deliberately; note the release note is one line, so no migration steps or signature changes are implied — but verify the region constants in the SDK source rather than trusting the changelog.
  • The package is thinly maintained (~24 stars, occasional single-line releases). That's the headline. Pin the exact version in composer.json, keep the SDK it wraps visible in the lockfile, and budget a morning to fork it if a PHP or Laravel upgrade breaks it. Because the package is thin (a config file, a service provider, a facade over the official SDK), replacing it with ~200 lines of your own HTTP client is a realistic fallback — which is another argument for the gateway-interface wrapper above. Treat releases as "test before deploying", not "patch freely": there are no meaningful automated tests upstream to catch a regression.
  • The facade is a stateful singleton. Fluent setters mutate the same instance. Two payment creations in a single request, a queued job that creates a payment, or a long-running worker (Octane, Horizon) can leak customer/cart details between calls. Set all the details you need every time, and if you are on Octane, app()->forgetInstance() the binding between requests.
  • Amount formatting is a classic silent failure. PayTabs expects a decimal string, not an integer of minor units and not a float with locale separators. number_format($total, 2, '.', '') is the safe shape; a float cast of 199.99 can serialise as 199.98999999999998 and get rejected. This matters more now that you may be sending QAR and MAD — check the minor-unit convention per currency rather than assuming two decimal places everywhere.
  • Region must match the profile's country. A SAU profile sent ARE returns a validation error that reads like a credential problem. Same for currency vs region, and now the same for QAT/MAR — a Qatari profile with region => 'ARE' inherited from a copy-pasted .env is the easiest mistake to make on a multi-country rollout.
  • Callback URL must be publicly reachable HTTPS. localhost and http:// are rejected or silently never called. Use ngrok/expose for local testing and confirm in the PayTabs dashboard logs that the webhook actually fires — the package will not surface a misconfigured callback to you. Verify the callback URL is configured per-profile; if you run separate Qatar/Morocco profiles, each has its own callback settings in the dashboard.
  • CSRF will bite the callback. Exclude it explicitly: validateCsrfTokens(except: ['checkout/callback']) in bootstrap/app.php (Laravel 11+) or VerifyCsrfToken::$except (10 and below). If you forget, you get 419s, PayTabs retries, and no order is ever marked paid.
  • Errors arrive as HTTP 200 with a failure code. createPaymentPage() returning an object does not mean success. Check $payment->getResponse()['code'] / response_status and log it; getUrl() on a failed response will be meaningless. Do not rely on exceptions for gateway declines. A new region that your merchant account isn't enabled for will show up here, as a generic failure — log the full response body on non-success, not just the message.
  • Config cache. After changing config/paytabs.php or the env vars in staging, run php artisan config:clear. A stale cached profile_id or region produces auth/validation errors that look like bad keys — particularly painful when you switch regions between deploys.
  • Log the tran_ref on everything. PayTabs support asks for it first, and it's the only reliable join key between your order, the callback, and the return redirect.
  • Testing. There's no built-in fake or sandbox assertion helper. Point config('paytabs.server_key') at the PayTabs test keys in .env.testing, or better, bind a fake gateway in the container and assert your own order-state transitions rather than the package's HTTP behaviour. Add at least one test per region you enable, asserting the region/currency pair that reaches the gateway.
  • Extension points are limited to what you control. In practice that means: the config/paytabs.php values, the service-provider binding you add yourself, and the URLs you pass per request. Don't edit files in vendor/ — fork the package under your own VCS namespace if you need a genuine change.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo