canihavesomecoffee/thetvdbapi
PHP client for TheTVDB API, giving your app simple access to TV series, episode, and artwork data. Wraps the API endpoints in an object-oriented interface so you can search shows and fetch series metadata with minimal setup.
There is no Laravel service provider here — this is a plain PHP library (ISC, ~10 stars, last release 2025-12-17) that wraps TheTVDB's v2 REST API. Budget ~30 minutes to wire it in yourself, then treat it as an internal dependency behind your own adapter.
composer require canihavesomecoffee/thetvdbapi:^2.0
Pin a minor version. With a small maintainer surface, a loose constraint is how you get a surprise breaking change on a Tuesday deploy.
Read vendor/canihavesomecoffee/thetvdbapi/src/ first. Two files matter:
/login, unlike v4's single bearer key);Put credentials in config/services.php, not in a facade call:
// config/services.php
'tvdb' => [
'api_key' => env('TVDB_API_KEY'),
'username' => env('TVDB_USERNAME'),
'userkey' => env('TVDB_USERKEY'),
'base_uri' => env('TVDB_BASE_URI', 'https://api.thetvdb.com'),
],
// app/Providers/AppServiceProvider.php
$this->app->singleton(\TvDb\Client::class, function ($app) {
$cfg = $app['config']->get('services.tvdb');
return new \TvDb\Client($cfg['api_key'], $cfg['username'], $cfg['userkey']);
});
shows.tvdb_id, imported from TMDB/IMDb), pull series metadata + episode list, and persist it locally. Run it as an artisan command first, not from a controller — you want to see the raw payload before you design tables.$client = app(\TvDb\Client::class);
$series = $client->getSeries(81189); // method name is version-dependent; isolate it
Wrap the client behind your own adapter. This is the single most important pattern given the package's age and the v2→v4 migration pressure. One class, one place to fix when the API moves.
namespace App\Services;
class TvdbService
{
public function __construct(private \TvDb\Client $client) {}
public function series(int $tvdbId): array
{
return Cache::remember("tvdb:series:{$tvdbId}", now()->addDays(3), function () use ($tvdbId) {
$raw = $this->client->getSeries($tvdbId);
return $this->normalizeSeries($raw);
});
}
public function episodes(int $tvdbId, string $order = 'default'): array
{
return Cache::remember("tvdb:episodes:{$tvdbId}:{$order}", now()->addDay(), function () use ($tvdbId, $order) {
return $this->normalizeEpisodes($this->client->getSeriesEpisodes($tvdbId, $order));
});
}
}
Normalize to your own arrays/DTOs immediately. TheTVDB payloads carry dozens of fields, half of them nullable and named inconsistently (seriesName, airsDayOfWeek, imdbId as a string). Never let those keys ripple into Blade templates or Eloquent casts.
Cache aggressively, and version your keys. Series metadata changes weekly at most; episode air dates change daily. tvdb:v1:series:{id} lets you bust everything by bumping the prefix when your normalizer changes shape.
Throttle the queue, not the request. TheTVDB enforces per-second limits; a 50-show backfill will blow through them. Use a rate-limited job:
class SyncSeriesJob implements ShouldQueue
{
public $middleware = [\Illuminate\Queue\Middleware\RateLimited::class . ':tvdb'];
public function handle(TvdbService $tvdb) { /* upsert */ }
}
Register the limiter in a service provider with RateLimiter::for('tvdb', fn ($job) => Limit::perSecond(1)->by('global')), and add retryUntil/backoff for 429s.
Upsert, don't mirror. Show::updateOrCreate(['tvdb_id' => $id], [...]) keyed on the external ID. You will re-run the sync; make it idempotent. Keep tvdb_id indexed and nullable-unique.
Store image paths, build URLs at render time. The API returns relative artwork paths against a banners host. Store /posters/xxx.jpg and compose with a config value in a Blade accessor — the host changes between API versions and you don't want a migration.
Use the updates endpoint for incremental sync. If your adapter exposes it, a nightly job pulling changes since your last cursor is dramatically cheaper than re-fetching everything. Persist the cursor in cache or a sync_state table.
Test at the adapter boundary. Don't mock Guzzle internals. Bind a fake TvdbService in the container, or return fixture JSON from a subclass of your adapter. For integration tests, one Http::fake won't help unless the package uses Laravel's HTTP client — check its Guzzle version and use Guzzle's MockHandler instead.
api4.thetvdb.com, single bearer token). Before you build anything, confirm your API key still returns data from the v2 base URI. If not, this package is a dead end and you want a v4 client. Writing the adapter first means that swap costs you one class.queue:work for hours, catch 401s in your adapter, re-authenticate, retry once. Never log the token — Guzzle's debug output will happily print the Authorization header.data can be null without being an error. v2 commonly returns HTTP 200 with {"data": null} or an Error key. Check array_key_exists('data', $response) and null-guard before indexing into it; otherwise you'll get Undefined index in production on shows with no aired episodes.absolute; everything else wants default. Cache them under separate keys, and make the order an explicit argument in your service signature so nobody guesses.Accept-Language (or the equivalent query param) for translated titles; the default is English and translated series names are frequently empty strings rather than falling back. Store the language alongside the record if you're multi-locale.airsTime plus airsDayOfWeek in the series' own timezone — no timezone field is guaranteed. If you're building a calendar, treat the raw values as display strings until you've confirmed the timezone from your own data.Middleware::retry() on 429/5xx plus a HandlerStack tap that logs latency. That's your only observability hook — take it, because the library ships none.composer audit and check the repo's issue cadence before committing. A single-maintainer package with a low release cadence is fine as a thin HTTP wrapper, not fine as something you build core domain behavior on top of.How can I help you explore Laravel packages today?