myfatoorah/symfony-bundle
Symfony bundle that integrates the MyFatoorah payment gateway into Symfony applications, with configuration, service definitions, and convenient APIs for creating payments, handling callbacks, and managing transactions.
Install and register. composer require myfatoorah/symfony-bundle. There is no Flex recipe, so add it manually to config/bundles.php:
MyFatoorah\SymfonyBundle\MyFatoorahBundle::class => ['all' => true],
Verify the actual FQCN from vendor/myfatoorah/symfony-bundle/src/ before pasting — bundles of this vintage often have inconsistent namespaces.
Configure per environment. Create config/packages/myfatoorah.yaml and wire it to env vars:
myfatoorah:
api_key: '%env(MYFATOORAH_API_KEY)%'
country_code: 'KWT' # or SAU, ARE, EGY, QAT, BHR, JOR, OMN
is_test: '%env(bool:MYFATOORAH_TEST)%'
Also add config/packages/test/myfatoorah.yaml with stub values so unit tests boot without secrets. Check the bundle's DependencyInjection/Configuration.php for the exact key names — they are not stable across releases.
Find the service first. Before writing code:
php bin/console debug:container --show-private | grep -i fatoorah
php bin/console debug:config myfatoorah
This tells you the service id, the autowireable type, and whether the bundle registered it as public. If you can't autowire it, define an alias in services.yaml.
First use case: a redirect-based invoice payment. The simplest end-to-end flow is: user clicks "Pay" → call the send/initiate method → redirect to the returned InvoiceURL → customer pays on the hosted page → MyFatoorah hits your callback → you verify status → mark order paid. Build this in a dedicated App\Payment\MyFatoorahGateway wrapper service, not in a controller, from day one.
Wrap the bundle in an application-level gateway service. Depend on the bundle client in exactly one place so you can mock it, swap it, or bypass it later:
final class MyFatoorahGateway
{
public function __construct(private MyFatoorahClient $client) {}
public function createInvoice(Order $order): string
{
$response = $this->client->sendPayment([
'InvoiceValue' => (float) $order->getTotal(),
'CustomerName' => $order->getCustomerName(),
'CustomerEmail' => $order->getCustomerEmail(),
'CustomerReference' => (string) $order->getId(), // your join key
'CallBackUrl' => $this->router->generate('payment_callback', [], UrlGeneratorInterface::ABSOLUTE_URL),
'ErrorUrl' => $this->router->generate('payment_error', [], UrlGeneratorInterface::ABSOLUTE_URL),
'Language' => 'en', // or 'ar'
'NotificationOption' => 'LNK', // 'LNK' | 'EML' | 'SMS'
]);
if (empty($response['IsSuccess'])) {
throw new PaymentFailedException($response['ValidationErrors'] ?? 'Unknown error');
}
return $response['Data']['InvoiceURL'];
}
}
Callback controller: verify, then fulfill. MyFatoorah appends paymentId to your callback URL. Never trust the query string alone — always re-query the gateway:
#[Route('/payment/callback', name: 'payment_callback')]
public function callback(Request $request, MyFatoorahGateway $gw, OrderRepository $orders): Response
{
$paymentId = $request->query->get('paymentId')
?? throw new BadRequestHttpException('Missing paymentId');
$status = $gw->getPaymentStatus($paymentId);
if ($status['Data']['InvoiceStatus'] !== 'Paid') {
return $this->redirectToRoute('payment_failed', ['ref' => $request->query->get('Id')]);
}
$order = $orders->findOneByReferenceOrFail($status['Data']['CustomerReference']);
return $this->redirectToRoute('order_receipt', ['id' => $order->getId()]);
}
Pass the invoice id (Id / InvoiceId) back into your fulfillment path, and store it on the order row at creation time.
Idempotent fulfillment with a webhook as the source of truth. The browser callback can be lost (user closes tab, network drop), and MyFatoorah can retry notifications. The robust pattern is:
POST /webhooks/myfatoorah.markPaidFromStatus(InvoiceId, status) service method.myfatoorah_invoice_id and a DB transaction:
$this->em->wrapInTransaction(function () use ($order, $status) {
if ($order->isPaid()) { return; } // no-op on retry
$order->markPaid($status['Data']['InvoiceId']);
});
Refunds and cancellations. Mirror the same wrapper style:
public function refund(string $paymentId, float $amount, string $reason = 'requested_by_customer'): void
{
$r = $this->client->makeRefund([
'Key' => $paymentId,
'KeyType' => 'PaymentId',
'Amount' => $amount,
'Reason' => $reason,
]);
if (empty($r['IsSuccess'])) { throw new RefundFailedException($r['ErrorMessage'] ?? ''); }
}
Record partial refunds as separate rows — MyFatoorah supports multiple refunds against one payment, and a single boolean flag will not survive the first partial.
Config and secrets. Put the API key in Symfony Secrets (bin/console secrets:set MYFATOORAH_API_KEY) for production, plain .env.local elsewhere. Keep the sandbox key in .env.test. Both keys are account-country-specific, so a developer in Egypt cannot test a Kuwaiti account's flow.
float in your domain model and hope; use integer minor units or a money library and convert only at the boundary. A 1-fils rounding difference causes InvoiceValue mismatch on refunds.is_test in config only flips the key and not the endpoint, you will send test keys to production and get opaque auth errors. Verify with debug:config and a curl against the resolved URL.symfony-cli's expose command, ngrok, or a tunnel, and put the tunnel base URL in an env var (APP_PUBLIC_URL) that feeds CallBackUrl/ErrorUrl. Hardcoding absolute URLs from the request context will send localhost to MyFatoorah and silently fail.IsSuccess is a string "true"/"false" in some responses — depending on the bundle version's deserialization. Compare with filter_var($r['IsSuccess'], FILTER_VALIDATE_BOOLEAN) rather than truthiness of a string "false" (which is truthy). This bites people in refund code paths.ValidationErrors (an array of {Name, Error}); runtime failures return ErrorMessage. Log both raw, but never log the api_key or the full Authorization header.InvoiceCurrency. Multi-currency storefronts must set it explicitly and confirm the account supports that currency.LNK returns InvoiceURL for a redirect; EML/SMS send a hosted link to the customer instead. If you redirect and use EML, customers get two links and both hit your callback — that's fine given idempotency, but confusing in support tickets.composer.json symfony/* constraints and run composer why-not symfony/framework-bundle. If they conflict with your version, skip the bundle and call the REST API via symfony/http-client — the endpoint surface is small (SendPayment, GetPaymentStatus, MakeRefund, CancelPayment, GetPaymentMethods) and a 200-line MyFatoorahHttpClient is often less maintenance than a stale bundle.services.yaml with decorates: if you need retry logic, request logging, or PSR-18 client injection. Do not fork the vendor directory.ErrorUrl to route to a page that says "payment not completed" and confirms via GetPaymentStatus — customers who abandon the hosted page frequently return through the browser back button, not your callback.How can I help you explore Laravel packages today?