In-App Purchases for NativePHP Mobile#
Auto-renewing subscriptions from PHP. StoreKit 2 on iOS, Google Play Billing 8 on Android, behind one small synchronous surface — the native half does all the waiting and hands PHP a single flattened array.
The two platforms are not equivalent, and the difference matters:
| iOS | Android | |
|---|---|---|
| Backend | StoreKit 2 | Play Billing 8.1.0 |
| Catalog model | One product is one plan | Product → base plan → offer |
expires_at |
ISO-8601 UTC, from the transaction | Always null — Play does not expose it to the client |
transaction_id |
Original transaction id | Purchase token |
verification_token |
Signed transaction (compact JWS) | Purchase token |
| Restore | AppStore.sync(), can prompt for credentials |
No separate step — purchases live on the Google account |
| Compiled | Yes, into a real iOS build | Yes, into a real Android build |
| Purchase completed | Never | Never — see Status |
Android's catalog model is genuinely different rather than merely differently spelled — a single subscription can carry several base plans where the App Store would need several products. No purchase has completed on either platform yet. See Play base plans and Status before you rely on any of this.
Subscriptions only. No consumables, no non-consumables, no proration.
Requirements#
- PHP 8.2+
nativephp/mobile^3.0 | ^4.0 | dev-main- iOS 18.2+ / Android API 26+
- Subscription products live in App Store Connect and/or the Play Console, and a build the store recognizes
- A server you control, if you intend to trust any of this — see Not covered
Installation#
This is a paid, private plugin — not on Packagist. You need a valid purchased license and read access to the repository.
"repositories": { "nativephp-iap": { "type": "vcs", }}
composer require codypchristian/nativephp-iap:^0.3
The URL must be the SSH one. Composer cannot authenticate an unauthenticated HTTPS clone of a private repository, and it does not say so — it reports
Could not find a matching version of package codypchristian/nativephp-iap, which reads like your constraint is wrong when the real problem is a 404. Check the URL scheme and your SSH key first.
Registration — do this or nothing works#
Plugins are opt-in: only providers listed in your NativeServiceProvider are
compiled into a build. A plugin that is installed but not listed is skipped
silently — no error, no warning, no log line — and the app behaves exactly as
though the plugin were never installed. InApp::isSupported() returns false
and every call returns ['ok' => false, 'reason' => 'unavailable'], which looks
identical to "the store is down".
If you have not published that provider yet:
php artisan vendor:publish --tag=nativephp-plugins-provider
Then register:
php artisan native:plugin:register codypchristian/nativephp-iap
which adds it to the plugins() array:
public function plugins(): array{ return [ \CodyPChristian\NativeIap\InAppPurchasesServiceProvider::class, ];}
Rebuild so the native code is compiled in:
php artisan native:install --force
Confirm with php artisan native:plugin:list — codypchristian/nativephp-iap
must appear under registered plugins with 5 bridge functions, not under
"Unregistered".
There is one config option and one env var, both covered under Verifying a purchase on your server. Publish the file only if you want to edit it in place:
php artisan vendor:publish --tag=iap-config
Product ids are still passed at the call site, and all timeouts are compiled into the native halves.
Apple setup#
1. The Paid Applications agreement must be Active#
Do this first. Until Agreements, Tax, and Banking → Paid Applications shows
Active, Product.products(for:) returns an empty array — and this plugin
reports that as a perfectly successful empty result:
['ok' => true, 'reason' => 'ok', 'error' => null, 'products' => []]
Consequence: your paywall renders with nothing on it and no error anywhere. It is indistinguishable from a typo in a product id. This is the single most common cause of "products don't load", and the agreement can sit in Pending User Info for days while you look for a bug in your code. Check the agreement before you debug anything else.
2. Every product needs complete localization#
A subscription missing its display name or description for the current storefront is omitted from the response — same silent-empty symptom. So is a product still in Missing Metadata, and one whose subscription group has no localization at all.
3. Simulator testing needs a StoreKit configuration file#
A purchase cannot run in the simulator without a .storekit configuration file
selected in the scheme (Xcode → Product → Scheme → Edit Scheme → Run → Options →
StoreKit Configuration). The generated project does not ship one.
The catch: the scheme lives at
nativephp/ios/NativePHP.xcodeproj/xcshareddata/xcschemes/NativePHP-simulator.xcscheme,
and native:install copies the framework's template over that path. A plain
native:run does not regenerate it, so your edit survives an ordinary
build — but php artisan native:install --force overwrites it and your StoreKit
selection is gone. native:run will also offer to invoke native:install --force for you if the bundled PHP minor version does not match your host, so
this can happen without you having typed it.
Consequence: purchases stop working in the simulator after an install, with no
message explaining why. Re-select the configuration after any
native:install, and prefer a sandbox Apple Account on a real device for
anything you actually care about.
Google Play setup#
1. A merchant account and at least one release#
Play returns no products at all for an application that has never had a release on a track. You need:
- a Google Payments merchant account linked to the Play Console;
- the app uploaded to at least one track (internal testing counts);
- the subscription activated, not merely created;
- the tester's Google account on the license testers or track testers list;
- the installed build signed with the same key as the uploaded one.
Miss any of these and products() comes back empty or store_unavailable —
again with no explanation the client can surface.
2. Play Billing 8 is required by Google, and the pin is deliberate#
The manifest pins com.android.billingclient:billing:8.1.0. Google requires
Billing 8 or later for new apps and for updates to existing apps; the Play
Console shows a policy warning under version 8, and uploads on version 7 stop
being accepted after August 31, 2026. Version 8 is supported until August
31, 2027.
8.1.0 rather than 8.0.0 because it is the lowest version exposing
Purchase.isSuspended() — see the caveat on suspended subscriptions in
Platform caveats. Not 8.2.x or 8.3.0, which add only
external-offer and external-payment APIs this package never calls. Every 8.x is
supported for the same window, so the choice costs nothing in runway.
Two things to know if you change this pin yourself:
- Going back to 7.x will not compile. In Billing 8 the second parameter of
the
queryProductDetailsAsynccallback is aQueryProductDetailsResultrather than aList<ProductDetails>, and this code readsproductDetailsListoff it. The direction of that break is now reversed, but it is still a compile error rather than a silent degradation. - Billing carries no Kotlin bytecode, in any 8.x artifact, so bumping it
cannot produce the "module was compiled with an incompatible version of
Kotlin" failure that some Play Services artifacts cause. Its one transitive
floor worth noting is
play-services-basement, raised to18.9.0at 8.1.0 (also pure Java,minSdk23).
Billing 9 exists and is out of scope here: it requires targetSdkVersion 35 and
AndroidX core 1.9+, and version 8 has a year of support left.
3. Acknowledge within three days or Play refunds the purchase#
Handled for you: every PURCHASED transaction is acknowledged the moment it is
seen, including renewals and purchases that resolved while the app was closed.
A subscription left unacknowledged for three days is automatically refunded by
Play. If you fork this code, do not remove acknowledgeIfNeeded.
Play base plans — the trap that charges the wrong price#
Play's model is subscription → base plan → offer. One product id can carry several base plans — a monthly plan and a yearly plan under a single product — where the App Store would model the same catalog as two separate products. The two stores differ in structure here, not merely in how identifiers are spelled.
In that shape the product id alone does not identify what the buyer chose.
Without a base plan id, subscriptionOfferDetails.firstOrNull() takes whichever
plan Play happens to return first, and the order is not yours to control. So a
buyer who taps Yearly can be charged the monthly price — silently, in
real money, with a successful-looking result handed back to your app. That is
the specific failure this parameter exists to prevent.
That is why purchase() takes an optional $basePlanId. What
preferredOffer() actually does:
| Case | Behavior |
|---|---|
$basePlanId given and Play offers it |
That base plan's offer token is used. The only way to be sure the buyer is billed for the plan they tapped. |
$basePlanId given and Play does not offer it |
Refuses. Logs Base plan '…' not found on <productId>; refusing to guess. and returns ['ok' => false, 'reason' => 'product_not_found', 'error' => 'That subscription has no purchasable base plan on Google Play.'] |
No $basePlanId |
The first offer Play returns is used. |
The refusal is deliberate: falling through to "any plan" would charge the wrong
price silently, and a failed purchase is recoverable in a way that a wrong
charge is not. Note the reason code is product_not_found even though the
product itself was found, so a bad base plan id and a missing product are
indistinguishable to the caller — check your logcat for the refusal line.
Two consequences you must design around:
products()never passes a base plan id. For a multi-base-plan product, theprice,currency, andperiodyou display come from whichever plan Play happened to list first. The row'sbase_plan_idtells you which one that was — read it and decide, rather than rendering the row as-is.- Within a base plan the first offer is still taken. Choosing between introductory, promotional, and win-back offers is not implemented. Configure a free trial and this code will pick an offer arbitrarily.
Price and period on Android come from the last pricing phase of the chosen offer — the recurring price, past any trial or intro phase.
The simplest way to avoid all of this is one product per billing period, each
with exactly one base plan. Then $basePlanId stays null forever and the two
platforms behave the same.
Usage#
use CodyPChristian\NativeIap\Facades\InApp; $result = InApp::purchase('acme_pro_annual'); if ($result['is_entitled']) { // Grant access — but see "Not covered" before you trust this alone.}
A realistic service:
use CodyPChristian\NativeIap\Facades\InApp; class Subscriptions{ private const PRODUCTS = ['acme_pro_monthly', 'acme_pro_annual']; public function paywall(): array { if (! InApp::isSupported()) { return []; } $result = InApp::products(self::PRODUCTS); // An empty list here is almost never a code bug. See "Apple setup". return $result['ok'] ? $result['products'] : []; } public function buy(string $productId, ?string $basePlanId = null): string { $result = InApp::purchase( productId: $productId, appAccountToken: $this->accountUuid(), basePlanId: $basePlanId, ); return match ($result['reason']) { 'entitled' => $this->grant($result), 'cancelled' => '', // say nothing default => $result['error'] ?? 'Something went wrong.', }; } public function refresh(): bool { return InApp::entitlement(self::PRODUCTS)['is_entitled']; } public function restore(): bool { return InApp::restore()['is_entitled']; }}
Method reference#
InApp::products(?array $ids = null): arrayInApp::product(string $id): arrayInApp::purchase(string $productId, int $quantity = 1, ?string $appAccountToken = null, ?string $basePlanId = null): arrayInApp::restore(): arrayInApp::entitlement(?array $productIds = null): arrayInApp::isSupported(): bool
products($ids)— ids are trimmed, de-duplicated, blanks dropped. A null or empty list short-circuits to a successful empty result without a bridge call.product($id)— adds'product': the single row, ornull. A successful query that matched nothing is downgraded toproduct_not_found.purchase()—$quantityis iOS-only, meaningful only for consumables, and clamped to a minimum of 1.$appAccountTokenmust parse as a UUID on iOS or StoreKit's option is not set at all; on Android it becomessetObfuscatedAccountIdwith no UUID requirement. On Android the current entitlement is checked first and returned unchanged if you already own the product, because Play refuses a repeat purchase and "already owned" is not something a user can act on.restore()— takes no arguments, so a restore is never scoped to a subset of ids.entitlement($ids)— null or empty accepts any active subscription. On iOS only verified transactions count; revoked, upgraded-past, and already-expired entries are skipped, and where several qualify the longest-lasting wins.- All three of
purchase(),restore()andentitlement()carry averification_token— the thing your server checks the purchase against. See Verifying a purchase on your server.
Response shapes#
products() / product():
[ 'ok' => true, 'reason' => 'ok', 'error' => null, 'products' => [[ 'id' => 'acme_pro_annual', 'title' => 'Pro (Annual)', 'description' => 'Everything in Pro, billed yearly', 'price' => '$39.99', // localized, ready to display 'price_amount' => 39.99, 'currency' => 'USD', // ANDROID ONLY — key absent on iOS 'period' => 'P1Y', // ISO-8601 duration 'type' => 'autoRenewable', 'base_plan_id' => 'annual', // ANDROID ONLY — key absent on iOS ]],]
purchase() / restore() / entitlement():
[ 'ok' => true, 'reason' => 'entitled', 'error' => null, 'is_entitled' => true, 'product_id' => 'acme_pro_annual', 'transaction_id' => '2000000123456789', // iOS: ORIGINAL transaction id // Android: purchase token 'expires_at' => '2027-08-17T00:00:00Z', // ALWAYS null on Android 'verification_token' => 'eyJhbGci…', // iOS: signed transaction (JWS) // Android: purchase token]
Those eight keys are guaranteed present on every return — verification_token
only while it is switched on, which is the default; switch it off and the key is
absent, not null. Three more appear on success and are not in the
contract — read them defensively:
| Key | iOS | Android |
|---|---|---|
latest_transaction_id |
current transaction id | absent |
order_id |
absent | Play order id |
purchased_at |
ISO-8601 UTC string | epoch milliseconds (integer) |
purchased_at genuinely has two different types. Normalize it yourself.
Verifying a purchase on your server#
purchase(), restore() and entitlement() return a verification_token
alongside transaction_id. It is the value your own server checks the purchase
against, and the two platforms give you materially different things:
| iOS | Android | |
|---|---|---|
| What it is | The compact JWS StoreKit signed for that transaction | The Play purchase token |
| How a server checks it | Offline, against Apple's public keys | A call to the Play Developer API |
| Network needed | No | Yes |
| A key you have to hold | None | A service account |
| Present when | There is a transaction | There is a purchase |
Why the iOS one is worth having#
StoreKit 2 signs every transaction, and the signature travels with it. A JWS is
three base64url segments — header, payload, signature — and the payload is the
transaction: product id, original transaction id, purchase and expiry dates,
appAccountToken, the whole thing. Your server verifies the signature against
the certificate chain in the header, checks that chain up to Apple's root, and
then trusts the payload.
That is strictly better than asking the App Store Server API the same question:
- No
.p8private key to hold. Nothing to leak, nothing to rotate, nothing to leave in an env var on a box you would rather not think about. - No network round trip, so verification costs microseconds instead of a request, and it works in a queue worker with no egress.
- No outage dependency. When Apple's API is slow or down, signature verification is unaffected — it is arithmetic, not a service.
Use the API for things only Apple knows right now — has this been refunded, is it in billing retry — and use the signature for "did this device really buy this". Most apps only ever need the second question answered.
Android has no equivalent. Play does not sign anything the client can check, so
the purchase token is what you get, and confirming it means calling
purchases.subscriptionsv2.get with a service account. The field carries the
same name on both platforms so one payload shape covers both; what your server
does with it differs.
What a server actually does with it#
Roughly, on iOS:
- Split the token on
.and base64url-decode the header. - Read the
x5cchain out of the header, verify it up to Apple's root CA, and check that no certificate has expired. - Verify the signature over
header.payloadwith the leaf certificate's public key. - Only then read the payload, and check
bundleIdis yours, the product id is one you sell, andexpiresDateis in the future. - Grant access from that, not from
is_entitled.
Steps 1 to 3 are what a JWS library does for you. Step 4 is the part people skip and should not: a signature proves the transaction is genuine, not that it is yours or that it is current.
On Android: send the token to your server, have it call the Play Developer API with a service account, and grant from the subscription state that comes back.
Treat it as a credential#
A verification token is a bearer credential. Whoever holds it holds a genuine, independently verifiable claim to that purchase. Treat it the way you would treat a password:
- Send it to your own server, over TLS, and nowhere else.
- Do not log it. Nothing in this package logs it — not the Swift, not the
Kotlin, not the PHP — and that is deliberate. It is easy to undo by accident:
a
Log::info($result)or add()in a paywall controller puts a working credential into a log file, a crash report, and whatever aggregator ships them onward. - Do not put it in a URL, a query string, or client-side storage.
- Do not hand it to a third party.
Turning it off#
On by default, so an upgrade gains the capability without a code change. To switch it off:
IAP_INCLUDE_VERIFICATION_TOKEN=false
The key is then absent from every response — not null — and the native halves are told not to produce it, so on iOS the signature never crosses the bridge at all.
On Android, switching it off does not remove the purchase token from the
payload. transaction_id has always carried it and still does, because it is
the only identifier Play gives a client. The flag governs the shared name, not
what Play hands over.
Errors#
Nothing in the PHP surface throws. ok means the store answered;
everything else is ok => false with a distinct reason and a distinct,
displayable error sentence, because a paywall shows a different message for
each.
reason |
ok |
Meaning |
|---|---|---|
ok |
true | A products query succeeded |
entitled |
true | There is an active subscription |
not_entitled |
true | There is none. Not an error |
cancelled |
false | The user dismissed the payment sheet or restore prompt |
pending |
false | Ask-to-Buy / deferred payment awaiting approval |
unverified |
false | iOS: failed Apple's signature check. Deliberately not finished, so StoreKit re-delivers it |
product_not_found |
false | No such product — or, on Android, no purchasable base plan |
store_unavailable |
false | Offline, wrong storefront, Play services missing or disconnected |
unavailable |
false | No native bridge, or the plugin was not compiled in |
timeout |
false | The store did not answer in time |
failed |
false | Anything else, including "another purchase is already in progress" |
Timeouts, all native-side and not configurable from PHP:
| Operation | iOS | Android |
|---|---|---|
| products / product / entitlement | 30s | 30s |
| purchase | 300s (includes time in the payment sheet) | 300s |
| restore | 180s | 30s |
Platform caveats#
iOS#
- Only verified transactions grant an entitlement. Verified purchases are
finish()ed before success is reported — an unfinished transaction is re-delivered forever. An unverified one is deliberately left unfinished. - The
verification_tokencomes from the sameVerificationResultthe entitlement check reads, so an unverified transaction never yields one — it failed Apple's signature check, and its signed representation must not be handed out as proof of anything. - A background
Transaction.updateslistener, started by the plugin's init function, finishes renewals, Ask-to-Buy approvals, and purchases made on other devices. Your app learns about them on its nextentitlement()call — there is no event or push into PHP, so sync the paywall on mount. - No pods, no plist entries, no entitlements are injected. In-App Purchase is enabled on Apple App IDs by default, so this plugin cannot break code signing.
Android#
- No purchase has ever completed. The code compiles and Play answers queries; the buying flow itself is unexercised. See Status.
- Only one purchase flow at a time; a concurrent
purchase()returnsreason: 'failed'. - The billing client connects on first use and readiness is re-checked on every call, because Play can drop the service at any time.
- Injects the
com.android.vending.BILLINGpermission and ProGuard keep rules. expires_atis alwaysnull. If your entitlement logic needs an expiry date, it will not work on Android without a server callingpurchases.subscriptionsv2.getwith the purchase token.- A suspended subscription still reports
is_entitled: true. Play keeps a subscription inPURCHASEDwhile it is paused or its renewal payment is being retried, andentitlement()treatsPURCHASEDas entitled. Billing 8.1.0 exposesPurchase.isSuspended()to tell the two apart, but this package does not yet call it — whether a paused subscriber keeps access is a product decision, not one this plugin should make for you. Resolve it server-side, or forkIapBilling.entitlement(). - Product ids Play could not return are logged, not reported. Billing 8
hands back the ids it failed to fetch alongside the ones it resolved; those
are written to logcat under the
InAppPurchasetag with a status code. They are not surfaced in theproductspayload, so an id that is misconfigured or not live on the current track still shows up to PHP as a short list rather than an error.
Both#
- Bridge calls block the PHP runtime's own serial queue — never the main thread — for as long as the store takes, bounded by the timeouts above. That is what makes this surface synchronous and event-free.
Status#
This is an early release. Read this section before you charge anyone money.
Verified:
- The iOS Swift compiles into a real iOS build against StoreKit 2, and against the StoreKit SDK on its own — see Testing.
- The Android half compiles into a real Android build and the plugin
registers —
native:plugin:listreports it with all 5 bridge functions. - Play answers. A build carrying this plugin queries Google Play with the correct subscription id and Play responds, so the bridge, the billing client connection, and the query path are all real rather than theoretical.
- The PHP layer is syntax-clean and
composer validate --strictpasses. verification_tokenis populated fromVerificationResult.jwsRepresentation, which is StoreKit's own signed representation and has been present since iOS 15 — this package targets 18.2. No token has been verified against Apple's keys by a real server, because no purchase has completed to produce one.- The response contract is normalized in PHP, so every guaranteed key is present regardless of what the native half returned.
Not verified — and this is the part that matters:
- No purchase has ever completed, on either platform. Not one. Every code path from the payment sheet onward — the purchase result handling, acknowledgment, entitlement after a real transaction, renewal, restore against genuine history — has never executed against a real store transaction. That includes the base-plan selection in Play base plans, which is described accurately but has never chosen an offer for a real buyer.
- On Android the flow cannot yet be exercised at all. Play returns an
empty product list for a debug-signed, sideloaded APK, no matter how
correct the catalog is. Purchases only become testable once the app ships
through a Play testing track signed with the upload key. Until then an empty
productsarray on Android tells you nothing about your code. - End-to-end renewal and refund behavior against live store products has not been measured.
- There is no automated test suite. The
autoload-devPSR-4 entry points at atests/directory that does not exist yet.
Three things that are true regardless of testing, and that will bite you if you forget them:
- Play Billing is pinned to 8.1.0, which is what Google now requires — version 7 stops being accepted for uploads after August 31, 2026. See Play Billing 8 is required by Google.
- A purchase left unacknowledged for three days is automatically refunded by Play. This package acknowledges for you; do not remove that if you fork it.
- Server-side receipt validation is not included and is your responsibility. See Not covered.
Not covered / out of scope#
- Server-side receipt validation — and this is the important one. This
plugin is a client. Everything it reports is only as trustworthy as the
device it runs on, and a modified client or a patched binary can claim an
entitlement it never bought.
is_entitledis a UI signal, not an authorization decision. Granting real access from your own server is your responsibility and is entirely outside this package. What this plugin does is hand you the material to do it with — see Verifying a purchase on your server. No signature checking, no API client, and no entitlement store ships here. - Subscription expiry on Android. Not available client-side; resolve it server-side from the purchase token.
- Consumables and non-consumables. Subscriptions only.
- Upgrades, downgrades, and proration.
setSubscriptionUpdateParamsis never set, so plan switching within a group is not supported. - Offer selection. Introductory, promotional, and win-back offers are not chosen deliberately.
- Refund handling, grace periods, billing retry, and account hold. No
callbacks, no state machine — query
entitlement()and act on what you get. - Price change consent flows.
- Restoring to a specific subset of products, since
restore()takes no arguments.
Testing#
There is no PHP test suite. There are two compile harnesses, which build the
plugin's Swift and Kotlin in isolation against the real toolchains — the same
SDK versions nativephp.json pins, plus the bridge registration NativePHP
generates from that manifest, so a renamed class or a changed constructor fails
here rather than in an app build:
./verify/typecheck.sh # Swift, against the iOS SDK's StoreKit 2./verify/typecheck-android.sh # Kotlin, against Play Billing 8.1.0
typecheck.sh needs Xcode; typecheck-android.sh needs a JDK 17+ and an
Android SDK with platform 36. Point NATIVEPHP_APP at an app that has
nativephp/mobile installed and both scripts will generate the registration
with core's own plugin compiler instead of their local copy of it, and the
Android one will use the app's Gradle wrapper.
Verify an installation with:
php artisan native:plugin:list # must show 5 bridge functions, registeredphp artisan native:plugin:validate
Then, on a device with a sandbox account:
\CodyPChristian\NativeIap\Facades\InApp::products(['your_product_id']);
An empty products array with ok => true means the store answered and had
nothing to say — start at Apple setup or
Google Play setup, not at your code.
Support#
License#
Proprietary. Copyright (c) 2026 Erudite Studios, LLC. All rights reserved. Use requires a valid purchased license — see LICENSE.md.