romanzipp/laravel-twitch
Laravel Twitch is a clean wrapper around the Twitch Helix API for Laravel apps. It handles OAuth token management and provides fluent methods for users, streams, games, clips, and more, letting you integrate Twitch data without writing boilerplate HTTP code.
Install and publish
composer require romanzipp/laravel-twitch
php artisan vendor:publish --provider="romanzipp\Twitch\Providers\TwitchServiceProvider"
php artisan migrate
Publishing gives you config/twitch.php (client ID, secret, redirect URI, scopes) and—depending on version—a twitch_access_tokens migration used to persist user OAuth tokens. Note that v4 removed the cache driver config key (see Gotchas), so don't add cache_driver back to the config.
Configure
.env and reference them only through config('twitch.*') so config:cache works.Read the source first
Spend ten minutes in src/Twitch.php, src/Api.php, and src/Result.php before writing anything. The wrapper is a thin, method-per-endpoint layer over Helix (getUsers, getStreams, getGames, getClips, getVideos, getChannelInformation, updateChannel, createClip, …). The exact method list is version-specific, so confirm against the Api class rather than the README.
Know the v4 Result contract
v4 made every Result property private and replaced the old fluent accessors with getters. This is the biggest day-to-day change:
| v3 | v4 |
|---|---|
$result->status() |
$result->getStatus() |
$result->data() |
$result->getData() |
$result->error() |
$result->getErrorMessage() |
$result->rateLimit() |
$result->getRateLimit() |
$result->success (bool) |
(removed) — check getStatus() === 200 |
$result->pagination |
$result->getPagination() |
| — | $result->getPaginator() (cursor helper object) |
| — | $result->getException() |
| — | $result->getTotal() |
Treat getStatus() + getData() as the contract, and use getPaginator() / getPagination() for cursors.
First use case
The fastest win is a public, token-optional read—resolve a login to a user, then check whether that user is live:
use romanzipp\Twitch\Twitch;
$twitch = new Twitch;
$twitch->withToken($appAccessToken); // client-credentials token
$user = $twitch->getUsers(['login' => 'shroud']);
if ($user->getStatus() === 200 && ! empty($user->getData())) {
$id = $user->getData()[0]->id;
$streams = $twitch->getStreams(['user_id' => [$id]]);
$live = ! empty($streams->getData());
}
Bind one client, don't new Twitch everywhere. Register it as a singleton and inject it, or wrap it in your own TwitchService so controllers and jobs never touch the vendor class directly. This is also your seam for tests and for adding endpoints the package lacks.
Two token types, one code path. Helix accepts either an app access token (client credentials, no user context, great for public reads) or a user access token (required for anything scoped—chat, follows, channel edits). Fetch and cache the app token (it's valid ~60 days), and store user tokens per user in twitch_access_tokens or your own table with user_id, access_token, refresh_token, expires_at. Refresh proactively before expiry rather than reacting to a 401.
OAuth flow. Generate the authorize URL yourself (or via the package helper if present):
https://id.twitch.tv/oauth2/authorize
?client_id=...&redirect_uri=...&response_type=code&scope=user:read:email&state=...
Use a signed state value, exchange the code for a token (POST to https://id.twitch.tv/oauth2/token, or the package's token method), then persist. On callback always verify the returned state against the session before exchanging. In v4, missing-credential setups throw a single RequestRequiresAuthenticationException instead of the old per-field exceptions—catch that one type around client construction and token minting.
Wrap reads in cache + a queued job. Streamer liveness is polled constantly; cache per-user lookups (user:login:{login} → id) for hours and stream status for 30–60 seconds. Since v4 dropped the built-in cache driver config, you now own this caching layer explicitly—put it in your wrapper, not the package config.
Normalize the Result in one place. Given the v3→v4 accessor rename, funnel vendor results through your own DTO/value object (e.g. TwitchResponse::fromResult()) so a future bump doesn't ripple through every call site. Your wrapper is the only place that should read getStatus()/getData()/getPaginator().
Paginate deliberately. Use the paginator helpers for bounded loops, not while (true):
$result = $twitch->getVideos(['user_id' => [$id], 'first' => 100]);
do {
foreach ($result->getData() as $video) { /* ... */ }
} while ($result->getStatus() === 200
&& ($cursor = $result->getPaginator())
&& $cursor->next());
Confirm the exact paginator method against romanzipp\Twitch\Objects\Paginator in v4 (it moved namespace). Cap iterations and stop on a non-200 so a bad cursor doesn't spin.
Respect the rate limit centrally. Helix is a token bucket (roughly 800 points/minute per client ID). Wrap calls in a small retry-with-backoff that treats 429 as "sleep and retry" and reads Ratelimit-Remaining / Ratelimit-Reset when available (exposed via getRateLimit()). Laravel's RateLimiter or a Redis token bucket works fine.
EventSub / webhooks. If you're reacting to stream online/offline, subscribe via EventSub and verify the Twitch-Eventsub-Message-Signature HMAC over the raw body before dispatching to a job. Return 200 fast; do the API work asynchronously.
Testing. Don't hit Twitch in CI. Either bind a fake Twitch implementation in the container, or if the package allows Guzzle handler injection (it now requires guzzle/http ≥6.3), use MockHandler with recorded Helix payloads. Assert on getStatus() and the data shape your code expects.
->status(), ->data(), ->error(), ->rateLimit(), or $result->success is now broken. Grep your codebase for those accessors as the first upgrade step and replace with getStatus() / getData() / getErrorMessage() / getRateLimit(). $success is gone—use getStatus() === 200.romanzipp\Twitch\Helpers\Paginator → romanzipp\Twitch\Objects\Paginator. Also surfaced as Result::getPaginator(). Any type-hint or instanceof check against the old namespace must be updated.RequestRequiresClientIdException, RequestRequiresClientSecretException, and RequestRequiresRedirectUriException are gone; catch RequestRequiresAuthenticationException instead. Audit any catch blocks for the removed classes.composer why-not guzzlehttp/guzzle 7 if the update fights you.config('twitch.cache_driver') to exist or be honored. Do caching in your own service layer.client_id must match the token's app. A token minted by a different application produces 401 even though the token hasn't expired. Send the Client-Id header alongside every request (the package does this from config—don't override it manually).401 vs 403 mean different things. 401 = expired/invalid token, refresh it. 403 = valid token, missing OAuth scope. Log the endpoint + required scope, not just the status.getStatus() === 200 with an empty getData() array is a normal "not found / not live" result, not a failure. Always guard getData()[0] access.getPaginator()/getPagination() cursors are endpoint-specific; don't persist them across requests as pagination state—page within a single job.game, language, thumbnail_url, stream.started_at can be missing or null. Don't assume structure; the API regularly changes optional fields.started_at, created_at, expires_at yourself; casting in the model is your responsibility.config:cache traps. Any direct env('TWITCH_CLIENT_ID') call outside config/twitch.php will return null in production. Everything through config.http vs https and www vs apex.Twitch/Api class, add a method that hits the path you need, and bind your subclass in the container—don't fork the package. Because Result internals are now private, build results through the public constructor/factory rather than poking at properties.How can I help you explore Laravel packages today?