wtfzdotnet/wtfz-tmdb-bundle
A Symfony bundle for integrating The Movie Database (TMDb) API, making it easy to fetch and use movie, TV show, cast, and related metadata in your PHP applications.
This bundle is a thin Symfony DI wrapper around php-tmdb/api. Its entire job is to read your config and hand you a configured Tmdb\Client. Everything else (movies, TV, people, search, discover) comes from the underlying library, so the first thing to internalize is that your day-to-day API surface is Tmdb\Client, not the bundle.
Install and verify the constraint fits your Symfony version first — the last tagged release is from March 2021, so expect friction on Symfony 7:
composer require wtfzdotnet/wtfz-tmdb-bundle
config/bundles.php (Symfony 4/5 style if you're on an older skeleton):Wtfz\TmdbBundle\WtfzTmdbBundle::class => ['all' => true],
Get credentials from themoviedb.org → Settings → API. You'll have either a v3 API key or a v4 read access token; know which one you copied.
Configure it and put the secret in .env:
# config/packages/wtfz_tmdb.yaml
wtfz_tmdb:
api_token: '%env(TMDB_API_TOKEN)%'
TMDB_API_TOKEN=your-token-here
bin/console config:dump-reference wtfz_tmdb
bin/console debug:autowiring tmdb
bin/console debug:container tmdb
The config reference tells you the real option names (don't trust blog posts — the api_token vs api_key key matters, see Gotchas). The autowiring dump tells you whether Tmdb\Client is aliased for injection.
#[Route('/search/{query}', name: 'movie_search')]
public function search(string $query, Tmdb\Client $tmdb): Response
{
$results = $tmdb->getSearchApi()->searchMovies($query, ['language' => 'en-US']);
return $this->render('movie/search.html.twig', ['results' => $results]);
}
If Tmdb\Client isn't autowired, inject the service id from debug:autowiring tmdb instead.
Wrap the client in your own gateway service. This is the single highest-value pattern. Controllers and Twig should never see Tmdb\Model\* objects or speak the vendor's method names.
final class TmdbGateway
{
public function __construct(private Tmdb\Client $client) {}
public function findMovie(int $id, string $locale = 'en-US'): ?MovieSummary
{
try {
$movie = $this->client->getMoviesApi()->getMovie($id, [
'language' => $locale,
'append_to_response' => 'credits,videos,images',
]);
} catch (Tmdb\Exception\TmdbApiException $e) {
if ($e->getCode() === 404) {
return null;
}
throw $e;
}
return MovieSummary::fromTmdb($movie); // your DTO, not theirs
}
}
Use append_to_response aggressively. One request for movie + credits + videos + images is the difference between a 150 ms page and a 600 ms page. Build the list based on what your template actually renders, not "everything."
Cache at the gateway boundary, not inside the vendor client. Movie metadata is stable; cache it for days or weeks.
public function findMovie(int $id, string $locale = 'en-US'): ?MovieSummary
{
$key = sprintf('tmdb.movie.%d.%s', $id, $locale);
return $this->cache->get($key, function (ItemInterface $item) use ($id, $locale) {
$item->expiresAfter(86400 * 7);
return $this->doFetch($id, $locale);
});
}
Be aware that caching null (missing IDs) requires care so you don't re-hit the API on every request for bad IDs — cache a sentinel or use negative-cache tags.
Bulk and warm-up work belongs in Messenger or a console command. Never loop over 200 IDs inside a web request. Dispatch one message per ID and let a handler warm the cache:
#[AsCommand('app:tmdb:warm', 'Warm the local TMDB cache')]
final class WarmTmdbCommand extends Command
{
protected function execute(InputInterface $in, OutputInterface $out): int
{
foreach ($this->idsToWarm() as $id) {
$this->bus->dispatch(new WarmMovie($id));
}
return Command::SUCCESS;
}
}
Testing: mock your gateway, not Tmdb\Client. Tmdb\Client is a concrete class with a dozen get*Api() methods and returns vendor models — mocking it produces brittle tests that encode the vendor's API shape. Mocking TmdbGateway lets you swap the bundle later without touching tests.
Where to inject: services and controllers only. Not entities, not Twig extensions that fire on every render, not Doctrine event listeners. If a template needs poster URLs, resolve them in the controller or in a view model, not via a Twig function that calls the API mid-render.
Image URLs: the API returns relative paths like /abc123.jpg. You must build https://image.tmdb.org/t/p/{size}{path} yourself. Sizes (w92, w185, w342, w500, original, …) can come from the configuration endpoint, but hardcoding the two or three you use is simpler and avoids an extra cached call.
Maintenance status is the real risk. Last release 2021-03-07, 35 stars, zero dependents. On Symfony 7 you will likely hit a symfony/framework-bundle version conflict. Your options, in order of preference: (1) call php-tmdb/api directly and register the client yourself in services.yaml — the bundle adds maybe 20 lines of value; (2) use Symfony HttpClient and skip the library entirely for read-only use cases; (3) fork and bump the constraint. Pin the version in composer.json regardless.
Credential mismatch produces confusing 401s. v3 API keys and v4 read access tokens are not interchangeable and map to different config keys. If you get 401 on every call, run bin/console config:dump-reference wtfz_tmdb, confirm which key feeds which credential, and re-check you copied the right value from the TMDB dashboard.
You won't see requests in the Symfony profiler's HTTP client panel. The underlying library uses Guzzle, not Symfony HttpClient, so the profiler's HTTP panel is empty. For debugging, attach a Guzzle middleware via the bundle's HTTP options to log method, URL, and status, or wrap your gateway in a logging decorator.
404s are exceptions, not null. TmdbApiException with code 404 for unknown IDs; other 4xx/5xx also throw. Map 404 to NotFoundHttpException in your gateway so controllers stay readable — and don't let a 401/429 leak to users as a 500.
Vendor models are partial and nullable. Depending on the endpoint and append_to_response, fields like overview, poster_path, runtime, and even release_date can be missing or null. Treat every property as optional; don't call getRuntime() and assume an int. Cast defensively in your DTO mapper.
Result collections aren't arrays. Search and discover return a Tmdb\Model\Collection\ResultCollection with pagination metadata. Iterate it directly or call toArray(), but don't count() it expecting the total result count — use the total_results field.
Pagination is capped at page 500, and total_pages can exceed that. If you're building an infinite-scroll or "browse everything" feature, you need to shard by date or sort order — page 501 is a hard error.
Pass language and region explicitly on every call where it matters. Defaults are en-US; a French site will silently render English overviews otherwise. Thread the locale through your gateway from the request.
Trim append_to_response per view. Over-requesting credits on a listing page multiplies payload size. Separate methods (findMovieSummary, findMovieDetail) with different append sets pay for themselves quickly.
Extension points: the library accepts an http config array that flows to Guzzle — set timeout, connect_timeout, and proxy there. Registering a custom event_dispatcher or decorating your gateway service are the safest extension seams; avoid subclassing Tmdb\Client, which has no stable extension contract and changes shape between library minors.
Be polite with concurrency. TMDB is generous, but warming 5,000 titles with 50 parallel Guzzle workers will get you throttled. Cap concurrency in Messenger (rate-limited transport or a --limit on your command) and let the cache do the rest of the work.
How can I help you explore Laravel packages today?