ekstremedia/laravel-youtube

A modern Laravel package for YouTube API v3 integration with OAuth2, automatic token refresh, and beautiful admin panel

Maintainers

Package info

github.com/ekstremedia/laravel-youtube

pkg:composer/ekstremedia/laravel-youtube

Transparency log

Statistics

Installs: 1 363

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 3

v1.3.2 2026-08-21 19:11 UTC

This package is auto-updated.

Last update: 2026-08-21 19:12:10 UTC


README

Latest Version on Packagist Tests Total Downloads License Laravel PHP

A comprehensive Laravel package for YouTube API v3 integration with OAuth2 authentication, automatic token refresh, upload management, and extensive API coverage. Perfect for content creators, video platforms, and automated video upload systems.

๐Ÿš€ Features

Core Features

  • ๐Ÿ” OAuth2 Authentication - Secure authentication with automatic token refresh
  • ๐Ÿ“น Video Management - Complete CRUD operations for videos
  • ๐Ÿ“ค Advanced Upload - Chunked uploads with progress tracking & 15+ metadata fields
  • ๐Ÿ“บ Channel Management - Designed for single-user/single-channel applications
  • ๐ŸŽจ Thumbnail Management - Custom thumbnail upload support
  • ๐ŸŽฌ Playlist Operations - Create, manage, and organize video playlists
  • ๐Ÿ’ฌ Caption/Subtitle Support - Upload, manage, and download captions in multiple formats
  • ๐Ÿ“ OAuth-Free Transcript Fetching - Read any public video's spoken text via yt-dlp, no OAuth needed
  • ๐Ÿ“‡ OAuth-Free Channel Resolution & Uploads Listing - Resolve a channel URL/handle/id and list every video it has uploaded via yt-dlp, no OAuth needed
  • ๐Ÿ“ Location & Recording Details - Geotag videos with coordinates and recording dates
  • ๐Ÿ”„ Queue Support - Background video uploads via Laravel jobs
  • ๐ŸŽฏ Rate Limiting - Built-in rate limiting to respect API quotas
  • ๐Ÿ“Š Statistics Tracking - View counts, likes, and engagement metrics
  • ๐ŸŒ Webhook Support - Upload completion notifications
  • ๐Ÿ“… Scheduled Publishing - Schedule videos for future publication

Special Features

  • ๐Ÿฅง Raspberry Pi Integration - Optimized for automated uploads from IoT devices
  • ๐Ÿ”„ Auto Token Refresh - Never worry about expired tokens
  • ๐Ÿ’พ Database Storage - Track all uploads and video metadata
  • ๐Ÿ”’ Encrypted Token Storage - Secure storage of OAuth tokens
  • ๐Ÿ“ Comprehensive Logging - Detailed logging for debugging
  • ๐Ÿงช Test Coverage - Extensive test suite with Pest

๐Ÿ“‹ Requirements

  • PHP 8.2 or higher
  • Laravel 11.0 or 12.0
  • Composer
  • Only if you opt into OAuth (see below): Google API credentials (OAuth 2.0 Client ID) and a database (MySQL, PostgreSQL, SQLite)
  • Optional, only for Transcript Fetching (see below): the yt-dlp binary and a JavaScript runtime (node recommended)

๐Ÿ“ฆ Installation

Step 1: Install via Composer

composer require ekstremedia/laravel-youtube

Step 2: Publish Configuration and Migrations

# Publish configuration
php artisan vendor:publish --provider="EkstreMedia\LaravelYouTube\YouTubeServiceProvider" --tag="youtube-config"

# Publish migrations
php artisan vendor:publish --provider="EkstreMedia\LaravelYouTube\YouTubeServiceProvider" --tag="youtube-migrations"

# Optional: Publish views for authorization page
php artisan vendor:publish --provider="EkstreMedia\LaravelYouTube\YouTubeServiceProvider" --tag="youtube-views"

Step 3: Configure Environment Variables

Add the following to your .env file:

# OAuth / Token Refresh Feature Toggle - opt in only if you use OAuth (video
# upload/management, etc). The OAuth-free Transcript Fetching and Channel
# Resolution features below don't need this, and it defaults to false so
# this package never registers OAuth routes or scheduled token-refresh
# commands against a database that may not exist.
YOUTUBE_OAUTH_ENABLED=true

# Required if YOUTUBE_OAUTH_ENABLED=true: YouTube OAuth2 Credentials
YOUTUBE_CLIENT_ID=your-client-id-here
YOUTUBE_CLIENT_SECRET=your-client-secret-here
YOUTUBE_REDIRECT_URI=https://yourdomain.com/youtube/callback

