tdw / tvdb-v4
Opinionated API implementation for TheTVDB.com's v4 API
Requires
- php: ^8.4
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/simple-cache: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.9
- nette/php-generator: ^4.1
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.0
- symfony/yaml: ^7.3
Suggests
- guzzlehttp/guzzle: Any PSR-18 HTTP client will do; Guzzle is auto-discovered if installed
- symfony/cache: Any PSR-16 cache will do; used to persist the bearer token between processes
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::$nameis nullable. Companies48384,48388and48403really do returnname: null. It is the most identity-like field there is, and it still is not safe.EpisodeBaseRecord::$nameis nullable even though/episodes/{id}always populates it — because/episodes?page=Nreturns it asnullfor 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.