The official PHP client for footballsoccerapi.com — 680,000 matches back to 2012 across 158 countries, with kickoff prices, team statistics, lineups and live scores.
No dependencies. Curl and json, both of which PHP already has.
There are two versions in here. Start with the simple one.
One file, three functions, no classes. Copy fsapi-simple.php next to your script.
require 'fsapi-simple.php';
$matches = fsapi_get('/v1/matches', [
'country' => 'England',
'status' => 'finished',
'limit' => 5,
]);
foreach ($matches as $m) {
printf("%s %d-%d %s\n",
$m['home_team_name'], $m['home_goals'], $m['away_goals'], $m['away_team_name']);
}That is the whole thing. Three functions:
fsapi_get($path, $params) |
the data |
fsapi_call($path, $params) |
the data and the meta |
fsapi_walk($path, $params) |
every row, paging for you |
Use this for a script, a cron job, or working out whether the data is what you need. Use the full client below if you are building something that has to keep working.
The key is a string. getenv() wants the NAME of an environment variable, not the key itself.
This is the mistake everyone makes once, so it is worth spelling out.
// WRONG — getenv is asked for a variable called "fsa_live_...", which does not exist
$api = new Client(getenv('fsa_live_af1f2c1b6cc47a646e1d57b3524ea654'));
// Right — the key, directly
$api = new Client('fsa_live_af1f2c1b6cc47a646e1d57b3524ea654');
// Better — the key stays out of the file
$api = new Client(getenv('FSAPI_KEY'));For the last one, set the variable when you run the script:
FSAPI_KEY=fsa_live_af1f2c1b6cc47a646e1d57b3524ea654 php your-script.phpOr for the whole session:
export FSAPI_KEY=fsa_live_af1f2c1b6cc47a646e1d57b3524ea654
php your-script.phpThe environment version is worth the extra step: a key in a file ends up in a repository, a screenshot or a support ticket eventually.
For the simple client you can also set it at the top of fsapi-simple.php:
const FSAPI_KEY = 'fsa_live_...';A free key takes a minute and needs no card: footballsoccerapi.com/free-key. It reaches eleven endpoints on a one to seven day delay — enough to build something real before deciding whether to pay.
Same data, more handled for you. Clone it and require the autoloader; that is the installation.
require 'src/autoload.php';
use FootballSoccerApi\Client;
$api = new Client(getenv('FSAPI_KEY'));
$res = $api->matches(['country' => 'England', 'status' => 'finished', 'limit' => 5]);
foreach ($res['data'] as $m) {
printf("%s %d-%d %s\n",
$m['home_team_name'], $m['home_goals'], $m['away_goals'], $m['away_team_name']);
}Composer works if you use it, but nothing here needs it.
The API never sends a bare status code, and neither does this.
use FootballSoccerApi\{PlanRequiredException, RateLimitException};
try {
$api->live();
} catch (PlanRequiredException $e) {
printf("Needs %s, you have %s — %s\n", $e->needsPlan(), $e->yourPlan(), $e->upgradeUrl());
} catch (RateLimitException $e) {
printf("Wait %d seconds\n", $e->retryAfter);
}Rate limits are retried once by default using the API's own figure, because guessing a backoff
when the response tells you the answer is worse for both sides. Pass retryOnRateLimit: false to
handle it yourself.
A response that is not JSON throws a TransportException saying so, rather than a confusing parse
error — that means something between you and the API answered, usually a proxy or a challenge
page, and knowing which layer failed saves an hour.
The cursor pages by sort position rather than offset, so the ten-thousandth page is as quick as the first and a match arriving mid-walk cannot shift the boundary and make you skip a row.
foreach ($api->walkMatches(['league_id' => 'lg_24T9Z0G', 'season' => 2024]) as $match) {
// every match in the season, one at a time, memory flat
}Fifty ids in one request instead of fifty requests. Longer lists are chunked rather than refused.
$matches = $api->matchesByIds(['mt_0CXSZRJ', 'mt_087EP2A', /* ... */]);Each id costs one call against your rate limit, the same as fetching them separately. What batching saves is round trips, not quota — on a slow connection or from a serverless function that is still often the difference between a page that loads and one that times out.
$res = $api->matches(['season' => 2024]);
$res['data']; // the matches
$res['meta']['total']; // how many matched
$res['meta']['data_as_of']; // when the archive was last rebuiltA client that stripped the meta to hand you a bare array would be throwing away the part that stops a figure being quoted without its base.
export FSAPI_KEY=your_key
php examples/simple.php # the simple client, start to finish
php examples/quickstart.php # recent results, with prices
php examples/walk-a-season.php # walk a season with the cursor
php examples/home-advantage.php # does home advantage differ by country?The last one reproduces a study from the Lab. It is here so you can check the figure rather than take our word for it.
Ids carry their type — mt_ for a match, lg_ for a competition, tm_ for a club, vn_ for a
ground. A club id passed where a competition belongs fails loudly instead of quietly returning the
wrong thing, and raw integers are refused.
680,916 matches from July 2012, across 928 competitions and 16,621 clubs. Kickoff prices on 53.7%, full-time results on 97.1%, team statistics on 132,441 matches, lineups and formations on 116,309.
Coverage varies by competition, and every competition and season publishes its own fill rate per field. That is the figure to build against rather than an archive-wide average — a field can be near complete in one competition and absent from another.
A field we do not hold comes back as null rather than vanishing from the response, so nothing on
your side changes as coverage grows.
- Every endpoint
- OpenAPI spec and Postman collection, both generated from the same registry as the documentation so they cannot drift from it
- Status
- Support — a person reads every ticket
MIT.