# Optional: Authentication Page
YOUTUBE_AUTH_PAGE_PATH=youtube-authorize

# Optional: Upload Settings
YOUTUBE_UPLOAD_CHUNK_SIZE=10485760  # 10MB chunks
YOUTUBE_UPLOAD_TIMEOUT=3600         # 1 hour
YOUTUBE_UPLOAD_MAX_SIZE=137438953472 # 128GB (YouTube's max)

# Optional: Default Settings
YOUTUBE_DEFAULT_PRIVACY=private     # private, unlisted, public
YOUTUBE_DEFAULT_CATEGORY=22         # People & Blogs
YOUTUBE_DEFAULT_LANGUAGE=en

# Optional: Rate Limiting
YOUTUBE_RATE_LIMIT_ENABLED=true
YOUTUBE_RATE_LIMIT_PER_MINUTE=60
YOUTUBE_RATE_LIMIT_PER_HOUR=3000

# Optional: Logging
YOUTUBE_LOGGING_ENABLED=true
YOUTUBE_LOGGING_CHANNEL=youtube
YOUTUBE_LOGGING_LEVEL=info

Step 4: Obtain Google API Credentials

  1. Go to Google Cloud Console
  2. Create a new project or select an existing one
  3. Enable the YouTube Data API v3
  4. Create OAuth 2.0 credentials:
    • Application type: Web application
    • Add authorized redirect URI: https://yourdomain.com/youtube/callback
  5. Copy the Client ID and Client Secret to your .env file

Step 5: Run Migrations

php artisan migrate

๐ŸŽฏ Quick Start

Basic Usage

use EkstreMedia\LaravelYouTube\Facades\YouTube;

// Authenticate user (redirect to Google)
return redirect()->to(YouTube::getAuthUrl());

// After authentication, upload a video
$video = YouTube::forUser(auth()->id())
    ->uploadVideo(
        '/path/to/video.mp4',
        [
            'title' => 'My Amazing Video',
            'description' => 'This is a great video!',
            'tags' => ['laravel', 'youtube', 'api'],
            'category_id' => '22',
            'privacy_status' => 'public',
        ]
    );

echo "Video uploaded: " . $video->watch_url;

Raspberry Pi Integration

Perfect for automated timelapse or security camera uploads. The package is designed for single-user/single-channel applications.

Using the Service Without User Context

// Method 1: Use the default (most recent) active token
YouTube::usingDefault()->uploadVideo($file, $metadata);

// Method 2: Use a specific channel by ID
YouTube::forChannel('UCxxxxxxxxxx')->uploadVideo($file, $metadata);

// Method 3: For legacy support, specify user ID
YouTube::forUser($userId)->uploadVideo($file, $metadata);

Complete Raspberry Pi Example

// In your Pi upload endpoint
use Ekstremedia\LaravelYouTube\Facades\YouTube;

Route::post('/api/pi/upload', function (Request $request) {
    $request->validate([
        'video' => 'required|file|mimes:mp4,avi,mov|max:5242880', // 5GB
        'camera_id' => 'required|string',
    ]);

    $file = $request->file('video');

    // Upload using default token (single-user mode)
    $video = YouTube::usingDefault()->uploadVideo($file, [
        'title' => "Pi Camera {$request->camera_id} - " . now()->format('Y-m-d H:i'),
        'description' => 'Automated timelapse from Raspberry Pi',
        'tags' => ['raspberry-pi', 'timelapse', $request->camera_id],
        'privacy_status' => 'unlisted',
    ]);

    return response()->json([
        'success' => true,
        'video_id' => $video->video_id,
        'watch_url' => $video->watch_url,
    ]);
});

From Raspberry Pi (Shell Script)

#!/bin/bash
# On your Raspberry Pi

VIDEO_FILE="/home/pi/videos/timelapse_$(date +%Y%m%d_%H%M%S).mp4"
API_ENDPOINT="https://your-domain.com/api/pi/upload"
API_TOKEN="your-api-token"

# Upload to your Laravel API
curl -X POST $API_ENDPOINT \
  -H "Authorization: Bearer $API_TOKEN" \
  -F "video=@$VIDEO_FILE" \
  -F "camera_id=pi_camera_1"

One-Time Google OAuth Setup

Visit the authorization page to connect your YouTube channel:

https://yourdomain.com/youtube-authorize

The page will:

  1. Check if credentials are configured
  2. Show your connected channel (if any)
  3. Allow you to authorize/re-authorize with Google
  4. Automatically refresh expired tokens

After authorization, all uploads will use this token automatically.

๐Ÿ“š Comprehensive Documentation

Authentication Page

The package includes a simple authentication page for connecting your YouTube channel:

