nietonchique / sofascore-api-bundle
Symfony bundle and standalone PHP client for the unofficial SofaScore API: matches, players, teams, tournaments and live per-sport statistics, with a pluggable HTTP / headless-Chrome transport.
Package info
github.com/nietonchique/Sofascore-API-Bundle
Type:symfony-bundle
pkg:composer/nietonchique/sofascore-api-bundle
Requires
- php: >=8.4
- psr/cache: ^3.0
- psr/log: ^3.0
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-client: ^8.0
- symfony/http-client-contracts: ^3.6
- symfony/http-kernel: ^8.0
Requires (Dev)
- chrome-php/chrome: ^1.11
- deptrac/deptrac: ^4.0
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5
- symfony/cache: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/rate-limiter: ^8.0
Suggests
- chrome-php/chrome: Required for the headless-Chrome transport fallback (Cloudflare 403 bypass) — transport: chrome|chain
- symfony/cache: Provides a PSR-6 pool for the cache transport decorator
- symfony/rate-limiter: Required when the rate_limit transport decorator is enabled
Provides
None
Conflicts
None
Replaces
None
README
A Symfony bundle and standalone PHP client for the (unofficial) SofaScore API — matches, players, teams, tournaments and live per-sport statistics.
It is a faithful PHP port of the Python sofascore-wrapper
library, redesigned around a pluggable transport layer, typed DTOs for the core
entities, and full PHPStan (max) / Deptrac / PHPUnit coverage.
Disclaimer. This is an unofficial client and is not affiliated with, endorsed by, or supported by SofaScore. The API it talks to is undocumented and may change or block access at any time. Using it may violate SofaScore's Terms of Service — use at your own risk.
Requirements
- PHP 8.4+
- Symfony 8.0+ components (when used as a bundle)
- Optional: a Chromium/Chrome binary +
chrome-php/chromefor the headless-browser transport (Cloudflare fallback)
Installation
composer require nietonchique/sofascore-api-bundle
In a Symfony application using Symfony Flex the bundle is registered
automatically. Otherwise add it to config/bundles.php:
return [ // ... Nietonchique\SofascoreApiBundle\SofascoreApiBundle::class => ['all' => true], ];
Usage
Standalone (any PHP project)
use Nietonchique\SofascoreApiBundle\SofascoreClient; $client = SofascoreClient::create(); // default HTTP transport $results = $client->search('arsenal')->searchAll(); $event = $client->match(12436870)->getMatch(); // Dto\Event $h2h = $client->match(12436870)->h2h(); // array $team = $client->team(42)->getTeam(); // Dto\Team $games = $client->basketball()->gamesByDate('basketball', '2026-01-15'); $events = $client->match()->scheduledEventsByDate('football', '2026-06-28');
In Symfony (dependency injection)
SofascoreClient and every endpoint group are autowired services:
use Nietonchique\SofascoreApiBundle\SofascoreClient; final class ScoresController { public function __construct(private readonly SofascoreClient $sofascore) { } public function live(): array { return $this->sofascore->match()->liveGames(); } }
Endpoint groups
Access each group via the client factory methods:
| Method | Group | Bound argument |
|---|---|---|
search(string $q, int $page = 0) |
Search |
search term + page |
match(?int $matchId = null) |
MatchEndpoint |
match (event) id |
player(int $playerId) |
Player |
player id |
playerSearch(string $query) |
PlayerSearch |
query |
team(int $teamId) |
Team |
team id |
league(int $leagueId) |
League |
unique-tournament id |
manager(int $managerId) |
Manager |
manager id |
transfers() |
Transfers |
— |
news() |
News |
— |
userData() |
UserData |
— |
flag(string $flagCode) |
Flag |
country code |
americanFootball() / baseball() / basketball() / cricket() / esports() / iceHockey() / mma() / motorsport() / rugby() / tennis() |
per-sport groups | — |
Return types
The API surface is large and SofaScore changes response fields without notice, so the return style is intentionally hybrid and predictable:
- The primary entity-detail getters return typed DTOs:
MatchEndpoint::getMatch(): Event,Player::getPlayer(): Player,Team::getTeam(): Team,League::getLeague(): Tournament. Every DTO keeps the full original payload accessible via->raw/->toArray(). - Every other method returns a decoded
array(the raw JSON), exactly as the Python library does.
Translations
Many SofaScore entities ship an embedded fieldTranslations dictionary. The typed
DTOs expose it as a ?FieldTranslations object:
$event = $client->match(12436870)->getMatch(); $team = $event->homeTeam; // Full translation maps $team?->fieldTranslations?->name; // ['ru' => 'Интер', 'sr' => 'Интер', ...] $team?->fieldTranslations?->shortName; // ['ru' => 'Инт', ...] // Convenience accessor $team?->fieldTranslations?->nameIn(LanguageCode::RU); // 'Интер' $team?->fieldTranslations?->nameIn(LanguageCode::SR); // 'Интер' $team?->fieldTranslations?->nameIn('xx', 'fallback'); // 'fallback'
LanguageCode is a convenience catalogue of known SofaScore locale codes
(including the base set en, ru, sr and the codes observed in real responses).
You can still pass any arbitrary string to nameIn() / shortNameIn().
For methods that return raw arrays (e.g. match()->gamesByDate() /
match()->scheduledEventsByDate()), the fieldTranslations key is preserved in
the response unchanged.
Error handling
Every exception thrown by the bundle implements SofascoreExceptionInterface:
| Exception | Thrown when |
|---|---|
ApiException |
any non-2xx / undecodable response — base class, exposes getStatusCode() and getUrl() |
ApiBlockedException (extends ApiException) |
compatible parent for an access denial or verified challenge; triggers the chain fallback |
AccessDeniedException (extends ApiBlockedException) |
generic HTTP/API 403; usually rotate or cool down the egress route |
ChallengeException (extends ApiBlockedException) |
challenge evidence was present (cf-mitigated, API reason, or known HTML marker) |
RateLimitedException |
HTTP/API 429; exposes a positive getRetryAfterSeconds() value when supplied |
EgressUnavailableException |
the client/browser could not complete the network request |
UpstreamTemporaryException |
HTTP/API 5xx or another explicitly temporary status |
ProtocolDriftException |
a successful response is not valid JSON/object data and has no challenge evidence |
NotFoundException (extends ApiException) |
HTTP 404 (unknown entity) |
InvalidArgumentException |
invalid argument, e.g. an unknown sport slug |
use Nietonchique\SofascoreApiBundle\Exception\ApiBlockedException; use Nietonchique\SofascoreApiBundle\Exception\NotFoundException; use Nietonchique\SofascoreApiBundle\Exception\SofascoreExceptionInterface; try { $event = $client->match(12436870)->getMatch(); } catch (NotFoundException) { // no such match } catch (ApiBlockedException $e) { // Cloudflare blocked this IP — configure a proxy (see below) } catch (SofascoreExceptionInterface $e) { // any other error from the bundle: $e->getMessage() }
Every ApiException also exposes getFailureType(): FailureType, so applications
can implement typed cooldown and failover without parsing messages. Existing
ApiBlockedException catches remain valid for both 403 subtypes.
Transports, access denials and browser challenges
SofaScore can deny an egress route with a generic 403 or return a browser
challenge. The bundle ships three transports behind a single TransportInterface:
http—HttpClientTransport, a plain Symfony HTTP client with a realistic browser header set. Fast, no external dependencies.chrome—ChromeTransport, drives a headless Chromium viachrome-php/chrome(no Node.js). Warms up on the site root to obtain a Cloudflare clearance cookie before calling the API.chain(default) — tries HTTP first and falls back to Chrome on a 403.
The
X-Requested-Withheader is required. SofaScore answers every API request that lacks it with a Cloudflare403 "challenge".HttpClientTransportsends it automatically (the value is not validated — a random per-instance hex token is used to mimic the site's own XHR token), so the defaulthttptransport reaches the API directly, no browser needed.Some IPs are geo/reputation-blocked (e.g. requests originating from Russia, or certain datacenter ranges) and get a
403regardless of headers. Route through a clean exit with thehttp.proxyoption — any SOCKS5/HTTP proxy works:sofascore_api: http: proxy: 'socks5h://127.0.0.1:1080'When still blocked, the transport raises
ApiBlockedExceptionrather than returning bogus data, andChainTransportfalls back to the Chrome transport.
Configuration (bundle)
All decorators are opt-in and disabled by default:
# config/packages/sofascore_api.yaml sofascore_api: transport: chain # http | chrome | chain http: timeout: 10.0 crypto_method: 768 # min TLS version; 768 = TLS 1.3 (default — Cloudflare 403s older handshakes) proxy: null # 'socks5h://127.0.0.1:1080' user_agent: null # override the default browser UA x_requested_with: null # override the required header (default: random token) headers: {} # any extra headers chrome: binary: google-chrome-stable headless: true timeout_ms: 30000 warmup_url: 'https://www.sofascore.com/' # null to disable proxy: null # 'socks5://127.0.0.1:1080' retry: enabled: false max_retries: 3 delay_ms: 1000 cache: # PSR-6 response cache enabled: false pool: cache.app ttl: 300 rate_limit: enabled: false limit: 60 interval: '1 minute' logging: enabled: false service: logger
Route-scoped transports and actual-hit accounting
Applications with multiple authorised egress routes can construct an isolated transport per attempt. The HTTP leg and its Chrome fallback use the same route:
use Nietonchique\SofascoreApiBundle\SofascoreClient; use Nietonchique\SofascoreApiBundle\Transport\RouteTransportFactory; use Nietonchique\SofascoreApiBundle\Transport\TransportRoute; use Symfony\Component\HttpClient\HttpClient; $factory = new RouteTransportFactory(HttpClient::create()); $route = new TransportRoute( id: 'bg-primary', provider: 'provider-a', proxy: 'socks5h://127.0.0.1:10841', chromeProxy: 'socks5://127.0.0.1:10841', ); $client = SofascoreClient::create($factory->create($route, 'chain'));
Pass a NetworkHitGuardInterface as the factory's guard argument to acquire a
shared rate-limit permit immediately before each real network hit. Pass a
NetworkHitObserverInterface as observer to receive one redacted NetworkHit
after each hit. Chrome warm-up and API navigation are separate hits. Route proxy
credentials are deliberately absent from observer payloads; route metadata
should contain diagnostic labels only, never secrets.
Quality
composer test # PHPUnit (network tests excluded by default) composer stan # PHPStan (level max) composer cs-check # php-cs-fixer (@PSR12 + @Symfony), dry run composer deptrac # architecture/layer rules composer qa # all of the above
Run the live integration tests (they skip automatically when Cloudflare blocks the current IP):
vendor/bin/phpunit --group network
Development
A bundle is a library — "developing" it means editing code and running the test suite (there is no app server to start). Use the host PHP (8.4+) directly:
composer install
composer qa # cs-fixer + phpstan + deptrac + phpunit
…or run everything in Docker (no PHP needed on the host; the container runs as your user, so it leaves no root-owned files):
make install # PHP 8.5 by default — `make PHP=8.4 qa` to pick a version make qa make test
Using the bundle in a Dockerized app
The bundle is pure PHP; a minimal consumer image needs only PHP + ext-curl:
FROM php:8.5-cli COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app COPY . . RUN composer install --no-dev --prefer-dist --no-progress
For the headless-Chrome fallback, also install a Chromium binary and the optional
dependency (composer require chrome-php/chrome, then chrome.binary: chromium):
RUN apt-get update && apt-get install -y --no-install-recommends chromium \
&& rm -rf /var/lib/apt/lists/*
On a server/cloud the container's IP is a datacenter IP that Cloudflare blocks — route through a proxy (
http.proxy) to a clean residential/mobile exit.
Troubleshooting
- Every request throws
ApiBlockedException(403). Three independent causes:- the
X-Requested-Withheader is missing — it is sent automatically, so this only happens if you overrodehttp.headersand dropped it; - the exit IP is geo/reputation-blocked (e.g. Russia, some datacenter ranges).
Set
http.proxy(andchrome.proxy) to a clean exit; - an older TLS handshake — Cloudflare fingerprints it (seen from inside
containers even with a clean IP + header). The client defaults to TLS 1.3,
which clears it; if you lowered
http.crypto_method, raise it back.
- the
transport: chromefails with "class not found". Install the optional dependency and make sure a Chromium binary is available:composer require chrome-php/chromeand setchrome.binary.rate_limit/cacheenabled but the container errors. Installsymfony/rate-limiter, and pointcache.poolat a PSR-6 pool (cache.appships with FrameworkBundle).- Headless Chrome still gets 403. SofaScore's Cloudflare challenge is hard for
headless automation; use the
httptransport with a clean residential/mobile proxy instead — it only needs theX-Requested-Withheader, which is sent for you.
Credits
- Ported from
tommhe14/sofascore-wrapper(Python, MIT).
License
MIT © Aleksandr Ryzhkov