tdw/tvdb-v4

Opinionated API implementation for TheTVDB.com's v4 API

Maintainers

Package info

github.com/predakanga/tvdb-v4-php

pkg:composer/tdw/tvdb-v4

Transparency log

Statistics

Installs: 26

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-08-06 14:54 UTC

This package is auto-updated.

Last update: 2026-08-06 14:59:55 UTC


README

An opinionated PHP 8.4 client for TheTVDB v4 API.

Readonly models with real types, enums instead of magic strings, transparent authentication, and pagination you can foreach over.

use TDW\Tvdb\TvdbClient;

$tvdb = new TvdbClient(apiKey: 'your-api-key');

$series = $tvdb->series()->extended(121361);

echo $series->name;                          // "Game of Thrones"
echo $series->originalNetwork?->name;        // "HBO"
echo $series->firstAiredDate()?->format('Y'); // "2011"

foreach ($series->genres as $genre) {
    echo $genre->name;
}

Installation

composer require tdw/tvdb-v4

You also need a PSR-18 HTTP client and a PSR-17 factory. If you have no preference:

composer require guzzlehttp/guzzle nyholm/psr7

They are discovered automatically. To use your own — with your timeouts, retry middleware, proxy or logging — pass it in and the library will not fight you:

$tvdb = new TvdbClient(apiKey: $key, httpClient: $myGuzzle);

Authentication

Pass your API key; the bearer token is fetched on first use and reused.

$tvdb = new TvdbClient(apiKey: $key);

// User-supported (subscriber) keys also need a PIN:
$tvdb = new TvdbClient(apiKey: $key, pin: $pin);

Tokens are valid for a month, so logging in on every request is wasteful. Pass any PSR-16 cache to persist it between processes:

$tvdb = new TvdbClient(apiKey: $key, cache: $psr16Cache);

If a token is revoked before it expires, the library notices the 401, discards the token, and retries once — you never see it.

Reading data

Every resource hangs off the client:

$tvdb->series()      $tvdb->movies()     $tvdb->episodes()
$tvdb->seasons()     $tvdb->people()     $tvdb->characters()
$tvdb->companies()   $tvdb->artwork()    $tvdb->awards()
$tvdb->genres()      $tvdb->lists()      $tvdb->search()
$tvdb->updates()     $tvdb->user()       $tvdb->reference()

Records come in base and extended flavours, matching the API:

$tvdb->series()->get(121361);          // base record
$tvdb->series()->extended(121361);     // + genres, seasons, characters, artwork
$tvdb->series()->bySlug('game-of-thrones');
$tvdb->series()->translation(121361, 'eng');

Search, narrowed by what you are looking for

/search takes twelve parameters and applies all of them — an inapplicable one does not get ignored, it silently returns nothing. ?query=Tom+Hanks&type=person gives three results; add &year=2004 and you get zero.

So rather than one method with twelve optional arguments, there is one per entity kind, exposing only the filters that work for it:

$tvdb->search()->querySeries('Lost', year: 2004, country: 'usa');
$tvdb->search()->queryMovies('Jaws', director: 'Steven Spielberg');
$tvdb->search()->queryPeople('Tom Hanks');
$tvdb->search()->queryCompanies('AMC', primaryType: CompanyTypeName::Network);
$tvdb->search()->queryLists('Marvel');

queryPeople() has no year parameter, so the query that returns nothing is not expressible. query() is still there if you want the raw endpoint.

Which parameters apply to which kind was measured against the live API, not read off the spec's prose — see spec/FINDINGS.md §8.

Enums, not magic strings

use TDW\Tvdb\Generated\Enum\{SeasonTypeSlug, SeriesMeta, SortDirection, GenreId};

$tvdb->series()->episodes(121361, SeasonTypeSlug::Official);
$tvdb->series()->extended(121361, meta: SeriesMeta::Episodes);
$tvdb->series()->filter(genre: GenreId::Drama, sortType: SortDirection::Desc);

That extends to the lookup tables the API models as bare ids — artwork types, genders, genres, people types, remote-id sources and the rest:

$artwork->type;      // ArtworkTypeId::SeriesPoster
$artwork->typeRaw;   // 2

Every one of those keeps the raw value alongside the enum, because these vocabularies live in server-side tables that TheTVDB can extend without a spec release. A value not yet in the enum resolves to null instead of throwing, and the raw property still holds it — so nothing is lost, and nothing breaks:

