Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
7.5 KiB
Skinbase AdSense Analytics Module
Status: Implemented
Project: Skinbase.org
Integration: Google AdSense Management API v2
Authorization: OAuth 2.0 Web Server Flow
Access level: Read-only (adsense.readonly)
This document is the implementation and operations guide for the admin AdSense analytics module. Public advertising behavior is unchanged: registered users remain ad-free; anonymous visitors may see AdSense units.
1. What the module does
Administrators can:
- connect Skinbase to Google AdSense with OAuth 2.0 (offline / refresh token);
- discover AdSense accounts, ad clients, ad units and custom channels;
- synchronize daily reporting into local MySQL tables;
- view an admin dashboard with totals, placement comparison, platform and country breakdowns;
- run hourly/nightly scheduled syncs or a manual Sync now;
- reconnect after Google revokes authorization.
No public page calls the AdSense Management API. The dashboard reads only local tables.
2. Google Cloud setup
- Create or select a Google Cloud project.
- Enable Google AdSense Management API.
- Configure the OAuth consent screen (Internal or production publishing as required).
- Create an OAuth Client ID of type Web application.
- Add this exact authorized redirect URI:
https://skinbase.org/admin/adsense/oauth/callback
- Copy the client ID and client secret into the Skinbase environment (never into git).
- Review Google OAuth publishing / testing limits before relying on unattended production refresh tokens.
3. Environment
Add to the deployment environment (see .env.example):
ADSENSE_CLIENT_ID=
ADSENSE_CLIENT_SECRET=
ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback
ADSENSE_SYNC_ENABLED=true
Configuration lives in config/adsense.php. Do not store the Google client secret in the database.
4. Deploy commands
php artisan migrate --force
php artisan config:clear
php artisan route:clear
php artisan view:clear
Scheduler already loads routes/console.php. Ensure the production scheduler / cron is running (php artisan schedule:run every minute). Horizon / Redis queues must be running so SyncAdsenseJob can process manual and post-OAuth synchronizations.
5. Connect from Skinbase admin
- Sign in as an admin (not manager/editorial).
- Open AdSense Analytics (
/admin/adsense) from the admin sidebar. - Click Connect Google AdSense.
- Approve read-only AdSense access.
- If multiple AdSense accounts exist, select one.
- Initial sync imports entities and the previous 30 days.
- Confirm the dashboard shows the account and last synchronization time.
Reconnect uses prompt=consent so Google issues a new refresh token. Token refresh never sends prompt=consent.
6. Artisan
php artisan adsense:sync
php artisan adsense:sync --days=3
php artisan adsense:sync --days=30
php artisan adsense:sync --from=2026-09-01 --to=2026-09-20
php artisan adsense:sync --entities-only
php artisan adsense:sync --force
--force is required when ADSENSE_SYNC_ENABLED=false. --days cannot be combined with --from/--to.
The command never prints tokens.
7. Scheduler
Registered in routes/console.php:
| Cadence | Command | Why |
|---|---|---|
| Hourly at :08 | adsense:sync --days=3 |
Recent totals, including today, can still change |
| Nightly at 04:35 | adsense:sync --days=30 |
Late adjustments to the last month |
Both use withoutOverlapping() and runInBackground(). The project does not use onOneServer().
8. Schema
adsense_connections
Encrypted refresh token (encrypted Eloquent cast), status (pending, connected, reconnect_required, error, disconnected), selected account, sync timestamps, last error. Access tokens are cached, not stored permanently.
adsense_entities
Upserted metadata for ad_unit and custom_channel. Entities are not deleted if they disappear from a single API page.
Inventory sync uses AdSense for Content (AFC / ca-pub-...) clients. YouTube host clients (ca-yt-host-pub-...) are listed by Google but do not expose ad units; they are skipped. A 404 on a single ad client does not fail the rest of the synchronization.
adsense_daily_stats
Idempotent daily rows keyed by account_resource_name + date + dimension_type + dimension_key.
Dimension types: total, ad_unit, custom_channel, platform, country.
Monetary columns are DECIMAL, not floats.
Disconnect removes credentials and cached access tokens. Historical statistics are retained.
9. Reports
Separate report definitions against GET /v2/{account}/reports:generate:
| Report | Dimensions |
|---|---|
| A total | DATE |
| B ad unit | DATE, AD_UNIT_ID |
| C custom channel | DATE, CUSTOM_CHANNEL_ID |
| D platform | DATE, PLATFORM_TYPE_CODE |
| E country | DATE, COUNTRY_CODE |
The parser maps cells by header name, not position, and reads currency from METRIC_CURRENCY headers.
Country (and any other isolated report) can fail without rolling back successful reports.
10. Public advertising (unchanged)
- Guests may see AdSense (
AdSenseUnit/<x-ad-unit>/ homepageins.adsbygoogle). - Authenticated users do not receive those units.
- Artwork placement remains below Tags & Categories and before Comments.
Recommended Google AdSense unit names (rename inside Google AdSense; Skinbase is read-only):
Skinbase_Artwork_BeforeComments
Skinbase_Home_AfterTrending
Skinbase_Home_AfterFresh
Skinbase_Browse_InFeed
Skinbase_Search_InFeed
Skinbase_News_InArticle
Current known slots:
| Surface | Slot | Recommended name |
|---|---|---|
| Artwork before comments | 4398418697 |
Skinbase_Artwork_BeforeComments |
| Homepage after trending (guest) | 8766048277 |
Skinbase_Home_AfterTrending |
11. Security
- OAuth routes:
auth+admin.role. - Cryptographic
statestored in the admin session and verified on callback. - Authorization code exchange is server-side only.
- Refresh tokens encrypted at rest; access tokens cached.
- Tokens, authorization codes and client secrets are never sent to Inertia/JS and must not be logged.
- Scope is only
https://www.googleapis.com/auth/adsense.readonly. - Production redirect URI is HTTPS:
https://skinbase.org/admin/adsense/oauth/callback.
12. Architecture
App\Services\Adsense\AdsenseOAuthService
App\Services\Adsense\AdsenseApiClient
App\Services\Adsense\AdsenseReportParser
App\Services\Adsense\AdsenseSyncService
App\Services\Adsense\AdsenseAnalyticsQueryService
App\Http\Controllers\Admin\AdsenseAnalyticsController
App\Jobs\SyncAdsenseJob
App\Console\Commands\AdsenseSyncCommand
HTTP uses Laravel's HTTP client. The Google PHP SDK is not required.
13. Routes
| Method | Path | Name |
|---|---|---|
| GET | /admin/adsense |
admin.adsense.index |
| GET | /admin/adsense/connect |
admin.adsense.connect |
| GET | /admin/adsense/oauth/callback |
admin.adsense.callback |
| POST | /admin/adsense/account |
admin.adsense.account |
| POST | /admin/adsense/sync |
admin.adsense.sync |
| DELETE | /admin/adsense/disconnect |
admin.adsense.disconnect |
These are registered before the legacy /admin/{path} → /moderation/{path} redirect so the Google redirect URI stays exact.
14. Tests
php artisan test --testsuite=Unit --filter=Adsense
php artisan test tests/Feature/Adsense
All Google HTTP is faked. Tests never require live Google credentials.