hristonev/sportmonks-client-bundle
A Symfony bundle that integrates the SportMonks football API client, wiring it via dependency injection and configuration so PHP apps can easily fetch fixtures, leagues, teams, players, standings, and live scores.
Reality check first. This is a Symfony bundle (last release April 2019, zero dependents). There is no Laravel service provider, no facade, no config publishing. Don't expect composer require + php artisan vendor:publish to work. The pragmatic path is to treat it as documentation of an API client and wire the underlying HTTP client yourself, or vendor the few classes you need.
Steps:
Read the bundle's composer.json and DI extension before installing. Open src/DependencyInjection/*Extension.php and DependencyInjection/Configuration.php. Those two files tell you the service IDs, the config tree (almost certainly api_token, maybe base_uri/timeout), and which HTTP client it depends on (Guzzle, Symfony HttpClient, or a sportmonks/sportmonks-php SDK). If it pins symfony/*: ^3.4|^4.x and php: ^7.1, it will fight with a modern Laravel app — install it in a scratch project, read it, then port.
Get a SportMonks token from my.sportmonks.com (API token, passed as api_token query param). Put it in .env as SPORTMONKS_TOKEN, never in a committed config file.
Bind it in a service provider rather than trying to load the bundle:
// app/Providers/SportMonksServiceProvider.php
$this->app->singleton(SportMonksClient::class, fn ($app) => new SportMonksClient(
token: config('services.sportmonks.token'),
baseUri: 'https://api.sportmonks.com/v3/football',
timeout: 10,
));
league → season → fixture. Fetch /leagues, pick a season_id, then /fixtures/between/{start}/{end}/{teamId?} with include=participants;scores;league. Cache the result — this single call is the highest-value, lowest-cost thing you can build.The first thing to build is a read-through cache, not a domain layer. The API is the bottleneck; caching is the architecture.
Thin client, fat repository. Keep the vendor client as a dumb transport (URL + query building + auth) and put all resource knowledge in repositories:
class FixtureRepository
{
public function __construct(private SportMonksClient $client) {}
public function between(Carbon $from, Carbon $to, array $includes = []): Collection
{
return Cache::remember(
"sm:fixtures:{$from->toDateString()}:{$to->toDateString()}:" . implode(',', $includes),
now()->addMinutes(15),
fn () => collect($this->client->get('fixtures/between/...', ['include' => implode(';', $includes)]))
);
}
}
Cache TTL by resource volatility, not one global value:
| Resource | TTL |
|---|---|
| leagues, countries, continents | 24h |
| seasons, stages, rounds | 6h |
| teams, players, venues | 12h |
| fixtures (future) | 1–6h |
| fixtures (finished) | 7d, effectively immutable |
| livescores / in-play | 15–30s, or don't cache |
Key the cache on sorted query params so include=a;b and include=b;a hit the same entry.
Use include aggressively but deliberately. One request with include=participants;scores;league;venue beats five requests with no includes, and it's usually cheaper against your rate limit too. Centralise the canonical include strings as constants per endpoint so you don't hand-tune them in 20 places. Watch the filters parameter similarly — filters=fixtureLeagues:8 on a livescores endpoint is how you avoid pulling 900 matches to show 12.
Handle pagination as a first-class concern. SportMonks returns data, pagination (with has_more, next_page), and rate_limit. Wrap it:
while ($page = $response->pagination['next_page'] ?? null) { ... }
Never assume a single page. For bulk imports, prefer the between/{from}/{to} and byDate style endpoints over paging through an entire season.
Queue the syncs, schedule the reads. Pattern that works well in Laravel:
SyncLeaguesJob (daily) → upsert into leaguesSyncSeasonsJob (daily) — dispatch per league, chain to fixturesSyncFixturesJob (hourly for today±2 days)SyncLivescoresJob (every minute, only during match windows) — ideally gated by a "is any fixture live right now?" DB check so you're not burning requests at 4amPersist to your own tables. Do not query SportMonks from a controller on a page request. The API is a sync source, not your read model.
Rate-limit aware transport. Wrap the client with a middleware/decorator that reads the rate_limit block from every response and stores it in cache. Then a simple guard:
if (RateLimit::remaining() < 20) { throw new RateLimitGuardException(); }
Back off on 429 with exponential delay and let the queue retry ($this->release($seconds)). Give the API-calling jobs their own queue with a low --tries and a RateLimited middleware so one runaway job doesn't starve everything else.
Testing. Use Guzzle's MockHandler/Laravel's Http::fake() with recorded JSON fixtures, including the pagination and rate_limit envelopes — the bugs live in those two blocks, not in the data payload. Write at least one "has_more" test and one "empty data + single-item pagination" test.
The Symfony DI mismatch is the biggest one. Even if it installs, the bundle class won't be registered by Laravel, services.yaml is never read, and constructor autowiring won't resolve. Don't fight it — copy the request-building logic and own it. Same for PHP version constraints: a 2019 bundle likely requires PHP 7.1–7.3 and will block installation on Laravel 10/11 (PHP 8.1+). Use --ignore-platform-reqs in a throwaway dir to inspect, then port.
The API token travels in the query string. Guzzle's exception messages include the full URI, so a failed request dumps your token into laravel.log and into Sentry. Redact it: catch exceptions, re-throw with a sanitised message, and add a Guzzle middleware that strips api_token from RequestException messages. Also strip it from any Http::fake-free debug output.
Rate limits are per plan and change without notice. Historically free tier was ~180 req/hour with a low entity allowance; paid tiers vary. Check the rate_limit block on every response and treat it as truth — don't hardcode a number from a blog post. Includes and nested relations consume more of your allowance than a bare call.
Timezone and date semantics. All kickoff times are UTC. Store UTC in the DB, convert in the presentation layer. fixtures/between/{from}/{to} expects YYYY-MM-DD — passing datetimes silently fails or returns the wrong window. Beware fixtures that change date: a postponed match moves out of your "today" window, so your sync range must always overlap ±48h, not just today.
Fixtures mutate. state_id transitions (NS → 1H → HT → FT → AET/PST/CANC), scores update, lineups appear ~1h before kickoff, and IDs stay the same. Always upsert on the SportMonks ID with updateOrCreate, and store state_id as a foreign-key-ish integer you map to your own enum. Never delete-and-reinsert — you'll destroy relationships.
Nulls are normal. Lineups, scores, and participants.meta are frequently absent for lower leagues and future fixtures. Map defensively (data_get()), and don't assume scores is a map — it's an array of {type_id, score: {goals, participant}} objects that you have to pivot yourself.
Sparse/optional fields and deleted flags. Some responses include soft-deleted entities. Filter on the flag rather than assuming the array is clean.
Extension points, if you keep the bundle code: the client is almost certainly a single injectable service — decorate it, don't fork it. Register your own implementation against the same interface, wrap the ClientInterface, and swap the underlying HTTP client (Guzzle handler) for logging/retry. Bundle-level event dispatchers (if any) are Symfony's — you'd need symfony/event-dispatcher, which Laravel ships; it's compatible but you won't get framework events for free.
Don't build live-score polling into web requests. If you must show in-play data, push it to the client (broadcast/Echo/poll your own /api/livescores backed by cache) rather than proxying SportMonks per user. N users × 1 request/minute will exhaust your plan before lunch.
How can I help you explore Laravel packages today?