$artwork->type;      // null  — a type id added since this release
$artwork->typeRaw;   // 31    — still there

That pair is also how the library detects its own staleness: composer drift and the weekly contract run compare each enum against its live endpoint and fail when a row has been added.

Pagination

Paginated endpoints return a Paginator. Iterate it and pages are fetched as you reach them — nothing is requested until you start:

foreach ($tvdb->series()->all() as $series) {
    echo $series->name, "\n";
}

count() reports the API's total without walking anything, and pages() gives you the boundaries when you want them:

$all = $tvdb->series()->all();

echo count($all);                  // 172,622 — one request

foreach ($all->pages() as $page) {
    echo count($page->items), ' of ', $page->totalItems(), "\n";
}

Most of the time you want a record, not a page:

$top = $tvdb->search()->querySeries('Lost')->firstItem();  // ?SearchResult
$second = $tvdb->search()->querySeries('Lost')->nth(1);
$topTen = $tvdb->series()->all()->take(10);

if ($tvdb->search()->queryMovies('nonesuch')->isEmpty()) { ... }

firstItem() and isEmpty() cost one request. take() and nth() fetch only the pages they need — but the API exposes no way to jump, so reaching offset n costs ceil((n+1) / page_size) requests. Fine for nth(1), a poor way to reach the millionth episode.

/episodes alone reports over seven million records, so prefer iteration to ->all() unless you know the result set is small.

Dates

Date fields keep the raw API string, and gain a typed accessor:

$episode->aired;           // "2011-04-17"
$episode->airedDate();     // DateTimeImmutable|null
$series->lastUpdatedDate() // DateTimeImmutable|null

The API uses an empty string for "no date" and 0 for "no timestamp"; both give you null rather than an exception.

Errors

Everything extends TvdbException:

use TDW\Tvdb\Exception\{NotFoundException, RateLimitException, TvdbException};

try {
    $series = $tvdb->series()->get($id);
} catch (NotFoundException) {
    return null;
} catch (RateLimitException $e) {
    // back off
} catch (TvdbException $e) {
    // BadRequestException, UnauthorizedException, ServerException,
    // TransportException, MalformedResponseException
}

ApiException subclasses carry ->status, ->apiMessage and the PSR-7 ->response.

A note on nullability

Many properties are nullable, and more than you might expect. That is deliberate and evidence-based, not laziness.

The OpenAPI spec marks nothing as required, so rather than guess, the live API was probed — ~400 requests, sampling the newest records, random pages, sort extremes, and freshly-created records specifically to find nulls. The results are in spec/FINDINGS.md. Two consequences:

  • Company::$name is nullable. Companies 48384, 48388 and 48403 really do return name: null. It is the most identity-like field there is, and it still is not safe.
  • EpisodeBaseRecord::$name is nullable even though /episodes/{id} always populates it — because /episodes?page=N returns it as null for the same record. Bulk endpoints return sparser projections than single-record endpoints, and one class models both.

Conversely, list-typed properties are never null. The API returns null where [] is meant (on 12,000 of 16,136 sampled series), so those are normalised and you can always foreach them safely.

Development

composer check        # lint, static analysis (PHPStan level 9), unit tests
composer generate     # regenerate src/Generated from the spec
composer test:live    # contract tests against the real API (needs a key)
composer drift        # report fields and lookup values the models do not know

The models, enums and resource classes under src/Generated/ are produced from swagger.yml plus spec/overrides.yaml and must not be edited by hand — CI asserts that regenerating produces no diff.

Live tests and the fixture recorder read TVDB_APIKEY from the environment or from a gitignored .env.local.

See DESIGN.md for how the library is put together, and UPGRADING.md for what to do when the spec changes.

AI disclaimer

This library was written with substantial assistance from an AI coding agent (Claude). That includes the code generator, the generated output, the runtime layer, the tests and this documentation.

What that does and does not mean:

  • The behaviour is grounded in real API responses, not in a model's guesses. The nullability rules, the fourteen spec corrections and the response shapes were all derived from ~650 probe requests against the live API. The evidence is written up in spec/FINDINGS.md, with observation counts, so you can check the reasoning rather than take it on trust.
  • It is tested. 167 unit tests run offline against recorded fixtures, and 41 contract tests run against the live API. Static analysis is PHPStan level 9 with no baseline.
  • It has not been battle-tested in production. Review it as you would any new dependency, and treat the version number as meaning what it says.

Bug reports are welcome and will be judged on the code, not on its provenance.

Licence

MIT.