Building Your Own Omnipay Payment Gateway Driver: A Step-by-Step Guide
If you have built an ecommerce site using custom PHP code or a PHP framework like Laravel, you have likely heard of Omnipay{:target="_blank"}. If you don't know what it is, Omnipay is a framework agnostic PHP package that provides an abstraction layer between your code and the payment gateway's API. It provides a consistent interface for multiple payment gateways, making it easier to switch between providers or support multiple options. Different APIs are built differently. How does the Omnipay package know how to talk to each one of them? It uses drivers. Basically, the developer installs league/omnipay
together with the driver of the gateway they intend to use. For example:
composer require league/omnipay omnipay/sagepay
There is a long list{:target="_blank"} of payment gateways for which Omnipay already has drivers, but the list is finite. You may want to use a gateway whose driver is not yet available. In our case, it was Tazapay. In this guide, I'll walk you through creating your own Omnipay driver for a payment gateway, using our recent implementation for Tazapay as a practical example.
Why Create a Custom Omnipay Driver?
Yeah, good question! If I'm going to be writing all that code, why not just write it directly into my app rather that make the driver and then implement the integration. Your reasons may differ, but this is why I thought it was a good idea:
- Consistent API: Remember the intro? Your application can use the same code structure regardless of the payment gateway.
- Easier Switching: If you need to change payment providers in the future, you only need to update configuration, not your entire codebase.
- Community Contribution: Sharing is caring. By publishing your driver, you help others who need to integrate with the same gateway and the community is richer for it.
Prerequisites
Before starting, you'll need:
- Basic understanding of PHP and object-oriented programming
- Familiarity with Composer for package management
- Documentation for your target payment gateway's API
- A development environment with PHP 7.2+ installed. PHP 7.2 works, but I hope that's not what you are using. If you're not yet using PHP 8.3+, please consider upgrading to save the polar bears π
Step 1: Set Up Your Project Structure
I did this with two separate directories. One had a sample Laravel installation. The other had the package itself. In the test installation directory, I installed three Omnipay drivers to copy from. The more the better, I believe.
composer require league/omnipay omnipay/mollie omnipay/paymentexpress omnipay/rabobank omnipay/sagepay
If you feel like flattering me, you can also add kajuzi/omnipay-tazapay
to that list. Why not π
When you dive into the vendor directory and inspect the drivers, you will notice that they usually have the following structure:
<gateway-name>/
βββ src/
β βββ Gateway.php
β βββ Message/
β β βββ AbstractRequest.php
β β βββ Response.php
β β βββ PurchaseRequest.php
β β βββ PurchaseResponse.php
β β βββ ... (other request/response classes)
βββ tests/
β βββ GatewayTest.php
β βββ Message/
β βββ ... (test classes for requests/responses)
βββ composer.json
βββ phpunit.xml.dist
βββ .gitignore
βββ README.md
βββ LICENSE.md
Step 2: Configure Your Composer File
Create a composer.json
file with the necessary dependencies and autoloading configuration:
{
"name": "yourvendor/omnipay-yourgateway",
"type": "library",
"description": "YourGateway driver for the Omnipay payment processing library",
"keywords": [
"gateway",
"merchant",
"omnipay",
"pay",
"payment",
"yourgateway"
],
"homepage": "https://github.com/yourvendor/omnipay-yourgateway",
"license": "MIT",
"authors": [
{
"name": "Your Name",
"email": "your.email@example.com"
}
],
"autoload": {
"psr-4": {
"Omnipay\\YourGateway\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Omnipay\\YourGateway\\Tests\\": "tests/"
}
},
"require": {
"php": "^7.2|^8.0",
"omnipay/common": "^3.0"
},
"require-dev": {
"omnipay/tests": "^3.0|^4.0",
"phpunit/phpunit": "^8.0|^9.0",
"squizlabs/php_codesniffer": "^3.0"
},
"prefer-stable": true,
"minimum-stability": "dev"
}
Replace yourvendor
and yourgateway
with appropriate values for your project.
Step 3: Copy or Create the Gateway Class
The Gateway class is the main entry point for your driver. It defines the available methods and parameters:
namespace Omnipay\YourGateway;
use Omnipay\Common\AbstractGateway;
class Gateway extends AbstractGateway
{
public function getName()
{
return 'YourGateway';
}
public function getDefaultParameters()
{
return [
'apiKey' => '',
'apiSecret' => '',
'testMode' => false,
];
}
// Parameter getters and setters
public function getApiKey()
{
return $this->getParameter('apiKey');
}
public function setApiKey($value)
{
return $this->setParameter('apiKey', $value);
}
public function getApiSecret()
{
return $this->getParameter('apiSecret');
}
public function setApiSecret($value)
{
return $this->setParameter('apiSecret', $value);
}
// Gateway methods
public function purchase(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\PurchaseRequest', $parameters);
}
public function authorize(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\AuthorizeRequest', $parameters);
}
public function capture(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\CaptureRequest', $parameters);
}
public function refund(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\RefundRequest', $parameters);
}
public function void(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\VoidRequest', $parameters);
}
public function fetchTransaction(array $parameters = [])
{
return $this->createRequest('\Omnipay\YourGateway\Message\FetchTransactionRequest', $parameters);
}
// Additional gateway-specific methods
}
It's those listed methods that then map the gateway's endpoints to the Omnipay object's convenient methods like when the developer uses $gateway->fetchTransaction([...])->send();
Step 4: Copy or Create the AbstractRequest Class
The AbstractRequest class handles common functionality for all request types:
namespace Omnipay\YourGateway\Message;
use Omnipay\Common\Message\AbstractRequest as OmnipayAbstractRequest;
abstract class AbstractRequest extends OmnipayAbstractRequest
{
protected $liveEndpoint = 'https://api.yourgateway.com';
protected $testEndpoint = 'https://api.sandbox.yourgateway.com';
protected function getEndpoint()
{
return $this->getTestMode() ? $this->testEndpoint : $this->liveEndpoint;
}
// Parameter getters and setters
public function getApiKey()
{
return $this->getParameter('apiKey');
}
public function setApiKey($value)
{
return $this->setParameter('apiKey', $value);
}
public function getApiSecret()
{
return $this->getParameter('apiSecret');
}
public function setApiSecret($value)
{
return $this->setParameter('apiSecret', $value);
}
// Common request methods
protected function getHttpMethod()
{
return 'POST';
}
protected function getHeaders()
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
public function sendData($data)
{
$method = $this->getHttpMethod();
$url = $this->getEndpoint() . $this->getEndpointPath();
$headers = $this->getHeaders();
// Add authentication headers
$headers['Authorization'] = 'Bearer ' . $this->getApiKey();
$body = $data ? json_encode($data) : null;
$httpResponse = $this->httpClient->request(
$method,
$url,
$headers,
$body
);
$responseData = json_decode($httpResponse->getBody()->getContents(), true);
return $this->createResponse($responseData, $httpResponse->getStatusCode());
}
protected function createResponse($data, $statusCode)
{
return $this->response = new Response($this, $data, $statusCode);
}
abstract protected function getEndpointPath();
}
Step 5: Copy or Create the Response Class
The Response class handles the gateway's API responses:
namespace Omnipay\YourGateway\Message;
use Omnipay\Common\Message\AbstractResponse;
use Omnipay\Common\Message\RequestInterface;
class Response extends AbstractResponse
{
protected $statusCode;
public function __construct(RequestInterface $request, $data, $statusCode = 200)
{
parent::__construct($request, $data);
$this->statusCode = $statusCode;
}
public function isSuccessful()
{
return $this->statusCode >= 200 && $this->statusCode < 300 &&
isset($this->data['status']) && $this->data['status'] === 'success';
}
public function getTransactionReference()
{
if (isset($this->data['data']['id'])) {
return $this->data['data']['id'];
}
return null;
}
public function getTransactionId()
{
if (isset($this->data['data']['reference_id'])) {
return $this->data['data']['reference_id'];
}
return null;
}
public function getMessage()
{
if (isset($this->data['message'])) {
return $this->data['message'];
}
if (isset($this->data['error']['message'])) {
return $this->data['error']['message'];
}
return null;
}
public function getCode()
{
if (isset($this->data['error']['code'])) {
return $this->data['error']['code'];
}
return $this->statusCode;
}
// Additional response methods
}
Step 6: Implement Specific Request Classes
Now, implement specific request classes for each payment operation. Here's an example of a PurchaseRequest class:
namespace Omnipay\YourGateway\Message;
class PurchaseRequest extends AbstractRequest
{
protected function getEndpointPath()
{
return '/v1/checkout';
}
// Parameter getters and setters
public function getCustomerId()
{
return $this->getParameter('customerId');
}
public function setCustomerId($value)
{
return $this->setParameter('customerId', $value);
}
// Additional parameters...
public function getData()
{
$this->validate('amount', 'currency');
$data = [
'amount' => $this->getAmountInteger(),
'currency' => $this->getCurrency(),
'description' => $this->getDescription(),
];
// Add customer details if available
if ($this->getCard()) {
$card = $this->getCard();
$data['customer'] = [
'name' => $card->getName(),
'email' => $card->getEmail(),
// Additional customer details...
];
}
// Add additional parameters...
return $data;
}
protected function createResponse($data, $statusCode)
{
return $this->response = new PurchaseResponse($this, $data, $statusCode);
}
}
And a corresponding PurchaseResponse class:
namespace Omnipay\YourGateway\Message;
class PurchaseResponse extends Response
{
public function isRedirect()
{
return $this->isSuccessful() && isset($this->data['data']['redirect_url']);
}
public function getRedirectMethod()
{
return 'GET';
}
public function getRedirectUrl()
{
if ($this->isRedirect()) {
return $this->data['data']['redirect_url'];
}
return null;
}
public function getRedirectData()
{
return [];
}
}
You get the drift. Following the same pattern, implement other request classes for different payment operations:
AuthorizeRequest.php
: For authorizing payments without capturing fundsCaptureRequest.php
: For capturing previously authorized paymentsRefundRequest.php
: For refunding transactionsVoidRequest.php
: For voiding/canceling transactionsFetchTransactionRequest.php
: For retrieving transaction details
Each class should extend AbstractRequest and implement the necessary methods for its specific operation.
Step 7: Write Tests
Create tests for your gateway and request classes to ensure they work correctly. This bit is not my favourite:
namespace Omnipay\YourGateway\Tests;
use Omnipay\YourGateway\Gateway;
use Omnipay\Tests\GatewayTestCase;
class GatewayTest extends GatewayTestCase
{
protected $gateway;
public function setUp(): void
{
parent::setUp();
$this->gateway = new Gateway($this->getHttpClient(), $this->getHttpRequest());
$this->gateway->setApiKey('test-api-key');
$this->gateway->setApiSecret('test-api-secret');
$this->gateway->setTestMode(true);
}
public function testPurchase()
{
$request = $this->gateway->purchase([
'amount' => '10.00',
'currency' => 'USD',
'description' => 'Test Purchase',
'card' => [
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john.doe@example.com',
],
]);
$this->assertInstanceOf('Omnipay\YourGateway\Message\PurchaseRequest', $request);
$this->assertSame('10.00', $request->getAmount());
$this->assertSame('USD', $request->getCurrency());
}
// Additional tests for other methods...
}
Step 8: Create Documentation
Create a helpful README.md file with installation instructions and usage examples. The Php League have already done a great job for us, but you may want to add something else or simply rephrase the instructions. I know I benefit from reading the same content rewritten by different brians.
Step 9: Publish Your Package
Once your driver is complete and tested:
- Publish it on your favourite Git hosting service such as GitHub, Gitlab, etc.
- Register your package on Packagist by submitting your Git repository URL
Real-World Example: Tazapay Omnipay Driver
Let's look at how we implemented the Tazapay driver as a practical example.
Understanding the API
Before writing any code, we thoroughly reviewed the Tazapay API documentation:
- Authentication method (Basic Auth)
- Available endpoints
- Request/response formats
- Error handling
Mapping API Endpoints to Omnipay Methods
We mapped Tazapay's endpoints to Omnipay's standard methods:
Omnipay Method | Tazapay Endpoint |
---|---|
purchase() | /v3/checkout |
authorize() | /v3/checkout (with capture=false) |
capture() | /v3/payin/{id}/confirm |
refund() | /v3/refund |
void() | /v3/payin/{id}/cancel |
fetchTransaction() | /v3/checkout/{id} or /v3/payin/{id} |
Handling Authentication
Tazapay uses Basic Authentication, which we implemented in the AbstractRequest class:
public function sendData($data)
{
$method = $this->getHttpMethod();
$url = $this->getEndpoint() . $this->getEndpointPath();
$headers = $this->getHeaders();
$auth = base64_encode($this->getApiKey() . ':' . $this->getApiSecret());
$headers['Authorization'] = 'Basic ' . $auth;
$body = $data ? json_encode($data) : null;
$httpResponse = $this->httpClient->request(
$method,
$url,
$headers,
$body
);
$responseData = json_decode($httpResponse->getBody()->getContents(), true);
return $this->createResponse($responseData, $httpResponse->getStatusCode());
}
Handling Redirects
Tazapay uses a hosted checkout page, so we implemented a PurchaseResponse class that handles redirects:
public function isRedirect()
{
return $this->isSuccessful() && isset($this->data['data']['url']);
}
public function getRedirectUrl()
{
if ($this->isRedirect()) {
return $this->data['data']['url'];
}
return null;
}
Testing the Implementation
We created tests for each method to ensure they work correctly:
use Omnipay\Tazapay\Message\PurchaseRequest;
public function testPurchase()
{
$request = $this->gateway->purchase([
'amount' => '10.00',
'currency' => 'USD',
'description' => 'Test Purchase',
'card' => [
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john.doe@example.com',
'country' => 'SG',
],
]);
$this->assertInstanceOf(PurchaseRequest::class, $request);
$this->assertSame('10.00', $request->getAmount());
$this->assertSame('USD', $request->getCurrency());
}
Common Challenges and Solutions
I didn't know this bit. Fortunately, the AI chatbot did.
1. Understanding the Gateway's API
Challenge: Each payment gateway has its own API structure, authentication methods, and response formats.
Solution: Thoroughly review the API documentation before starting. Create a mapping between the gateway's endpoints and Omnipay's standard methods.
2. Handling Different Authentication Methods
Challenge: Gateways use various authentication methods (API keys, OAuth, JWT, etc.).
Solution: Implement the appropriate authentication method in your AbstractRequest class's sendData
method.
3. Mapping Gateway-Specific Parameters
Challenge: Gateways often have unique parameters that don't directly map to Omnipay's standard parameters.
Solution: Create custom getter and setter methods for gateway-specific parameters, and map standard Omnipay parameters to their gateway equivalents in your getData methods.
4. Handling Redirects
Challenge: Many gateways use hosted checkout pages that require redirecting the customer.
Solution: Implement the isRedirect, getRedirectUrl, and getRedirectMethod methods in your response classes.
5. Error Handling
Challenge: Gateways have different error formats and codes.
Solution: Implement robust error handling in your response classes to extract error messages and codes from the gateway's responses.
Conclusion
Building an Omnipay driver for a payment gateway can initially feel overwhelming, but don't worryβit's entirely manageable with a clear plan and a good grasp of the gateway's API. Think of it like assembling a puzzle: once you understand how the pieces fit together, you'll be able to create a robust and maintainable integration. And remember, there's no need to reinvent the wheel. Take advantage of the wealth of existing drivers out there. Clone and tweak. Happy coding!
This blog post was created based on our experience developing an Omnipay driver for Tazapay. The code examples are simplified for clarity and may need adjustments for your specific use case.