Access the page: https://yourdomain.com/youtube-authorize (configurable)

Features:

  • Check if credentials are configured
  • View connected YouTube channel
  • One-click authorize/re-authorize
  • Automatic token refresh
  • Clean, simple interface
  • Protected by authentication - Requires logged-in user by default

Security:

By default, the authorization page is protected with the auth middleware. Only logged-in users can access it.

To customize the middleware protection, publish and edit the config file:

// config/youtube.php
'routes' => [
    'auth_page' => [
        'middleware' => ['web', 'auth'], // Default: requires login

        // Examples:
        // 'middleware' => ['web', 'auth:admin'],           // Admin guard
        // 'middleware' => ['web', 'can:manage-youtube'],  // Laravel Gate
        // 'middleware' => ['web'],                         // No authentication
    ],
],

Configuration:

YOUTUBE_AUTH_PAGE_PATH=youtube-authorize

Usage in your app:

// Link to authentication page
<a href="{{ route('youtube.authorize') }}">Connect YouTube</a>

// Or use the configured path
<a href="/{{ config('youtube.routes.auth_page.path') }}">Authorize YouTube</a>

The page shows:

  1. Configuration status (credentials check)
  2. Connected channel information
  3. Authorization button
  4. Token expiration and refresh status

Authentication & Token Management

OAuth Flow

use EkstreMedia\LaravelYouTube\Services\AuthService;

$authService = app(AuthService::class);

// Generate OAuth URL with state for CSRF protection
$state = Str::random(40);
session(['youtube_oauth_state' => $state]);
$authUrl = $authService->getAuthUrl($state);

// In callback handler
if (request('state') !== session('youtube_oauth_state')) {
    abort(403, 'Invalid state');
}

// Exchange code for tokens
$tokens = $authService->exchangeCode(request('code'));

Token Storage & Management

use EkstreMedia\LaravelYouTube\Services\TokenManager;

$tokenManager = app(TokenManager::class);

// Store tokens after OAuth
$token = $tokenManager->storeToken(
    $tokens,
    $channelInfo,
    auth()->id()
);

// Get active token for user
$token = $tokenManager->getActiveToken(auth()->id());

// Check if refresh needed (within 5 minutes of expiry)
if ($tokenManager->needsRefresh($token)) {
    $newTokens = $authService->refreshAccessToken($token->refresh_token);
    $tokenManager->updateToken($token, $newTokens);
}

// Handle multiple channels
$tokens = $tokenManager->getUserTokens(auth()->id());
foreach ($tokens as $token) {
    echo "Channel: {$token->channel_title}\n";
}

Video Management

Upload Videos

use EkstreMedia\LaravelYouTube\Facades\YouTube;

// Simple upload
$video = YouTube::forUser(auth()->id())->uploadVideo(
    $request->file('video'),
    [
        'title' => 'My Video',
        'description' => 'Video description',
        'tags' => ['tag1', 'tag2'],
        'category_id' => '22', // People & Blogs
        'privacy_status' => 'private', // private, unlisted, public
    ]
);

// Advanced upload with all metadata options
$video = YouTube::forUser(auth()->id())->uploadVideo(
    '/path/to/large-video.mp4',
    [
        // Basic metadata
        'title' => 'Large Video Upload',
        'description' => 'Testing chunked upload',
        'tags' => ['large', 'chunked'],
        'category_id' => '22',

        // Privacy & Status
        'privacy_status' => 'private',
        'made_for_kids' => false,
        'self_declared_made_for_kids' => false,
        'embeddable' => true,
        'public_stats_viewable' => true,
        'publish_at' => '2024-12-31T12:00:00Z', // Scheduled publishing

        // License & Language
        'license' => 'creativeCommon',
        'default_language' => 'en',
        'default_audio_language' => 'en-US',

        // Recording details (great for travel vlogs, security cameras)
        'recording_date' => '2024-01-15T10:00:00Z',
        'location' => [
            'latitude' => 59.9139,
            'longitude' => 10.7522,
            'altitude' => 100.0,
            'description' => 'Oslo, Norway',
        ],

        // Custom thumbnail
        'thumbnail' => '/path/to/thumbnail.jpg',
    ],
    [
        'chunk_size' => 50 * 1024 * 1024, // 50MB chunks
        'notify_url' => 'https://yourapp.com/webhook',
        'progress_callback' => function ($uploaded, $total) {
            $percent = round(($uploaded / $total) * 100);
            Log::info("Upload progress: {$percent}%");
        }
    ]
);

Manage Videos

