cable8mm / youtube
Renew Laravel PHP Facade/Wrapper for the Youtube Data API v3
Requires
- php: ^8.3
- ext-curl: *
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- maatwebsite/excel: ^4.0
- nesbot/carbon: ^3.0
Requires (Dev)
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5.50|^12.5.8
- vlucas/phpdotenv: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v6.0.0
- v5.1.1
- v5.1.0
- v5.0.1
- v5.0.0
- v4.2.0
- v4.1.0
- v4.0.0
- v3.2.1
- v3.2.0
- v3.1.2
- v3.1.1
- v3.1.0
- v3.0.0
- dev-readme-rewrite
- dev-t-031-validation-never-throws
- dev-t-030-channel-url-segments
- dev-t-029-video-url-host
- dev-docs-url-parsing-fixes
- dev-readme-refresh
- dev-t-027-readme-sync
- dev-t-026-sync-command
- dev-t-025-unavailable-videos
- dev-t-024-sync-job
- dev-t-023-channel-import
- dev-t-022-channel-input-resolver
- dev-t-021-quota-block
- dev-t-020-failure-reasons
- dev-t-019-records-and-migrations
- dev-t-028-e2e-suite
- dev-t-018-channel-by-handle
- dev-docs-laravel-sync-design
- dev-t-017-php83-laravel12
- dev-t-016-live-tests-remaining-operations
- dev-t-015-shared-client-and-helper-tests
- dev-docs/replworks-baseline
- dev-refactor/split-youtube-class
This package is auto-updated.
Last update: 2026-10-25 08:44:26 UTC
README
๐ Keep YouTube channels and their videos in your Laravel database. Import channels from CSV or Excel, sync their videos with a scheduled queue job, and call the YouTube Data API v3 directly when you need to. API key only, no OAuth.
The package has two parts:
- Laravel channel sync - Eloquent models and migrations, a channel import command, a queued video sync, failure records, a quota guard, events, and a validation rule for video URLs.
- API client - one method per YouTube Data API v3 read operation, URL parsing, and response caching, for anything the sync does not cover.
โจ Features
- ๐๏ธ Channel and video records - Eloquent models and publishable migrations
- ๐ฅ Channel import - CSV or Excel files with Channel IDs,
@handles, or channel URLs - ๐ Queued video sync - adds new uploads, refreshes statistics, and flags videos that became private or were deleted
- ๐ Quota aware - classifies sync failures and pauses syncing until the daily quota resets
- ๐ฃ Events -
ChannelSynced,ChannelSyncFailed,QuotaExceeded - ๐ Validation rule - check that a submitted URL points to an existing YouTube video
- ๐ฏ One method per API call - videos, search, channels, playlists, playlist items, comment threads, activities, and categories
- ๐ URL parsing - get a Video ID from watch, embed, short, mobile, YouTube Music, and privacy-enhanced (
youtube-nocookie.com) URLs; resolve a channel from its URL - ๐ Response caching - optional, through your application's cache store
- ๐ Auto-Discovery - the service provider and the
Youtubefacade register themselves
๐ Requirements
- PHP 8.3 or higher
- Laravel 12.x or 13.x
- A YouTube Data API v3 key (get one here)
maatwebsite/excel is installed with the package and is used by the channel import.
๐ Installation
composer require cable8mm/youtube
โ๏ธ Configuration
Publish the config file:
php artisan vendor:publish --provider="Cable8mm\Youtube\YoutubeServiceProvider"
Add your API key to .env:
YOUTUBE_API_KEY=your_api_key_here
| Setting | Env | Default | Effect |
|---|---|---|---|
youtube.key |
YOUTUBE_API_KEY |
YOUR_API_KEY |
API key of the shared client. |
youtube.sync.queue |
YOUTUBE_SYNC_QUEUE |
null |
Queue for sync jobs. null uses your default queue. |
youtube.sync.max_pages |
1 |
Pages of 50 uploads read per sync, unless youtube:sync --full is used. |
Publish and run the migrations for the channel sync tables:
php artisan vendor:publish --tag=youtube-migrations php artisan migrate
๐ Channel Sync
Store channels in your database and keep their videos up to date with a queued job. The usual flow:
- Import channels from a CSV or Excel file.
- Schedule
youtube:syncand run a queue worker. - Read the records in your app, and watch failures and events.
Import Channels
Make a CSV or Excel file (csv, xlsx, xls) with a channel column. Only the first sheet is read, and other columns are ignored.
channel
UCk1SpWNzOs4MYmr0uICEntg
@Google
https://www.youtube.com/channel/UCk1SpWNzOs4MYmr0uICEntg
https://www.youtube.com/@Google
https://www.youtube.com/user/Google
Accepted inputs: a Channel ID, a @handle, or a URL that starts with /channel/, /@, or /user/. Import never searches, so /c/ URLs and plain names fail with unsupported input.
php artisan youtube:import-channels channels.csv
The command prints a count for each result (created, updated, failed: <cause>, skipped: quota exceeded) and one line per failed row:
created: 2
updated: 1
failed: unsupported input: 1
row 5: https://www.youtube.com/c/Google: unsupported input
It exits 1 when any row failed. Importing again updates channels and keeps their sync state and videos.
To attach the channels to one of your models, add the HasYoutubeChannels trait and import in code:
use Cable8mm\Youtube\Concerns\HasYoutubeChannels; use Cable8mm\Youtube\Imports\YoutubeChannelsImport; use Maatwebsite\Excel\Facades\Excel; class Artist extends Model { use HasYoutubeChannels; } $import = new YoutubeChannelsImport($artist); Excel::import($import, $path); $import->results(); // [['row' => 2, 'input' => '@Google', 'result' => 'created'], ...] $artist->youtubeChannels; // the imported channels
Schedule the Sync
youtube:sync queues one SyncChannelVideos job per active channel. Schedule it in routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('youtube:sync')->hourly();
Then run a queue worker:
php artisan queue:work --queue=default
Use the queue name you set in YOUTUBE_SYNC_QUEUE if it is not default.
php artisan youtube:sync # every active channel php artisan youtube:sync --channel=UCk1SpWNzOs4MYmr0uICEntg # one channel, even if inactive php artisan youtube:sync --full # read every page of uploads
Each sync reads the channel's newest uploads, then looks up those videos and the channel's stored videos together, 50 per request.
- New uploads are added, and stored videos get fresh titles and statistics.
- A stored video that the API no longer returns gets
unavailable_atset. Private and deleted videos look the same to an API key, so they are not told apart. - A video that comes back gets
unavailable_atcleared. - No video is deleted.
To stop syncing a channel, set is_active to false.
Records
| Table | Model (Cable8mm\Youtube\Models) |
Holds |
|---|---|---|
youtube_channels |
YoutubeChannel |
channel_id, handle, title, uploads_playlist_id, is_active, sync_status, last_synced_at, last_error_reason, optional owner |
youtube_videos |
YoutubeVideo |
video_id, title, description, published_at, duration, thumbnail_url, view, like, and comment counts, unavailable_at |
youtube_sync_errors |
YoutubeSyncError |
reason, http_code, message of each failed sync |
Relations: $channel->videos, $channel->syncErrors, $channel->owner, and $video->channel.
Sync Failures
A failed sync is not retried. It creates a YoutubeSyncError record, sets the channel's sync_status to failed, and stores the reason in last_error_reason. Videos saved before the failure are kept.
| Failure Reason | When |
|---|---|
quota_exceeded |
The API quota is used up. |
invalid_key |
The API key is not valid. |
forbidden |
The API refused the request (HTTP 403). |
channel_not_found |
The channel or its uploads playlist no longer exists. |
transport |
A network error or an invalid response. |
unknown |
Anything else. |
Quota Block: a quota_exceeded failure starts a Quota Block that lasts until the next midnight in America/Los_Angeles, when the daily quota resets. During the block, youtube:sync queues nothing and prints quota blocked until <time>, and queued jobs send no request. The block is stored in your default cache store.
use Cable8mm\Youtube\Sync\QuotaBlock; $block = new QuotaBlock; $block->isActive(); // bool $block->until(); // ?Carbon $block->clear();
Events
All events are in Cable8mm\Youtube\Events.
| Event | Fired | Properties |
|---|---|---|
ChannelSynced |
A sync finished | $channel, $created, $updated, $unavailable (newly flagged videos) |
ChannelSyncFailed |
A sync failed | $channel, $reason, $exception |
QuotaExceeded |
A Quota Block started | $until |
use Cable8mm\Youtube\Events\ChannelSyncFailed; use Illuminate\Support\Facades\Event; Event::listen(function (ChannelSyncFailed $event) { logger()->warning("YouTube sync failed for {$event->channel->title}: {$event->reason}"); });
Admin Screen
The package ships no admin screen. Build one in your app on top of the models. These examples are not dependencies of this package.
Filament resource:
use Cable8mm\Youtube\Models\YoutubeChannel; use Filament\Resources\Resource; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; class YoutubeChannelResource extends Resource { protected static ?string $model = YoutubeChannel::class; public static function table(Table $table): Table { return $table->columns([ TextColumn::make('title')->searchable(), TextColumn::make('handle'), IconColumn::make('is_active')->boolean(), TextColumn::make('sync_status')->badge(), TextColumn::make('last_error_reason'), TextColumn::make('last_synced_at')->since(), TextColumn::make('videos_count')->counts('videos'), ]); } }
Nova resource:
use Cable8mm\Youtube\Models\YoutubeSyncError; use Laravel\Nova\Fields\DateTime; use Laravel\Nova\Fields\Number; use Laravel\Nova\Fields\Text; use Laravel\Nova\Fields\Textarea; use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Resource; class YoutubeSyncErrorResource extends Resource { public static $model = YoutubeSyncError::class; public function fields(NovaRequest $request): array { return [ Text::make('Channel', fn () => $this->channel?->title), Text::make('Reason'), Number::make('HTTP Code', 'http_code'), Textarea::make('Message'), DateTime::make('Created At')->sortable(), ]; } }
โ Validation Rule
Check that a submitted URL points to an existing YouTube video:
use Cable8mm\Youtube\Rules\ValidYoutubeVideo; $request->validate([ 'video_url' => ['bail', 'required', new ValidYoutubeVideo], ]);
The rule accepts every video URL in URL Parsing and looks the video up with the shared client, or the client you pass: new ValidYoutubeVideo($youtube).
| Input | Result |
|---|---|
| A URL of an existing video | passes |
| Not a string, or not a supported video URL | The :attribute does not look like a valid Youtube URL. (no API call) |
| A video that does not exist, or is private or deleted | The :attribute does not exist on Youtube. |
| The API call failed | The :attribute could not be validated. |
The rule never throws.
๐ฏ API Client
Use the client for anything the sync does not cover. It calls the YouTube Data API v3 directly and stores nothing.
use Cable8mm\Youtube\Facades\Youtube; // One video, or several at once $video = Youtube::getVideoInfo('rie-hPVJ7Sw'); $videos = Youtube::getVideoInfo(['rie-hPVJ7Sw', 'iKHTawgyKWQ']); // Search $results = Youtube::searchVideos('Laravel Tutorial', 10); // A channel by ID, @handle, or URL $channel = Youtube::getChannelById('UCk1SpWNzOs4MYmr0uICEntg'); $channel = Youtube::getChannelByHandle('@Google'); $channel = Youtube::getChannelFromURL('https://www.youtube.com/@Google/videos'); // Popular videos in a country $popular = Youtube::getPopularVideos('US', 10);
Results are the API's own resource objects (stdClass), not mapped or renamed. Read them as $video->snippet->title.
๐ง The Client
The facade, app('youtube'), and app(\Cable8mm\Youtube\Youtube::class) all return one shared client built from youtube.key. A setting changed on it applies to every later call in the same request.
To use another API key or settings, build your own client:
use Cable8mm\Youtube\Youtube; $youtube = new Youtube($apiKey, [ 'cache_enabled' => true, // default false 'cache_ttl' => 1800, // seconds, default 3600 'use-http-host' => true, // send Referer: $_SERVER['HTTP_HOST'], default false ]); // Or change settings fluently $youtube = (new Youtube($apiKey)) ->useHttpHost(true) ->cache() ->setCacheTtl(1800);
Caching
With caching on, a response is stored in your default cache store and an identical call is served from the cache without a request. Error responses are never cached. When no cache store is bound, caching is skipped without an error.
๐ API Reference
Videos
$video = Youtube::getVideoInfo('video_id'); // object or null $videos = Youtube::getVideoInfo(['id1', 'id2']); // array or null $video = Youtube::getLocalizedVideoInfo('video_id', 'ko'); $videos = Youtube::getPopularVideos('KR', 20);
Search
Every search call costs 100 quota units.
$results = Youtube::search('Laravel', 10); $videos = Youtube::searchVideos('Laravel', 10, 'viewCount'); $videos = Youtube::searchChannelVideos('keyword', 'channel_id', 20); $videos = Youtube::listChannelVideos('channel_id', 10, 'date'); // Newest videos of a channel, published before a time $page = Youtube::getChannelVideos('channel_id', 10, '2026-01-01T00:00:00Z'); // Any search.list parameters; true adds page info $page = Youtube::searchAdvanced([ 'q' => 'Laravel', 'type' => 'video', 'part' => 'id,snippet', 'maxResults' => 50, 'order' => 'date', ], true);
Channels
$channel = Youtube::getChannelById('channel_id'); $channels = Youtube::getChannelById(['id1', 'id2']); $channel = Youtube::getChannelByHandle('@Google'); $channel = Youtube::getChannelByName('username'); // legacy /user/ name $channel = Youtube::searchChannelByName('Google'); // first search hit $channel = Youtube::getChannelFromURL('https://www.youtube.com/channel/UCk1SpWNzOs4MYmr0uICEntg');
Playlists
$playlist = Youtube::getPlaylistById('playlist_id'); $playlists = Youtube::getPlaylistById(['id1', 'id2']); $page = Youtube::getPlaylistsByChannelId('channel_id'); $page = Youtube::getPlaylistItemsByPlaylistId('playlist_id', '', 50);
Comments, Activities, Categories
$comments = Youtube::getCommentThreadsByVideoId('video_id', 20, 'time'); $comments = Youtube::getCommentThreads(channelId: 'channel_id'); $activities = Youtube::getActivitiesByChannelId('channel_id'); $categories = Youtube::getCategories('US');
Result Values
| Call | Items found | Nothing found |
|---|---|---|
By ID with a string (getVideoInfo('id')) |
one object | null |
By ID with an array (getVideoInfo([...])) |
array of objects | null |
getChannelByHandle, getChannelByName, searchChannelByName |
one object | null |
Lists (search, getPopularVideos, ...) |
array of objects | false |
| Lists with page info | ['results' => ..., 'info' => [...]] |
results is false |
getPlaylistsByChannelId and getPlaylistItemsByPlaylistId always return ['results' => ..., 'info' => ['totalResults', 'nextPageToken', 'prevPageToken']], with false for a missing token.
๐ Pagination
Pass the nextPageToken from one page to get the next:
$params = [ 'q' => 'Laravel', 'type' => 'video', 'part' => 'id,snippet', 'maxResults' => 50, ]; $page = Youtube::paginateResults($params); while ($page['info']['nextPageToken']) { $page = Youtube::paginateResults($params, $page['info']['nextPageToken']); }
Playlist items work the same way:
$page = Youtube::getPlaylistItemsByPlaylistId('playlist_id'); if ($page['info']['nextPageToken']) { $next = Youtube::getPlaylistItemsByPlaylistId('playlist_id', $page['info']['nextPageToken']); }
No call reads more than one page by itself.
๐ URL Parsing
Video URLs
Youtube::parseVidFromURL('https://youtu.be/rie-hPVJ7Sw'); // 'rie-hPVJ7Sw'
| URL | Video ID from |
|---|---|
https://www.youtube.com/watch?v=VIDEO_ID |
v |
https://m.youtube.com/watch?v=VIDEO_ID |
v |
https://music.youtube.com/watch?v=VIDEO_ID |
v |
https://www.youtube.com/embed/VIDEO_ID |
the path |
https://www.youtube-nocookie.com/embed/VIDEO_ID |
the path |
https://youtu.be/VIDEO_ID |
the path |
- The host decides, not the text:
https://example.com/?ref=youtube.com/watch?v=abcis rejected. - Other query parameters, such as
t,list, orfeature=embed, are ignored. - A URL without
https://works too:youtube.com/watch?v=VIDEO_ID. /shorts/and/live/URLs are not supported.- An unsupported URL throws
InvalidArgumentException.
Channel URLs
getChannelFromURL() accepts youtube.com, www.youtube.com, and m.youtube.com. Trailing slashes and sub-pages such as /videos or /about are ignored.
| URL | Lookup | Quota |
|---|---|---|
/channel/UC... |
getChannelById |
1 |
/@handle |
getChannelByHandle |
1 |
/user/name |
getChannelByName |
1 |
/c/name or /name |
searchChannelByName |
101 |
Non-channel pages such as /feed/trending or /playlist throw InvalidArgumentException.
โ ๏ธ Errors
| When | Exception | Message |
|---|---|---|
new Youtube('') |
InvalidArgumentException |
Google API key is Required, ... |
A search without q, channelId, or videoCategoryId |
InvalidArgumentException |
at least the Search query or Channel ID or videoCategoryId must be supplied |
getActivitiesByChannelId('') |
InvalidArgumentException |
ChannelId must be supplied |
| An unsupported video or channel URL | InvalidArgumentException |
The supplied URL does not look like a Youtube URL or ... Youtube Channel URL |
| The request failed | Cable8mm\Youtube\Exceptions\YoutubeApiException |
Curl Error : ... |
| The response is not a JSON object | YoutubeApiException |
Invalid response from Youtube API : ... |
| The API returned an error | YoutubeApiException |
Error <code> <message> : <reason>, with the HTTP code as the exception code |
The InvalidArgumentException cases are thrown before any request is sent. A response with no items is not an error. See Result Values.
๐งช Testing
# Unit tests (no API key, no network; this is what CI runs) composer test # E2E tests against the live YouTube Data API (needs YOUTUBE_API_KEY in .env) composer test:e2e
- โ 223 unit tests with Orchestra Testbench (no API key, no network)
- โ 46 E2E tests against the live YouTube Data API (requires API key)
๐ Package Structure
src/
โโโ Youtube.php # The client
โโโ YoutubeServiceProvider.php # Laravel service provider
โโโ Facades/
โ โโโ Youtube.php # Laravel facade
โโโ Http/ # API transport and response decoder
โโโ Resources/ # One class per API resource group
โโโ Support/
โ โโโ UrlParser.php # Video and channel URL parsing
โโโ Rules/
โ โโโ ValidYoutubeVideo.php # Validation rule
โโโ Cache/
โ โโโ YoutubeCache.php # Caching layer
โโโ Exceptions/
โ โโโ YoutubeApiException.php # API exception
โโโ Models/ # YoutubeChannel, YoutubeVideo, YoutubeSyncError
โโโ Concerns/
โ โโโ HasYoutubeChannels.php # Owner model trait
โโโ Imports/
โ โโโ YoutubeChannelsImport.php # CSV / Excel channel import
โโโ Console/ # youtube:import-channels, youtube:sync
โโโ Jobs/
โ โโโ SyncChannelVideos.php # Queued channel sync
โโโ Events/ # ChannelSynced, ChannelSyncFailed, QuotaExceeded
โโโ Sync/ # Failure reasons, Quota Block, channel input resolver
โโโ config/
โโโ youtube.php # Configuration file
database/
โโโ migrations/ # Publishable migrations (youtube-migrations)
๐ค Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
๐ Changelog
See CHANGELOG.md for recent changes.
๐ Links
- ๐ฆ Packagist
- ๐ Issue Tracker
- ๐ป GitHub Repository
๐ Credits
- Built on code from Alaouy/youtube
- Inspired by the Laravel community
๐ License
This package is open-sourced software licensed under the MIT license.
Made with โค๏ธ by cable8mm