imdhemy/appstore-iap
Verify Apple App Store receipts and handle in-app purchases with imdhemy/appstore-iap. It validates receipts, parses App Store server notifications, and integrates with Laravel, letting you confirm purchases and subscriptions server-side without writing Apple's verification logic yourself.
imdhemy/appstore-iap is a framework‑agnostic PHP library (not a Laravel package with a service provider baked in), so "getting started" means: install it, publish/define your own config, and bind a couple of singletons. Expect roughly 15 minutes to a first validated receipt.
1. Install and orient yourself
composer require imdhemy/appstore-iap
Before writing any code, read src/ in this order — it mirrors Apple's data model, so the layout tells you more than the README:
src/Client/ — the HTTP client(s) that talk to Apple (ReceiptClient for /verifyReceipt).src/Receipts/ — ReceiptResponse, Status, and the response parsers.src/ValueObjects/ — Receipt, LatestReceiptInfo, PendingRenewalInfo, Time, Status. These are where 90% of your day-to-day work happens.src/ServerNotifications/ — V1 notification parsing.src/Jws/ — V2 signedPayload decoding/verification (JwsDecoder, Jws, header/payload objects).src/Exceptions/ — the exception tree you'll be catching in jobs.The library has no global config and no facades. In Laravel, put the credentials in config/services.php and bind the clients yourself:
// config/services.php
'app_store' => [
'bundle_id' => env('APP_STORE_BUNDLE_ID'),
'shared_secret' => env('APP_STORE_SHARED_SECRET'),
'environment' => env('APP_STORE_ENV', 'production'), // production|sandbox
],
// app/Providers/AppStoreServiceProvider.php
public function register(): void
{
$this->app->singleton(ReceiptClient::class, fn () => ReceiptClient::create());
$this->app->singleton(JwsDecoder::class, fn () => JwsDecoder::create());
$this->app->bind(AppStore::class, fn ($app) => new AppStore(
$app->make(ReceiptClient::class),
$app->make(JwsDecoder::class),
config('services.app_store.shared_secret'),
config('services.app_store.bundle_id'),
));
}
Wrap the library in one thin application service (AppStore) rather than calling it from controllers. That wrapper is your seam for tests, for swapping sandbox/production, and for later adding Google Play.
2. First use case: validate a purchase on the client's first launch
The iOS client sends the base64 receipt (or a StoreKit 2 jwsRepresentation) to your API after purchase. Your endpoint verifies it and persists a transaction keyed by transaction_id. That single flow gets you "is this purchase real, and what did they buy?" — everything else (renewals, refunds, expiry) is handled by webhooks later.
3. Get yourself a sandbox account early
Create an Apple Sandbox Tester in App Store Connect before you write code. You cannot meaningfully test this library without receipt payloads, and real ones only come from a sandbox purchase or a captured production payload.
Pattern 1 — Controller → Action → Job, with a persisted transactions table
The single most important design decision is making the database the source of truth, with Apple's payloads as an event stream. Never compute entitlement from a live Apple call at request time.
// migration sketch
Schema::create('app_store_transactions', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->nullable()->index();
$t->string('transaction_id')->unique(); // idempotency key
$t->string('original_transaction_id')->index(); // subscription identity
$t->string('product_id')->index();
$t->string('environment', 16);
$t->timestamp('purchased_at')->nullable();
$t->timestamp('expires_at')->nullable();
$t->timestamp('revoked_at')->nullable();
$t->json('raw');
$t->timestamps();
});
transaction_id unique + insertOrIgnore/updateOrCreate is your entire idempotency story for both the verify endpoint and the webhook. Apple retries notifications for days.
Pattern 2 — Verify endpoint returns entitlement, not a receipt dump
final class VerifyPurchaseController
{
public function __invoke(VerifyPurchaseRequest $request, AppStore $appStore)
{
$response = $appStore->verifyReceipt($request->string('receipt'));
abort_unless($response->getStatus()->isValid(), 422, 'Invalid receipt');
foreach ($response->getLatestReceiptInfo() as $info) {
AppStoreTransaction::updateOrCreate(
['transaction_id' => $info->getTransactionId()],
[
'original_transaction_id' => $info->getOriginalTransactionId(),
'product_id' => $info->getProductId(),
'purchased_at' => $info->getPurchaseDate()->toCarbon(),
'expires_at' => $info->getExpiresDate()?->toCarbon(),
'raw' => $info->toArray(),
]
);
}
return EntitlementResource::make($request->user());
}
}
Note the asymmetry: latest_receipt_info contains the whole history for a subscription, so the loop is naturally idempotent and self-healing. This is why you should always use latest_receipt_info rather than receipt.in_app for subscriptions.
Pattern 3 — App Store Server Notifications V2 webhook (the important one)
Prefer V2 (signedPayload) over V1. The controller must be thin, verify, then queue:
Route::post('/webhooks/app-store', AppStoreWebhookController::class);
// remember to exclude it from CSRF (bootstrap/app.php or VerifyCsrfToken $except)
public function __invoke(Request $request, AppStore $appStore)
{
$request->validate(['signedPayload' => ['required', 'string']]);
// Decode/verify synchronously — an unverified payload must never be queued.
$notification = $appStore->parseNotification($request->string('signedPayload'));
// Dedupe on notificationUUID *before* dispatching.
if (AppStoreNotification::where('uuid', $notification->getNotificationUUID())->exists()) {
return response()->noContent();
}
ProcessAppStoreNotification::dispatch($notification->toArray())->onQueue('webhooks');
return response()->noContent(); // 200 fast; Apple times out and retries otherwise
}
Pattern 4 — Map notificationType + subtype to your own subscription state machine
Don't store Apple's strings as your domain status. Build an explicit map, because the combinations are where the bugs live:
| notificationType | subtype | Your state |
|---|---|---|
SUBSCRIBED |
INITIAL_BUY / RESUBSCRIBE |
active |
DID_RENEW |
— | active (extend expires_at) |
DID_FAIL_TO_RENEW |
GRACE_PERIOD |
in_grace |
DID_FAIL_TO_RENEW |
BILLING_RETRY |
past_due |
EXPIRED |
VOLUNTARY / BILLING_RETRY |
expired |
DID_CHANGE_RENEWAL_STATUS |
AUTO_RENEW_DISABLED |
still active, flag will_renew = false |
REFUND / REVOKE |
— | revoked (claw back access immediately) |
GRACE_PERIOD_EXPIRED |
— | expired |
Two rules that save you: DID_CHANGE_RENEWAL_STATUS does not mean cancellation — access continues until expires_at; and EXPIRED can arrive out of order relative to DID_RENEW, so always apply max(expires_at) rather than overwriting.
Pattern 5 — Reconcile with a scheduled command
Webhooks get lost. Run a nightly job over subscriptions expiring in the next 24–48h and re-verify the stored receipt (or query current status if you're on Server API). This is your safety net and also how you catch refunds that never notified you.
Pattern 6 — Test against fixtures, not Apple
Because the library's value objects parse JSON, you can snapshot real payloads into tests/Fixtures/appstore/*.json and assert your state machine deterministically. Bind a fake AppStore implementation into the container for feature tests; that's the whole reason the wrapper class exists.
/verifyReceipt is deprecated. Apple has been telling people to migrate to the App Store Server API + Notifications V2 for a while. This library's receipt-verification path still works, but treat it as legacy: check whether your pinned version ships an App Store Server API client (/inApps/v1/subscriptions/{transactionId} etc.) before planning around it. If it doesn't, you'll be hand-rolling an ES256 JWT (iss, kid, bid, aud: appstoreconnect-v1) with firebase/php-jwt alongside this package. Verify that early — it's the biggest scope risk.
Sandbox vs production is not automatic. /verifyReceipt against the wrong environment returns status 21007 (sandbox receipt sent to production) or 21008 (production sent to sandbox). Handle both by retrying against the other host exactly once, then persisting which environment the receipt came from. Never let a sandbox transaction grant entitlement in production — key your environment column and filter on it.
Status 21006 is not an error. It means "receipt is valid but the subscription has expired." getStatus()->isValid() semantics matter here; a naive if ($status !== 0) abort() will break legitimate renewals. Enumerate 0, 21006 as "receipt is real", then decide entitlement from expires_at.
JWS verification needs Apple's root certs. V2 signedPayload is a JWS with an x5c chain that must chain to Apple Root CA G3. If the decoder doesn't ship the roots, you must provide them — and cache them, because fetching them per request will destroy your p99. Also check signedDate freshness (reject anything older than a few minutes) to blunt replay attacks.
Time is milliseconds since epoch, and the value objects are not Carbon. Time objects (from purchase_date_ms etc.) need explicit conversion before you persist or compare; don't rely on implicit __toString. Store UTC, compare with now()->utc().
excludeOldTransactions: true when verifying subscriptions — otherwise latest_receipt_info grows unbounded for long-lived subscribers and your webhook job payloads balloon. Only pull full history in an explicit backfill/admin path.
Consumables need different handling entirely. Nothing in the receipt tells you whether a consumable was already credited to the user — that's your transaction_id unique index. This is the #1 production incident with IAP libraries.
Retries and rate limits. Apple returns 429/5xx under load. Always wrap the verify call in retry(3, fn () => ..., 200) on a queue job, never inline in a user-facing request you can't afford to fail. Webhooks should always return 2xx even when processing fails — otherwise Apple's retry storm amplifies your outage. Log and alert instead.
Don't couple user identity to original_transaction_id. Users restore purchases on new accounts, families share, and Apple IDs change. Keep original_transaction_id as a nullable linked identifier, not a user foreign key.
Xcode StoreKit Testing receipts are local-only. A .storekit-generated transaction cannot be validated server-side. Budget for manual sandbox testing on device, or stub the verifier in CI. Teams routinely discover this on the day before release.
Extension points worth knowing: the value objects are immutable and constructed via fromArray, so you can hydrate them from any source (Server API responses, your own raw JSON columns) without going through the client — useful for backfills and for replaying historical notifications during a bug fix.
How can I help you explore Laravel packages today?