// Get user's videos
$videos = YouTube::forUser(auth()->id())->getVideos([
    'maxResults' => 50,
    'order' => 'date', // date, rating, relevance, title, viewCount
    'type' => 'video',
    'videoDefinition' => 'high', // any, high, standard
    'videoDuration' => 'medium', // short (<4min), medium (4-20min), long (>20min)
]);

// Get single video details
$video = YouTube::forUser(auth()->id())->getVideo('video-id', [
    'snippet',
    'contentDetails',
    'statistics',
    'status',
    'processingDetails'
]);

// Update video metadata
$updated = YouTube::forUser(auth()->id())->updateVideo('video-id', [
    'title' => 'Updated Title',
    'description' => 'Updated description',
    'tags' => ['new', 'tags'],
    'category_id' => '24',
    'privacy_status' => 'public',
]);

// Delete video
YouTube::forUser(auth()->id())->deleteVideo('video-id');

Channel Management

// Get channel info
$channel = YouTube::forUser(auth()->id())->getChannel([
    'snippet',
    'contentDetails',
    'statistics',
    'brandingSettings',
    'contentOwnerDetails',
    'localizations',
    'status',
    'topicDetails'
]);

// Get channel videos
$videos = YouTube::forUser(auth()->id())->getChannelVideos('channel-id', [
    'maxResults' => 50,
    'order' => 'date',
]);

// Switch between multiple channels
$tokens = YouTubeToken::where('user_id', auth()->id())->get();
foreach ($tokens as $token) {
    $youtube = YouTube::withToken($token);
    $channel = $youtube->getChannel();
    echo "Channel: {$channel['title']} ({$channel['subscriberCount']} subscribers)\n";
}

Playlist Management

// Create a new playlist
$playlist = YouTube::usingDefault()->createPlaylist('My Playlist', [
    'description' => 'Collection of my favorite videos',
    'privacy_status' => 'public', // private, public, unlisted
    'tags' => ['favorites', 'collection'],
]);

// Get all playlists
$result = YouTube::usingDefault()->getPlaylists();
foreach ($result['playlists'] as $playlist) {
    echo "{$playlist['title']} - {$playlist['item_count']} videos\n";
}

// Add video to playlist
YouTube::usingDefault()->addVideoToPlaylist(
    'video-id',
    'playlist-id',
    0 // Position (0 = first)
);

// Get videos in a playlist
$result = YouTube::usingDefault()->getPlaylistVideos('playlist-id');
foreach ($result['videos'] as $video) {
    echo "{$video['title']} (position: {$video['position']})\n";
}

// Update playlist
YouTube::usingDefault()->updatePlaylist('playlist-id', [
    'title' => 'Updated Title',
    'privacy_status' => 'public',
]);

// Delete playlist
YouTube::usingDefault()->deletePlaylist('playlist-id');

Caption/Subtitle Management

// Upload captions (supports SRT, VTT, TTML, SBV)
$caption = YouTube::usingDefault()->uploadCaption(
    'video-id',
    'en', // Language code
    '/path/to/captions.srt',
    [
        'name' => 'English Subtitles',
        'is_draft' => false,
    ]
);

// List all captions for a video
$captions = YouTube::usingDefault()->getCaptions('video-id');
foreach ($captions as $caption) {
    echo "{$caption['name']} ({$caption['language']})\n";
}

// Update caption metadata or file
YouTube::usingDefault()->updateCaption(
    'caption-id',
    ['name' => 'Updated English'],
    '/path/to/new-captions.srt' // Optional: new file
);

// Download captions in different formats
$srt = YouTube::usingDefault()->downloadCaption('caption-id', 'srt');
$vtt = YouTube::usingDefault()->downloadCaption('caption-id', 'vtt');
file_put_contents('captions.srt', $srt);

// Delete captions
YouTube::usingDefault()->deleteCaption('caption-id');

Transcript Fetching (OAuth-Free)

YouTube::usingDefault()->downloadCaption() above uses the Data API v3 captions.download endpoint, and that endpoint only ever succeeds for videos the authenticated identity owns - it is useless for reading the spoken text of an arbitrary public video. The YouTubeTranscript facade is a separate, OAuth-free path built for exactly that: given any public video, it returns manual subtitles when the uploader wrote them, or YouTube's auto-generated captions otherwise. No OAuth credentials, no .env client ID or secret, and no media (audio or video) is ever downloaded - only a caption track.

It works by spawning yt-dlp --js-runtimes node -J --skip-download --no-playlist once to get the video's metadata and a catalog of signed caption-track URLs, then issuing a single plain HTTP GET for the one chosen track.

Prerequisites (install these yourself)

This feature needs two things on the host that are not installed by Composer:

  1. The yt-dlp binary, on PATH (or pointed at via YOUTUBE_YTDLP_BINARY). yt-dlp is updated frequently because YouTube changes its extraction surface over weeks to months; a binary pinned at install time will quietly degrade, so keep it current with its own self-updater: yt-dlp -U.
  2. A JavaScript runtime. Without one, yt-dlp prints "No supported JavaScript runtime could be found. Only deno is enabled by default" and deprecates extraction. node is the runtime this package has verified working, and it must be passed explicitly - node is not one of yt-dlp's own defaults. That is why js_runtime defaults to node in config/youtube.php and is always passed as --js-runtimes node (or whatever YOUTUBE_YTDLP_JS_RUNTIME is set to); install node alongside yt-dlp or the fetch will fail.

Usage

use Ekstremedia\LaravelYouTube\Facades\YouTubeTranscript;

$transcript = YouTubeTranscript::fetch('https://www.youtube.com/watch?v=dQw4w9WgXcQ');

// Accepts a bare id too, and an optional language override:
$transcript = YouTubeTranscript::fetch('dQw4w9WgXcQ', language: 'en');

echo $transcript->title;
echo $transcript->cues->toPlainText();
echo $transcript->cues->toSrt();  // SubRip (.srt)
echo $transcript->cues->toVtt();  // WebVTT (.vtt)

if ($transcript->isGenerated) {
    // Auto-generated (ASR) captions contain real transcription errors.
    // A UI presenting this transcript should tell the reader which kind
    // they are looking at rather than presenting it as an exact record.
    echo 'Auto-generated captions - may contain errors';
}

echo $transcript->cues->wordCount();
echo $transcript->cues->characterCount();

fetch() returns a TranscriptResult:

Property Type Description
videoId string The resolved 11-character video id
title string Video title
channel string Channel name
durationSeconds int Video duration
uploadDate string Upload date as reported by yt-dlp
thumbnailUrl string Thumbnail URL
language string Language code of the selected caption track
languageName ?string Human-readable language name, if yt-dlp reported one
isGenerated bool true for auto-generated (ASR) captions, false for manual subtitles
cues CueCollection The transcript itself

CueCollection holds an ordered array of Cue (startMs, durationMs, text, endMs()) and exposes toPlainText(), toSrt(), toVtt(), toArray(), wordCount() and characterCount().

Configuration

// config/youtube.php
'transcript' => [
    'binary' => env('YOUTUBE_YTDLP_BINARY', 'yt-dlp'),
    'js_runtime' => env('YOUTUBE_YTDLP_JS_RUNTIME', 'node'),
    'process_timeout' => (int) env('YOUTUBE_YTDLP_TIMEOUT', 60),
    'http_timeout' => (int) env('YOUTUBE_TRANSCRIPT_HTTP_TIMEOUT', 30),
    'max_duration' => (int) env('YOUTUBE_TRANSCRIPT_MAX_DURATION', 14400), // 4h, 0 = unlimited
    'preferred_languages' => ['nb', 'no', 'nn', 'en'],
],
Env var Default Meaning
YOUTUBE_YTDLP_BINARY yt-dlp Binary name or path
YOUTUBE_YTDLP_JS_RUNTIME node Passed as --js-runtimes <value>
YOUTUBE_YTDLP_TIMEOUT 60 Seconds allowed for the yt-dlp subprocess
YOUTUBE_TRANSCRIPT_HTTP_TIMEOUT 30 Seconds allowed for the caption-track HTTP request
YOUTUBE_TRANSCRIPT_MAX_DURATION 14400 Refuse videos longer than this many seconds (0 = unlimited)

Exceptions

All extend the package's base YouTubeException and live under Ekstremedia\LaravelYouTube\Exceptions\Transcript:

Exception Means
InvalidVideoUrl The given input is not a bare video id or a recognised YouTube URL
VideoUnavailable The video exists but cannot be transcribed: private, members-only, removed, exceeds max_duration, or a live stream still in progress
NoCaptionsAvailable No usable manual subtitles or auto-generated captions exist in a requested or preferred language
RateLimited YouTube returned HTTP 429 for the metadata request or the caption-track download; retry later
YtDlpUnavailable The yt-dlp binary itself could not be executed - not installed, not on PATH, or not executable
YtDlpFailed yt-dlp ran but failed for a reason that doesn't map to a more specific exception above. getMessage() deliberately carries no process output; call stderrTail() for the last lines of stderr, for logging only - it can contain request internals that should not reach a visitor

Rate limits are real - request one track, cache the result

Measured directly against YouTube: requesting caption tracks with a language glob (rather than one specific language) earned HTTP Error 429: Too Many Requests on the third request. This package always requests exactly one caption track per video, never a glob, for that reason - but a consuming application calling fetch() repeatedly for the same video is exposed to the same limit.

