PHP client for the AppBlockList App Lookup API. Give it an iOS bundle ID, an App Store ID or an Android package name, and it tells you what the app is: name, developer, genre, App Store age rating, ranked filtering categories, IAB category and the twin package on the other store.
composer require alphaquantum/appblocklistRequirements: PHP 7.4 or newer with ext-json and ext-curl. No other runtime dependencies, so it drops into Laravel, Symfony, WordPress plugins, Drupal modules or a single legacy script without pulling in an HTTP framework.
- The thirty-second version
- What a lookup returns
- Client reference
- When things go wrong
- Example: a school device portal in plain PHP
- Example: Laravel service and request validation
- Example: nightly MDM inventory to blocklist
- Why category data instead of a hand-kept list
- Plans
- FAQ
- Links and Related packages
<?php
require 'vendor/autoload.php';
$client = new AppBlockList\Client('YOUR_API_KEY'); // or set APPBLOCKLIST_API_KEY
$app = $client->lookup('com.zhiliaoapp.musically');
echo $app->name, PHP_EOL; // TikTok - Videos, Shop & LIVE
echo $app->ageRating, PHP_EOL; // 12+
echo implode(', ', $app->filteringCategories), PHP_EOL; // Social Networking, Streaming MediaAny of these IDs reach the same record:
| You have | Pass it as |
|---|---|
| iOS bundle ID from an MDM inventory | com.zhiliaoapp.musically |
| App Store ID from a store URL | 835599320 or id835599320 |
| Android package from Google Play or an EMM console | com.openai.chatgpt |
The client sends the key in the X-API-Key header, never in the URL, so it does not end up in proxy or web server access logs.
lookup() returns an AppBlockList\AppResult. Every property is always set, with empty strings or an empty array when the service has no value, so templates never need isset() checks.
| Property | API field | Example |
|---|---|---|
found |
found |
true |
name |
name |
TikTok - Videos, Shop & LIVE |
developer |
developer |
TikTok Ltd. |
bundleId |
bundle_id |
com.zhiliaoapp.musically |
appStoreId |
app_store_id |
"835599320" (string) |
androidPackage |
android_package |
com.zhiliaoapp.musically, or "" when the app is only on one store |
appStoreGenre |
app_store_genre |
Entertainment |
ageRating |
age_rating |
4+, 9+, 12+ or 17+ |
filteringCategories |
filtering_categories |
["Social Networking", "Streaming Media"] |
developerDomain |
developer_domain |
tiktok.com |
iabCategory |
iab_category |
Technology & Computing > Computing > Internet > Social Networking |
error |
error |
message on a not-found answer, otherwise null |
raw |
the decoded JSON array as received |
filteringCategories is ranked. The first entry is the most descriptive one, which is what primaryCategory() returns. The list comes from the same set of 58 filtering categories used for 120 million web domains, so the label that blocks tiktok.com on your network is the label that blocks the TikTok app on a managed iPad.
Helper methods on the result:
| Method | Returns | Use it for |
|---|---|---|
primaryCategory() |
?string |
dashboards, a single label in a table |
hasCategory('Gaming') |
bool |
one-off checks, case-insensitive |
matchingCategories($list) |
string[] |
explaining why an app was blocked |
isBlockedBy($list) |
bool |
the yes or no decision |
minimumAge() |
?int |
12 for 12+, null if the rating is missing |
isPendingAndroid() |
bool |
Google Play apps not yet categorized |
toArray() / json_encode($app) |
array |
caching, API responses, logs |
new AppBlockList\Client(?string $apiKey = null, array $options = [])With no key argument, the constructor reads APPBLOCKLIST_API_KEY from the environment. If neither is set it throws AuthenticationException right away, so a misconfigured deploy fails at boot instead of on the first student request.
| Method | Network calls | Description |
|---|---|---|
lookup(string $id): AppResult |
1 (plus retries on 5xx) | One app. 404 gives found === false. |
lookupMany(iterable $ids, int $pauseMs = 0): array |
1 per unique ID | Sequential, de-duplicated, keyed by ID. Invalid IDs map to null. |
Client::parseResponse(int $status, string $body): AppResult |
0 | Static. Turns a raw HTTP answer into a result or exception. |
Client::normalizeId(string $id): string |
0 | Static. Trims and enforces the 200 character limit. |
Options:
| Key | Default | Meaning |
|---|---|---|
timeout |
15 |
Total seconds per HTTP request. |
retries |
2 |
Extra attempts after a 5xx or network failure. 4xx answers are never retried. |
retry_delay |
1.0 |
Seconds before the first retry; doubled on each further attempt. |
base_url |
https://www.appblocklist.com/api/v1/app |
Point at your own proxy if you put one in front. |
user_agent |
appblocklist-php/1.0.0 (+https://www.appblocklist.com) |
Override if your organisation tags outbound traffic. |
transport |
cURL | callable(string $url, array $headers, int $timeout): array{int, string} for tests or Guzzle. |
lookupMany() exists because the service asks for one ID per request and requests sent one after another, not in parallel bursts. The method does exactly that. Pass $pauseMs if you want extra spacing on a shared key.
All exceptions extend AppBlockList\Exception\AppBlockListException, which itself extends RuntimeException. Each carries getStatusCode() and getBody() (the decoded error JSON).
| HTTP status | What it means | What you get |
|---|---|---|
| 200 | App found | AppResult with found === true |
| 400 | id missing, or not a bundle ID, App Store ID or Android package |
InvalidIdException |
| 401 | Missing or invalid api_key |
AuthenticationException |
| 403 | Monthly lookups used up | QuotaExceededException, with getMonthlyLookups() |
| 404 | Not in the database, or on Google Play but not yet categorized | AppResult with found === false |
| 500 | Temporary error | retried, then ServerException |
| none | DNS, TLS, timeout, non-JSON body | TransportException |
A 404 is deliberately not an exception. In an inventory sync, an unknown sideloaded or internal app is normal data, and you will want to log it next to the others rather than break out of a loop.
use AppBlockList\Exception\{AuthenticationException, QuotaExceededException, AppBlockListException};
try {
$app = $client->lookup($id);
} catch (QuotaExceededException $e) {
error_log('AppBlockList quota reached: ' . $e->getMonthlyLookups() . ' lookups this month');
$app = null; // fall back to "needs review"
} catch (AuthenticationException $e) {
throw $e; // configuration problem, let it surface
} catch (AppBlockListException $e) {
error_log("Lookup failed for $id: HTTP {$e->getStatusCode()} {$e->getMessage()}");
$app = null;
}Many districts run a small intranet page where teachers request apps for classroom iPads. The page below gives an instant first answer, so IT only reviews the borderline cases. It caches each answer for a day on disk, which keeps repeated requests for the same popular app from spending lookups.
The full file ships as examples/device_portal_check.php.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use AppBlockList\Client;
use AppBlockList\Exception\{InvalidIdException, AppBlockListException};
const BLOCKED = ['Social Networking', 'Streaming Media', 'Gambling', 'Dating', 'Adult', 'VPN / Proxy / Anonymizers'];
const MAX_AGE = 12;
header('Content-Type: application/json');
$id = (string) ($_GET['id'] ?? '');
$cache = sys_get_temp_dir() . '/abl_' . sha1($id) . '.json';
if ($id !== '' && is_file($cache) && filemtime($cache) > time() - 86400) {
readfile($cache);
exit;
}
try {
$app = (new Client())->lookup($id);
} catch (InvalidIdException $e) {
http_response_code(422);
exit(json_encode(['decision' => 'invalid']));
} catch (AppBlockListException $e) {
http_response_code(503);
exit(json_encode(['decision' => 'unavailable']));
}
if (!$app->found) {
$out = ['decision' => 'review', 'reason' => $app->error];
} elseif ($app->isBlockedBy(BLOCKED)) {
$out = ['decision' => 'blocked', 'reason' => $app->matchingCategories(BLOCKED)];
} elseif (($age = $app->minimumAge()) !== null && $age > MAX_AGE) {
$out = ['decision' => 'blocked', 'reason' => "Age rating {$app->ageRating}"];
} else {
$out = ['decision' => 'allowed'];
}
$out['app'] = $app->toArray();
file_put_contents($cache, $json = json_encode($out));
echo $json;Two details matter in a school setting:
- Circumvention first.
VPN / Proxy / Anonymizersbelongs in every list. Without it, a VPN app tunnels around the web filter and every other rule stops counting. - Age ratings complement categories. A drawing app rated
17+because of user-generated content will not show up asAdult, but your policy may still want it reviewed.
US schools that take E-Rate money also have to meet the Children's Internet Protection Act, and managed devices are part of that picture. AppBlockList publishes a starter list of apps to block on school iPads that pairs well with the portal above.
In Laravel, bind the client once in a service provider and inject it wherever you need it. The key goes in .env as APPBLOCKLIST_API_KEY.
// app/Providers/AppBlockListServiceProvider.php
namespace App\Providers;
use AppBlockList\Client;
use Illuminate\Support\ServiceProvider;
class AppBlockListServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Client::class, fn () => new Client(
config('services.appblocklist.key'),
['timeout' => 5, 'retries' => 1]
));
}
}// config/services.php
'appblocklist' => ['key' => env('APPBLOCKLIST_API_KEY')],A small wrapper keeps the cache policy in one place. Cache::remember stores the array form, so you can use any cache driver:
// app/Services/AppPolicy.php
namespace App\Services;
use AppBlockList\{Client, AppResult};
use Illuminate\Support\Facades\Cache;
class AppPolicy
{
public const BLOCKED = ['Gaming', 'Gambling', 'Dating', 'Adult', 'AI / LLM Tools', 'VPN / Proxy / Anonymizers'];
public function __construct(private Client $client) {}
public function app(string $id): AppResult
{
$data = Cache::remember('abl:' . strtolower(trim($id)), now()->addDay(), function () use ($id) {
return $this->client->lookup($id)->toArray();
});
return AppResult::fromArray($data);
}
public function blockedReason(string $id): ?string
{
$app = $this->app($id);
if (!$app->found) {
return null; // unknown apps go to manual review, not auto-deny
}
$hits = $app->matchingCategories(self::BLOCKED);
return $hits ? implode(', ', $hits) : null;
}
}Then reject disallowed apps at validation time in a Form Request for the "request an app" form:
// app/Http/Requests/AppRequest.php
namespace App\Http\Requests;
use App\Services\AppPolicy;
use Illuminate\Foundation\Http\FormRequest;
class AppRequest extends FormRequest
{
public function rules(): array
{
return [
'app_id' => ['required', 'string', 'max:200', function ($attr, $value, $fail) {
if ($reason = app(AppPolicy::class)->blockedReason($value)) {
$fail("This app is not allowed on school devices ($reason).");
}
}],
];
}
}Note that the constructor promotion in AppPolicy needs PHP 8.0; on 7.4 assign the property by hand. The client itself stays 7.4 compatible.
Most MDM consoles can export installed apps as CSV. This cron script reads such an export, looks up every distinct bundle ID once, and writes a plain text blocklist plus a review file. Apple MDM consumes bundle IDs through the Restrictions payload described in the Device Management documentation, and the AppBlockList guide to blocking apps with a .mobileconfig profile shows where the list goes.
#!/usr/bin/env php
<?php
// bin/inventory-to-blocklist.php inventory.csv out-dir
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use AppBlockList\Client;
use AppBlockList\Exception\QuotaExceededException;
[$_, $csv, $outDir] = $argv + [null, 'inventory.csv', 'out'];
$blocked = ['Gaming', 'Gambling', 'Dating', 'Adult', 'Social Networking', 'VPN / Proxy / Anonymizers'];
$fh = fopen($csv, 'r');
$header = array_map('strtolower', fgetcsv($fh));
$col = array_search('bundle_id', $header, true);
$ids = [];
while (($row = fgetcsv($fh)) !== false) {
$ids[] = $row[$col] ?? '';
}
fclose($fh);
$client = new Client(null, ['retries' => 3]);
try {
$results = $client->lookupMany($ids, 100); // 100 ms between requests
} catch (QuotaExceededException $e) {
fwrite(STDERR, "Monthly lookups used; rerun after renewal.\n");
exit(2);
}
@mkdir($outDir, 0775, true);
$block = $review = [];
foreach ($results as $id => $app) {
if ($app === null || !$app->found) {
$review[] = $id;
} elseif ($app->isBlockedBy($blocked)) {
$block[] = $app->bundleId ?: $id;
}
}
sort($block);
file_put_contents("$outDir/blocked_bundle_ids.txt", implode("\n", $block) . "\n");
file_put_contents("$outDir/needs_review.txt", implode("\n", $review) . "\n");
printf("%d apps checked, %d to block, %d to review\n", count($results), count($block), count($review));Crontab entry:
15 2 * * * cd /opt/mdm-sync && php bin/inventory-to-blocklist.php exports/latest.csv out >> var/log/abl.log 2>&1
Because the job de-duplicates first, a fleet of 4,000 iPads with 30,000 installed-app rows usually costs a few hundred lookups a night. If you would rather not run a job at all, the monthly MDM blocklist feed ships ready-made lists in six formats, including Intune and Jamf import files.
A list of forbidden apps that someone maintains by hand ages within weeks. New VPN apps, new AI chat clients and new casino games appear constantly, and the copycat with a slightly different name slips through.
Categories change the question from "is this exact app on our list?" to "what kind of app is this?". That makes the policy readable for a school board and stable over time:
- One vocabulary for web and apps. The 58 categories match the ones used for websites, so a single policy line covers
Gamblingon the network and on the device. - Both stores. iOS records carry their Google Play twin when one exists (about 800,000 apps do), and 3.9 million Google Play package names are tracked.
- Explainable decisions.
matchingCategories()gives the exact reason, which is what a parent or teacher asks for when an app is refused. - Plain JSON. Responses follow RFC 8259, so the cached arrays work in any other language your team uses.
For background on how device management fits together, the Wikipedia article on mobile device management is a fair neutral summary.
Prices from the AppBlockList pricing page:
| Product | Price | Notes |
|---|---|---|
| App Lookup API | $99 per month | 100,000 lookups; higher volumes quoted |
| App Blocklists for MDM, School or Business | $99/month or $990/year | per organization, not per device |
| Districts and IT Providers | $249/month or $2,490/year | unlimited schools or client organizations, separate files each, includes the API with 100,000 lookups a month |
| Mobile App Database | $999 (top 100,000 apps) or $1,999 (top 500,000 apps) | one-time licensed file, 12 columns |
A lookup is counted only for a 200 answer.
No. One lookup is counted per 200 response. A 404 returns found === false and costs nothing against your monthly allowance.
No, the API takes one ID per request. Use lookupMany(), which sends them one after another and skips duplicates. For whole-catalog work, the licensed mobile app database with 1,874,397 iPhone and iPad apps is the better fit.
If the package is on Google Play but not yet categorized, you get found === false, androidPackage set and isPendingAndroid() === true. Treat it as "needs review". The package name lookup tool is handy for checking one by hand.
Your MDM inventory lists them. For a single app, paste its App Store link into the bundle ID lookup, or pass the numeric App Store ID straight to lookup().
Cache instead. App metadata changes slowly, so a day of caching is reasonable, as both the plain PHP and Laravel examples show.
Yes. Pass a transport callable that performs the GET with the given headers and returns [$status, $body]. Everything else, including error mapping, stays the same.
The service accepts api_key as a query parameter, but this client always uses the X-API-Key header so the key stays out of URL logs.
The PHPUnit suite needs no network and no key:
composer install
composer testTo test your own code, inject a fake transport:
$client = new AppBlockList\Client('test', [
'transport' => fn () => [404, '{"found": false, "error": "App not found."}'],
]);- Product home: AppBlockList
- API documentation: app categorization API
- MDM guides: Intune, Jamf, Google Admin
- Ready lists: AI apps list, VPN and proxy apps list
- Support: contact form or info@alpha-quantum.com
- Source: GitHub
From the same team, for the network side of the same policy:
- CIPA web filter for schools and libraries
- blocklist of AI tool domains
- web filtering database with 120M domains
- domain categorization database download
- website categorization API
The same API, written for other ecosystems:
| Language | Package |
|---|---|
| JavaScript / Node.js | appblocklist on npm |
| Python | appblocklist on PyPI |
| Ruby | appblocklist-ruby |
| Dart and Flutter | appblocklist-dart |
| Elixir | appblocklist-elixir |
| Go | appblocklist-go |
| Java | appblocklist-java |
| Rust | appblocklist-rust |
| .NET | appblocklist-dotnet |
Product repository: explainableaixai/appblocklist, mirrored on GitLab.
MIT. Copyright (c) 2026 Alpha Quantum. See LICENSE.