Bonaventure OgetoBy Bonaventure Ogeto|

Build Paystack Subscriptions in Symfony

To build Paystack subscriptions in Symfony, create a plan, initialize a transaction with the plan code, handle subscription webhooks to update a Doctrine entity, and use Security Voters to gate premium content behind active subscription checks.

Subscription Entity

// src/Entity/Subscription.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Subscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\OneToOne(targetEntity: User::class)]
    private User $user;

    #[ORM\Column(length: 100, unique: true)]
    private string $subscriptionCode;

    #[ORM\Column(length: 100)]
    private string $planCode;

    #[ORM\Column(length: 100)]
    private string $emailToken;

    #[ORM\Column(length: 20)]
    private string $status = 'active';

    #[ORM\Column(nullable: true)]
    private ?\DateTimeInterface $nextPaymentDate = null;

    // Getters and setters...
}

Subscribe Endpoint

#[Route('/subscribe', methods: ['POST'])]
public function subscribe(Request $request, PaystackService $paystack): Response
{
    $user = $this->getUser();
    $planCode = $request->request->get('plan_code');

    $result = $paystack->initializeWithPlan($user->getEmail(), $planCode);

    if ($result['status'] ?? false) {
        return $this->redirect($result['data']['authorization_url']);
    }

    return new Response('Failed', 400);
}

Webhook Handlers

private function handleSubscriptionCreate(array $data, EntityManagerInterface $em): void
{
    $email = $data['customer']['email'];
    $user = $em->getRepository(User::class)->findOneBy(['email' => $email]);

    if (!$user) return;

    $sub = $em->getRepository(Subscription::class)->findOneBy(['user' => $user]);

    if (!$sub) {
        $sub = new Subscription();
        $sub->setUser($user);
    }

    $sub->setSubscriptionCode($data['subscription_code']);
    $sub->setPlanCode($data['plan']['plan_code']);
    $sub->setEmailToken($data['email_token'] ?? '');
    $sub->setStatus('active');

    $em->persist($sub);
    $em->flush();
}

Subscription Voter

// src/Security/Voter/SubscriptionVoter.php
namespace App\Security\Voter;

use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class SubscriptionVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === 'ACTIVE_SUBSCRIPTION';
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user) return false;

        $sub = $user->getSubscription();
        return $sub && $sub->getStatus() === 'active';
    }
}

// In controller:
#[IsGranted('ACTIVE_SUBSCRIPTION')]
#[Route('/premium')]
public function premium(): Response
{
    return $this->render('premium.html.twig');
}

Cancel Subscription

#[Route('/subscription/cancel', methods: ['POST'])]
public function cancel(PaystackService $paystack, EntityManagerInterface $em): Response
{
    $sub = $this->getUser()->getSubscription();

    if (!$sub) {
        return $this->redirectToRoute('subscribe');
    }

    $result = $paystack->disableSubscription($sub->getSubscriptionCode(), $sub->getEmailToken());

    if ($result['status'] ?? false) {
        $sub->setStatus('cancelled');
        $em->flush();
    }

    return $this->redirectToRoute('dashboard');
}

Ship Payments Faster

Key Takeaways

  • Paystack handles recurring billing. Create plans once and subscribe customers to them.
  • Use a Doctrine entity for subscription tracking with status, subscription_code, and email_token.
  • Webhook events drive subscription state. Handle subscription.create, charge.success, and invoice.payment_failed.
  • Symfony Security Voters provide fine-grained access control for subscription-gated features.
  • Store email_token for cancellation. It comes in the subscription.create webhook.
  • Use Symfony Messenger for async webhook processing.

Frequently Asked Questions

Can I use Symfony Security for subscription checks?
Yes. Security Voters are the Symfony way to implement fine-grained authorization. Create a voter that checks the subscription status and use #[IsGranted] on your controllers.
How do I handle plan upgrades?
Cancel the current subscription and create a new one. Paystack does not support direct plan changes.
What if a renewal fails?
Paystack retries automatically. You receive invoice.payment_failed webhooks. Update the status to past_due and notify the customer.

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