aporat/store-receipt-validator
A Laravel/PHP package for validating Apple App Store receipts, including auto-renewable subscriptions and StoreKit purchases. It supports sandbox and production environments, provides a simple API, helps verify transactions, and adds secure receipt validation to your app's backend.
This is a framework-agnostic PHP library, not a Laravel package — there is no service provider or publishable config. Install it and wrap it yourself.
composer require aporat/store-receipt-validator
Pin the major version explicitly (^4.0 or whatever the current major is). The namespaces and validator constructors have changed across majors, so an unpinned upgrade can break your checkout flow silently.
Where to look first, in order:
README.md — the per-platform examples are the only reliable source for the current constructor/setter names for your pinned version.src/iTunes/, src/GooglePlay/, src/Amazon/ — one directory per platform, each with a Validator and a Response class.src/iTunes/Response.php (and equivalents) — this is the DTO you will actually spend your time with: isValid(), getResultCode()/getStatus(), getPurchases(), getLatestReceiptInfo(), getTransactionId(), getOriginalTransactionId(), getExpiresDate().First use case — validating an Apple non-consumable before granting an entitlement:
use ReceiptValidator\iTunes\Validator as iTunesValidator;
$validator = (new iTunesValidator())
->setReceiptData($request->string('receipt')) // base64 blob from StoreKit
->setEndpoint(config('services.apple.endpoint')) // production | sandbox
->setSharedSecret(config('services.apple.shared_secret')); // auto-renewables only
$response = $validator->validate();
if (! $response->isValid()) {
// 21006 = subscription expired, 21007 = sandbox receipt sent to prod, 21008 = inverse
abort(422, 'Invalid receipt');
}
Google Play needs a configured Google_Client with a service-account JSON; Amazon needs a developer secret and a receipt ID. Both follow the same set*()->validate() shape.
Wrap the library behind one application-level service. Do not sprinkle new Validator() through controllers. Define an interface like ReceiptVerifier::verify(Platform $p, string $payload): VerifiedPurchase and bind it in a service provider. This gives you a single seam for tests, for logging, and for a future migration to Apple's App Store Server API or Google's purchases.subscriptionsv2 endpoint.
Keep credentials in config/services.php, never in the client. Apple shared secret, Google service-account JSON path, Amazon developer secret — all env-driven. Note the Apple shared secret is per-app and only required for auto-renewable subscriptions; skip it for consumables/non-consumables.
The controller flow: form request validates the platform + payload → call the verifier → assert the returned productId and bundleId against a server-side allowlist (never trust what the client claims it bought) → DB::transaction(fn () => grantEntitlement(...)) → return the entitlement. The receipt is evidence, not the source of truth for what was purchased.
Idempotency is the core pattern. Clients retry, users double-tap, and Apple replays receipts. Store a purchases row keyed on a unique index over (platform, original_transaction_id, environment) and use firstOrCreate/upsert inside the transaction. Granting an entitlement twice because of a retried POST is the most common production bug with this kind of library.
Subscriptions: treat original_transaction_id as the stable subscription identity and transaction_id as the individual renewal. Use getLatestReceiptInfo() to roll forward through renewals, persist expires_date_ms as a UTC timestamp, and compare against now() at request time rather than trusting isValid() alone — a 21006 response still tells you when it lapsed. Model grace periods and is_upgraded/is_in_intro_offer_period explicitly if your tier logic depends on them.
Sandbox fallback: drive the endpoint from config, but for Apple add an explicit retry — if production returns 21007, re-submit once against the sandbox endpoint; if sandbox returns 21008, re-submit against production. Encapsulate this inside ReceiptVerifier so callers never see it.
Queue the work where it can be slow. Validation is an outbound HTTPS call to a third party. For webhook-driven or batch flows, dispatch a job with $tries = 3 and backoff() — but classify failures first: ConnectionException/5xx are retryable, "receipt invalid" is terminal and should fail fast without burning retries.
Testing: bind a fake ReceiptVerifier in your test container for feature tests, and keep a handful of real (anonymized) fixture receipts for integration tests against the library itself. Mock the HTTP layer (Guzzle MockHandler) rather than the library classes when you want to test your error mapping — 21007, 21006, Google 400s, and Amazon 400/496 each need a distinct branch in your code.
Pair it with server notifications. Validation is pull-based. Refunds, chargebacks, and cancellations will not appear in a validation response you already stored. If revenue integrity matters, run App Store Server Notifications V2 and Google Real-time Developer Notifications alongside this library; this package alone cannot keep entitlements accurate over time for subscriptions.
isValid() is not uniform across platforms. On iTunes it's derived from result code 0 (and treats 21006 as "valid but expired" in some versions); on Google it reflects the purchase state. Branch on the explicit result/status code and assert validity yourself rather than relying on one boolean.base64 and can be large. With many auto-renewable renewals the receipt grows past what some proxies or post_max_size allow. Send it as a POST body field, not a URL parameter, and check body size limits.latest_receipt and latest_receipt_info. They are not the same; prefer the decoded transaction array and don't confuse the two when rolling subscriptions forward.purchaseToken, not orderId. Tokens expire/rotate on some flows; persist the token from the client at purchase time. Also confirm the Android Publisher API is enabled and the service account has the right Play Console permissions — misconfiguration surfaces as an opaque 401/403, not a friendly error.developerSecret is a server secret and Amazon's endpoint has sandbox vs live variants for a reason. Never let the client choose the endpoint.expires_date_ms); Carbon::createFromTimestampMs() or divide by 1000. Mixing seconds and ms produces silently wrong expiry dates that only show up months later.environment in your unique index, or sandbox testers will collide with production rows.ReceiptVerifier interface with per-platform adapters — that lets you swap in Apple's newer Server API or a queue-and-webhook architecture without touching controllers.How can I help you explore Laravel packages today?