Skip to content

About

PHP client for the AppBlockList App Lookup API: filtering categories, age rating and iOS/Android IDs for 1.87M apps

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

appblocklist-php

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/appblocklist

Requirements: 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.


Contents

  1. The thirty-second version
  2. What a lookup returns
  3. Client reference
  4. When things go wrong
  5. Example: a school device portal in plain PHP
  6. Example: Laravel service and request validation
  7. Example: nightly MDM inventory to blocklist
  8. Why category data instead of a hand-kept list
  9. Plans
  10. FAQ
  11. Links and Related packages

The thirty-second version

<?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 Media

Any 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.


What a lookup returns

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

Client reference

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.


When things go wrong

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;
}

Example: a school device portal in plain PHP

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 / Anonymizers belongs 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 as Adult, 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.


Example: Laravel service and request validation

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.


Example: nightly MDM inventory to blocklist

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.


Why category data instead of a hand-kept list

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 Gambling on 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.


Plans

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.


FAQ

Does a "not found" answer use up a lookup?

No. One lookup is counted per 200 response. A 404 returns found === false and costs nothing against your monthly allowance.

Can I look up many apps in one request?

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.

What happens with an Android-only app?

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.

How do I find an app's bundle ID?

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().

Should I call the API on every page view?

Cache instead. App metadata changes slowly, so a day of caching is reasonable, as both the plain PHP and Laravel examples show.

Can I use Guzzle or Symfony HttpClient instead of cURL?

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.

Is the API key safe in the query string?

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.


Testing

The PHPUnit suite needs no network and no key:

composer install
composer test

To test your own code, inject a fake transport:

$client = new AppBlockList\Client('test', [
    'transport' => fn () => [404, '{"found": false, "error": "App not found."}'],
]);

Links

From the same team, for the network side of the same policy:


Related packages

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.


License

MIT. Copyright (c) 2026 Alpha Quantum. See LICENSE.

About

PHP client for the AppBlockList App Lookup API: filtering categories, age rating and iOS/Android IDs for 1.87M apps

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages