madcoda/php-youtube-api
A lightweight PHP wrapper for the YouTube Data API v3 (non-OAuth) that makes it easy to fetch public video, channel, and playlist data. No third-party runtime dependencies, and it returns decoded JSON so you can follow the Google docs directly.
composer require madcoda/php-youtube-api:^1.2
Grab a Server key (or browser key if you only call from the client, which you shouldn't) from Google Cloud Console → APIs & Services → Credentials, with YouTube Data API v3 enabled. Then either use the class directly or wire the bundled Laravel provider:
// config/app.php (packages this old usually aren't auto-discovered)
'providers' => [Madcoda\Youtube\Laravel\YoutubeServiceProvider::class],
'aliases' => ['Youtube' => Madcoda\Youtube\Facades\Youtube::class],
php artisan vendor:publish --provider="Madcoda\Youtube\Laravel\YoutubeServiceProvider" --tag=config
// config/youtube.php
'key' => env('YOUTUBE_API_KEY'),
'referer' => env('YOUTUBE_REFERER', ''), // set if the key is HTTP-referrer restricted
First call — verify the key, quota, and payload shape in one hit:
$video = Youtube::getVideoInfo('dQw4w9WgXcQ', 'snippet,statistics,contentDetails');
dump($video->snippet->title, $video->statistics->viewCount);
Where to look first:
Youtube.php class — it is ~500 lines and every method is a thin part/params passthrough to a cURL call. Reading it once takes ten minutes and removes all mystery.videos.list / search.list docs for the accepted part and filter parameters — the library deliberately does not abstract these, so Google's docs are your API docs from here on.A good first use case is metadata hydration: take a YouTube ID or URL already in your app, call getVideoInfo, cache the result, and render title/thumbnail/duration instead of a bare iframe.
Wrap it, don't sprinkle it. The library is a concrete class with no interface, so put it behind one of your own. You get caching, logging, error normalisation, and a swap path to googleapis/google-api-php-client later:
// app/Services/YouTube/YouTubeClient.php
interface YouTubeClient {
public function video(string $id): ?object;
public function search(string $q, int $max = 10): array;
}
// app/Services/YouTube/MadcodaYouTubeClient.php
class MadcodaYouTubeClient implements YouTubeClient
{
public function __construct(private \Madcoda\Youtube\Youtube $api) {}
public function video(string $id): ?object
{
return Cache::remember("yt:video:$id", now()->addDay(), function () use ($id) {
$v = $this->api->getVideoInfo($id, 'snippet,statistics,contentDetails');
if (!$v || isset($v->error)) { Log::warning('YT video failed', ['id' => $id, 'res' => $v]); return null; }
return $v;
});
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Madcoda\Youtube\Youtube::class, fn () => new \Madcoda\Youtube\Youtube([
'key' => config('youtube.key'),
'referer' => config('youtube.referer'),
]));
$this->app->bind(YouTubeClient::class, MadcodaYouTubeClient::class);
Keep the Youtube facade for tinker/one-off scripts, but inject YouTubeClient in controllers, jobs and commands.
Two-step search → hydrate. search.list costs 100 quota units; videos.list costs 1. Search once to get IDs, then hydrate in batches of 50 in a single videos.list call. This is the single biggest quota lever you have:
$ids = collect(Youtube::search($q, 25))
->pluck('id.videoId')->filter()->take(50)->all();
$full = Youtube::getVideosByParameters('snippet,statistics', ['id' => implode(',', $ids)]);
Pagination. List endpoints cap at 50 (search at 50, videos at 50). Loop on nextPageToken; the advanced methods accept arbitrary params:
$params = ['playlistId' => $playlistId, 'maxResults' => 50, 'part' => 'snippet,contentDetails'];
do {
$res = Youtube::getPlaylistItemsByPlaylistIdAdvanced($params, true);
foreach ($res->items ?? [] as $item) { /* ... */ }
$params['pageToken'] = $res->nextPageToken ?? null;
} while ($params['pageToken']);
Push bulk work to a queue. Channel/playlist backfills are dozens of calls — do them in a ShouldQueue job with RateLimited/WithoutOverlapping middleware, and cache aggressively. Trending data: 5–15 min TTL. Channel metadata: 24 h. Video statistics: hourly.
Testing. Because there's no interface, bind a fake of your interface:
$this->instance(YouTubeClient::class, new class implements YouTubeClient {
public function video(string $id): ?object { return json_decode(file_get_contents(base_path('tests/fixtures/video.json'))); }
public function search(string $q, int $max = 10): array { return []; }
});
Alternatively point the library's public $apis URL map at a local stub server for a real end-to-end cURL test.
getVideoInfo() returns a single decoded object (or false when nothing matches); search(), getPlaylistItemsByPlaylistId() etc. return an array of items (or a decoded response object for the advanced *Advanced() variants). Don't assume one shape across the API.$result->error->errors[0]->reason (quotaExceeded, keyInvalid, ipRefererBlocked, rateLimitExceeded). Always guard isset($result->error) before touching ->items, and translate to your own exception/report().part = missing data. The default part is usually just snippet. If ->statistics or ->contentDetails->duration is undefined, you forgot to request it. Pass 'snippet,statistics,contentDetails' explicitly.viewCount is "1234"; duration is ISO-8601 ("PT4M13S"). Cast numerics and use CarbonInterval::make($duration) or a small PTnHnMnS parser — don't hand-roll regexes.search.list is 100 units — ten searches and you're done. Cache keys should be deterministic (yt:search:{sha1($q.$filters)}) and you should log unit estimates in the job that spends them.new \Madcoda\Youtube\Youtube([...]) per instance rather than resolving the facade..env/config cache is available. Run php artisan config:clear after changing YOUTUBE_API_KEY, and never commit the key.referer. A key restricted to IPs breaks the moment you deploy to a new host/queue worker without an elastic IP.getChannelByName, searchVideos etc. wrap a subset of parameters. For anything else (regionCode, relevanceLanguage, publishedAfter, channelId filters), use searchAdvanced($params) and read the Google docs — you're passing raw query params.composer.json, keep the adapter layer thin, and accept that the migration to googleapis/google-api-php-client may eventually be a 200-line rewrite of one class, not a project.rateLimitExceeded rather than retrying blind.$apis array of endpoint URLs to route through an HTTP caching proxy or a test stub — the cheapest way to test without burning quota.How can I help you explore Laravel packages today?