Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in Symfony

To verify a Paystack payment in Symfony, use your PaystackService to GET the verify endpoint. Check status, amount, and currency against your Doctrine Order entity. Use DQL UPDATE with a condition on paid=false for idempotent fulfillment.

Verification Service Method

// In PaystackService
public function verifyAndFulfill(string $reference, EntityManagerInterface $em): array
{
    $order = $em->getRepository(Order::class)->findOneBy(['reference' => $reference]);

    if (!$order) {
        return ['success' => false, 'error' => 'Order not found'];
    }

    if ($order->isPaid()) {
        return ['success' => true, 'already_fulfilled' => true];
    }

    $result = $this->verifyTransaction($reference);

    if (!($result['status'] ?? false) || $result['data']['status'] !== 'success') {
        return ['success' => false, 'error' => 'Payment not successful'];
    }

    if ($result['data']['amount'] !== $order->getAmount()) {
        return ['success' => false, 'error' => 'Amount mismatch'];
    }

    // Atomic update
    $updated = $em->createQueryBuilder()
        ->update(Order::class, 'o')
        ->set('o.paid', true)
        ->set('o.paidAt', ':now')
        ->where('o.reference = :ref')
        ->andWhere('o.paid = false')
        ->setParameter('ref', $reference)
        ->setParameter('now', new \DateTime())
        ->getQuery()
        ->execute();

    return [
        'success' => true,
        'already_fulfilled' => $updated === 0,
    ];
}

Controller Usage

#[Route('/payment/callback')]
public function callback(Request $request, PaystackService $paystack, EntityManagerInterface $em): Response
{
    $reference = $request->query->get('reference') ?? $request->query->get('trxref');

    if (!$reference) {
        return new Response('Missing reference', 400);
    }

    $result = $paystack->verifyAndFulfill($reference, $em);

    if ($result['success']) {
        return $this->render('payment/success.html.twig', ['reference' => $reference]);
    }

    return $this->render('payment/failed.html.twig', ['error' => $result['error']]);
}

Common Mistakes

  • Using $em->flush() without atomic conditions. Use DQL UPDATE with a WHERE clause instead.
  • Not checking currency. Always verify the currency matches your entity.
  • Trusting request parameters. Verify against your database, not URL params.

Ship Payments Faster

Key Takeaways

  • Extend your PaystackService with a verifyAndFulfill method that checks amount, currency, and status.
  • Use Doctrine DQL or QueryBuilder for atomic updates that prevent double-granting.
  • Both callback and webhook handlers should call the same verification service method.
  • Always check amount and currency against your Doctrine entity, not request parameters.
  • If the verify call fails, retry with exponential backoff.
  • The Paystack verify endpoint is idempotent. Calling it multiple times returns the same result.

Frequently Asked Questions

Can I use the Symfony Messenger component for async verification?
Yes. Dispatch a message from your webhook handler and process it asynchronously. The idempotent verification logic ensures safety.
Should I verify in the webhook handler?
Yes. Both callback and webhook should call verifyAndFulfill. The atomic update ensures only one fulfills the order.
How do I handle verification timeouts?
Set a timeout on the HttpClient request. If it times out, retry. The transaction state on Paystack does not change.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon