imdhemy/google-play-billing
A PHP package for server-side Google Play Billing integration. Validate in-app purchases and subscriptions, verify purchase tokens with Google Play, handle acknowledgements, and add billing support to Laravel or any PHP app.
Install and confirm which major version you're on. composer require imdhemy/google-play-billing — then immediately check vendor/imdhemy/google-play-billing/CHANGELOG.md and composer show. The 1.x line wraps google/apiclient; the 2.x line is a leaner, typed client over the REST API and renames/moves several classes (Publisher, ProductPurchase, versioned subscriptionsV2). Any snippet you find online is probably for the other major.
Do the Google-side setup before touching code. The library is a thin typed wrapper — 90% of first-day failures are console misconfiguration, not PHP:
acknowledge/consume).Where to look first. Read src/Publisher/ (the resource tree mirroring purchases.products, purchases.subscriptions, purchases.voidedpurchases), then src/Products/, src/Subscriptions/, and src/DeveloperNotifications/ for RTDN parsing. The tests/ fixtures are the best documentation of what real API responses look like — they'll save you guessing at field names.
Minimal first use case — verify a subscription token on the server, never on the device:
use Imdhemy\GooglePlay\ClientFactory;
use Imdhemy\GooglePlay\Publisher\Publisher;
$client = ClientFactory::create([ClientFactory::SCOPE_ANDROID_PUBLISHER]);
$client->setAuthConfig(storage_path('google/service-account.json'));
$publisher = new Publisher($client);
$purchase = $publisher->apps()->purchases()->subscriptions()
->get('com.example.app', 'premium_monthly', $purchaseToken);
$purchase->getPaymentState(); // 0 pending, 1 received, 2 free trial
$purchase->getExpiryTime(); // epoch seconds
$purchase->getAutoRenewing();
If that returns an object, you're done with setup. Everything else is business logic you own.
Verify → persist → acknowledge → grant. Treat this as a fixed pipeline, in that order, and make it idempotent:
DB::transaction(function () use ($purchase, $token) {
$record = Purchase::firstOrCreate(
['order_id' => $purchase->getOrderId()], // natural idempotency key
['user_id' => auth()->id(), 'token' => $token, 'sku' => $sku]
);
if (!$record->wasRecentlyCreated) {
return $record; // replay, no-op
}
if ($purchase->getPurchaseState() === ProductPurchase::PURCHASE_STATE_PURCHASED) {
$publisher->apps()->purchases()->products()
->acknowledge($packageName, $sku, $token); // or ->consume() for consumables
$record->markEntitled();
}
});
Never acknowledge before you've persisted — if the ack succeeds and your DB write fails, you've granted nothing and Google thinks you're done.
Push RTDN into a queue, not into your handler. The Pub/Sub push endpoint should decode, validate, dispatch a job, and return 200 in single-digit milliseconds. Anything slower causes Pub/Sub to retry and you'll process duplicates.
$data = json_decode(base64_decode($request->input('message.data')), true);
$notification = DeveloperNotification::parse($data); // signature varies by version
ProcessPlayNotification::dispatch($notification->getPackageName(), $data);
Switch on notification type, don't interpret raw JSON. SUBSCRIPTION_RENEWED, SUBSCRIPTION_CANCELED, SUBSCRIPTION_EXPIRED, SUBSCRIPTION_REVOKED, SUBSCRIPTION_ON_HOLD, SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_RECOVERED. Note that several of these only tell you something changed — the reliable pattern is to re-fetch the subscription with subscriptions()->get() inside the job and reconcile your DB to that source of truth, rather than mapping each notification type to a state transition by hand.
Own an explicit entitlement model. The library gives you Google's raw state; you need your own active | grace | on_hold | expired | refunded enum plus a expires_at column. Every read path in your app (middleware, feature flags, API responses) should hit your table, not the Play API. That keeps you off Google's quota and keeps your app up when Google is slow.
Reconcile nightly. RTDN is at-least-once and occasionally lossy during outages. A scheduled job that lists active local subscriptions and re-fetches a rolling window (or calls purchases()->voidedpurchases()->list() with a startTime since the last run) catches refunds and expiries you missed. This is the single highest-value thing you can add after the webhook.
For consumables, consume inside the same transaction boundary as the credit grant, or you'll either double-credit on retry or lose a purchase on crash. Store the token as a unique key and let the DB reject duplicates.
Multi-app / multi-tenant: keep one Publisher per package name, resolved from config, and cache the constructed client in the container. Constructing and authenticating a Google client per request is a measurable latency hit (OAuth token fetch).
Testing: inject a fake Publisher (or a PSR-18 mock client, depending on version) behind an interface you define, and build test doubles from the fixtures in the package's tests/ directory. Don't hit the live API in CI — the sandbox is shared and quota-limited.
purchaseState === 0 means PENDING, not purchased. Slow payment methods (cash, boleto) produce pending transactions that later flip to purchased. Granting on "not cancelled" is a common and expensive bug. Only grant on PURCHASED (or, for subscriptions, a positive paymentState).purchaseType. In the v2 subscription API, testPurchase / purchaseType == 0 marks license-tester transactions. Strip entitlement at the end of the test cycle and make sure your reconciliation job doesn't treat a tester's expiry as churn.get() mean "invalid token," not "server error." Catch them and return a clean 422 to the client. Letting a Google_Service_Exception bubble into a 500 will make your error dashboards useless.packageName and productId from the response against your expectation. A token from another app in your portfolio, or from a cheap SKU swapped in by a tampered client, is a real attack vector. Compare the returned SKU to the one you're granting for.message.messageId (store it with a unique index) or on (orderId, eventTimeMillis). Retries are frequent during your own deploys.Authorization header, or use a long unguessable secret path plus a shared header — but don't expose a naked /webhooks/play.message.data field is base64. Decoding it twice or zero times are both common; log the first raw payload you ever receive and hard-code your expectations from it.expiryTime is epoch seconds, and it's Google's clock. Add a small grace window (30–120 s) before treating an expiry as authoritative, and never trust the client device time for anything.getExpiryTime() may be absent for pending or on-hold subscriptions. Null-check it; the value objects return null rather than throwing for missing optional fields.subscriptionsV2.get() if your version of the package exposes it — the v1 subscription resource is deprecated and doesn't model multi-line/upgrade states well.setAuthConfig() takes a path or array; feed it from an env-injected JSON blob or a secret manager mount, and add service-account*.json to .gitignore on day one.Publisher (so you can swap in a fake), and your own value-object layer on top of the returned DTOs. Don't subclass the response objects — they're hydrated from JSON.SUBSCRIPTION_REVOKED/_EXPIRED notifications). Handle both; the notification can be missed, the list can be polled.composer.json ("^2.0" or "^1.0"), and read the changelog before any minor bump — the 1→2 migration is a namespace-and-return-type rewrite, not a patch.How can I help you explore Laravel packages today?