The omnipay/firstdata driver plugs First Data's Global Gateway (e4) into the framework-agnostic Omnipay payment library. In a Laravel app you rarely touch it directly — you install it, wrap it in a service provider, and call it from a payment service.
1. Install and check the HTTP adapter. Omnipay 3 uses PSR-18 discovery, so the app needs a PSR-18 client. Most Laravel apps already have Guzzle, but the adapter is explicit:
composer require omnipay/firstdata php-http/guzzle7-adapter
The driver's last release was February 2019, so pin the major version (^2.0) and verify it resolves against your PHP and omnipay/common versions before doing anything else.
2. Put credentials in config, not code. First Data Global Gateway e4 needs a store ID, a shared secret, and (depending on how the account was provisioned) an API password. The driver's getDefaultParameters() is the source of truth for exact parameter names in your installed version — check it before wiring config.
// config/services.php
'firstdata' => [
'store_id' => env('FIRSTDATA_STORE_ID'),
'shared_secret' => env('FIRSTDATA_SHARED_SECRET'),
'api_password' => env('FIRSTDATA_API_PASSWORD'),
'test_mode' => env('FIRSTDATA_TEST_MODE', true),
],
3. Bind the gateway once. Create it in a service provider so the driver's XML signing and endpoint selection live in one place:
// app/Providers/FirstDataServiceProvider.php
public function register(): void
{
$this->app->singleton(GlobalGateway::class, function () {
$gateway = Omnipay::create('FirstData_GlobalGateway'); // confirm short name in README
$gateway->setStoreId(config('services.firstdata.store_id'));
$gateway->setSharedSecret(config('services.firstdata.shared_secret'));
$gateway->setApiPassword(config('services.firstdata.api_password'));
$gateway->setTestMode(config('services.firstdata.test_mode'));
return $gateway;
});
}
4. First use case: a single-shot purchase(). This is the shortest path to a working checkout and validates credentials, currency, and test-mode setup before you add authorize/capture complexity:
$response = $gateway->purchase([
'amount' => number_format($order->total_cents / 100, 2, '.', ''), // "10.00", not 1000
'currency' => 'USD',
'description' => "Order #{$order->id}",
'card' => $request->only(['number', 'expiryMonth', 'expiryYear', 'cvv', 'firstName', 'lastName']),
])->send();
if ($response->isSuccessful()) {
$order->update([
'status' => 'paid',
'gateway_ref' => $response->getTransactionReference(),
'auth_code' => $response->getAuthorizationCode(),
]);
}
Run it against the demo endpoint first (testMode = true), then swap in live credentials.
Authorize, then capture. For anything beyond a simple sale — partial shipments, delayed settlement, inventory checks — use authorize() at checkout and capture() when you ship. The reference returned by authorize is what capture, refund, and void all key off of:
$auth = $gateway->authorize([...])->send();
$capture = $gateway->capture([
'amount' => '10.00',
'currency' => 'USD',
'transactionReference' => $auth->getTransactionReference(),
])->send();
Store the reference, not the card. Persist getTransactionReference() (and getAuthorizationCode() for support tickets) on the order row. Every subsequent operation — refund, void, capture — is a lookup against that reference, so losing it means manual reconciliation with First Data. Never store PAN/CVV.
Keep Omnipay objects out of your domain. Wrap the gateway behind your own interface (App\Payments\PaymentGateway) that takes a DTO and returns a result object with success, reference, message, and code. Controllers and models shouldn't call $response->getData() directly — that makes swapping gateways (or adding a second one) a rewrite instead of a config change.
Idempotency is your job. The driver won't stop a double-submit from creating two charges. Wrap the call in DB::transaction() with lockForUpdate() on the order, disable the submit button, and use a unique invoice/order reference so you can spot duplicates during reconciliation. Generate and store a UUID per attempt if the order ID isn't stable yet.
Map declines to redirects, not exceptions. A declined card is a normal 200 response with isSuccessful() === false. Only transport failures throw. Catch \Omnipay\Common\Exception\InvalidCreditCardException (card-number/expiry validation happens client-side in the CreditCard object) and convert it to a Laravel validation error so users get a form message instead of a 500.
Fire side effects via events. After a successful charge, emit OrderPaid and let queued listeners send receipts and trigger fulfillment. This keeps the HTTP request fast and keeps payment code decoupled from email/inventory code.
Test with a mock PSR-18 client, not Http::fake(). Laravel's Http::fake() won't intercept Omnipay's traffic because the driver uses its own client. Inject a mock via setHttpClient() (php-http/mock-client or a hand-rolled Omnipay\Common\Http\ClientInterface) and assert on the response handling. Reserve one live smoke test per deploy for the demo endpoint.
Log the wire traffic, not just the outcome. Add a listener that logs $response->getData() and getMessage() on failure. For the request side, inject a Guzzle client with Middleware::log() (or HTTPlug's LoggerPlugin) via setHttpClient() — Omnipay itself is silent by default.
sharedSecret, mismatched store ID, or clock skew surfaces as a generic auth error with little detail. Check server time (NTP) first, then credentials, then that test/live credentials aren't crossed."10.00", not 1000 and not 10.0. Send an integer cents value and you'll charge a hundred times too much. Keep money as integer minor units in the database and format at the boundary.refund() against the original reference. Track settlement timing if you offer same-day cancellation.createCard()/stored-card support or SCA readiness. If you need recurring billing or EU strong customer authentication, validate against the installed driver or choose a different gateway.4111 1111 1111 1111 with any future expiry and a valid CVV shape, but rejects patterns it considers malformed. If a "valid" test card declines, that's usually the demo gateway, not your code.isSuccessful(), never the message string. A 200 HTTP status can still be a decline. Use getCode() and getData() for gateway-specific fields, and treat getMessage() as display text only.createRequest() to add custom transaction types, inject a custom HTTP client for logging/retries/timeouts (set timeouts on the client, not the gateway), and keep the shared omnipay/common version aligned if you use other Omnipay drivers in the same app.How can I help you explore Laravel packages today?