phanan/koel
Koel is a personal music streaming server built with Laravel. It scans your music library and lets you stream it from anywhere through a clean web interface, with playlists, favorites, multi-user support, and rich media metadata.
First, calibrate the expectation: Koel is not a library you composer require into an existing Laravel app. It is a Laravel application (Laravel 12-era, Vue 3 + TypeScript frontend, PHPUnit). Its Dependents: 0 is the tell: there is no stable PHP API surface intended for embedding. "Leveraging" it day-to-day means one of three things:
Minimal self-host path:
git clone https://github.com/koel/koel.git && cd koel
composer install
cp .env.example .env
# set DB_*, APP_URL, CACHE_STORE, and your music directory
php artisan koel:init # key, migrations, admin user, media path prompt
php artisan koel:sync # index the library
php artisan serve # or the published Docker image
Then open Settings once — as of v9.15.0 it is split into tabs, with a Services tab for install-wide integrations (branding, AI assistant) and per-user profile integrations kept separate. That screen is now a first-class configuration surface, not just a profile page.
Where to look first, in this order:
config/koel.php — the authoritative list of every file-level tunable. Treat it as the docs for env keys; don't guess KOEL_* names from blog posts.config/koel.php. Find the settings migration/table before assuming a knob is env-only.app/Console/Commands/ — koel:init, koel:sync, koel:prune, koel:doctor are the real operational interface. Run --help on each rather than trusting third-party writeups.routes/api.php and the corresponding controllers/resources — this is your integration surface if you're building a client.docs.koel.dev for the API/auth and Subsonic compatibility notes.First use case to get working end-to-end: put one album in the media path, koel:sync, confirm it appears in the UI with correct cover art, then point it at the full library. Scanning 50k tracks before you've verified permissions and tag quality on 10 tracks is how people lose an afternoon.
Two config layers now, not one. File/env config goes through config/koel.php and config('koel.…') — never env() directly in your code. But install-wide settings (branding, AI assistant, service integrations) live in the database and are edited from Settings → Services. If a value "won't take," check which layer owns it before editing .env and restarting. If you need a new file-level knob, add it to the config file in your fork and document it in your deployment notes — otherwise the next git pull silently drops it.
Library sync as a scheduled, queued workflow, not an interactive command. For anything beyond a few thousand files:
// routes/console.php
Schedule::command('koel:sync')->hourly()->withoutOverlapping();
Schedule::command('koel:prune')->daily();
Point QUEUE_CONNECTION at Redis/database and run a supervised worker — scanning is the slowest thing Koel does, and doing it synchronously on a web request is a timeout generator. After every deploy, php artisan queue:restart so workers pick up new code.
API integration pattern. Authenticate once, cache the token, wrap it in a dedicated client rather than sprinkling Http:: calls through controllers:
final class KoelClient
{
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
) {}
public function songs(int $page = 1): array
{
return Http::baseUrl($this->baseUrl)
->withToken($this->token)
->acceptJson()
->get('/api/songs', ['page' => $page])
->throw()
->json();
}
}
Bind it in a service provider from config, and handle 401 by re-authenticating rather than failing the job. Paginate — never assume /api/songs returns the whole library.
Uploads are presigned-first; treat size as a contract. The upload flow issues presigned targets and validates the completed object against the declared file size (tightened in v9.15.0, with completion serialised behind a cache lock). If you script uploads from outside the UI, declare the size up front and expect a rejection rather than a truncation when they disagree. Multi-file uploads now show aggregated progress sorted by state, and multi-song downloads are zipped client-side — worth knowing before you assume a server-side ZIP endpoint exists.
Ownership matters on shared instances. v9.15.0 enforces that only a song's owner can edit it. If your team shares one library, decide up front whether songs are owned by a service account, and grant edit rights by role rather than by sharing credentials — otherwise "can't edit this track" becomes a permissions bug report.
Subsonic endpoint as a free client ecosystem. If the goal is "play my music on my phone," don't write a client: Koel exposes a Subsonic-compatible surface, so existing Subsonic apps work against it. Use this before building anything custom.
Fork discipline. There is no plugin API. Customization means patching the app. Keep your diff minimal and upstream-shaped: land changes in the same directories and conventions as surrounding code (Form Requests for validation, Eloquent resources for API output, ShouldQueue jobs for slow work), so rebasing onto the next release is mechanical. Anything you can do via config, a DB-backed setting, a service provider, or a scheduled command, do that instead of editing a controller.
Testing your patches. Koel is PHPUnit, not Pest. Follow the project's own instruction set: php artisan make:test --phpunit Name, use existing factories and their states, run php artisan test --compact tests/Feature/WhateverTest.php. If you're changing sync behavior, test scan → DB state, not the command's stdout.
Deployment shape. Docker image + reverse proxy + bind-mounted media volume (or object storage, if you're on the presigned-upload path). Post-deploy sequence: php artisan migrate --force, php artisan koel:doctor, php artisan queue:restart, php artisan optimize. koel:doctor is underused — run it before users report broken streaming.
composer require phanan/koel is the wrong move. You'll get an app skeleton's dependencies, not reusable routes or services. If you need music-library functionality inside your own Laravel app, reuse the concepts (Eloquent models for artists/albums/songs, a sync command, an API resource layer) rather than the package.CACHE_STORE=array will not work, and a misconfigured store surfaces as uploads that hang or silently never finish. Prefer redis, database, file, or dynamodb.koel:sync will happily report success while indexing nothing. Verify with a sudo -u www-data ls on the mount before debugging anything else.koel:sync --help for the current spelling — flags have moved between releases).koel:prune. Manual DELETEs leave orphaned pivots and stale cover art.Range headers. Symptom: tracks "play" but seeking fails or playback stalls at a few seconds. Fix at the proxy, not in Koel.APP_URL. Wrong scheme/host behind a proxy produces mixed-content cover art and broken frontend assets — and now also wrong branding/AI-service callbacks. Set the trusted-proxy/APP_URL config properly rather than patching URLs in Blade. Plain URLs (not just hash URLs) are supported as of v9.15.0; keep absolute URLs consistent with APP_URL.pnpm install && pnpm run build) — and if a UI change "didn't apply," that's almost always a missing build, not a caching bug. Container/release images ship prebuilt assets.koel:doctor. Pin to a known-good tag for production; don't track master on a library you care about. v9.15.0 is worth taking promptly for the presigned-upload size check alone.memory_limit for the CLI SAPI and chunk the work across queued jobs. Watch storage/logs/laravel.log during the first full scan — it's more informative than the terminal output.koel:doctor → storage/logs/laravel.log → browser console/browser logs for the Vue side → confirm APP_URL, cache driver, and media permissions. Most "Koel is broken" reports resolve at step one or four.bootstrap/providers.php, and Eloquent model events. Anything deeper is a fork — budget for it.How can I help you explore Laravel packages today?