hans-peter-ording/sleeper-api-symfony-bundle
A Symfony bundle that wraps the Sleeper API, giving your app ready-to-use services and configuration for Sleeper fantasy football data. Register the bundle, set your options, and inject the client to fetch leagues, rosters, users, and player information.
Read this first: the package is a Symfony bundle, not a Laravel package. It ships a Bundle subclass and a DependencyInjection/Extension, wires services into a Symfony container, and almost certainly has an optional Doctrine ORM dependency for the "import" half of its job. composer require hans-peter-ording/sleeper-api-symfony-bundle inside a Laravel app will install, but nothing will auto-register — there is no Illuminate\Support\ServiceProvider. Plan on one of two paths:
requires the bundle's Resources/config/services.php/.yaml manually or, more realistically, instantiates the bundle's client/importer class yourself and binds it into the container.src/ in vendor/hans-peter-ording/sleeper-api-symfony-bundle and copy the HTTP client + DTO/entity mapping into app/Services/Sleeper/. Given 0 stars and 0 dependents, this is the lower-risk option for most Laravel teams.Where to look first, in this order:
composer.json — confirms the Symfony version constraint and whether doctrine/orm is required or suggested. This tells you if you're borrowing a client or a whole persistence layer.src/DependencyInjection/Configuration.php — the authoritative list of config keys (base URI, API token placeholder, entity manager, cache, timeout). This is the file that documents the package better than its README.src/ service classes — look for anything named *Client, *Importer, *Api, *Fetcher. That is the actual surface area you'd use day to day.First use case, and the one that justifies the whole exercise: resolve a Sleeper username to their leagues, then pull rosters for one league.
// Bootstrap without the Symfony container
$client = new \HansPeterOrding\SleeperApiSymfonyBundle\...\Client(
baseUri: 'https://api.sleeper.app',
timeout: 10,
);
$user = $client->user('some_username'); // ->user_id, ->display_name
$leagues = $client->leagues($user->user_id, 'nfl', '2025');
$rosters = $client->rosters($leagues[0]->league_id);
If the method names don't match, the underlying calls are the plain Sleeper REST endpoints (/v1/user/{username}, /v1/user/{user_id}/leagues/nfl/{season}, /v1/league/{league_id}/rosters) and you can call them with Http::get() in five minutes. That is the honest baseline against which to judge whether the bundle earns its place.
Also verify the last-release date in composer.json/tags before committing — the metadata claims a 2026 release, which is worth confirming against Packagist rather than trusting the listing.
Bind it behind your own interface, not the bundle's concrete class. Whether you bridge or vendor, never let HansPeterOrding\...\Client leak into controllers and jobs. One SleeperClientInterface in app/Contracts, one adapter, one binding in AppServiceProvider. That way the bundle is swappable in an afternoon when it stops being maintained.
// app/Providers/SleeperServiceProvider.php
public function register(): void
{
$this->app->singleton(SleeperClientInterface::class, fn () =>
new SleeperClientAdapter(
baseUri: config('services.sleeper.base_uri', 'https://api.sleeper.app'),
timeout: config('services.sleeper.timeout', 10),
)
);
}
Split reads into two tiers: the player dump and everything else. The bundle's importer is most valuable for the bulk path. /v1/players/nfl is a multi-megabyte single JSON object covering every NFL player; Sleeper explicitly asks clients to fetch it at most once per day. Everything else (leagues, rosters, users, matchups, transactions, traded_picks, drafts) is small, league-scoped, and cheap to re-fetch.
// app/Console/Commands/ImportSleeperPlayers.php
public function handle(SleeperClientInterface $sleeper): int
{
$players = $sleeper->players('nfl'); // ~5MB associative array keyed by player_id
DB::transaction(function () use ($players) {
foreach (array_chunk($players, 500, true) as $chunk) {
Player::upsert(
collect($chunk)->map(fn ($p, $id) => [
'sleeper_id' => $id,
'full_name' => $p['full_name'] ?? null,
'position' => $p['position'] ?? null,
'team' => $p['team'] ?? null,
'status' => $p['status'] ?? null,
'payload' => json_encode($p),
])->values()->all(),
uniqueBy: ['sleeper_id'],
update: ['full_name', 'position', 'team', 'status', 'payload'],
);
}
});
return self::SUCCESS;
}
// schedule: ->dailyAt('06:15') in routes/console.php
Everything else is a scheduled sync, not a request-time call. Sleeper has no webhooks, so there is no push channel to subscribe to. The bundle gives you pullers; you supply the cadence. Practical schedule:
| Job | Cadence | Endpoint family |
|---|---|---|
| Player dump | daily | /v1/players/nfl |
| NFL state (season/week) | hourly | /v1/state/nfl |
| Leagues/rosters/users | every 6h in season, daily off-season | /v1/league/* |
| Matchups | every 15–30 min during game windows | /v1/league/{id}/matchups/{week} |
| Transactions | every 15–30 min, backfill rounds | /v1/league/{id}/transactions/{round} |
Drive the whole schedule off /v1/state/nfl — it returns season, week, season_type, and leg. If season_type !== 'regular' or week === 0, skip the matchup job entirely. Hardcoding "week 1 = September" is the single most common way these integrations rot.
Upsert on Sleeper's IDs, never on names. league_id and user_id are digit strings, roster_id is an int, player_id is a string. Keep them as the natural key and store the raw payload as a JSON column so you can re-derive fields the bundle doesn't map without re-fetching:
League::upsert($leagues->map(fn ($l) => [
'sleeper_id' => $l->league_id,
'name' => $l->name,
'season' => $l->season,
'previous_league_id' => $l->previous_league_id,
'payload' => json_encode($l),
])->all(), uniqueBy: ['sleeper_id'], update: ['name', 'season', 'payload']);
Respect the rate limit centrally. Sleeper's documented ceiling is roughly 1,000 calls/minute; you will brush against it if you loop rosters over many leagues or iterate transaction rounds 1–18. Put a RateLimiter::attempt() or a ->middleware('throttle:...') on the job rather than inside the bundle's client, so the limit survives if you swap the client out.
Test with recorded fixtures, not live calls. Capture one real response per endpoint into tests/Fixtures/sleeper/*.json and Http::fake() them. The bundle, if it uses Symfony HttpClient, is not faked by Http::fake() — another argument for the adapter, whose Http:: calls you can fake. Never point CI at the real API; it's unauthenticated, so a test suite becomes an unintentional load generator.
api_key or token might suggest. If the bundle's Configuration.php demands one, it's a placeholder and you can pass any string. The real constraints are the ~1,000 req/min ceiling and the once-daily player-dump guidance.EntityManagerInterface type-hints where you want Eloquent, Symfony\Component\HttpClient\* (not fakeable by Http::fake()), and exceptions extending Symfony\Contracts\HttpClient\Exception\* rather than Illuminate\Http\Client\RequestException. Catch broadly and re-throw as your own exception type at the adapter boundary.league_id is new each year and links backwards via previous_league_id. Storing one ID and assuming permanence breaks in January. Walk the chain to build league history, and guard against cycles (previous_league_id can be "0" at the origin).roster_id is not user_id. Rosters are keyed by roster_id; the owning user is in owner_id, and co-owners live in a separate co_owners array. Joining rosters to users on the wrong field silently produces "empty team" bugs that look like API failures.team: null means free agent; status: "Active" vs missing; injury_status absent vs "Questionable". Coalesce deliberately ($p['team'] ?? null) rather than trusting presence, and never ->team on a typed DTO without a null check.Carbon::createFromTimestampMs() — not createFromTimestamp() — or you'll land in 1970.sleeper_api_symfony:); in Laravel you'll be hand-merging it. Publish nothing, mirror the keys into config/services.php under sleeper, and read via config('services.sleeper.*') so you get config:cache compatibility for free.Log::debug('sleeper.response', ['path' => $path, 'status' => $status, 'body' => Str::limit($body, 2000)]). When the bundle's DTO mapping throws a TypeError on a field Sleeper changed, that log line is the difference between a 2-minute and a 2-hour fix. Telescope's HTTP watcher won't see Symfony HttpClient traffic — another adapter argument.LeagueSynced, RosterSynced) so your app can react without forking; (3) override the service definition if you must stay in the Symfony container. Avoid editing vendor/.composer.json, keep the adapter thin so replacement is a one-file change, and be prepared to vendor the source into app/ if the repo goes quiet. Treat the bundle as a reference implementation of Sleeper's schema — that's genuinely its highest-value asset — rather than as a long-lived dependency.How can I help you explore Laravel packages today?