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.
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.
Split creation from fulfilment. The flow that ages well in production is three endpoints, not one:
POST /checkout/pay — builds the payment page, stores tran_ref + amount + currency on your order, redirects to $payment->getUrl().POST /checkout/callback — the PayTabs server-to-server webhook. This is the authoritative one. Verify server-side, then mark the order paid.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:
PaytabsGateway constructed with a region, or a scoped config(['paytabs.region' => $region]) followed by app()->forgetInstance() of the binding), orEither 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.
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.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.app()->forgetInstance() the binding between requests.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.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.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.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.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/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.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.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.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.How can I help you explore Laravel packages today?