AdMob for NativePHP Mobile#
Google AdMob banner, interstitial and rewarded ads, driven from PHP. Every call is synchronous, returns the same canonical array, and never blocks on the ad network.
The two platforms are not equivalent, and the difference matters:
| iOS | Android | |
|---|---|---|
| Backend | Google Mobile Ads SDK v12 (CocoaPods) | Play Services Ads 24.8.0, pinned (Gradle) |
| Consent | GoogleUserMessagingPlatform (pod) | com.google.android.ump:user-messaging-platform |
| Banner placement | Overlaid on the key window, pinned to the safe area | Added to the activity content view, above the measured system-bar inset |
| Banner refresh while backgrounded | The SDK stops it | AdView.pause() / .resume(), wired to the activity lifecycle |
| App id source | Info.plist → GADApplicationIdentifier |
Manifest meta-data → com.google.android.gms.ads.APPLICATION_ID |
| App Tracking Transparency | Opt-in, off by default | Does not exist |
| SKAdNetwork | 50 identifiers, written after every build | Not applicable |
Formats: banner, interstitial, rewarded video and rewarded interstitial. No app-open ads and no native ads.
Requirements#
- PHP 8.2+
nativephp/mobile^3.0 | ^4.0 | dev-main- iOS 18.2+ / Android API 26+
- An AdMob account with an app registered in it, and real ad unit ids, before anything can serve in production
- On Android, the Gradle that
nativephp/mobileships (Kotlin 2.0.0). Do not raise the ads dependency past 24.8.0 to reach a newer one — see Why Play Services Ads is pinned
Installation#
This is a paid, private plugin — not on Packagist. You need a valid purchased license and read access to the repository.
"repositories": { "nativephp-admob": { "type": "vcs", }}
composer require codypchristian/nativephp-admob:^0.2
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-admob, 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. Ads::isSupported() returns false and
every call returns ['ok' => false, 'reason' => 'unavailable']. It looks like a
broken plugin; it is a missing line.
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-admob
which adds it to the plugins() array:
public function plugins(): array{ return [ \CodyPChristian\NativeAdmob\AdMobServiceProvider::class, ];}
Rebuild so the native code is compiled in:
php artisan native:install --force
Confirm with php artisan native:plugin:list — codypchristian/nativephp-admob
must appear under registered plugins with 18 bridge functions, not under
"Unregistered".
Optionally publish the config:
php artisan vendor:publish --tag=admob-config
The provider merges config/admob.php under the admob key either way, so the
defaults (test mode on, consent off, tracking off, diagnostics off) apply
without publishing. This is the file the plugin actually reads — AdMob
resolves everything through config('admob.*') at call time.
AdMob setup#
App id — required, and separate from ad unit ids#
Both SDKs abort at startup without a valid application id, so initialize()
checks for one first and returns reason: 'no_app_id' rather than letting the
app die.
| Env var | Substituted into | Example |
|---|---|---|
ADMOB_IOS_APP_ID |
Info.plist → GADApplicationIdentifier |
ca-app-pub-0000000000000000~0000000001 |
ADMOB_APP_ID |
AndroidManifest.xml → com.google.android.gms.ads.APPLICATION_ID |
ca-app-pub-0000000000000000~0000000002 |
Substitution happens at build time, from the manifest. Google's documented
test app ids — ca-app-pub-3940256099942544~1458002511 (iOS) and
ca-app-pub-3940256099942544~3347511713 (Android) — are the safe values before
you have an account.
A stale app id survives an env change#
nativephp/ios/ is generated once by native:install and then edited in
place by each build. NativePHP's plist injection adds a key when it is missing
and updates it when it is present. It never removes one, and nothing runs at
all for a plugin that is not registered.
- Change
ADMOB_IOS_APP_IDwhile the plugin is not registered and the oldGADApplicationIdentifierstays in the generatedInfo.plist. You ship the previous app id. - Turn the plugin off after having built with it on, and
GADApplicationIdentifierremains inInfo.plistand ships in a binary that has no ads code in it.
Check nativephp/ios/NativePHP/Info.plist and
NativePHP-simulator-Info.plist by hand before any release build, or run
php artisan native:install ios --force after ever turning a plugin off.
Turning one on is fine; turning one off is not.
This plugin's own two keys are the exception — SKAdNetworkItems and
NSUserTrackingUsageDescription are written by
a post-compile hook that sets them rather than
merging into them, and removes them when they are turned off. That still only
runs while the plugin is registered.
Ad unit ids are per placement, per platform, and cannot be derived#
An ad unit id is not the app id and cannot be computed from it. Each placement on each platform has its own, created in the AdMob console.
| Config key | Env var (with fallback) |
|---|---|
admob.ad_units.banner.ios |
ADMOB_IOS_BANNER_AD_UNIT_ID → ADMOB_BANNER_AD_UNIT_ID |
admob.ad_units.banner.android |
ADMOB_ANDROID_BANNER_AD_UNIT_ID → ADMOB_BANNER_AD_UNIT_ID |
admob.ad_units.interstitial.ios |
ADMOB_IOS_INTERSTITIAL_AD_UNIT_ID → ADMOB_INTERSTITIAL_AD_UNIT_ID |
admob.ad_units.interstitial.android |
ADMOB_ANDROID_INTERSTITIAL_AD_UNIT_ID → ADMOB_INTERSTITIAL_AD_UNIT_ID |
admob.ad_units.rewarded.ios |
ADMOB_IOS_REWARDED_AD_UNIT_ID → ADMOB_REWARDED_AD_UNIT_ID |
admob.ad_units.rewarded.android |
ADMOB_ANDROID_REWARDED_AD_UNIT_ID → ADMOB_REWARDED_AD_UNIT_ID |
admob.ad_units.rewarded_interstitial.ios |
ADMOB_IOS_REWARDED_INTERSTITIAL_AD_UNIT_ID → ADMOB_REWARDED_INTERSTITIAL_AD_UNIT_ID |
admob.ad_units.rewarded_interstitial.android |
ADMOB_ANDROID_REWARDED_INTERSTITIAL_AD_UNIT_ID → ADMOB_REWARDED_INTERSTITIAL_AD_UNIT_ID |
"Ads work in dev, nothing serves in production"#
This is the expected failure, and it is worth understanding exactly.
While ADMOB_TEST_MODE=true, slot resolution reads admob.test_ad_units and
never consults your real ids at all. Them being empty, wrong, or absent
makes no difference — Google's test units serve, and every placement looks
healthy.
Flip ADMOB_TEST_MODE=false and the same empty id now resolves to null.
Every load and show returns reason: 'no_ad_unit' without touching the bridge,
and no ad ever appears. Nothing crashes; nothing warns.
Verify with Ads::adUnit('banner') under ADMOB_TEST_MODE=false before you
ship, or open the diagnostics page, which prints the
resolved id for every slot. A null there is a production outage you can see
from artisan tinker.
Google's public test units are safe to use and to commit:
banner ios ca-app-pub-3940256099942544/2934735716banner android ca-app-pub-3940256099942544/6300978111interstitial ios ca-app-pub-3940256099942544/4411468910interstitial android ca-app-pub-3940256099942544/1033173712rewarded ios ca-app-pub-3940256099942544/1712485313rewarded android ca-app-pub-3940256099942544/5224354917rewarded_interstitial ios ca-app-pub-3940256099942544/6978759866rewarded_interstitial android ca-app-pub-3940256099942544/5354046379
Test mode must be on for any device you tap ads on. Clicking a live ad on your own device is invalid traffic and gets AdMob accounts suspended.
Test mode also skips frequency capping entirely — see Frequency capping.
Named slots#
banner, interstitial, rewarded and rewarded_interstitial are just the
default slot names. An app with two interstitial placements wants them reported
separately in AdMob, which means two ad units, which means two names:
// config/admob.php'slots' => [ 'level_complete' => [ 'format' => 'rewarded', 'ios' => env('ADMOB_IOS_LEVEL_COMPLETE_AD_UNIT_ID', ''), 'android' => env('ADMOB_ANDROID_LEVEL_COMPLETE_AD_UNIT_ID', ''), ], 'between_rounds' => [ 'format' => 'interstitial', 'ios' => env('ADMOB_IOS_BETWEEN_ROUNDS_AD_UNIT_ID', ''), 'android' => env('ADMOB_ANDROID_BETWEEN_ROUNDS_AD_UNIT_ID', ''), ],],
Ads::loadRewarded('level_complete');Ads::showRewarded('level_complete'); Ads::loadInterstitial('between_rounds');Ads::showInterstitial('between_rounds');
format is one of banner, interstitial, rewarded,
rewarded_interstitial. It decides two things: which of Google's demo units the
slot falls back to under test mode, and which
frequency rules apply to it. A slot named after a format
carries that format without saying so, which is why the four defaults work with
nothing declared.
The old admob.ad_units shape is read exactly as before, and a slot
declared in slots of the same name wins. Nothing you already have breaks.
Ads::slots() returns every declared slot as name => format.
Usage#
use CodyPChristian\NativeAdmob\Facades\Ads; Ads::initialize(); // start the SDK, apply the request configurationAds::showBanner(); // anchored adaptive banner, bottomAds::loadInterstitial(); // preload — fire and forget // ... later, at a natural break$result = Ads::showInterstitial(); if (! $result['shown']) { Ads::loadInterstitial(); // nothing was ready; warm the next one}
The SDK is deliberately not started when the plugin loads — starting it is a
network call and a privacy-relevant act, so it waits for initialize().
A realistic driver:
use CodyPChristian\NativeAdmob\Facades\Ads; class AdDriver{ public function boot(): void { if (! Ads::isSupported()) { return; // desktop dev server, tests, or unregistered } // Also runs the consent update when consent management is on. Ads::initialize(); if (Ads::consentStatus()['form_available'] ?? false) { Ads::showConsentForm(); } Ads::loadInterstitial(); } public function actionCompleted(int $count): void { if ($count % 3 !== 0) { return; } // Never blocks. 'shown' => false is the normal answer, not an error. Ads::showInterstitial(); Ads::loadInterstitial(); // an interstitial is single-use }}
Rewarded ads and the reward#
The reward is not in showRewarded()'s answer, and cannot be. It is
delivered by the SDK in a callback that fires while the ad is playing — long
after the bridge call that started it returned — so the native side parks it and
you collect it when the user comes back:
Ads::loadRewarded('level_complete'); // ... the user asks for the reward$shown = Ads::showRewarded('level_complete'); if (! $shown['shown']) { // Nothing was ready. Tell them, and warm the next one. Ads::loadRewarded('level_complete'); return;}
Then, on the next request — a poll, a Livewire refresh, whatever brings the user back to your UI:
$reward = Ads::reward(); if ($reward['earned']) { $user->grant($reward['amount'], $reward['type']); // e.g. 10 'coins'} Ads::loadRewarded('level_complete');
Two things to know:
shown => truemeans the ad started, never that it was watched through. Onlyreward()answers that.- Collecting is destructive.
reward()hands the reward over and the native side forgets it, so a second call reportsearned => false. That is deliberate: a reward that stayed readable would be granted again by the next piece of code to look, and "did the user watch an ad?" is exactly the question an app asks more than once. Starting a new rewarded ad also clears an uncollected reward, so a user who watches twice and is credited once cannot be credited for the first viewing twice.
Rewarded interstitials work identically: loadRewardedInterstitial(),
showRewardedInterstitial(), same reward(). Both formats share one reward
slot, because a user can only be watching one of them.
Positioning the banner#
The banner is an overlay. It is drawn on top of your app's content and does not reflow it — nothing moves out of the way. Anchored flush to the bottom, it therefore sits on top of whatever chrome you draw there. If your app has a bottom tab bar, the banner covers the tabs and they stop being tappable.
The fix is admob.banner.offset: a gap between the banner and the edge it is
anchored to, in dp on Android and points on iOS.
// config/admob.php'banner' => [ 'position' => env('ADMOB_BANNER_POSITION', 'bottom'), 'offset' => [ 'ios' => (int) env('ADMOB_BANNER_OFFSET_IOS', env('ADMOB_BANNER_OFFSET', 0)), 'android' => (int) env('ADMOB_BANNER_OFFSET_ANDROID', env('ADMOB_BANNER_OFFSET', 0)), ], 'clear_system_bars' => (bool) env('ADMOB_BANNER_CLEAR_SYSTEM_BARS', true),],
| Env var | Applies to | Default |
|---|---|---|
ADMOB_BANNER_OFFSET_IOS |
iOS only, in points | ADMOB_BANNER_OFFSET, then 0 |
ADMOB_BANNER_OFFSET_ANDROID |
Android only, in dp | ADMOB_BANNER_OFFSET, then 0 |
ADMOB_BANNER_OFFSET |
both, when neither of the above is set | 0 |
ADMOB_BANNER_CLEAR_SYSTEM_BARS |
Android only | true |
Both platforms now measure from the same place#
Your offset only ever has to clear your app's own chrome. The system bars are handled for you on both platforms:
- iOS constrains the banner against the window's
safeAreaLayoutGuide, which already excludes the home indicator. - Android reads the real system-bar inset from the window at layout time and adds your offset on top of it.
That is a change. Before, the Android banner sat in a fitsSystemWindows
container, which applied an inset nothing could read back — so the configured
number had to absorb whatever that device's navigation bar happened to be, and
the value that looked right on one phone was wrong on the next.
Upgrading? If you tuned an Android offset under the old behavior, it is now roughly (that device's navigation bar + the gap you actually wanted) and the banner will sit too high. Reduce it — usually to the same value as your iOS one. One app that needed 44 on iOS and 70 on Android now needs 44 on both.
Set clear_system_bars to false to get the old flush-to-the-window behavior
back, for an app that already pushes content clear of the system bars itself.
Changing the offset needs the banner rebuilt#
The offset — and clear_system_bars — are part of the banner's cached identity
on both platforms, alongside the ad unit id and the position. That is
deliberate: without it, showBanner() would take its early return, decide the
banner already on screen is still correct, and your new offset would appear to
do nothing until the ad unit or position happened to change too.
Because it is in the identity, changing the config value and calling
showBanner() again rebuilds the banner. Config is cached, though —
php artisan config:clear after editing, and a rebuild if the value came from a
.env baked in at build time.
The banner and the app lifecycle#
You do not have to do anything. The banner is paused, resumed and destroyed with the host:
- Android calls
AdView.pause()when the activity pauses and.resume()when it resumes, and.destroy()when it goes away for good. Without that the refresh timer keeps running in the background — battery spent on ads nobody can see, and impressions the ad network is entitled to discount. - iOS has no pause/resume API; the Google Mobile Ads SDK stops the refresh
itself. What the plugin does there instead is hide the banner on the way out
(so it is not captured in the app-switcher snapshot), re-point the banner's
weak
rootViewControllerat whatever the root is on the way back in (an app that swaps its root while backgrounded otherwise returns to a banner that silently stops filling), and tear it down at termination.
Frequency capping#
Two limits per format and per named slot, both optional, both 0 for no limit:
// config/admob.php'cadence' => [ 'enabled' => (bool) env('ADMOB_CADENCE', true), 'store' => env('ADMOB_CADENCE_CACHE_STORE') ?: null, 'key_prefix' => 'admob:cadence', 'formats' => [ 'interstitial' => ['min_interval' => 90, 'daily_max' => 20], 'rewarded' => ['min_interval' => 0, 'daily_max' => 0], ], 'slots' => [ 'between_rounds' => ['min_interval' => 300, 'daily_max' => 5], ],],
| Key | Meaning |
|---|---|
min_interval |
Seconds that must pass between two shows |
daily_max |
Shows allowed in one calendar day |
Both the slot rule and the format rule are checked, and the stricter one wins. "No more than 20 interstitials a day anywhere, and no more than 5 from the between-rounds screen" is two independent rules rather than one arithmetic problem.
A refused show is a value, not an exception:
$result = Ads::showInterstitial('between_rounds'); if ($result['reason'] === 'capped') { // $result['retry_after'] seconds // $result['limit'] 'min_interval' | 'daily_max' // $result['scope'] 'slot:between_rounds' | 'format:interstitial'}
Three things worth knowing:
- Only a show that actually happened is counted. A
not_readydoes not spend the day's allowance, so an empty ad cache cannot silently exhaust it. - The cache store must be a persistent one. The counters go through the
cache because a mobile PHP runtime is not one long-lived process — background
the app and the next request is a new one. On an
arraystore the counters reset with the process and "3 a day" quietly becomes "3 per relaunch", which is worse than no limit because it looks like it is working.fileordatabaseis fine; name one inadmob.cadence.storeif your default is not. - Capping is skipped entirely while
test_modeis on. Testing a placement means showing it over and over, and a cap you have to wait out is a cap you disable by hand and forget to put back.
Ads::cadence()->inspect($format, $slot) returns the current counters, and
Ads::cadence()->forget($format, $slot) clears them — which is also what the
diagnostics page's "Clear frequency counters" button does.
Consent, and App Tracking Transparency#
These are two different questions and only one of them is about the EEA.
- Consent (UMP) asks may ads be personalized for this user, in the EEA, the UK and several US states. Serving ads to those users without asking is a policy violation.
- App Tracking Transparency asks may this app read the advertising identifier, on iOS, everywhere.
Consent#
Off by default, because turning it on changes when ads are allowed to serve.
// config/admob.php'consent' => [ 'enabled' => (bool) env('ADMOB_CONSENT', false), 'gate_ads' => (bool) env('ADMOB_CONSENT_GATE_ADS', true), 'request_on_initialize' => (bool) env('ADMOB_CONSENT_ON_INITIALIZE', true), 'timeout' => (int) env('ADMOB_CONSENT_TIMEOUT', 6), 'debug_geography' => env('ADMOB_CONSENT_DEBUG_GEOGRAPHY') ?: null, 'test_device_ids' => [/* … */], 'under_age_of_consent' => null,],
The flow, in full:
Ads::initialize(); // runs the consent update for you $status = Ads::consentStatus(); if ($status['form_available']) { Ads::showConsentForm(); // returns immediately; the user takes their time} // Ads start serving on their own once the gate's next read comes back positive.
And, permanently, wherever your settings live:
if (Ads::consentStatus()['privacy_options_required']) { // Show a "Privacy options" row that calls: Ads::showPrivacyOptions();}
The gate asks the native SDK on every call. That is the part worth
understanding, because the obvious implementation is broken in a way that is
very hard to see: consent is stored on the device and outlives the PHP process,
so a flag remembered in PHP is null on the first request after every cold
start — and a gate built on that refuses every ad in the app while looking like
a consent problem. The cost of asking natively is one extra local bridge call
per gated call; it is not a network round trip.
The other half of the same trap: a bridge that does not answer is not a
refusal. If the plugin was never compiled into the build, gating on the
missing answer would report every ad as consent_required instead of the
unavailable it actually is. It does not; the call goes through and reports its
own reason.
requestConsent() is the one call in this plugin that waits on the network,
up to consent.timeout seconds, because the caller has nothing useful to do
until it comes back. A timeout hands back whatever the SDK already had stored,
with timed_out => true. Showing a form does not wait — that completes when
the user dismisses it, which is theirs to decide.
On a fresh install the SDK refuses ads until a consent update has completed at least once. That is why
initialize()runs one. Turnrequest_on_initializeoff only if you are driving the flow by hand — with the gate on and nothing running the update, an app shows no ads at all andreason: 'consent_required'is your only clue.
To see the form outside a regulated region, set debug_geography to 'eea'
and put your device's hashed id in test_device_ids. Both platforms print
that id to the device log the first time they are asked for consent info; the
SDK will not serve a form to a device it has not been told is a test device.
Ads::resetConsent() throws the stored decision away so you can see the flow
again.
App Tracking Transparency, and the child-directed rule#
Read this before turning ATT on.
If your app is tagged child-directed — which is the default in
config('admob.request') — it must not ask for tracking. The child-directed
tag exists to suppress exactly the identifier use the ATT prompt asks permission
for, so doing both is a policy contradiction, and App Review treats a tracking
prompt in a kids-category app as a reason to reject.
The plugin enforces that in both places it can:
| Child-directed true | Child-directed false or null | |
|---|---|---|
admob.tracking.enabled false (default) |
No purpose string in the build. requestTracking() returns disabled. |
Same. |
admob.tracking.enabled true |
No purpose string is written, and the build prints a warning saying why. requestTracking() returns not_permitted. iOS cannot show the prompt at all. |
NSUserTrackingUsageDescription is written into both Info.plist files. requestTracking() prompts. |
The build-time half is the important one: without a purpose string iOS cannot show the prompt, so the contradiction is unreachable rather than merely discouraged.
Turning it on, for an app that is genuinely not directed at children:
// config/admob.php'request' => [ 'tag_for_child_directed_treatment' => false, // or null // …], 'tracking' => [ 'enabled' => (bool) env('ADMOB_TRACKING', false), 'usage_description' => env( 'ADMOB_TRACKING_USAGE_DESCRIPTION', 'This identifier is used to show you ads that are more relevant to you.' ),],
Ads::requestTracking(); // returns as soon as the prompt is on its way// … laterAds::trackingStatus()['status']; // authorized | denied | restricted | not_determined
Apple rejects a vague purpose string; say what the identifier is used for in
your app. Rebuild after changing it — it goes into Info.plist at build
time, not at runtime. Turning tracking back off removes the key on the next
build, so it really does turn off.
Android has no per-app tracking prompt; the advertising id is a system setting
there. trackingStatus() reports 'unavailable' rather than a status that
would read as "denied".
Child-directed treatment and content rating#
config('admob.request') is applied to the SDK on every initialize() call.
| Key | Default | Values |
|---|---|---|
tag_for_child_directed_treatment |
true |
true, false, null |
tag_for_under_age_of_consent |
null |
true, false, null |
max_ad_content_rating |
'G' |
'G', 'PG', 'T', 'MA' |
Both tags are genuinely three-valued: null means "send no tag", which is
not the same as tagging false. An unrecognized max_ad_content_rating leaves
whatever the SDK already had rather than silently loosening the rating.
admob.consent.under_age_of_consent defaults to null, which means "use
tag_for_under_age_of_consent". Two different answers to the same question
about the same user is a bug waiting to happen, so leave it alone unless you
have a reason.
SKAdNetwork identifiers#
iOS attributes an install to the ad that caused it through SKAdNetwork, and Apple only delivers a postback to a network the app has declared. An undeclared network is not an error you can see: the ad serves, the install happens, and the credit silently never arrives.
The plugin ships Google's full published set — 50 identifiers, Google's own plus
every mediation partner's — in
resources/skadnetwork/identifiers.json, and writes them into both generated
Info.plist files after every build. They are inert until an ad actually comes
from that network, so they are worth declaring before you turn mediation on.
'skadnetwork' => [ 'enabled' => (bool) env('ADMOB_SKADNETWORK', true), 'additional' => [], // for a network Google does not list],
The injection is a post-compile hook, not a manifest info_plist entry, and
that is not an implementation detail:
SKAdNetworkItemsis an array of dicts. NativePHP's plist merge is built for strings and arrays of strings, and a merge that cannot compare a dict cannot tell an existing entry from a new one — so the array grows by the whole list on every build. A real app reached eleven copies of the same entry.- The hook sets the key instead. The result is identical after one build and after fifty, and an array that has already duplicated is repaired rather than extended.
If you are upgrading from 0.1.x and your generated Info.plist has
accumulated duplicates, they are cleaned up on the next build. Nothing to do by
hand.
'additional' entries get the .skadnetwork suffix added if you leave it off,
and duplicates are collapsed. 'enabled' => false writes no key at all and
removes one a previous build left.
The diagnostics page#
"Did the banner fill?" is not in showBanner()'s answer — that reports the view
was placed. A fill arrives later, in an ad callback, and used to go to the
device log and nowhere else.
// config/admob.php'diagnostics' => [ 'enabled' => (bool) env('ADMOB_DIAGNOSTICS', false), 'path' => env('ADMOB_DIAGNOSTICS_PATH', '_admob'), 'middleware' => ['web'], 'log_size' => 100,],
With ADMOB_DIAGNOSTICS=true, visit /_admob inside the app and you get a
button for every format, a slot picker, the resolved ad unit id for each slot,
the live frequency counters, and two logs side by side: what PHP asked for, and
what the ad SDK did about it. Fills, no-fills, ready, dismissed, rewards earned,
consent-form errors and the ATT answer all land in the second one.
Ads::events() returns the same native log if you would rather read it in code.
Off by default, and the routes are not registered at all when it is off — not registered and guarded. The page prints resolved ad unit ids and can spend real ad requests, and a route that does not exist cannot be found by guessing the path. Add your own auth middleware if the build is going anywhere near a user.
Method reference#
Ads::initialize(?array $request = null): arrayAds::showBanner(string $slot = 'banner', ?string $position = null): arrayAds::hideBanner(): arrayAds::loadInterstitial(string $slot = 'interstitial'): arrayAds::showInterstitial(?string $slot = null): arrayAds::loadRewarded(string $slot = 'rewarded'): arrayAds::showRewarded(?string $slot = null): arrayAds::loadRewardedInterstitial(string $slot = 'rewarded_interstitial'): arrayAds::showRewardedInterstitial(?string $slot = null): arrayAds::reward(): array Ads::requestConsent(): arrayAds::showConsentForm(): arrayAds::showPrivacyOptions(): arrayAds::consentStatus(): arrayAds::canRequestAds(): boolAds::resetConsent(): arrayAds::requestTracking(): arrayAds::trackingStatus(): arrayAds::trackingEnabled(): bool Ads::events(bool $clear = false): arrayAds::isSupported(): boolAds::testMode(): boolAds::adUnit(string $slot, ?string $format = null): ?stringAds::slots(): arrayAds::cadence(): CadenceAds::fake(array $answers = []): RecordingAdBridge
initialize($request)—$requestoverridesconfig('admob.request')key by key,nullincluded. Extras:initialized,test_mode.showBanner($slot, $position)—$positionis'bottom'or'top', defaulting toconfig('admob.banner.position'); anything else becomes'bottom'. Extras:visible,ad_unit_id,position. Calling it again with the same slot, position, offset andclear_system_barsre-shows the existing banner instead of burning a fresh ad request. There is no$offsetargument — it comes from config.hideBanner()— idempotent. Extra:visible.load*($slot)— extras:loading,loaded,ad_unit_id.loaded => truemeans one was already waiting. A load already cached or in flight for the same unit is a no-op; re-requesting would discard the cached ad.show*($slot)— extra:shown.$slotis only used to attribute the show to a placement for frequency capping; the native side presents whatever was loaded. Omitted, it is the format's own name.reward()— extras:earned,type,amount,format,ad_unit_id. Destructive; see Rewarded ads.consentStatus()/requestConsent()— extras:can_request_ads,status(unknown|required|not_required|obtained),form_available,privacy_options_required.requestConsent()addsupdatedandtimed_out.canRequestAds()— alwaystruewhen consent management is off.requestTracking()/trackingStatus()— extras:available,status,requested.available => falsemeans no prompt is possible in this build.adUnit($slot)— the resolved id for the current platform, ornull. Blank strings count as nothing.events()— extra:events, a list of['at' => int, 'event' => string, 'detail' => string], oldest first, capped at 100 natively.
Checking availability up front#
if (! Ads::isSupported()) { // The bridge function is not in the build — desktop dev server, tests, // or the plugin was never registered.}
This asks the native bridge registry, not the container. Do not gate on
class_exists() of the facade or the service provider: those come from the
Composer package and are true even when no native code was compiled in.
Errors#
Nothing in the PHP surface throws. Every failure is a value:
[ 'ok' => bool, // the bridge answered 'reason' => string, 'error' => ?string, // human-readable; null on success // ... per-call extras]
reason |
ok |
Meaning |
|---|---|---|
ok |
true | The call did what it says |
not_ready |
true | Nothing had finished loading. Not an error |
capped |
false | A frequency limit refused this show. Extras: retry_after, limit, scope |
consent_required |
false | The consent SDK says ads may not be requested yet |
not_permitted |
false | The build or the configuration forbids this — a tracking prompt in a child-directed app |
disabled |
false | The feature is switched off in config |
unavailable |
false | No native bridge, or the plugin was not compiled in — usually a missing plugins() entry |
no_ad_unit |
false | No unit id for that slot on this platform |
no_app_id |
false | No valid AdMob application id in the build |
failed |
false | The SDK could not complete the request, or the main thread did not answer within 5 seconds |
When more than one applies, the most specific one wins: no_ad_unit before
consent_required before capped. A slot with nothing configured is not a
consent problem, and reporting it as one sends you to the wrong place entirely.
ok => true, shown => false is the normal outcome when nothing was ready. The
caller carries on — the ad waits for the user, never the reverse.
Testing#
Without a device#
Swap the native bridge for one that answers from a script and records what it was asked:
use CodyPChristian\NativeAdmob\Facades\Ads; $ads = Ads::fake([ 'AdMob.ShowInterstitial' => ['ok' => true, 'reason' => 'ok', 'shown' => true],]); $driver->actionCompleted(3); $ads->assertCalled('AdMob.ShowInterstitial');$ads->assertNotCalled('AdMob.ShowBanner');
Everything above the bridge stays real — slot resolution, the consent gate, frequency capping, the canonical response shape — so what you exercise is the code that ships.
| Method | |
|---|---|
answer($function, array|Closure) |
Script one function; a closure receives the call's parameters |
silence($function) |
Make it answer with nothing, the way an uncompiled plugin does |
supported(bool) |
Flip what Ads::isSupported() reports |
calls() / callsTo($function) / called($function) |
What was asked |
flush() |
Forget what was recorded, keep the script |
assertCalled($function, ?Closure $matching) |
|
assertNotCalled($function) |
|
assertCallCount($function, int) |
|
assertNothingCalled() |
Nothing reached the bridge — the assertion for "this was refused in PHP" |
An unscripted function answers ['ok' => true, 'reason' => 'ok'], which is
enough for the canonical shape to survive and nothing more.
The package's own suite#
composer installcomposer test
76 tests, no device involved.
Compiling the native halves#
Both halves compile in isolation against the real SDKs, which is what catches a renamed symbol or a bad ProGuard rule before a consuming app's build does:
NATIVEPHP_APP=/path/to/your-app ./verify/typecheck.sh # iOSNATIVEPHP_APP=/path/to/your-app ./verify/typecheck-android.sh # Android
They need an app whose vendor/ has nativephp/mobile (the generated bridge
registration is rendered with NativePHP's own compiler, so it cannot drift), a
JDK 17+, and an Android SDK with platform 36. The Android one runs a release
build so R8 sees the manifest's ProGuard rules — a malformed rule otherwise
fails a consumer's release build only, which is the worst possible time to find
out.
Verifying an installation#
php artisan native:plugin:list # must show 18 bridge functions, registeredphp artisan native:plugin:validatephp artisan tinker>>> \CodyPChristian\NativeAdmob\Facades\Ads::adUnit('banner');
Why Play Services Ads is pinned#
nativephp.json declares com.google.android.gms:play-services-ads:24.8.0 as an
exact version rather than a floating range, on purpose. 24.9.0 breaks the
Android build.
24.9.0 and later are compiled with Kotlin 2.2.0. Gradle 8.14.5 — the version
nativephp/mobile ships — bundles Kotlin 2.0.0, and a 2.0.0 compiler will not
read 2.2.0 metadata. The build dies in dozens of repetitions of:
Module was compiled with an incompatible version of Kotlin.The binary version of its metadata is 2.2.0, expected version is 2.0.0.
Consequence: the error names Kotlin, not AdMob. Not one line of it mentions
play-services-ads, this plugin, or a version constraint, so it reads like your
own Kotlin toolchain is misconfigured — and the natural next move, editing
Kotlin or Gradle plugin versions, cannot fix it. The ads artifact is the cause.
The line was measured rather than guessed, by compiling this plugin's Kotlin against each version with the exact toolchain the Android template ships:
| Version | |
|---|---|
| 23.6.0 – 24.8.0 | compiles |
| 24.9.0 and later | fails, metadata 2.2.0 |
(The kotlin-stdlib: 2.1.0 that appears in the POM from 24.6.0 is not the
problem — a 2.0.0 compiler reads it. What breaks at 24.9.0 is the metadata
version of the artifact's own classes.)
24.8.0 is therefore the newest release that works. Move off it only after
checking which Kotlin your Gradle actually bundles; if nativephp/mobile later
ships a Gradle on Kotlin 2.2.0 or newer, the ceiling moves and the pin can be
raised.
The banner sets its unit with setAdUnitId(...) rather than the property form,
for one reason only: inside AdView(activity).apply { … } the local adUnitId
parameter shadows the property, so the assignment would read as assigning the
parameter to itself. Both spellings compile on both majors.
Platform caveats#
iOS#
- Every Google Mobile Ads API is main-thread-only. Bridge calls arrive on the
PHP runtime's own serial queue, so each call hops to the main queue and waits
for the bookkeeping, never the network, with a 5-second ceiling. A wedged
main thread degrades to
reason: 'failed'rather than a hung app. The one exception isrequestConsent(), which waits deliberately — see Consent. adDidPresentFullScreenContentisNS_UNAVAILABLEin SDK v12 and is deliberately not implemented;adWillPresentFullScreenContentis the replacement. Do not "fix" that.- The banner is constrained to the window's
safeAreaLayoutGuide, which already excludes the home indicator. SKAdNetworkItemsand, when you ask for it,NSUserTrackingUsageDescriptionare written after every build.- No entitlements are injected, so this plugin cannot break code signing.
- Pods:
Google-Mobile-Ads-SDK ~> 12.0andGoogleUserMessagingPlatform ~> 3.0.
Android#
- Play Services Ads is pinned to 24.8.0 on the Gradle
nativephp/mobileships — see Why Play Services Ads is pinned. - The banner goes into the activity's content view; there is no key-window overlay equivalent.
- The system-bar inset is measured from the window and
admob.banner.offsetis added on top of it. AdView.pause(),.resume()and.destroy()are driven from the activity's lifecycle.- No App Tracking Transparency;
trackingStatus()reports'unavailable'. - Injects
INTERNETandACCESS_NETWORK_STATEpermissions, thecom.google.android.gms.ads.APPLICATION_IDmeta-data entry, and ProGuard keep rules forcom.google.android.gms.ads.**andcom.google.android.ump.**.
Status#
What has actually been verified:
- Both native halves compile against the real SDKs, through
verify/typecheck.sh(Google Mobile Ads 12.14.0 + User Messaging Platform 3.1.0, iOS 18.2, Swift 5) andverify/typecheck-android.sh(Play Services Ads 24.8.0 + UMP 3.1.0, Gradle 8.14.5 / AGP 8.13.2 / Kotlin 2.0.0, release build with R8 over the plugin's own ProGuard rules). - The PHP layer has 76 tests against the recording bridge, and
composer validate --strictpasses. - Banner and interstitial have been exercised on physical devices against Google's test units, at Play Services Ads 23.6.0 — the version before this release's pin.
Not verified:
- No real ad unit has ever been served, on either platform. Everything observed so far came from Google's test units. Fill rate, live inventory behavior, and revenue reporting against a real AdMob account are unmeasured.
- Rewarded video, rewarded interstitial, the consent flow, the ATT prompt, the banner lifecycle wiring and the measured system-bar inset have not been run on a device. They compile against the real SDKs; that is not the same thing.
- Play Services Ads 24.8.0 has not been run on a device. It compiles, and every API this plugin uses is unchanged from 23.6.0, but the major version bump is untested at runtime.
Not covered / out of scope#
- Mediation. The
SKAdNetworkItemsentries a mediation partner needs are declared, but no adapter is bundled and no mediation is configured. - App-open and native ad formats.
- Per-request targeting, keywords, and content mapping.
app-ads.txton your developer domain.- Ad revenue reporting. The event log records what the SDK did; it does not report paid-event data.
- Server-side verification for rewarded ads. The reward is reported to your PHP from the device, which is the client telling you what it says happened. If the reward is worth money to someone, you want AdMob's server-side verification callback as well; this plugin does not set it up.
config('admob.app_id')is inert. It is populated but nothing in the package reads it; the values that matter reach the SDKs through build-time manifest substitution. Changing it at runtime does nothing.
Support#
License#
Proprietary. Copyright (c) 2026 Erudite Studios, LLC. All rights reserved. Use requires a valid purchased license — see LICENSE.md.