NativePHP In-App Purchases#
A NativePHP Mobile plugin providing in-app purchases via StoreKit 2 (iOS) and Google Play Billing (Android) for Laravel apps.
What This Plugin Does (and Does Not Do)#
The plugin:
- Loads in-app product and subscription metadata (localized titles, descriptions, prices) from the stores.
- Starts native purchase flows through Apple StoreKit 2 and Google Play Billing.
- Restores previous purchases.
- Reads the device's current store entitlements.
- Returns transaction and purchase information to your Laravel app as structured arrays.
- Returns verification proof where available: Apple
jws(iOS) and GooglepurchaseToken(Android).
The plugin does not:
- Process payments itself — Apple and Google handle all billing.
- Build or call an application backend for you.
- Manage subscription renewals, cancellations, or refunds — the stores manage the financial subscription lifecycle.
- Receive store lifecycle notifications while the app is closed.
- Automatically grant server-side entitlements.
If your app needs continuously synchronised server-side access (e.g. web + mobile), implement store server notifications (App Store Server Notifications / Google Play Real-time Developer Notifications) and backend proof verification separately — see Verifying Purchases.
Installation#
If you install through the NativePHP plugin marketplace, make sure the marketplace Composer repository is configured in your app and you have authenticated with your licence credentials (see the NativePHP plugin documentation). Then install the package via composer:
composer require developernauts/nativephp-inapp-purchases
Register the plugin with NativePHP:
php artisan native:plugin:register developernauts/nativephp-inapp-purchases
Optionally publish the configuration file (see Configuration):
php artisan vendor:publish --provider="Developernauts\NativephpInappPurchases\NativephpInappPurchasesServiceProvider" --tag=config
Finally, rebuild the native app — the Swift/Kotlin sources are compiled into the generated iOS/Android projects during the build:
php artisan native:run # or: php artisan native:build
A rebuild is required after installing the plugin, after upgrading it, and after any nativephp.json manifest change. Changes to PHP code or config values do not require a native rebuild.
Requirements#
- PHP >= 8.3 (PHP >= 8.4 when using NativePHP Mobile v4)
- Laravel 12 or 13 (
illuminate/support ^12.0|^13.0) - NativePHP Mobile v3 or v4 (
^3.0|^4.0) - iOS 18.2+ (StoreKit 2)
- Android 8.0+ (API 26); Google Play Billing Library 9.1.0 — declared by the plugin manifest and added to the generated Android project automatically
- Livewire v3/v4 (optional — for Livewire-based apps)
- Inertia + Vue/React (optional — for SPA-based apps)
Configuration#
The published config/inapp.php contains:
product_ids— optional default product IDs used whenInApp::products()is called with no arguments. Empty by default; most apps pass IDs explicitly.server_validation— the legacy built-in validator, disabled by default and deprecated for use inside mobile builds. See On-Device Validation.
Product identifiers themselves are created in App Store Connect and Google Play Console (see Configuring Prices) — the plugin never creates store products; the IDs you pass must exactly match the ones configured in the stores.
Quick Start#
use Developernauts\NativephpInappPurchases\Facades\InApp; // Fetch a single product (convenience wrapper around products())$result = InApp::product('com.app.premium_monthly');if ($result['ok']) { $product = $result['product']; // single product array echo $product['price_formatted'];} // Fetch multiple products at once$result = InApp::products(['com.app.premium_monthly', 'com.app.premium_yearly']);if ($result['ok']) { foreach ($result['products'] as $product) { echo $product['title'] . ': ' . $product['price_formatted']; }} // Initiate a purchase$result = InApp::purchase('com.app.premium_monthly'); // Restore previous purchases$restored = InApp::restore(); // Check entitlement status$entitlement = InApp::entitlement();if ($entitlement['is_premium']) { // Device reports an active entitlement. For server-authoritative access, // verify the transaction proof (jws / purchaseToken) on your backend — // see "Verifying Purchases" below.}
Usage with Livewire#
This plugin returns structured arrays synchronously to PHP — it does not emit NativePHP events. You call facade methods from Livewire component actions and render results directly in your Blade templates.
<?php namespace App\Livewire; use Developernauts\NativephpInappPurchases\Facades\InApp;use Livewire\Component; class Paywall extends Component{ public array $products = []; public array $entitlements = []; public bool $isPremium = false; public string $message = ''; public function mount(): void { $this->loadProducts(); $this->checkEntitlement(); } public function loadProducts(): void { $result = InApp::products(['com.app.premium_monthly', 'com.app.premium_yearly']); if ($result['ok']) { $this->products = $result['products']; } } public function purchaseProduct(string $productId): void { $result = InApp::purchase($productId); if ($result['ok']) { $this->message = 'Purchase successful!'; $this->checkEntitlement(); } else { $this->message = $result['message'] ?? $result['error'] ?? 'Purchase failed'; } } public function restorePurchases(): void { $result = InApp::restore(); if ($result['ok']) { $this->message = $result['message']; $this->checkEntitlement(); } else { $this->message = $result['message'] ?? 'Restore failed'; } } public function checkEntitlement(): void { $result = InApp::entitlement(); if ($result['ok']) { $this->isPremium = $result['is_premium']; $this->entitlements = $result['entitlements']; } } public function render() { return view('livewire.paywall'); }}
{{-- resources/views/livewire/paywall.blade.php --}}<div> @if ($isPremium) <p>You have an active subscription.</p> @else @foreach ($products as $product) <div> <h3>{{ $product['title'] }}</h3> <p>{{ $product['description'] }}</p> <p>{{ $product['price_formatted'] }}</p> @if (!empty($product['introductory_offer'])) <p> Start with {{ $product['introductory_offer']['price_formatted'] }} for {{ $product['introductory_offer']['billing_cycle_count'] }} {{ $product['introductory_offer']['period']['unit'] }}(s) </p> @endif <button wire:click="purchaseProduct('{{ $product['id'] }}')">Subscribe</button> </div> @endforeach <button wire:click="restorePurchases">Restore Purchases</button> @endif @if ($message) <p>{{ $message }}</p> @endif</div>
Note: This plugin does not emit NativePHP native events (
"events": []in the manifest). All bridge functions return structured responses synchronously via the facade, so there is no need for#[OnNative]event listeners.
Usage with Inertia (Vue/React)#
For SPA-based apps using Inertia with Vue or React, this plugin provides a JavaScript bridge library that calls the NativePHP bridge endpoint directly from the frontend.
Importing the JS Library#
import { products, purchase, restore, entitlement, ping } from '../../vendor/developernauts/nativephp-inapp-purchases/resources/js/index.js';
Adjust the relative path based on your component location. The JS library calls
POST /_native/api/callunder the hood.
Vue Example#
<script setup>import { ref, onMounted } from 'vue';import { products, purchase, restore, entitlement } from '../../vendor/developernauts/nativephp-inapp-purchases/resources/js/index.js'; const productList = ref([]);const isPremium = ref(false);const message = ref(''); onMounted(async () => { await loadProducts(); await checkEntitlement();}); async function loadProducts() { try { const result = await products(['com.app.premium_monthly', 'com.app.premium_yearly']); productList.value = result.products; } catch (err) { message.value = err.message; }} async function buyProduct(productId) { try { const result = await purchase(productId); message.value = result.message; await checkEntitlement(); } catch (err) { message.value = err.data?.message ?? err.message; }} async function restorePurchases() { try { const result = await restore(); message.value = result.message; await checkEntitlement(); } catch (err) { message.value = err.data?.message ?? err.message; }} async function checkEntitlement() { try { const result = await entitlement(); isPremium.value = result.is_premium; } catch (err) { console.error('Entitlement check failed:', err); }}</script> <template> <div v-if="isPremium"> <p>You have an active subscription.</p> </div> <div v-else> <div v-for="product in productList" :key="product.id"> <h3>{{ product.title }}</h3> <p>{{ product.description }}</p> <p>{{ product.price_formatted }}</p> <p v-if="product.introductory_offer"> Start with {{ product.introductory_offer.price_formatted }} for {{ product.introductory_offer.billing_cycle_count }} {{ product.introductory_offer.period.unit }}(s) </p> <button @click="buyProduct(product.id)">Subscribe</button> </div> <button @click="restorePurchases">Restore Purchases</button> </div> <p v-if="message">{{ message }}</p></template>
React Example#
import { useState, useEffect } from 'react';import { products, purchase, restore, entitlement } from '../../vendor/developernauts/nativephp-inapp-purchases/resources/js/index.js'; export default function Paywall() { const [productList, setProductList] = useState([]); const [isPremium, setIsPremium] = useState(false); const [message, setMessage] = useState(''); useEffect(() => { loadProducts(); checkEntitlement(); }, []); async function loadProducts() { try { const result = await products(['com.app.premium_monthly', 'com.app.premium_yearly']); setProductList(result.products); } catch (err) { setMessage(err.message); } } async function buyProduct(productId) { try { const result = await purchase(productId); setMessage(result.message); await checkEntitlement(); } catch (err) { setMessage(err.data?.message ?? err.message); } } async function restorePurchases() { try { const result = await restore(); setMessage(result.message); await checkEntitlement(); } catch (err) { setMessage(err.data?.message ?? err.message); } } async function checkEntitlement() { try { const result = await entitlement(); setIsPremium(result.is_premium); } catch (err) { console.error('Entitlement check failed:', err); } } if (isPremium) { return <p>You have an active subscription.</p>; } return ( <div> {productList.map((product) => ( <div key={product.id}> <h3>{product.title}</h3> <p>{product.description}</p> <p>{product.price_formatted}</p> {product.introductory_offer && ( <p> Start with {product.introductory_offer.price_formatted} for{' '} {product.introductory_offer.billing_cycle_count}{' '} {product.introductory_offer.period.unit}(s) </p> )} <button onClick={() => buyProduct(product.id)}>Subscribe</button> </div> ))} <button onClick={restorePurchases}>Restore Purchases</button> {message && <p>{message}</p>} </div> );}
Error Handling in JS#
All JS bridge functions throw an Error on failure with additional properties:
try { const result = await purchase('com.app.premium_monthly');} catch (err) { console.error(err.message); // Human-readable message console.error(err.code); // Error code (e.g. "user_cancelled") console.error(err.data); // Full response object from the bridge}
API Overview#
| Method | Description | iOS | Android |
|---|---|---|---|
product() |
Fetch a single product | StoreKit 2 | Play Billing |
products() |
Fetch multiple products | StoreKit 2 | Play Billing |
purchase() |
Initiate a purchase | StoreKit 2 | Play Billing |
restore() |
Restore previous purchases | StoreKit 2 | Play Billing |
entitlement() |
Check active entitlements | StoreKit 2 | Play Billing |
All methods return a consistent ['ok' => bool, ...] response shape and the same method set is available on both platforms. The payloads differ per platform where the stores themselves differ — notably: environment is only meaningful on iOS (Android always reports production), subscription expirationDate is iOS-only (Android expiry requires backend verification of the purchaseToken), the offers array and introductory_offer are Android-only, and the verification proof differs (jws on iOS, purchaseToken on Android). See Platform-Specific Notes.
products()#
Fetch product metadata from the native store. Signature: InApp::products(?array $ids = null): array — pass null (or no argument) to use the inapp.product_ids config defaults.
use Developernauts\NativephpInappPurchases\Facades\InApp; $result = InApp::products(['com.app.premium_monthly', 'com.app.premium_yearly']); if ($result['ok']) { foreach ($result['products'] as $product) { echo $product['title']; // "Premium Monthly" echo $product['price_formatted']; // "$9.99" echo $product['type']; // "auto_renewable" }}
Product Payload#
| Field | Type | Description |
|---|---|---|
id |
string | Product identifier (e.g., com.app.premium_monthly) |
title |
string | Localized display name |
description |
string | Localized description |
price |
float | Raw recurring price value (e.g., 9.99) |
price_formatted |
string | Localized recurring price with currency (e.g., $9.99) |
currency |
string | ISO 4217 currency code (e.g., USD) |
type |
string | Product type: consumable, non_consumable, auto_renewable, non_renewable |
subscription_period |
object | (subscription only) { "value": 1, "unit": "month" } |
subscription_group_id |
string | (iOS subscription only) Subscription group identifier |
introductory_offer |
object? | (Android subscription only, when present) Free trial or discounted intro pricing — see below |
offers |
array? | (Android subscription only) Read-only metadata for every available subscription offer — see below |
_offerToken |
string? | (Android subscription only) Legacy — the first offer's token, retained for backward compatibility. New code should read tokens from offers[].offer_token |
introductory_offer (Android subscriptions only)
Present when a Google Play subscription has a free trial or discounted introductory period. Always null/absent for iOS and for one-time products. price and price_formatted reflect the intro price (e.g. 0.0 / "Free" for a trial).
| Field | Type | Description |
|---|---|---|
price |
float | Raw introductory price (e.g., 0.0 for a free trial) |
price_formatted |
string | Localized introductory price (e.g., "Free") |
billing_cycle_count |
int | Number of billing cycles the intro price applies for |
period |
object | { "value": 2, "unit": "week" } — duration of each intro cycle |
Example subscription payload with a free trial:
[ 'id' => 'com.app.premium_yearly', 'title' => 'Premium Yearly', 'description' => '…', 'price' => 990.0, // recurring price 'price_formatted' => 'R990.00', 'currency' => 'ZAR', 'type' => 'auto_renewable', 'subscription_period' => ['value' => 1, 'unit' => 'year'], 'introductory_offer' => [ 'price' => 0.0, 'price_formatted' => 'Free', 'billing_cycle_count' => 1, 'period' => ['value' => 2, 'unit' => 'week'], ],]
offers (Android subscriptions only)
Read-only metadata describing every offer Google Play returned for the subscription, in the order Play returned them. Purchasing still uses the first offer automatically; explicit offer selection is planned for a future release.
| Field | Type | Description |
|---|---|---|
base_plan_id |
string | Base plan identifier from Play Console |
offer_id |
string? | Offer identifier — absent for a base plan's default offer |
offer_token |
string | Opaque token identifying this offer |
pricing_phases |
array | Chronological pricing phases for the offer |
Each pricing phase contains price, price_formatted, currency, billing_cycle_count, recurrence_mode (infinite_recurring, finite_recurring, non_recurring), and period ({ "value": 1, "unit": "month" }, when parseable).
Error Codes#
| Error | Description |
|---|---|
invalid_product_ids |
Product IDs array is empty or missing |
no_products_found |
No products found for the provided identifiers |
storekit_error |
(iOS) StoreKit returned an error |
billing_unavailable |
(Android) Play Billing is unavailable on the device (e.g. Play Store missing, blocked, or signed out) |
billing_error |
(Android) Unexpected Play Billing failure |
timeout |
Request timed out (30 seconds) |
On Android, when some IDs cannot be fetched the error
messagelists each unfetched product with its Play Billing status code (Unfetched products: id (type, status N)) — check it when debugging "product not found".
product()#
PHP convenience wrapper around products() that returns a single product instead of an array. Returns product_not_found when no matching product exists.
$result = InApp::product('com.app.premium_monthly'); // Success: ['ok' => true, 'product' => ['id' => '...', 'title' => '...', ...], 'platform' => '...', 'environment' => '...']// Not found: ['ok' => false, 'error' => 'product_not_found', 'message' => 'Product not found', 'productId' => 'com.app.premium_monthly']
purchase()#
Initiate a purchase for a product. Signature: InApp::purchase(string $productId, int $quantity = 1, ?string $appAccountToken = null): array
$quantity— iOS consumables only (applied when > 1); ignored on Android.$appAccountToken— optional UUID string to associate the purchase with a user account. On iOS it is passed as StoreKit'sappAccountToken(must be a valid UUID); on Android it is passed as Play Billing'sobfuscatedAccountId.
use Developernauts\NativephpInappPurchases\Facades\InApp; // Basic purchase$result = InApp::purchase('com.app.premium_monthly'); // With quantity (consumables only)$result = InApp::purchase('com.app.coins_100', quantity: 2); // With app account token (for server-side user association)$result = InApp::purchase('com.app.premium_monthly', appAccountToken: '550e8400-e29b-41d4-a716-446655440000'); if ($result['ok']) { $transaction = $result['transaction']; echo $transaction['id']; // Transaction ID echo $transaction['environment']; // "xcode", "sandbox", or "production"}
Purchase Response#
[ 'ok' => true, 'message' => 'Purchase successful', 'productId' => 'com.app.premium_monthly', 'transaction' => [ 'id' => 123456789, 'originalId' => 123456789, 'productId' => 'com.app.premium_monthly', 'purchaseDate' => '2024-01-15T10:30:00Z', 'expirationDate' => '2024-02-15T10:30:00Z', // optional 'revocationDate' => null, // optional 'ownershipType' => 'purchased', 'environment' => 'sandbox', 'jws' => 'eyJhbGciOiJFUzI1NiIs…', // iOS — verification proof // 'purchaseToken' => '…', // Android — verification proof ]]
Error Codes#
| Error | Description |
|---|---|
missing_product_id |
Product ID is required |
no_product_found |
Product not found in App Store |
purchase_failed |
Purchase failed or timed out |
verification_failed |
Transaction failed StoreKit verification |
user_cancelled |
User cancelled the purchase |
pending |
Purchase is pending approval (e.g., Ask to Buy) |
restore()#
Restore previously purchased products. Signature: InApp::restore(): array
- iOS: calls
AppStore.sync()— this may present an App Store sign-in sheet — then collects current entitlements. - Android: queries acknowledged purchases from Google Play Billing; no UI is presented.
use Developernauts\NativephpInappPurchases\Facades\InApp; $result = InApp::restore(); if ($result['ok']) { foreach ($result['transactions'] as $transaction) { echo $transaction['productId']; echo $transaction['environment']; }}
Restore Response#
[ 'ok' => true, 'message' => 'Restored 2 purchase(s)', 'transactions' => [ [ 'id' => 123456789, 'originalId' => 123456789, 'productId' => 'com.app.premium_yearly', 'purchaseDate' => '2024-01-15T10:30:00Z', 'expirationDate' => '2025-01-15T10:30:00Z', 'ownershipType' => 'purchased', 'environment' => 'production' ] ]]
Error Codes#
| Error | Description |
|---|---|
restore_failed |
Restore failed or timed out |
user_cancelled |
User cancelled the restore prompt |
entitlement()#
Check the user's current entitlement status. Signature: InApp::entitlement(?array $productIds = null): array — pass an array of product IDs to filter, or null for all. Returns only active entitlements (not revoked, not expired).
use Developernauts\NativephpInappPurchases\Facades\InApp; // Check all entitlements$result = InApp::entitlement(); if ($result['is_premium']) { // User has at least one active entitlement} // Filter by specific product IDs$result = InApp::entitlement(['com.app.premium_monthly', 'com.app.premium_yearly']);
Entitlement Response#
[ 'ok' => true, 'message' => 'Found 1 entitlement(s)', 'platform' => 'ios', 'is_premium' => true, 'entitlements' => [ [ 'id' => 123456789, 'originalId' => 123456789, 'productId' => 'com.app.premium_yearly', 'purchaseDate' => '2024-01-15T10:30:00Z', 'expirationDate' => '2025-01-15T10:30:00Z', 'ownershipType' => 'purchased', 'environment' => 'production' ] ]]
Active Entitlement Definition#
An entitlement is considered active if:
revocationDateisnull(not revoked)expirationDateisnullORexpirationDate > now(not expired)
Error Codes#
| Error | Description |
|---|---|
entitlement_failed |
Entitlement check failed or timed out |
Transaction Metadata#
All transaction objects (from purchase(), restore(), entitlement()) share a common shape:
| Field | Type | Description |
|---|---|---|
id |
int|string | Unique transaction ID (int on iOS; on Android the Play orderId, falling back to the purchase token when orderId is absent) |
originalId |
int|string | Original transaction ID (same as id for initial purchase) |
productId |
string | Product identifier |
purchaseDate |
string | ISO 8601 timestamp of purchase |
expirationDate |
string? | ISO 8601 expiration date (subscriptions only) |
revocationDate |
string? | ISO 8601 revocation date (if refunded) |
ownershipType |
string | purchased or familyShared |
environment |
string | StoreKit environment (see below) |
jws |
string? | (iOS, added in v1.4.0) StoreKit 2 signed JWS representation of the transaction — see "Verification proof" below |
purchaseToken |
string | (Android) Google Play purchase token — see "Verification proof" below |
orderId |
string? | (Android) Google Play order ID — absent for some purchases (e.g. certain test purchases) |
Verification proof (jws / purchaseToken)#
These fields exist so your backend can independently verify a purchase:
- iOS
jws— the transaction signed by Apple as a JWS (compact JSON Web Signature, roughly 2–4 KB). Verify its signature against Apple's certificate chain, or look the transaction up via the App Store Server API. Transactions from Xcode StoreKit Configuration files are signed with a local Xcode key and can never be verified against Apple's servers. - Android
purchaseToken— verify via the Google Play Developer API (purchases.subscriptionsv2.getfor subscriptions,purchases.products.getfor one-time products).
Treat every field the plugin returns — including these — as untrusted client input until your backend has verified the proof. The whole response travels through the device and can be forged by a compromised client. Grant entitlements from backend-verified data, not from the device-reported payload.
environment#
Indicates the StoreKit environment in which the transaction was processed. Android always reports production — Google Play does not expose environment information in the client API.
| Value | Description |
|---|---|
xcode |
StoreKit testing via Xcode (StoreKit Configuration files) |
sandbox |
Apple sandbox environment (TestFlight or sandbox Apple IDs) |
production |
Live App Store transactions |
unknown |
Fallback for future or unrecognized environments |
Use this value to:
- Differentiate test purchases from real purchases
- Adjust entitlement logic during development
- Improve logging and debugging across environments
ownershipType#
Indicates how the user obtained access to the product.
| Value | Description |
|---|---|
purchased |
User purchased directly |
familyShared |
Access via Family Sharing |
Verifying Purchases — Backend Is the Source of Truth#
The mobile plugin's job is to run the store flows and hand you verifiable proof of each transaction:
- iOS — the
jwsfield: the transaction signed by Apple as a JWS. - Android — the
purchaseTokenfield: the token Google issues for the purchase.
The recommended architecture is:
- The app completes the purchase natively and receives the transaction payload (including
jws/purchaseToken). - The app sends that proof to your backend over your normal authenticated API.
- The backend verifies it against the store — Apple App Store Server API (or JWS signature verification against Apple's certificate chain) for iOS,
purchases.subscriptionsv2.get/purchases.products.getfor Android. - The backend records the entitlement and is the only place that decides what the user is entitled to. Everything the device reports — including
is_premium— is untrusted client input until this verification has happened.
Only the backend should hold store API credentials.
Credential safety in NativePHP builds#
Apple .p8 private keys and Google service-account JSON grant API access to your store accounts. Handle them accordingly:
- Never bundle a Google service-account JSON file or an Apple
.p8key file inside the mobile app. A packaged.ipa/.apk/.aabcan be unzipped by anyone; anything inside it is public. - Do not put raw private-key or service-account JSON contents in the mobile app's
.env(APPLE_PRIVATE_KEY,GOOGLE_PLAY_SERVICE_ACCOUNT_JSON, etc.). The.envships inside the bundle. - NativePHP strips environment values matching its
cleanup_env_keysconfig during bundling. That helps, but treat it as a safety net, not a strategy:- The protection only covers keys matching the configured patterns. The defaults shipped with NativePHP Mobile v3 and v4 are
AWS_*,GITHUB_*,DO_SPACES_*,*_SECRET,DB_PASSWORD,DB_USERNAME— none ofAPP_STORE_*,APPLE_PRIVATE_KEY, orGOOGLE_PLAY_SERVICE_ACCOUNT_JSONmatch them. If any store credential name appears in the.envof a machine that builds the app, add it tocleanup_env_keysyourself (your publishedconfig/nativephp.phpmay already extend the defaults — check it, don't assume). hot_reload.exclude_patternsaffects hot reload only — it does not control what goes into a production bundle.- Credential files (a
.p8or.jsoninside your project directory) are not automatically excluded from the bundle unless your production cleanup configuration explicitly covers them. Keep them outside the project tree entirely. - Even with perfect stripping, env-cleanup is not a substitute for the real rule: backend credentials live on a separate server that never takes part in the app build.
- The protection only covers keys matching the configured patterns. The defaults shipped with NativePHP Mobile v3 and v4 are
On-Device Validation (Built-in — Deprecated for Mobile Builds)#
Deprecated. This layer runs the Apple/Google API calls from wherever the Laravel app is executing. Inside a NativePHP mobile build that means on the customer's device: the credentials it needs would ship with the app (extractable), and the
validatedverdict it produces is computed client-side (forgeable). It remains available and unchanged for backward compatibility — nothing breaks if you have it enabled — but it logs a deprecation warning when enabled under a NativePHP runtime, it is disabled by default, and it will not gain new features. Use the proof-export + backend flow above instead.The validator classes themselves are still legitimate to run on a backend (they are plain Laravel services talking to Apple/Google), e.g. from a companion server app that receives the exported proof.
Validation is opt-in, additive, and never breaks a successful native purchase — if validation is unavailable or misconfigured the plugin continues normally.
How it works#
When enabled, every successful purchase(), restore(), and entitlement() call automatically contacts the platform's server API and attaches a server.validation payload to the response:
$result = InApp::purchase('com.app.premium_monthly'); if ($result['ok']) { $validation = $result['server']['validation']; // new additive key $validated = $result['validated']; // bool|null $expiresAt = $result['expiresAt'] ?? null; // ISO 8601 | null}
For entitlement(), is_premium is additionally overridden when the server returns a confident result:
$result = InApp::entitlement(); // is_premium is:// true — if server confirms at least one entitlement is 'active'// false — if server says all entitlements are expired/revoked// native value — if any result is unverified or missingif ($result['is_premium']) { ... }
Validation payload shape#
$result['server']['validation'] = [ 'ok' => true, // did the server call succeed? 'validated' => true, // bool | null (null = skipped/unavailable) 'status' => 'active', // 'active'|'expired'|'revoked'|'unverified'|'unknown' 'expiresAt' => '2025-01-15T…', // ISO 8601 | null 'error' => null, 'message' => null, 'raw' => [...], // small excerpt of the raw API response];
Enabling validation#
Add to your .env:
INAPP_SERVER_VALIDATION_ENABLED=trueINAPP_VALIDATION_CACHE_TTL=300 # seconds; 0 disables caching
Apple (iOS) configuration#
The
.envexamples below are for a backend or CI environment — a server-side Laravel app using these validator classes, or local development. Do not putAPPLE_PRIVATE_KEYor a bundled.p8in a mobile app build (see Credential safety).
You need an App Store Connect API key with the App Store Server API role.
APPLE_BUNDLE_ID=com.yourapp.idAPPLE_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxAPPLE_KEY_ID=XXXXXXXXXXAPPLE_IAP_ENV=production # or sandbox # Option A — multiline PEM (use literal \n in .env)APPLE_PRIVATE_KEY="-----BEGIN EC PRIVATE KEY-----\nMHQC…\n-----END EC PRIVATE KEY-----" # Option B — base64-encoded PEMAPPLE_PRIVATE_KEY=LS0tLS1CRUdJTiBFQ… # Option C — path to a .p8 file on the server filesystem (local dev / CI only)APP_STORE_API_KEY_PATH=/secure/path/AuthKey_XXXXXXXXXX.p8
The plugin automatically retries against the sandbox endpoint when a production transaction is not found (e.g. for TestFlight builds).
Using NativePHP App Store API env vars#
If your project already sets the standard NativePHP / App Store API variables, the plugin will use them automatically as fallbacks — no duplicate config required:
# Enable validationINAPP_SERVER_VALIDATION_ENABLED=true # These NativePHP vars double as fallbacks for bundle_id, key_id, and issuer_id.NATIVEPHP_APP_ID=com.yourcompany.yourappAPP_STORE_API_KEY_ID=XXXXXXXXXXAPP_STORE_API_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Path to the .p8 file — only needs to exist where PHP runs (backend / CI).APP_STORE_API_KEY_PATH=./credentials/AuthKey_XXXXXXXXXX.p8
Explicit APPLE_* variables always take precedence over the NativePHP fallbacks.
For Google, set the package name fallback via the same NATIVEPHP_APP_ID:
# Either set GOOGLE_PLAY_PACKAGE_NAME explicitly, or let NATIVEPHP_APP_ID fill it.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=/secure/path/service-account.json
⚠ Security warning — do NOT ship secrets inside a mobile app build. Apple private keys and Google service account JSON contain sensitive credentials.
APP_STORE_API_KEY_PATHis intentionally read from the server filesystem at runtime; the file path must exist where your PHP process runs (backend server or CI), never inside the packaged.ipa/.apk. For production deployments, run validation on your backend API and never embed these secrets in the app bundle.
Android (Google Play) configuration#
As with Apple: these values belong in a backend or CI environment only. Never place service-account JSON (raw or as a bundled file) in a mobile app build.
You need a Google Cloud service account with the Android Publisher role and the Google Play Android Developer API enabled.
GOOGLE_PLAY_PACKAGE_NAME=com.yourapp.id # Option A — raw JSON stringGOOGLE_PLAY_SERVICE_ACCOUNT_JSON='{"type":"service_account","client_email":"…","private_key":"…","token_uri":"…"}' # Option B — path to the downloaded JSON fileGOOGLE_PLAY_SERVICE_ACCOUNT_JSON=/etc/secrets/google-play-service-account.json
Android subscription expiration accuracy: subscription
expiresAtis only available via server-side validation because the Play Billing client API does not expose it. Plugin v1.1+ includespurchaseTokenin every transaction for this purpose. IfpurchaseTokenis absent, server validation is skipped withstatus: 'unverified'and does not affect the purchase result.
Fallback behaviour#
If server validation cannot run (missing config, network error, missing purchaseToken):
- The native purchase result is never changed or failed.
server.validationis still attached withvalidated: nullandstatus: 'unverified'.is_premiumis preserved from the native device result.
// Example fallback payload:$result['server']['validation'] = [ 'ok' => true, 'validated' => null, 'status' => 'unverified', 'expiresAt' => null, 'error' => 'validation_unavailable', 'message' => 'Apple validation not configured.', 'raw' => null,];
Platform-Specific Notes#
iOS (StoreKit 2)#
- environment: Returns
xcode,sandbox, orproductionbased on the StoreKit environment - expirationDate: Available for subscriptions directly from the transaction
- Transactions are automatically verified using StoreKit's built-in verification
- StoreKit Configuration files (
.storekit) are for local Xcode testing only — they are not used in sandbox or production builds
Android (Google Play Billing)#
- environment: Always returns
production(Google Play doesn't expose sandbox info in client API) - expirationDate: Returns
null(subscription expiration requires server-side verification with Google Play Developer API) - quantity: Not supported for purchases (Google Play Billing limitation)
- Subscriptions require configured base plans and offers in Google Play Console
- Purchases are automatically acknowledged after successful completion
- Subscription pricing phases: Google Play Billing returns pricing phases in chronological order — introductory/trial phases first, the recurring billing phase last. The plugin selects the
INFINITE_RECURRINGphase as the mainprice/price_formatted/subscription_period. If the subscription has a free trial or discounted intro period, that data is returned separately underintroductory_offer. Do not useprice == 0to detect a free trial — checkintroductory_offerinstead.
Cross-Platform Considerations#
When building cross-platform apps, handle these differences:
$entitlement = InApp::entitlement(); if ($entitlement['is_premium']) { // User has active entitlement on this platform $platform = $entitlement['platform']; // "ios" or "android" // For subscription expiration, you may need server-side verification on Android if ($platform === 'ios') { $expiresAt = $entitlement['entitlements'][0]['expirationDate'] ?? null; }}
Permissions & Entitlements#
This plugin declares the required billing permissions automatically via nativephp.json. No extra runtime permissions are required from the user.
| Platform | Permission | Declared In |
|---|---|---|
| iOS | (none — StoreKit 2 does not require an entitlement key) | — |
| Android | com.android.vending.BILLING |
nativephp.json → android.permissions |
iOS note: StoreKit 2 in-app purchases and subscriptions do not require any entry in your app's
.entitlementsfile. The "In-App Purchase" capability is enabled via App Store Connect and Xcode, not via an entitlement key. Do not addcom.apple.developer.in-app-payments— that is the Apple Pay entitlement and will cause a provisioning profile build failure.
These are added to your app's build automatically by NativePHP during php artisan native:build.
Bridge Functions#
The following native bridge functions are declared in nativephp.json and available on both platforms:
| Bridge Method | PHP Facade | JS Export | Description |
|---|---|---|---|
InAppPurchases.Products |
InApp::products() |
products() |
Query product details from native store |
InAppPurchases.Purchase |
InApp::purchase() |
purchase() |
Initiate native purchase flow |
InAppPurchases.Restore |
InApp::restore() |
restore() |
Restore previous purchases |
InAppPurchases.Entitlement |
InApp::entitlement() |
entitlement() |
Check active entitlements |
InAppPurchases.Ping |
(direct call) | ping() |
Connectivity smoke test |
Events#
This plugin does not emit NativePHP native events. All bridge functions return structured response arrays synchronously. The "events" array in nativephp.json is empty.
Configuring Prices (iOS & Android)#
Prices are not set in code; they come from the stores and are returned as price_formatted / localized price. You must create your products in both App Store Connect and Google Play Console before they will appear in your app.
iOS (App Store Connect)#
- Go to App Store Connect → Apps → [Your App] → Subscriptions (or In-App Purchases for non-subscription products)
- Create a Subscription Group if you don't already have one (e.g., "Premium")
- Add subscription products with the exact IDs you use in code:
com.app.premium_monthlycom.app.premium_yearly
- Set pricing per territory — Apple handles currency conversion and localization automatically
- Ensure each product status is Ready for Sale (or "Ready to Submit" for new apps)
- For testing, add Sandbox testers under Users and Access → Sandbox → Test Accounts
Prices returned by
products()are automatically localized to the user's App Store region and currency.
Android (Google Play Console)#
-
Go to Play Console → [Your App] → Monetize → Products → Subscriptions
-
Create subscriptions with the exact IDs you use in code:
com.app.premium_monthlycom.app.premium_yearly
-
For each subscription, create a Base Plan and activate it — products will not be returned by Play Billing without an active base plan
-
Set pricing on the base plan (Play Console lets you set per-country pricing)
-
Publish your app to at least an internal testing track — products are not queryable from unpublished apps
-
Add License testers under Settings → License testing (use the Google accounts of your test devices)
-
For production: make sure you have set up your payment method under Settings → Payments profile
Google Play Billing will not return products unless the app is published to a testing track, the subscription has an active base plan, and the device is signed in with a license tester account.
Troubleshooting (Products/Prices)#
If products() returns no results, product() returns product_not_found, or purchase() fails unexpectedly, work through this checklist:
-
product_not_founderror —product()returns this when the store has no match for the given ID. Verify the product exists and is active in App Store Connect / Google Play Console - Product IDs mismatch — The IDs passed to
products()orpurchase()must exactly match what you created in App Store Connect / Google Play Console (e.g.,com.app.premium_monthly) - Products not active / not approved — iOS products must be "Ready for Sale"; Android subscriptions must have status "Active"
- Android base plan missing or inactive — Each Android subscription requires at least one activated base plan, otherwise Play Billing silently returns nothing
- App not published to a testing track (Android) — Google Play Billing requires the app to be published to at least an internal testing track
- Not signed in with a test account — iOS requires a Sandbox Apple ID; Android requires a License tester Google account
- Wrong build type or unsigned build — On Android, the app must be signed with the same key uploaded to Play Console. On iOS, use a development or TestFlight build (not a simulator build for real StoreKit testing)
- Store cache delays — Both stores can cache product data. Try uninstalling the app, clearing Play Store cache (Android), or waiting a few minutes after creating new products
Testing Purchases (Sandbox, TestFlight, License Testers)#
Environment matrix#
| Environment | How purchases run | Backend-verifiable? |
|---|---|---|
Xcode StoreKit Configuration (.storekit file) |
Signed locally by Xcode | Never — these transactions are not registered with Apple's servers; the jws cannot be verified against Apple |
| iOS Sandbox (development build + sandbox Apple Account) | Real Apple sandbox servers | Yes — App Store Server API sandbox endpoint |
| TestFlight | Real Apple sandbox servers | Yes — sandbox endpoint (production lookups fall back to sandbox) |
| App Store (production) | Production servers | Yes — production endpoint |
| Android internal testing track + license tester | Real Play servers | Yes — Google Play Developer API |
| Android production | Real Play servers | Yes |
iOS sandbox#
- Create sandbox testers in App Store Connect under Users and Access → Sandbox → Test Accounts.
- On iOS 18+, sign in with the sandbox Apple Account on-device under Settings → Developer → Sandbox Apple Account (not the main Apple Account sign-in).
- Sandbox subscriptions auto-renew at accelerated speed, up to 12 renewals, and the renewal speed is adjustable per tester in App Store Connect.
TestFlight#
- TestFlight purchases use the sandbox environment; testers are not charged.
- Apple's documented renewal behaviour: subscriptions are "renewed daily, up to 6 times within a 1-week period, regardless of the subscription's duration."
- Transactions report
environment: sandbox— your backend must query Apple's sandbox endpoint for them (the plugin's validator, and any correct backend implementation, falls back from production to sandbox automatically).
Android license testers#
- Add license testers under Play Console → Settings → License testing; publish the app to at least an internal testing track.
- License testers can also sideload debug builds signed with the upload key — the build does not have to be installed from the Play Store, but the Google account on the device must be a license tester and the package/app must exist on a testing track.
- Test purchases use accelerated renewal periods and are not charged.
Advanced: Direct Native Bridge Access#
For advanced use cases, you can call the native bridge functions directly using nativephp_call(). This bypasses the facade's error handling and response normalization.
// Direct restore call$raw = nativephp_call('InAppPurchases.Restore', '{}');$result = json_decode($raw, true)['data'] ?? []; // Direct entitlement call with filter$raw = nativephp_call('InAppPurchases.Entitlement', json_encode([ 'productIds' => ['com.app.premium_monthly']]));$result = json_decode($raw, true)['data'] ?? [];
Note: The facade is recommended for most use cases as it provides consistent error handling and response normalization.
Mobile Plugin Structure#
nativephp-inapp-purchases/├── nativephp.json # Plugin manifest├── src/ # PHP code (facade, manager, service provider)└── resources/ ├── js/index.js # JavaScript bridge library for SPA apps ├── boost/guidelines/core.blade.php # AI assistant guidelines ├── ios/Sources/InAppPurchasesFunctions.swift # iOS StoreKit 2 implementation └── android/src/InAppPurchasesFunctions.kt # Android Google Play Billing implementation
How Discovery Works#
- The
nativephp.jsonmanifest declares bridge functions and platform requirements - NativePHP's build system scans the manifest during
php artisan native:build(also triggered bynative:run) - Native code is compiled into the iOS/Android projects
- PHP calls
nativephp_call()to invoke the native bridge functions
Testing#
composer test
Troubleshooting#
- "Bridge method not found" — The method name does not match what the plugin registered. Run
php artisan native:plugin:listto verify the available bridge functions and check thatnativephp.jsonis correctly discovered. Rebuild withphp artisan native:buildafter any manifest changes. - "No products returned" — Product IDs passed to
products()must exactly match those configured in App Store Connect or Google Play Console. On Android, ensure the subscription has an active base plan and the app is published to at least an internal testing track. See Troubleshooting (Products/Prices) above for a detailed checklist. - "Purchase cancelled" (
user_cancelled) — This is expected behaviour when the user dismisses the purchase sheet. Handle it gracefully in your UI rather than treating it as an error. - "Restore returns nothing" — The device must be signed in with the same Apple ID or Google account that made the original purchase. Only non-consumable and subscription product types support restore; consumable purchases cannot be restored.
- StoreKit Configuration file confusion —
.storekitconfiguration files are for local Xcode testing only. They are not used in sandbox or production builds and do not need to be included in your app bundle. - SPA fetch errors to
/_native/api/call— This endpoint is only available when the app runs inside the NativePHP runtime. Requests from a normal browser will fail. Ensure the route is not blocked by middleware, CSRF tokens are handled correctly, and that you are testing on a device or simulator running the native app.
Upgrading to v1.4.0#
v1.4.0 is backward compatible. Existing purchase(), restore(), and entitlement() integrations continue to work, and the new response fields are additive.
- Added NativePHP Mobile v4 support while retaining v3 compatibility.
- Upgraded Google Play Billing from
billing-ktx:7.1.1tobilling:9.1.0. - Added Apple
jwsproof to iOS transaction responses. - Added Android subscription
offers[]metadata. - Retained the legacy Android
_offerTokenfield for backward compatibility. - Deprecated the optional on-device validator. It remains available and disabled by default.
⚠ Upgrade warning (Android): NativePHP's plugin compiler appends missing Gradle dependencies but never removes old ones. If your generated
nativephp/androidproject was built with plugin ≤1.3.x, delete the stalecom.android.billingclient:billing-ktx:7.1.1line fromapp/build.gradle.kts— or regenerate the Android project — after upgrading, then rebuild.
Support#
For issues or enquiries please email: [email protected]
Changelog#
Please see CHANGELOG for more information what has changed recently.
Contributing#
Please see CONTRIBUTING for details.
Security#
If you discover any security related issues, please email [email protected] instead of using the issue tracker.
Credits#
License#
This is commercial, proprietary software — it is not open source. Use is governed by the commercial license terms agreed at the time of purchase. Please see the License File for more information.