NativePHP In-App Purchases#
A NativePHP Mobile plugin providing in-app purchases via StoreKit 2 (iOS) and Google Play Billing (Android) for Laravel apps.
Installation#
You can install the package via composer:
composer require developernauts/nativephp-inapp-purchases
Then register the plugin with NativePHP:
php artisan native:plugin:register developernauts/nativephp-inapp-purchases
Requirements#
- PHP >= 8.3
- Laravel >= 12.0
- NativePHP Mobile v3
- Livewire v3/v4 (optional — for Livewire-based apps)
- Inertia + Vue/React (optional — for SPA-based apps)
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']) { // User has active subscription}
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. Both platforms have full feature parity.
products()#
Fetch product metadata from the native store.
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 |
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'], ],]
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 |
StoreKit returned an error |
timeout |
Request timed out (30 seconds) |
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.
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' ]]
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. Calls AppStore.sync() and collects current entitlements.
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. 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 | Unique transaction ID |
originalId |
int | 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) |
environment#
Indicates the StoreKit environment in which the transaction was processed.
| 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 |
Server-Side Validation (Built-in)#
This plugin includes a built-in server-side receipt/purchase validation layer for both iOS (Apple App Store Server API) and Android (Google Play Developer API). 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#
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#
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 setup 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
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.
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.