bjthecod3r / laravel-spotify-api-wrapper
A Laravel wrapper for the Spotify Web API.
Package info
github.com/BJTheCod3r/laravel-spotify-api-wrapper
pkg:composer/bjthecod3r/laravel-spotify-api-wrapper
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
This package is auto-updated.
Last update: 2026-05-23 01:07:06 UTC
README
Laravel Spotify API Wrapper
A Laravel wrapper for the Spotify Web API. Search across tracks, albums, artists, playlists, shows, episodes, and audiobooks with a fluent facade and fully-typed responses.
Highlights
- Fluent search for every Spotify item type, with Spotify's full filter syntax (
artist:,year:,tag:new,isrc:, …) supported out of the box. - Typed responses. No more reaching into nested arrays — every response is hydrated into PHP objects with public typed properties (
$track->album->name,$album->releaseDateis aCarboninstance, etc.). - Pagination built in.
Paginatedexposesitems,total,limit,offset,next, andpreviousso you can page or drive a "Load more" button without parsing URLs. - Auth handled for you. Client-credentials tokens are fetched, cached for the duration Spotify reports, and transparently refreshed on a 401.
- Typed exceptions mapped from Spotify's status codes — catch
RateLimitExceptionto readretryAfter,AuthenticationExceptionfor credential issues, etc. - Drop-in JSON. Resources implement
Arrayable+JsonSerializable, soreturn $results;from a controller serializes correctly.
Requirements
- PHP
^8.2 - Laravel
^11.0,^12.0, or^13.0
Installation
composer require bjthecod3r/laravel-spotify-api-wrapper
Publish the config:
php artisan vendor:publish --tag=spotify-config
Add your Spotify app credentials to .env:
SPOTIFY_CLIENT_ID=your-client-id SPOTIFY_CLIENT_SECRET=your-client-secret # Optional defaults SPOTIFY_MARKET=US SPOTIFY_LOCALE=en_US SPOTIFY_CACHE_STORE=redis
Search
Single-type search
The most common case — search one type, get a typed Paginated back:
use BjTheCod3r\Spotify\Facades\Spotify; $tracks = Spotify::searchTracks('Doxy')->limit(20)->get(); $albums = Spotify::searchAlbums('Kind of Blue')->market('NG')->get(); $artists = Spotify::searchArtists('Miles Davis')->get(); $playlists = Spotify::searchPlaylists('focus')->get(); $shows = Spotify::searchShows('how i built this')->get(); $episodes = Spotify::searchEpisodes('startups')->includeExternalAudio()->get(); $audiobooks = Spotify::searchAudiobooks('atomic habits')->get(); foreach ($tracks->items as $track) { echo $track->name.' — '.$track->artists[0]->name.PHP_EOL; } $tracks->total; // int $tracks->next; // ?string — URL for the next page $tracks->previous; // ?string
Get a playlist
$playlist = Spotify::playlist('74oVZlOSwpy31tSplEWONa') ->market('GB') ->get(); $playlist->followers->total; $playlist->tracks->items[0]->track->name;
Search playlists hydrate as SimplifiedPlaylist summaries. Direct playlist lookups hydrate as
Playlist so followers and paginated tracks.items are only present on the
endpoint that returns them.
Get a single resource by ID
Direct lookups exist for every searchable resource, plus user profiles. They all
return a fully-typed resource (the same classes the search endpoints hydrate),
and accept ->market() where Spotify supports it.
$album = Spotify::album('4aawyAB9vmqN3uQ7FjRGTy')->market('US')->get(); $artist = Spotify::artist('0TnOYISbd1XYRBk9myaseg')->get(); $track = Spotify::track('11dFghVXANMlKmJXsNCbNl')->market('US')->get(); $show = Spotify::show('38bS44xjbVVZ3No3ByF1dJ')->market('US')->get(); $episode = Spotify::episode('512ojhOuo1ktJprKbVcKyQ')->market('US')->get(); $audiobook = Spotify::audiobook('7iHfbu1YPACw6oZPAFJtqe')->market('US')->get(); $user = Spotify::user('smedjan')->get();
Multi-type search
When you want several item types in one request:
use BjTheCod3r\Spotify\Enums\SearchType; $results = Spotify::search('remaster track:Doxy artist:Miles Davis', [ SearchType::Track, SearchType::Album, ]) ->market('ES') ->limit(10) ->get(); $results->tracks->items[0]->name; // Track::$name $results->tracks->items[0]->album->name; // nested Album $results->albums->total; // paging total $results->artists; // null — wasn't requested
Type strings work too if you'd rather skip the enum import:
Spotify::search('miles davis', ['track', 'album'])->get();
Field filters
Spotify supports inline filters in the query string. Just pass them through:
Spotify::searchTracks('artist:Burna Boy year:2022')->get(); Spotify::searchAlbums('tag:new')->get(); Spotify::searchTracks('isrc:USAT22003158')->get();
Pagination
$page = Spotify::searchTracks('miles')->limit(20)->offset(0)->get(); $page->items; // array<Track> $page->total; // 8462 $page->offset; // 0 $page->next; // 'https://api.spotify.com/v1/search?...&offset=20'
Typed resources
Every search response hydrates into objects under BjTheCod3r\Spotify\Resources\:
| Resource | Notable fields |
|---|---|
Track |
name, durationMs, explicit, popularity, previewUrl, album, artists |
Album |
name, albumType, totalTracks, releaseDate (Carbon), images, artists |
Artist |
name, genres, popularity, images, followers (Followers — href, total) |
SimplifiedPlaylist |
name, description, public, owner (User), tracks (TracksLink — href, total), items (PlaylistItemsLink), images |
Playlist |
name, description, public, followers, owner (User), tracks (TracksLink — href, total, items), images |
Show |
name, description, publisher, totalEpisodes, images |
Episode |
name, description, durationMs, releaseDate (Carbon), audioPreviewUrl |
Audiobook |
name, description, authors (Author[]), narrators (Narrator[]), publisher, totalChapters |
Image |
url, height, width |
Paginated<T> |
items, total, limit, offset, next, previous, href |
Date fields are real Illuminate\Support\Carbon instances. Spotify's date precision (year, month, day) is preserved on round-trip via releaseDatePrecision.
List fields (items, artists, images, genres, languages, authors, narrators, …) are Illuminate\Support\Collection instances, so you get the full Laravel Collection API:
$tracks->items ->filter(fn (Track $t) => $t->popularity > 50) ->sortByDesc('popularity') ->map(fn (Track $t) => $t->name); $artist->genres->contains('jazz'); $album->artists->pluck('name');
Resources implement Arrayable + JsonSerializable, so this works:
public function index() { return Spotify::searchTracks(request('q'))->get(); }
Laravel will serialize the Paginated<Track> to JSON automatically.
Error handling
| Status | Exception |
|---|---|
| 400 / 422 | BjTheCod3r\Spotify\Exceptions\ValidationException |
| 401 | BjTheCod3r\Spotify\Exceptions\AuthenticationException (after a transparent token refresh + retry) |
| 429 | BjTheCod3r\Spotify\Exceptions\RateLimitException — exposes retryAfter in seconds |
| Other 4xx/5xx | BjTheCod3r\Spotify\Exceptions\ApiException |
All inherit from BjTheCod3r\Spotify\Exceptions\SpotifyException, so you can catch broadly:
try { $tracks = Spotify::searchTracks($q)->get(); } catch (RateLimitException $e) { return response('Slow down', 429)->header('Retry-After', (string) $e->retryAfter); } catch (SpotifyException $e) { report($e); return back()->with('error', 'Spotify is having a moment. Try again.'); }
Authentication
The package uses Spotify's Client Credentials grant — no user login required, suitable for any endpoint that doesn't need user context (Search, Browse, Albums, Artists, Tracks). Tokens are cached using Laravel's cache for the duration Spotify reports in expires_in, minus a small safety buffer, so you only hit the auth endpoint when a token actually needs refreshing.
User authentication
For endpoints that act on a listener's account — their playlists, library, top items, listening history — connect their Spotify account via the Authorization Code + PKCE flow.
About playback. The Spotify Web API does not return playable audio URLs even for authenticated users. Full playback is gated to Spotify's Web Playback SDK (browser, Premium) and the mobile SDKs. The user-auth surface here is for reading user data and (in a future release) controlling an active device, not for direct streaming.
Setup
Publish the migration that stores per-user tokens, then run it:
php artisan vendor:publish --tag=spotify-migrations php artisan migrate
Tokens are encrypted at rest using Laravel's app key.
Set the OAuth redirect URI on your .env (and register the same value on the Spotify dashboard for your app):
SPOTIFY_REDIRECT_URI=https://your-app.test/spotify/callback
The package registers three opt-in routes under the spotify prefix (configurable):
| Method | URI | Name |
|---|---|---|
| GET | /spotify/connect |
spotify.connect |
| GET | /spotify/callback |
spotify.callback |
| POST | /spotify/disconnect |
spotify.disconnect |
Set spotify.oauth.routes.enabled to false (or env SPOTIFY_OAUTH_ROUTES_ENABLED=false) to disable them and wire your own controllers using the Spotify::redirect() / Spotify::handleCallback() helpers.
Connecting a user
Have the authenticated user hit the connect route — by default it requires the web + auth middleware:
<a href="{{ route('spotify.connect') }}">Connect Spotify</a>
Pass extra scopes via ?scopes=playlist-modify-public,user-modify-playback-state to merge with the configured defaults.
After consent, Spotify redirects to /spotify/callback. The controller exchanges the code, captures the listener's Spotify user id, persists encrypted tokens, dispatches SpotifyConnected, and redirects to oauth.after_connect.
If anything fails (state mismatch, user denied consent on Spotify, exchange error, …), the callback still redirects to oauth.after_connect but flashes a spotify.oauth.error payload onto the session so the destination can render error UX:
@if ($error = session('spotify.oauth.error')) <div class="alert"> Spotify connect failed: {{ $error['reason'] }} @if ($error['description']) ({{ $error['description'] }}) @endif </div> @endif
The reason is one of state_mismatch, user_denied, authorize_error, or exchange_failed; description carries the underlying Spotify error code or exception message.
Reading user data
use BjTheCod3r\Spotify\Facades\Spotify; // Implicit: resolves the current user via the configured guard. $profile = Spotify::me()->profile()->get(); $playlists = Spotify::me()->playlists()->limit(50)->get(); $savedTracks = Spotify::me()->savedTracks()->market('US')->limit(50)->get(); $savedAlbums = Spotify::me()->savedAlbums()->get(); $topTracks = Spotify::me()->topTracks()->timeRange('short_term')->get(); $topArtists = Spotify::me()->topArtists()->get(); $recent = Spotify::me()->recentlyPlayed()->limit(50)->get(); $following = Spotify::me()->followedArtists()->limit(50)->get(); // Explicit: act as a specific user id (queue workers, jobs). Spotify::asUser($userId)->me()->playlists()->get();
All me() endpoints that return collections come back as the same Paginated resource the rest of the package uses. The me/tracks, me/albums, me/shows, me/episodes, and me/player/recently-played envelopes are unwrapped — the items collection contains the inner Track / Album / etc. directly. The added_at / played_at timestamps from those envelopes are not exposed in v0.3.
Token refresh & 401 handling
Access tokens are refreshed transparently when stale. A 401 from any API call forces an out-of-band refresh and retries the original request once, so a token revoked between issuance and use is recovered automatically.
If Spotify rejects the refresh token (invalid_grant) — typically because the user revoked access from their Spotify settings — the stored row is deleted and SpotifyDisconnected is fired with reason = invalid_grant, so your app can prompt the user to reconnect.
Concurrent refreshes are serialised per-user via Cache::lock, so a fan-out of queue workers doesn't double-spend a rotating refresh token.
Events
Listen for any of these to integrate with your app:
| Event | When |
|---|---|
SpotifyConnected |
After a successful callback exchange. |
SpotifyTokenRefreshed |
After any successful refresh-token grant. |
SpotifyDisconnected |
On explicit disconnect() or invalid_grant from refresh. |
SpotifyConnectFailed |
State mismatch, user denied consent, authorize error, exchange failure. |
Disconnecting
Spotify::disconnect(); // current user via guard Spotify::disconnect($userId); // explicit
Or POST to route('spotify.disconnect') from a form. The default route stack includes web middleware, so the form must carry a CSRF token:
<form method="POST" action="{{ route('spotify.disconnect') }}"> @csrf <button type="submit">Disconnect Spotify</button> </form>
Custom token storage
The default Eloquent-backed repository covers most apps. To swap implementations (Redis, encrypted file, another DB connection), implement BjTheCod3r\Spotify\Contracts\UserTokenRepository and point at it:
// config/spotify.php 'oauth' => [ 'token_repository' => App\Spotify\RedisUserTokenRepository::class, ],
Testing
The package ships with Pest + Orchestra Testbench:
composer install
composer test
In your own application's tests, fake the HTTP layer with Laravel's standard helpers:
Http::fake([ 'accounts.spotify.com/*' => Http::response(['access_token' => 'x', 'token_type' => 'Bearer', 'expires_in' => 3600]), 'api.spotify.com/v1/search*' => Http::response(['tracks' => ['items' => []]]), ]);
Roadmap
- Search
- Albums, Artists, Tracks (Get-by-ID)
- Episodes, Shows, Audiobooks (Get-by-ID)
- Playlists (Get-by-ID, read-only)
- Users — Authorization Code + PKCE,
me/*reads - Tracks (audio features / analysis)
- Browse (categories, new releases, featured playlists)
- Markets, Genres
- Player — playback control on the user's active device
- Playlist mutations (create / reorder / add-remove items)
License
MIT.