A small, framework-agnostic toolkit for direct carrier billing (DCB) in PHP — the part that's the same across every carrier: one gateway interface, normalized callback events, idempotent charging, and signature verification. Zero runtime dependencies.
I built and ran 20+ carrier-billing integrations across ~10 countries on a
production platform (subscriptions, one-off charges, and millions of async
billing callbacks). The carriers were all different — different APIs, different
notification formats, different quirks — but the shape of the problem was
always the same. dcb-kit is that shape, distilled. It is not any carrier's
proprietary integration (those stay under NDA where they belong); it's the
scaffolding you hang your own adapters on.
composer require gardi/dcb-kitA carrier integration always comes down to a few operations — subscribe a number,
charge it, cancel — plus a stream of async notifications (activated, renewed,
charged, out of balance, unsubscribed). Every carrier names these differently;
dcb-kit normalizes them to one enum so the rest of your app never cares which
carrier it's talking to:
Subscribed · Renewed · Charged · InsufficientBalance · Unsubscribed · Failed · Unknown
Implement one interface per carrier:
use Gardi\DcbKit\Contracts\CarrierGateway;
use Gardi\DcbKit\Callbacks\{CallbackEvent, CallbackType};
use Gardi\DcbKit\Results\{ChargeResult, SubscriptionResult};
use Gardi\DcbKit\{Money, Support\Signature};
final class AcmeTelecom implements CarrierGateway
{
public function __construct(private string $apiKey, private string $secret) {}
public function name(): string { return 'acme'; }
public function subscribe(string $msisdn, string $plan): SubscriptionResult { /* call carrier */ }
public function charge(string $msisdn, Money $amount, string $reference): ChargeResult { /* ... */ }
public function unsubscribe(string $msisdn, string $subscriptionId): void { /* ... */ }
public function parseCallback(array $payload): CallbackEvent
{
$type = match ($payload['event']) {
'SUB_OK' => CallbackType::Subscribed,
'BILL_OK' => CallbackType::Charged,
'NO_FUND' => CallbackType::InsufficientBalance,
default => CallbackType::Unknown,
};
return new CallbackEvent($type, $payload['msisdn'], raw: $payload);
}
public function verifyCallback(string $rawBody, string $signature): bool
{
return Signature::verify($rawBody, $signature, $this->secret);
}
}Most carriers are just a base URL + a status table + an auth scheme + a signature
scheme. For those, skip the class — hand HttpCarrierGateway the moving parts:
use Gardi\DcbKit\{HttpCarrierGateway, CallbackUrl};
use Gardi\DcbKit\Callbacks\{StatusMap, CallbackType};
use Gardi\DcbKit\Contracts\Transport;
use Gardi\DcbKit\Auth\BearerAuth;
use Gardi\DcbKit\Verification\HmacVerifier;
// 1. The HTTP seam — wrap whatever client you use (Guzzle, Laravel Http, curl).
final class GuzzleTransport implements Transport
{
public function request(string $method, string $url, array $payload, array $headers = []): array
{
// ...send it (with $headers) and return the decoded JSON response as an array
}
}
// 2. Configure the carrier — no subclass needed.
$carriers->extend('acme', fn () => new HttpCarrierGateway(
name: 'acme',
baseUrl: $config['acme']['base_url'], // <- your base URL
transport: new GuzzleTransport(),
statusMap: StatusMap::make([ // <- this carrier's status codes
'ACTIVATION' => CallbackType::Subscribed,
'RENEWAL' => CallbackType::Renewed,
'BILL_OK' => CallbackType::Charged,
'NO_FUNDS' => CallbackType::InsufficientBalance,
'CANCEL' => CallbackType::Unsubscribed,
]),
verifier: new HmacVerifier($config['acme']['secret']), // <- how callbacks are signed
auth: new BearerAuth($config['acme']['token']), // <- how requests authenticate
callbackUrl: CallbackUrl::for($config['webhook_base'], 'acme'),
statusField: 'event', // <- which payload key holds the status
msisdnField: 'phone',
transactionIdField: 'data.txn.id', // <- dot paths work for nested responses
));The pieces that vary most between carriers are all strategies you pick:
- Auth (
Gardi\DcbKit\Auth\*):BearerAuth,ApiKeyAuth,BasicAuth,QueryKeyAuth,NoAuth— or implementAuthenticationfor request signing. - Callback verification (
Gardi\DcbKit\Verification\*):HmacVerifier(HMAC of the raw body) orNoVerification— or implementCallbackVerifier. - Field names accept dot paths (
data.transaction.id), so renamed and nested response/callback shapes need no code.
Carriers that still don't fit extend HttpCarrierGateway and override the one
method that differs.
When the carriers are config-shaped, build the whole manager from an array — no per-carrier code at all:
use Gardi\DcbKit\CarrierManager;
$carriers = CarrierManager::fromArray([
'acme' => [
'base_url' => 'https://api.acme.test',
'auth' => ['type' => 'bearer', 'token' => $config['acme']['token']],
'verifier' => ['type' => 'hmac', 'secret' => $config['acme']['secret']],
'statuses' => [
'ACTIVATION' => 'subscribed',
'BILL_OK' => 'charged',
'NO_FUNDS' => 'insufficient_balance',
'CANCEL' => 'unsubscribed',
],
'status_field' => 'event',
'msisdn_field' => 'phone',
'transaction_id_field' => 'data.txn.id',
],
// 'mtn' => [ ... ], 'zain' => [ ... ]
], new GuzzleTransport());
$carriers->gateway('acme')->charge(/* ... */);auth.type is one of bearer / api_key / basic / query / none;
verifier.type is hmac / none. This maps straight onto a Laravel/Symfony
config file — adding a carrier becomes a config edit, not a code deploy.
Register your carriers and resolve them by name:
use Gardi\DcbKit\{CarrierManager, Money};
$carriers = new CarrierManager();
$carriers->extend('acme', fn () => new AcmeTelecom($apiKey, $secret)); // lazy
$gateway = $carriers->gateway('acme');
// $reference is your idempotency key — never charge the same one twice.
$result = $gateway->charge('9647501234567', Money::of(500, 'IQD'), 'order-42');
// In your webhook controller — verify the RAW body, then parse the decoded array:
if ($gateway->verifyCallback($rawBody, $signature)) {
$event = $gateway->parseCallback($payload); // $payload = json_decode($rawBody, true)
if ($event->isSuccessful()) {
// mark the subscription/charge as confirmed
}
}See tests/FakeCarrier.php for a complete reference gateway.
Three opt-in decorators cover the production concerns — all composable with the above.
Retry transient failures — wrap your Transport. Retrying a charge is safe
because the reference is the carrier's idempotency key, so the same reference is
never a second charge:
use Gardi\DcbKit\Transport\RetryingTransport;
$transport = new RetryingTransport(
new GuzzleTransport(),
maxAttempts: 3,
baseDelayMs: 100, // 100ms, then 200ms, then 400ms ... (exponential)
retryOn: fn (\Throwable $e) => $e instanceof MyTimeoutException, // optional: scope it
);Don't double-charge your own retries — wrap a gateway with an
IdempotencyStore. A charge already completed under a reference is replayed from
the store instead of charged again (failed charges aren't remembered, so they can
be retried):
use Gardi\DcbKit\Idempotency\IdempotentGateway;
$gateway = new IdempotentGateway($carriers->gateway('acme'), new RedisIdempotencyStore());
$gateway->charge('9647501234567', Money::of(500, 'IQD'), 'order-42'); // safe to repeatThe shipped InMemoryIdempotencyStore is for tests / a single process — back the
IdempotencyStore interface with Redis or a unique-indexed table in production.
(This guards against your app repeating a charge; the in-flight case — you
charged, the response was lost, you retry — is covered by the carrier's own
reference idempotency.)
Handle a webhook in one call — resolve the carrier, verify the raw body, decode, and parse:
use Gardi\DcbKit\Webhooks\WebhookHandler;
use Gardi\DcbKit\Exceptions\InvalidCallbackSignatureException;
$handler = new WebhookHandler($carriers);
try {
$event = $handler->handle($carrier, $request->getContent(), $request->header('X-Signature'));
// ... act on $event, then 200
} catch (InvalidCallbackSignatureException) {
// 403 — spoofed or misconfigured
}CarrierGateway— the per-carrier interface.HttpCarrierGateway— a configurable base: stand up a carrier from a base URL +StatusMap+ auth + verifier + (dot-path) field names; override only the unusual bits.CarrierManager— register/resolve carriers by name (eager or lazy), orCarrierManager::fromArray()to build them all from one config array.Authentication(Auth\*) — pluggable outgoing-request auth: bearer, API key, basic, query key, or your own.CallbackVerifier(Verification\*) — pluggable callback verification: HMAC of the raw body, none, or your own.CallbackEvent/CallbackType/StatusMap— normalized notifications + the status table that feeds them.Transport— the HTTP seam (bring your own client; keeps the kit dependency-free).Money,SubscriptionResult,ChargeResult,CallbackUrl— small value objects.Signature— the constant-time HMAC primitive behindHmacVerifier.RetryingTransport— aTransportdecorator: retry transient failures with exponential backoff.IdempotencyStore+IdempotentGateway— dedupe charges by reference (ships an in-memory store; bring your own for production).WebhookHandler— resolve + verify + parse an incoming carrier webhook in one call.
Deliberately a toolkit, not a platform:
- No real carrier adapters ship with it. Carrier APIs are proprietary and
under contract.
HttpCarrierGatewaygets a standard one running from config; unusual ones you finish by overriding a method. - Idempotency is opt-in, and the shipped store isn't persistent.
IdempotentGatewaydedupes charges byreference, butInMemoryIdempotencyStoreis single-process — back theIdempotencyStoreinterface with Redis or a unique-indexed table in production. - Retries, not queueing.
RetryingTransporthandles transient failures; durable async work (queues/workers) is still yours to wire.
composer install
composer test # pestMIT — see LICENSE.