Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Google Play Billing Laravel Package

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.

Deep Wiki
Context7

Getting Started

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:

  1. Enable "Google Play Android Developer API" in a Google Cloud project.
  2. Create a service account, download the JSON key, store it outside the webroot.
  3. In Play Console → Users & Permissions, invite the service account email with View financial data + Manage orders (order management is required for acknowledge/consume).
  4. Have at least one product/subscription and one license-tester account ready.

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.

Implementation Patterns

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.

Gotchas and Tips

  • The 3-day acknowledgement clock is real. Unacknowledged one-time purchases are auto-refunded after three days. Same for consumption. If you see "users get refunded automatically," check your ack path silently swallowing exceptions.
  • 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).
  • Test purchases are indistinguishable except by 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.
  • 400 and 404 from 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.
  • Validate 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.
  • Pub/Sub is at-least-once. Deduplicate on message.messageId (store it with a unique index) or on (orderId, eventTimeMillis). Retries are frequent during your own deploys.
  • Authenticate the push endpoint. The payload is unauthenticated by default. Either validate the Google-signed JWT in the Authorization header, or use a long unguessable secret path plus a shared header — but don't expose a naked /webhooks/play.
  • Pub/Sub payload encoding trips everyone up. The 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.
  • Prefer 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.
  • Credentials in the repo is the #1 leaked-secret pattern for this package. 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.
  • The library does no retry, no caching, and no quota management. Wrap calls in a single retry with jitter for 5xx/429, cache OAuth tokens, and remember the per-project quota is shared across every app in the Cloud project.
  • Extension points are limited by design. The clean places to intervene are: a custom PSR-18 HTTP client passed into the factory (for logging/tracing), your own wrapper service around 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.
  • Refunds arrive two ways (voided-purchases list and SUBSCRIPTION_REVOKED/_EXPIRED notifications). Handle both; the notification can be missed, the list can be polled.
  • Version churn is the biggest ongoing cost. Pin the major version in 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core