PHPackages                             viewtrender/php-youtube-testkit-laravel - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. viewtrender/php-youtube-testkit-laravel

ActiveLibrary[Framework](/categories/framework)

viewtrender/php-youtube-testkit-laravel
=======================================

Laravel integration for php-youtube-testkit

v0.7.0(1mo ago)123MITPHPPHP ^8.3CI passing

Since Feb 19Pushed 1mo agoCompare

[ Source](https://github.com/viewtrender/php-youtube-testkit-laravel)[ Packagist](https://packagist.org/packages/viewtrender/php-youtube-testkit-laravel)[ RSS](/packages/viewtrender-php-youtube-testkit-laravel/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (12)Versions (14)Used By (0)

php-youtube-testkit-laravel
===========================

[](#php-youtube-testkit-laravel)

Laravel integration for mocking YouTube Data, Analytics, and Reporting APIs in tests.

[![Tests](https://github.com/viewtrender/php-youtube-testkit-laravel/actions/workflows/tests.yml/badge.svg)](https://github.com/viewtrender/php-youtube-testkit-laravel/actions/workflows/tests.yml)[![Latest Version](https://camo.githubusercontent.com/5bd01eed10c629e5deffc385ef1297fc4f179b8df0f130d14f7d48e2a31b926e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f766965777472656e6465722f7068702d796f75747562652d746573746b69742d6c61726176656c)](https://packagist.org/packages/viewtrender/php-youtube-testkit-laravel)[![PHP 8.3+](https://camo.githubusercontent.com/42df5991a968c0783a689ce697865ac6fbe316246743abc265ab342ae158dc7d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e332532422d626c7565)](https://php.net)[![License: MIT](https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e)](LICENSE)

Overview
--------

[](#overview)

Laravel service provider, facades, and automatic container swaps for [`viewtrender/php-youtube-testkit-core`](https://github.com/viewtrender/php-youtube-testkit-core). Supports three YouTube APIs:

- **YouTube Data API** — videos, channels, playlists, search, comments
- **YouTube Analytics API** — on-demand metrics queries
- **YouTube Reporting API** — bulk data exports and scheduled jobs

When you call `fake()` on any API facade, the service provider replaces the Google Service container binding with a fake instance — controllers that type-hint the service receive the fake automatically.

Installation
------------

[](#installation)

```
composer require --dev viewtrender/php-youtube-testkit-laravel
```

The service provider is auto-discovered. To publish the config file:

```
php artisan vendor:publish --tag=youtube-testkit-config
```

Requirements
------------

[](#requirements)

- PHP 8.3+
- Laravel 10, 11, or 12
- `google/apiclient` ^2.15

Setup
-----

[](#setup)

Register the real Google services in your `AppServiceProvider`:

```
use Google\Client as GoogleClient;
use Google\Service\YouTube;
use Google\Service\YouTubeAnalytics;
use Google\Service\YouTubeReporting;

public function register(): void
{
    // Shared Google Client (configure once)
    $this->app->singleton(GoogleClient::class, function () {
        $client = new GoogleClient();
        $client->setApplicationName(config('services.youtube.application_name', 'My App'));
        $client->setDeveloperKey(config('services.youtube.api_key'));
        // For Analytics/Reporting, also set OAuth credentials
        return $client;
    });

    // YouTube Data API
    $this->app->singleton(YouTube::class, function ($app) {
        return new YouTube($app->make(GoogleClient::class));
    });

    // YouTube Analytics API
    $this->app->singleton(YouTubeAnalytics::class, function ($app) {
        return new YouTubeAnalytics($app->make(GoogleClient::class));
    });

    // YouTube Reporting API
    $this->app->singleton(YouTubeReporting::class, function ($app) {
        return new YouTubeReporting($app->make(GoogleClient::class));
    });
}
```

Controllers can then type-hint any service:

```
use Google\Service\YouTube;
use Google\Service\YouTubeAnalytics;

class DashboardController extends Controller
{
    public function index(YouTube $youtube, YouTubeAnalytics $analytics)
    {
        $videos = $youtube->videos->listVideos('snippet', ['chart' => 'mostPopular']);
        $stats = $analytics->reports->query([...]);

        return view('dashboard', compact('videos', 'stats'));
    }
}
```

---

Testing with Pest
-----------------

[](#testing-with-pest)

### Base Setup

[](#base-setup)

Create a base test file or add to `tests/Pest.php`:

```
use Viewtrender\Youtube\YoutubeAnalyticsApi;
use Viewtrender\Youtube\YoutubeDataApi;
use Viewtrender\Youtube\YoutubeReportingApi;

afterEach(function () {
    YoutubeDataApi::reset();
    YoutubeAnalyticsApi::reset();
    YoutubeReportingApi::reset();
});
```

### Import Factories

[](#import-factories)

```
use Viewtrender\Youtube\Factories\YoutubeVideo;
use Viewtrender\Youtube\Factories\YoutubeChannel;
use Viewtrender\Youtube\Factories\YoutubePlaylist;
use Viewtrender\Youtube\Factories\YoutubePlaylistItems;
use Viewtrender\Youtube\Factories\YoutubeSearchResult;
use Viewtrender\Youtube\Factories\YoutubeSubscriptions;
use Viewtrender\Youtube\Factories\YoutubeComments;
use Viewtrender\Youtube\Factories\YoutubeCommentThreads;
use Viewtrender\Youtube\Factories\YoutubeActivities;
use Viewtrender\Youtube\Factories\YoutubeCaptions;
use Viewtrender\Youtube\Factories\YoutubeChannelSections;
use Viewtrender\Youtube\Factories\YoutubeMembers;
use Viewtrender\Youtube\Factories\YoutubeMembershipsLevels;
use Viewtrender\Youtube\Factories\YoutubeI18nLanguages;
use Viewtrender\Youtube\Factories\YoutubeI18nRegions;
use Viewtrender\Youtube\Factories\YoutubeVideoCategories;
use Viewtrender\Youtube\Factories\YoutubeVideoAbuseReportReasons;
use Viewtrender\Youtube\Factories\YoutubeGuideCategories;
use Viewtrender\Youtube\Factories\YoutubeThumbnails;
use Viewtrender\Youtube\Factories\YoutubeWatermarks;
use Viewtrender\Youtube\YoutubeDataApi;
```

---

Factory Examples — Pest
-----------------------

[](#factory-examples--pest)

### Videos

[](#videos)

```
it('fetches video details', function () {
    YoutubeDataApi::fake([
        YoutubeVideo::listWithVideos([
            [
                'id' => 'dQw4w9WgXcQ',
                'snippet' => [
                    'title' => 'Never Gonna Give You Up',
                    'description' => 'Official music video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'publishedAt' => '2009-10-25T06:57:33Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'],
                        'high' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'],
                    ],
                ],
                'statistics' => [
                    'viewCount' => '1500000000',
                    'likeCount' => '15000000',
                    'commentCount' => '3000000',
                ],
                'contentDetails' => [
                    'duration' => 'PT3M33S',
                    'dimension' => '2d',
                    'definition' => 'hd',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ');

    $response->assertOk()
        ->assertJsonPath('title', 'Never Gonna Give You Up')
        ->assertJsonPath('statistics.viewCount', '1500000000');

    YoutubeDataApi::assertListedVideos();
    YoutubeDataApi::assertSentCount(1);
});

it('handles video not found', function () {
    YoutubeDataApi::fake([
        YoutubeVideo::empty(),
    ]);

    $response = $this->getJson('/api/videos/nonexistent');

    $response->assertNotFound();
});
```

### Channels

[](#channels)

```
it('fetches channel details', function () {
    YoutubeDataApi::fake([
        YoutubeChannel::listWithChannels([
            [
                'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                'snippet' => [
                    'title' => 'Rick Astley',
                    'description' => 'Official channel',
                    'customUrl' => '@RickAstleyYT',
                    'publishedAt' => '2006-09-19T01:03:26Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://yt3.ggpht.com/example/default.jpg'],
                    ],
                    'country' => 'GB',
                ],
                'statistics' => [
                    'viewCount' => '2000000000',
                    'subscriberCount' => '4500000',
                    'videoCount' => '150',
                ],
                'brandingSettings' => [
                    'channel' => [
                        'title' => 'Rick Astley',
                        'keywords' => 'music pop 80s',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw');

    $response->assertOk()
        ->assertJsonPath('snippet.title', 'Rick Astley')
        ->assertJsonPath('statistics.subscriberCount', '4500000');

    YoutubeDataApi::assertListedChannels();
});
```

### Playlists

[](#playlists)

```
it('fetches user playlists', function () {
    YoutubeDataApi::fake([
        YoutubePlaylist::listWithPlaylists([
            [
                'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
                'snippet' => [
                    'title' => 'My Favorite Videos',
                    'description' => 'A collection of favorites',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'publishedAt' => '2020-01-15T12:00:00Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/example/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'itemCount' => 25,
                ],
                'status' => [
                    'privacyStatus' => 'public',
                ],
            ],
            [
                'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efde',
                'snippet' => [
                    'title' => 'Watch Later',
                    'description' => 'Videos to watch',
                ],
                'contentDetails' => [
                    'itemCount' => 100,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/playlists');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.title', 'My Favorite Videos');

    YoutubeDataApi::assertListedPlaylists();
});
```

### Playlist Items

[](#playlist-items)

```
it('fetches videos in a playlist', function () {
    YoutubeDataApi::fake([
        YoutubePlaylistItems::listWithPlaylistItems([
            [
                'id' => 'UExmWEZ...',
                'snippet' => [
                    'title' => 'First Video',
                    'description' => 'Description of first video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'playlistId' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
                    'position' => 0,
                    'resourceId' => [
                        'kind' => 'youtube#video',
                        'videoId' => 'dQw4w9WgXcQ',
                    ],
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'videoPublishedAt' => '2009-10-25T06:57:33Z',
                ],
            ],
            [
                'snippet' => [
                    'title' => 'Second Video',
                    'position' => 1,
                    'resourceId' => [
                        'kind' => 'youtube#video',
                        'videoId' => 'abc123xyz',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/playlists/PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf/items');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.position', 0);
});
```

### Search Results

[](#search-results)

```
it('searches for videos', function () {
    YoutubeDataApi::fake([
        YoutubeSearchResult::listWithResults([
            [
                'id' => [
                    'kind' => 'youtube#video',
                    'videoId' => 'dQw4w9WgXcQ',
                ],
                'snippet' => [
                    'title' => 'Never Gonna Give You Up',
                    'description' => 'Official music video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'publishedAt' => '2009-10-25T06:57:33Z',
                    'liveBroadcastContent' => 'none',
                ],
            ],
            [
                'id' => [
                    'kind' => 'youtube#channel',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                ],
                'snippet' => [
                    'title' => 'Rick Astley',
                    'description' => 'Official channel',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/search?q=rick+astley');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.id.videoId', 'dQw4w9WgXcQ');

    YoutubeDataApi::assertSearched();
});
```

### Subscriptions

[](#subscriptions)

```
it('fetches channel subscriptions', function () {
    YoutubeDataApi::fake([
        YoutubeSubscriptions::listWithSubscriptions([
            [
                'id' => 'subscription123',
                'snippet' => [
                    'title' => 'PewDiePie',
                    'description' => 'Gaming and entertainment',
                    'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw',
                    'resourceId' => [
                        'kind' => 'youtube#channel',
                        'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw',
                    ],
                    'thumbnails' => [
                        'default' => ['url' => 'https://yt3.ggpht.com/pewdiepie/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'totalItemCount' => 4500,
                    'newItemCount' => 3,
                ],
            ],
            [
                'snippet' => [
                    'title' => 'MrBeast',
                    'resourceId' => [
                        'kind' => 'youtube#channel',
                        'channelId' => 'UCX6OQ3DkcsbYNE6H8uQQuVA',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/subscriptions');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.title', 'PewDiePie');
});
```

### Comments

[](#comments)

```
it('fetches video comments', function () {
    YoutubeDataApi::fake([
        YoutubeComments::listWithComments([
            [
                'id' => 'comment123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'textDisplay' => 'This song is a masterpiece!',
                    'textOriginal' => 'This song is a masterpiece!',
                    'authorDisplayName' => 'MusicFan123',
                    'authorChannelId' => ['value' => 'UCxxx'],
                    'authorProfileImageUrl' => 'https://yt3.ggpht.com/user/default.jpg',
                    'likeCount' => 1500,
                    'publishedAt' => '2023-01-15T10:30:00Z',
                    'updatedAt' => '2023-01-15T10:30:00Z',
                ],
            ],
            [
                'snippet' => [
                    'textDisplay' => 'Classic!',
                    'authorDisplayName' => 'RetroLover',
                    'likeCount' => 500,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comments');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.textDisplay', 'This song is a masterpiece!');
});
```

### Comment Threads

[](#comment-threads)

```
it('fetches comment threads with replies', function () {
    YoutubeDataApi::fake([
        YoutubeCommentThreads::listWithCommentThreads([
            [
                'id' => 'thread123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'topLevelComment' => [
                        'id' => 'comment123',
                        'snippet' => [
                            'textDisplay' => 'Best song ever!',
                            'authorDisplayName' => 'TopCommenter',
                            'likeCount' => 5000,
                        ],
                    ],
                    'canReply' => true,
                    'totalReplyCount' => 50,
                    'isPublic' => true,
                ],
                'replies' => [
                    'comments' => [
                        [
                            'snippet' => [
                                'textDisplay' => 'I agree!',
                                'authorDisplayName' => 'Replier1',
                            ],
                        ],
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comment-threads');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.totalReplyCount', 50);
});
```

### Activities

[](#activities)

```
it('fetches channel activity feed', function () {
    YoutubeDataApi::fake([
        YoutubeActivities::listWithActivities([
            [
                'id' => 'activity123',
                'snippet' => [
                    'title' => 'Uploaded: New Music Video',
                    'description' => 'Check out my new video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'type' => 'upload',
                    'publishedAt' => '2024-01-15T12:00:00Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/newvideo/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'upload' => [
                        'videoId' => 'newvideo123',
                    ],
                ],
            ],
            [
                'snippet' => [
                    'title' => 'Liked: Amazing Cover',
                    'type' => 'like',
                ],
                'contentDetails' => [
                    'like' => [
                        'resourceId' => [
                            'kind' => 'youtube#video',
                            'videoId' => 'cover123',
                        ],
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.type', 'upload');
});
```

### Captions

[](#captions)

```
it('fetches video captions', function () {
    YoutubeDataApi::fake([
        YoutubeCaptions::listWithCaptions([
            [
                'id' => 'caption123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'language' => 'en',
                    'name' => 'English',
                    'audioTrackType' => 'primary',
                    'trackKind' => 'standard',
                    'isDraft' => false,
                    'isAutoSynced' => false,
                    'isCC' => false,
                    'status' => 'serving',
                ],
            ],
            [
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'language' => 'es',
                    'name' => 'Spanish',
                    'trackKind' => 'standard',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/captions');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.language', 'en');
});
```

### Channel Sections

[](#channel-sections)

```
it('fetches channel sections', function () {
    YoutubeDataApi::fake([
        YoutubeChannelSections::listWithChannelSections([
            [
                'id' => 'section123',
                'snippet' => [
                    'type' => 'singlePlaylist',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'title' => 'Popular Uploads',
                    'position' => 0,
                ],
                'contentDetails' => [
                    'playlists' => ['PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'],
                ],
            ],
            [
                'snippet' => [
                    'type' => 'recentActivity',
                    'title' => 'Recent Activity',
                    'position' => 1,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/sections');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.type', 'singlePlaylist');
});
```

### Members (OAuth Required)

[](#members-oauth-required)

```
it('fetches channel members', function () {
    YoutubeDataApi::fake([
        YoutubeMembers::listWithMembers([
            [
                'id' => 'member123',
                'snippet' => [
                    'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'memberDetails' => [
                        'channelId' => 'UCfan123',
                        'channelUrl' => 'https://youtube.com/channel/UCfan123',
                        'displayName' => 'SuperFan',
                        'profileImageUrl' => 'https://yt3.ggpht.com/fan/default.jpg',
                    ],
                    'membershipsDetails' => [
                        'highestAccessibleLevel' => 'level1',
                        'highestAccessibleLevelDisplayName' => 'Bronze Member',
                        'memberSince' => '2023-06-01T00:00:00Z',
                        'memberTotalDurationMonths' => 8,
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channel/members');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.memberDetails.displayName', 'SuperFan');
});
```

### Membership Levels (OAuth Required)

[](#membership-levels-oauth-required)

```
it('fetches membership levels', function () {
    YoutubeDataApi::fake([
        YoutubeMembershipsLevels::listWithLevels([
            [
                'id' => 'level1',
                'snippet' => [
                    'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'levelDetails' => [
                        'displayName' => 'Bronze Member',
                    ],
                ],
            ],
            [
                'id' => 'level2',
                'snippet' => [
                    'levelDetails' => [
                        'displayName' => 'Silver Member',
                    ],
                ],
            ],
            [
                'id' => 'level3',
                'snippet' => [
                    'levelDetails' => [
                        'displayName' => 'Gold Member',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channel/membership-levels');

    $response->assertOk()
        ->assertJsonCount(3, 'items');
});
```

### I18n Languages

[](#i18n-languages)

```
it('fetches supported languages', function () {
    YoutubeDataApi::fake([
        YoutubeI18nLanguages::listWithLanguages([
            [
                'id' => 'en',
                'snippet' => [
                    'hl' => 'en',
                    'name' => 'English',
                ],
            ],
            [
                'id' => 'es',
                'snippet' => [
                    'hl' => 'es',
                    'name' => 'Spanish',
                ],
            ],
            [
                'id' => 'ja',
                'snippet' => [
                    'hl' => 'ja',
                    'name' => 'Japanese',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/languages');

    $response->assertOk()
        ->assertJsonCount(3, 'items')
        ->assertJsonPath('items.0.snippet.name', 'English');
});
```

### I18n Regions

[](#i18n-regions)

```
it('fetches supported regions', function () {
    YoutubeDataApi::fake([
        YoutubeI18nRegions::listWithRegions([
            [
                'id' => 'US',
                'snippet' => [
                    'gl' => 'US',
                    'name' => 'United States',
                ],
            ],
            [
                'id' => 'GB',
                'snippet' => [
                    'gl' => 'GB',
                    'name' => 'United Kingdom',
                ],
            ],
            [
                'id' => 'JP',
                'snippet' => [
                    'gl' => 'JP',
                    'name' => 'Japan',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/regions');

    $response->assertOk()
        ->assertJsonCount(3, 'items')
        ->assertJsonPath('items.0.snippet.name', 'United States');
});
```

### Video Categories

[](#video-categories)

```
it('fetches video categories', function () {
    YoutubeDataApi::fake([
        YoutubeVideoCategories::listWithVideoCategories([
            [
                'id' => '10',
                'snippet' => [
                    'channelId' => 'UCBR8-60-B28hp2BmDPdntcQ',
                    'title' => 'Music',
                    'assignable' => true,
                ],
            ],
            [
                'id' => '20',
                'snippet' => [
                    'title' => 'Gaming',
                    'assignable' => true,
                ],
            ],
            [
                'id' => '22',
                'snippet' => [
                    'title' => 'People & Blogs',
                    'assignable' => true,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/video-categories?regionCode=US');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.title', 'Music');
});
```

### Video Abuse Report Reasons

[](#video-abuse-report-reasons)

```
it('fetches abuse report reasons', function () {
    YoutubeDataApi::fake([
        YoutubeVideoAbuseReportReasons::listWithReasons([
            [
                'id' => 'S',
                'snippet' => [
                    'label' => 'Spam or misleading',
                    'secondaryReasons' => [
                        ['id' => 'S.1', 'label' => 'Mass advertising'],
                        ['id' => 'S.2', 'label' => 'Misleading thumbnail'],
                    ],
                ],
            ],
            [
                'id' => 'V',
                'snippet' => [
                    'label' => 'Violent or repulsive content',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/abuse-report-reasons');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.label', 'Spam or misleading');
});
```

### Thumbnails (Write-Only)

[](#thumbnails-write-only)

```
it('uploads a video thumbnail', function () {
    YoutubeDataApi::fake([
        YoutubeThumbnails::setWithThumbnail([
            'default' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg',
                'width' => 120,
                'height' => 90,
            ],
            'medium' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
                'width' => 320,
                'height' => 180,
            ],
            'high' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
                'width' => 480,
                'height' => 360,
            ],
        ]),
    ]);

    $response = $this->postJson('/api/videos/dQw4w9WgXcQ/thumbnail', [
        'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg'),
    ]);

    $response->assertOk()
        ->assertJsonPath('items.0.default.url', 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg');
});
```

### Watermarks (Write-Only)

[](#watermarks-write-only)

```
it('sets channel watermark', function () {
    YoutubeDataApi::fake([
        YoutubeWatermarks::setWithWatermark([
            'timing' => [
                'type' => 'offsetFromStart',
                'offsetMs' => 15000,
                'durationMs' => 0,
            ],
            'position' => [
                'type' => 'corner',
                'cornerPosition' => 'topRight',
            ],
            'imageUrl' => 'https://example.com/watermark.png',
            'imageBytes' => 'base64data...',
        ]),
    ]);

    $response = $this->postJson('/api/channel/watermark', [
        'image' => UploadedFile::fake()->image('watermark.png'),
    ]);

    $response->assertOk();
});
```

---

Pagination
----------

[](#pagination)

Two factories support multi-page responses via the `HasPagination` trait: **`YoutubePlaylistItems`** and **`YoutubeActivities`**. These are the Data API endpoints that return `nextPageToken` for iterating through large result sets.

Both expose two static methods that return `array` — spread them into `fake([])` to queue all pages at once:

MethodPurpose`paginated(pages, perPage)`Auto-generate items with sensible defaults`pages(array)`Explicit control over each page's itemsEach factory also provides a named constructor for building individual items:

- `YoutubePlaylistItems::playlistItem(array $overrides = [])`
- `YoutubeActivities::activity(array $overrides = [])`

### `paginated()` — auto-generated items

[](#paginated--auto-generated-items)

Generate multiple pages of fake items with a single call. Each page includes a `nextPageToken` except the last, and `pageInfo` reflects the total count.

```
it('syncs all playlist items across multiple pages', function () {
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5),
    ]);

    // Your service will make 3 requests, each returning 5 items.
    // The first two responses include nextPageToken; the last does not.
    $response = $this->postJson('/api/playlists/PLrAXtmErZgOe/sync');

    $response->assertOk();
    YoutubeDataApi::assertSentCount(3);
});
```

#### Manual pagination loop

[](#manual-pagination-loop)

If your code paginates through results directly (e.g., in a job or service class), you can test the full loop:

```
it('collects all items across pages', function () {
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5),
    ]);

    $youtube = YoutubeDataApi::youtube();
    $pageToken = null;
    $allItems = [];

    do {
        $response = $youtube->playlistItems->listPlaylistItems(
            'snippet',
            ['playlistId' => 'PLxxx', 'pageToken' => $pageToken]
        );
        $allItems = array_merge($allItems, $response->getItems());
        $pageToken = $response->getNextPageToken();
    } while ($pageToken !== null);

    expect($allItems)->toHaveCount(15);
});
```

### `pages()` — explicit items per page

[](#pages--explicit-items-per-page)

For full control over each page's contents, pass an array of pages, where each page is an array of item overrides. Use the named constructors (`::playlistItem()`, `::activity()`) or pass raw override arrays.

```
it('handles paginated activity feed with specific items', function () {
    YoutubeDataApi::fake([
        ...YoutubeActivities::pages([
            // Page 1 — has nextPageToken
            [
                YoutubeActivities::activity([
                    'snippet' => ['title' => 'Uploaded: First Video', 'type' => 'upload'],
                    'contentDetails' => ['upload' => ['videoId' => 'vid1']],
                ]),
            ],
            // Page 2 — last page, no nextPageToken
            [
                YoutubeActivities::activity([
                    'snippet' => ['title' => 'Liked: Some Video', 'type' => 'like'],
                ]),
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities/all');

    $response->assertOk();
    YoutubeDataApi::assertSentCount(2);
});
```

You can also pass raw arrays to `pages()` — they'll be merged with fixture defaults:

```
YoutubeDataApi::fake([
    ...YoutubePlaylistItems::pages([
        // Page 1
        [
            ['snippet' => ['title' => 'First Video', 'resourceId' => ['videoId' => 'vid1']]],
            ['snippet' => ['title' => 'Second Video', 'resourceId' => ['videoId' => 'vid2']]],
        ],
        // Page 2
        [
            ['snippet' => ['title' => 'Third Video', 'resourceId' => ['videoId' => 'vid3']]],
        ],
    ]),
]);
```

### Single page (no `nextPageToken`)

[](#single-page-no-nextpagetoken)

Passing one sub-array to `pages()` produces a single response with no `nextPageToken` — useful when you want explicit item control without multi-page behavior:

```
YoutubeDataApi::fake([
    ...YoutubePlaylistItems::pages([
        [YoutubePlaylistItems::playlistItem(['snippet' => ['title' => 'Only Video']])],
    ]),
]);
```

### Testing pagination in Laravel jobs

[](#testing-pagination-in-laravel-jobs)

A common pattern is testing a Laravel job that syncs all items from a paginated endpoint:

```
it('syncs video library across paginated playlist items', function () {
    // Seed the channel
    $channel = Channel::factory()->create(['uploads_playlist_id' => 'UUxxx']);

    // Queue 2 pages of playlist items
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 2, perPage: 50),
    ]);

    // Dispatch the job
    SyncVideoLibraryJob::dispatchSync($channel);

    // Verify all 100 items were persisted
    expect($channel->videos()->count())->toBe(100);
    YoutubeDataApi::assertSentCount(2);
});
```

---

Testing with PHPUnit
--------------------

[](#testing-with-phpunit)

### Base Test Case

[](#base-test-case)

```