The package deliberately does no caching of its own - that decision (TTL, store, invalidation) belongs to the consuming application, which knows how often a given transcript actually changes. Cache the TranscriptResult (or at least its cues) aggressively; do not call fetch() again for a video you have already fetched unless you have a reason to believe the captions changed.

Channel Resolution and Uploads Listing (OAuth-Free)

ChannelLister is the channel-level counterpart to YouTubeTranscript: given a channel URL, handle, or id, it resolves the channel's identity and lists every video it has uploaded - no OAuth credentials, and no media ever downloaded, only yt-dlp --flat-playlist metadata. It shares the same YtDlpRunner process seam as TranscriptFetcher, so it needs the same yt-dlp prerequisite described above (a JavaScript runtime is not required for this feature - flat-playlist listings do not need one).

use Ekstremedia\LaravelYouTube\Transcript\ChannelLister;

$lister = app(ChannelLister::class);

// Accepts a bare channel id, a bare @handle, or a youtube.com/@handle,
// /channel/UC..., /c/Name, or /user/Name URL.
$channel = $lister->resolve('https://www.youtube.com/@kurzgesagt');

echo $channel->channelId;         // UCsXVk37bltHxD1rDPwtNM8Q
echo $channel->title;
echo $channel->thumbnailUrl;
echo $channel->uploadsPlaylistId; // UUsXVk37bltHxD1rDPwtNM8Q

// Pages the channel's uploads playlist (includes Shorts - see note
// below), one yt-dlp spawn per page, until a page returns fewer entries
// than requested. Bounded by config('youtube.channel.max_pages') even if
// every page comes back full.
foreach ($lister->listUploads($channel->channelId) as $video) {
    echo $video->videoId;
    echo $video->title;
    echo $video->durationSeconds; // ?int - flat-playlist listings don't report an upload date
    echo $video->viewCount;       // ?int
}

resolve() returns a ResolvedChannel (channelId, title, thumbnailUrl, uploadsPlaylistId). listUploads() returns an array of ChannelVideo (videoId, title, durationSeconds, viewCount) - videoId is exactly what YouTubeTranscript::fetch() accepts, so a caller can go straight from a channel to every one of its transcripts.

A channel's uploads playlist and its "/videos" tab are not the same listing - measured directly (2026-08-21): the uploads playlist (UC โ†’ UU in the channel id, YouTube's own long-standing convention) includes Shorts, which the current /videos tab excludes. listUploads() uses the uploads playlist so "every video it has uploaded" is actually every video.

Configuration

// config/youtube.php
'channel' => [
    'binary' => env('YOUTUBE_YTDLP_BINARY', 'yt-dlp'),
    'process_timeout' => (int) env('YOUTUBE_YTDLP_CHANNEL_TIMEOUT', 60),
    'page_size' => (int) env('YOUTUBE_CHANNEL_PAGE_SIZE', 200),
    'max_pages' => (int) env('YOUTUBE_CHANNEL_MAX_PAGES', 100),
],
Env var Default Meaning
YOUTUBE_YTDLP_BINARY yt-dlp Binary name or path (shared with the transcript feature)
YOUTUBE_YTDLP_CHANNEL_TIMEOUT 60 Seconds allowed for each yt-dlp subprocess (one per page)
YOUTUBE_CHANNEL_PAGE_SIZE 200 Uploads requested per --playlist-items page
YOUTUBE_CHANNEL_MAX_PAGES 100 Hard cap on pages requested per listUploads() call - a backstop, not an expected ceiling

Exceptions

Also under Ekstremedia\LaravelYouTube\Exceptions\Transcript:

Exception Means
InvalidChannelReference The given input is not a bare channel id/handle or a recognised channel URL
ChannelUnavailable The reference was well-formed but yt-dlp could not find the channel (404/"not found")
RateLimited YouTube returned HTTP 429 for a listing request; retry later
YtDlpUnavailable The yt-dlp binary itself could not be executed
YtDlpFailed yt-dlp ran but failed for a reason that doesn't map to a more specific exception above

Background Jobs & Queues

You can implement your own background job processing. Here's an example:

// Create your own job
namespace App\Jobs;

use Ekstremedia\LaravelYouTube\Facades\YouTube;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class UploadYouTubeVideo implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public string $videoPath,
        public array $metadata
    ) {}

    public function handle(): void
    {
        $video = YouTube::usingDefault()->uploadVideo(
            $this->videoPath,
            $this->metadata
        );

        // Clean up temporary file
        unlink($this->videoPath);
    }
}

// Dispatch the job
UploadYouTubeVideo::dispatch(
    videoPath: '/path/to/video.mp4',
    metadata: [
        'title' => 'Queued Upload',
        'description' => 'Uploaded via queue',
        'tags' => ['queued', 'background'],
        'privacy_status' => 'private',
    ]
)->onQueue('media');

Advanced Features

Live Streaming

// Create live broadcast
$broadcast = YouTube::forUser(auth()->id())->createLiveBroadcast([
    'title' => 'Live Stream',
    'description' => 'Live streaming event',
    'scheduled_start_time' => now()->addHour(),
    'scheduled_end_time' => now()->addHours(2),
    'privacy_status' => 'public',
]);

// Create live stream
$stream = YouTube::forUser(auth()->id())->createLiveStream([
    'title' => 'Stream',
    'description' => 'Stream description',
    'cdn' => [
        'frameRate' => '30fps',
        'resolution' => '1080p',
        'ingestionType' => 'rtmp',
    ],
]);

// Bind stream to broadcast
YouTube::forUser(auth()->id())->bindBroadcastToStream(
    $broadcast['id'],
    $stream['id']
);

Comments Management

// Get video comments
$comments = YouTube::forUser(auth()->id())->getVideoComments('video-id', [
    'maxResults' => 100,
    'order' => 'time', // time, relevance
    'textFormat' => 'plainText', // plainText, html
]);

// Reply to comment
YouTube::forUser(auth()->id())->replyToComment('comment-id', 'Thank you for watching!');

// Moderate comments
YouTube::forUser(auth()->id())->setCommentModerationStatus('comment-id', 'published'); // heldForReview, published, rejected

Analytics Integration

// Get video analytics (requires YouTube Analytics API)
$analytics = YouTube::forUser(auth()->id())->getVideoAnalytics('video-id', [
    'metrics' => 'views,estimatedMinutesWatched,averageViewDuration',
    'dimensions' => 'day',
    'startDate' => now()->subDays(30)->format('Y-m-d'),
    'endDate' => now()->format('Y-m-d'),
]);

๐Ÿงช Testing

The package includes comprehensive test coverage:

# Run all tests
composer test

# Run specific test suite
vendor/bin/pest --filter="Upload"

# Run with coverage
composer test-coverage

Test categories:

  • Unit tests for configuration and models
  • Feature tests for services and API endpoints
  • Integration tests for OAuth flow
  • Upload tests including chunked uploads
  • Token management and refresh tests
  • Rate limiting and authentication tests

๐Ÿ”ง Configuration

Full configuration options in config/youtube.php:

return [
    'credentials' => [
        'client_id' => env('YOUTUBE_CLIENT_ID'),
        'client_secret' => env('YOUTUBE_CLIENT_SECRET'),
        'redirect_uri' => env('YOUTUBE_REDIRECT_URI', '/youtube/callback'),
    ],

    'scopes' => [
        'https://www.googleapis.com/auth/youtube',
        'https://www.googleapis.com/auth/youtube.upload',
        'https://www.googleapis.com/auth/youtube.readonly',
        'https://www.googleapis.com/auth/youtube.force-ssl',
        'https://www.googleapis.com/auth/youtubepartner',
        'https://www.googleapis.com/auth/youtubepartner-channel-audit',
    ],

    'admin' => [
        'enabled' => env('YOUTUBE_ADMIN_ENABLED', true),
        'prefix' => env('YOUTUBE_ADMIN_PREFIX', 'youtube-admin'),
        'middleware' => ['web'],
        'auth_middleware' => ['auth'],
    ],

    'routes' => [
        'api' => [
            'enabled' => env('YOUTUBE_API_ENABLED', true),
            'prefix' => env('YOUTUBE_API_PREFIX', 'youtube'),
            'middleware' => ['api'],
            'api_middleware' => ['auth:sanctum', 'throttle:60,1'],
        ],
    ],

    'token' => [
        'driver' => 'database',
        'table' => 'youtube_tokens',
        'cache_key' => 'youtube.token.',
        'cache_ttl' => env('YOUTUBE_TOKEN_CACHE_TTL', 3600),
    ],

    'upload' => [
        'chunk_size' => env('YOUTUBE_UPLOAD_CHUNK_SIZE', 1024 * 1024), // 1MB
        'timeout' => env('YOUTUBE_UPLOAD_TIMEOUT', 3600),
        'max_file_size' => env('YOUTUBE_UPLOAD_MAX_SIZE', 128 * 1024 * 1024 * 1024), // 128GB
        'temp_path' => env('YOUTUBE_UPLOAD_TEMP_PATH', storage_path('app/youtube-uploads')),
    ],

    'defaults' => [
        'privacy_status' => env('YOUTUBE_DEFAULT_PRIVACY', 'private'),
        'category_id' => env('YOUTUBE_DEFAULT_CATEGORY', '22'),
        'language' => env('YOUTUBE_DEFAULT_LANGUAGE', 'en'),
    ],

    'rate_limiting' => [
        'enabled' => env('YOUTUBE_RATE_LIMIT_ENABLED', true),
        'max_requests_per_minute' => env('YOUTUBE_RATE_LIMIT_PER_MINUTE', 60),
        'max_requests_per_hour' => env('YOUTUBE_RATE_LIMIT_PER_HOUR', 3000),
    ],

    'logging' => [
        'enabled' => env('YOUTUBE_LOGGING_ENABLED', true),
        'channel' => env('YOUTUBE_LOGGING_CHANNEL', 'youtube'),
        'level' => env('YOUTUBE_LOGGING_LEVEL', 'info'),
    ],
];

๐Ÿ› ๏ธ Console Commands

# Refresh expiring tokens
php artisan youtube:refresh-tokens
php artisan youtube:refresh-tokens --token-id=1
php artisan youtube:refresh-tokens --user-id=1 --force

# Clean up expired tokens
php artisan youtube:clear-expired-tokens
php artisan youtube:clear-expired-tokens --days=30 --dry-run

# Sync video statistics
php artisan youtube:sync-videos
php artisan youtube:sync-videos --user-id=1
php artisan youtube:sync-videos --video-id=abc123

๐Ÿ”„ Scheduled Tasks

youtube:refresh-tokens (hourly) and youtube:clear-expired-tokens (daily) are auto-registered onto Laravel's scheduler by the package's service provider, but only when YOUTUBE_OAUTH_ENABLED=true (see Requirements/Step 3 above) - both read the youtube_tokens table, so they stay off by default for consumers with no SQL database. youtube:sync-videos is not part of this package and is shown below only as an example of adding your own scheduled command alongside it:

protected function schedule(Schedule $schedule)
{
    // Automatically refresh expiring tokens
    $schedule->command('youtube:refresh-tokens')->hourly();

    // Clean up expired tokens daily
    $schedule->command('youtube:clear-expired-tokens')->daily();

    // Sync video statistics every 6 hours
    $schedule->command('youtube:sync-videos')->everySixHours();
}

๐Ÿšจ Error Handling

The package provides specific exception types:

use EkstreMedia\LaravelYouTube\Exceptions\{
    YouTubeException,
    YouTubeAuthException,
    UploadException,
    TokenException,
    QuotaExceededException
};

try {
    $video = YouTube::forUser($userId)->uploadVideo($file, $metadata);
} catch (QuotaExceededException $e) {
    // Handle quota exceeded
    Log::error("YouTube quota exceeded: " . $e->getMessage());
    // Retry after reset
} catch (UploadException $e) {
    // Handle upload failure
    Log::error("Upload failed: " . $e->getMessage());
} catch (TokenException $e) {
    // Handle token issues
    return redirect()->route('youtube.auth');
} catch (YouTubeException $e) {
    // Handle general YouTube API errors
    Log::error("YouTube API error: " . $e->getYouTubeError());
}

๐Ÿ” Security

  • OAuth tokens are encrypted using Laravel's encryption
  • CSRF protection on OAuth flow
  • Rate limiting on API endpoints
  • Scoped access control
  • Automatic token rotation
  • Secure webhook signatures

๐Ÿ“Š Events

The package dispatches Laravel events:

// Listen for events in EventServiceProvider
protected $listen = [
    \EkstreMedia\LaravelYouTube\Events\VideoUploaded::class => [
        \App\Listeners\ProcessUploadedVideo::class,
    ],
    \EkstreMedia\LaravelYouTube\Events\TokenRefreshed::class => [
        \App\Listeners\LogTokenRefresh::class,
    ],
    \EkstreMedia\LaravelYouTube\Events\UploadFailed::class => [
        \App\Listeners\NotifyUploadFailure::class,
    ],
];

๐Ÿค Contributing

Please see CONTRIBUTING for details.

๐Ÿงช Testing

composer test
composer test-coverage
composer format
composer analyse

๐Ÿ“ Changelog

Please see CHANGELOG for more information on what has changed recently.

๐Ÿ”’ Security

If you discover any security-related issues, please email security@ekstremedia.no instead of using the issue tracker.

๐Ÿ“œ License

The MIT License (MIT). Please see License File for more information.

๐Ÿ‘ฅ Credits

๐Ÿ™ Acknowledgments

  • Thanks to Google for the YouTube Data API
  • Laravel framework for the excellent foundation
  • The open-source community for inspiration

๐Ÿ“ž Support

Made with โค๏ธ by Ekstre Media