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

Appstore Iap Laravel Package

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.

Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

/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.

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.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment