How to Get Football Data in PHP: Live Scores, Standings & Odds
Fetch live football scores, standings, player stats, and odds in PHP using GOAL API. Copy-paste cURL and Guzzle code, real endpoints, Laravel-ready examples.
GOAL API

Search "football API Python" and you'll drown in tutorials. Search "football API PHP" and you'll find almost nothing — despite PHP powering a huge share of the web, from WordPress fan sites to Laravel betting dashboards. This is the tutorial that should exist.
By the end, you'll have working PHP code for live scores, standings, match events, odds, and player stats using GOAL API — with both a zero-dependency cURL version and a clean Guzzle version, plus a Laravel service class. If you've already read the Python tutorial or the JavaScript tutorial, these are the same endpoints in PHP.
Get your free API key (no credit card): goal-api.com/signup
Setup: cURL or Guzzle
Store your key in an environment variable — never hardcode it. Every GOAL API response follows the same envelope: { "success": true, "data": {...} }, so $json["data"] is always your payload.
Option A — plain cURL (zero dependencies)
<?php
$BASE = 'https://api.goal-api.com/v1';
$KEY = getenv('GOAL_API_KEY');
function goal_get(string $path, array $query = []): array {
global $BASE, $KEY;
$url = $BASE . $path . ($query ? '?' . http_build_query($query) : '');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $KEY],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) throw new RuntimeException("GOAL API error: $code");
return json_decode($body, true)['data'];
}Option B — Guzzle (recommended for real projects)
<?php
// composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.goal-api.com/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('GOAL_API_KEY')],
'timeout' => 10,
]);
function goal_get(Client $client, string $path, array $query = []): array {
$res = $client->get(ltrim($path, '/'), ['query' => $query]);
$json = json_decode((string) $res->getBody(), true);
return $json['data'];
}The examples below use the goal_get() helper. Both versions return the same array, so the rest of the tutorial works with either.
1. Fetch Live Scores
The endpoint GET /fixtures/live returns every match currently in play.
$matches = goal_get('/fixtures/live');
foreach ($matches as $m) {
printf("[%s] %s %d-%d %s (%s')\n",
$m['league']['name'],
$m['homeTeam']['name'], $m['homeTeamScore'],
$m['awayTeamScore'], $m['awayTeam']['name'],
$m['matchMinute'] ?? $m['matchStatus']
);
}Sample output:
[Premier League] Arsenal 1-0 Chelsea (67')
[La Liga] Real Madrid 2-1 Barcelona (81')Endpoint: GET /fixtures/live
Each match carries an id — the fixture ID you'll use for events, lineups, and odds.
2. Fixtures by Date
$fixtures = goal_get('/fixtures/date/2026-09-20'); // YYYY-MM-DD
foreach ($fixtures as $m) {
// Always build on kickoffUtc — it's ISO-8601 in UTC.
$kickoff = new DateTime($m['kickoffUtc']);
$kickoff->setTimezone(new DateTimeZone('Europe/London'));
printf("%s | [%s] %s vs %s | %s\n",
$kickoff->format('H:i T'),
$m['league']['name'],
$m['homeTeam']['name'], $m['awayTeam']['name'],
$m['matchStatus']
);
}Endpoint: GET /fixtures/date/:date
Build on kickoffUtc — it's a proper ISO-8601 UTC timestamp. Convert to the user's timezone with DateTimeZone. matchDate and matchTime exist for compatibility but carry no offset.
3. League Standings
// Find a league ID first (England's Premier League is the first match)
$leagues = goal_get('/leagues', ['search' => 'premier league']);
$pl = array_values(array_filter($leagues, fn($l) =>
$l['countryName'] === 'England'))[0];
// Then pull the table
$table = goal_get("/standings/{$pl['id']}");
printf("%-4s %-26s %-4s %-4s %-4s\n", 'Pos', 'Team', 'P', 'GD', 'Pts');
foreach ($table as $row) {
$gd = $row['overallLeagueGF'] - $row['overallLeagueGA'];
printf("%-4d %-26s %-4d %-4d %-4d\n",
$row['overallLeaguePosition'], $row['team']['name'],
$row['overallLeaguePlayed'], $gd, $row['overallLeaguePTS']
);
}Endpoints: GET /leagues?search= | GET /standings/:leagueId
League IDs and what data each competition holds are on the coverage page. Split views are available too: /standings/:leagueId/home, /away, /form, and /zones.
4. Match Events, Goals & Cards
// Goals, cards and substitutions are three endpoints; merge them into one timeline.
$timeline = [];
foreach (goal_get("/fixtures/$fixtureId/events") as $g) {
$timeline[] = [$g['timeNum'], "\u{26BD}", ($g['homeScorer'] ?: $g['awayScorer']) . " ({$g['score']})"];
}
foreach (goal_get("/fixtures/$fixtureId/cards") as $c) {
$icon = $c['card'] === 'red card' ? "\u{1F7E5}" : "\u{1F7E8}";
$timeline[] = [$c['timeNum'], $icon, $c['homeFault'] ?: $c['awayFault']];
}
foreach (goal_get("/fixtures/$fixtureId/substitutions") as $s) {
[$off, $on] = array_map('trim', explode('|', $s['substitution']));
$timeline[] = [$s['timeNum'], "\u{1F504}", "$on on for $off ({$s['team']})"];
}
usort($timeline, fn($a, $b) => $a[0] <=> $b[0]);
foreach ($timeline as [$minute, $icon, $text]) {
printf("%s %d' %s\n", $icon, $minute, $text);
}Endpoints: GET /fixtures/:id/events (goals) | GET /fixtures/:id/cards | GET /fixtures/:id/substitutions
5. Pre-Match & Live Odds
This is where PHP betting and preview products live. Pre-match odds cover 1X2, both-teams-to-score, and over/under lines. Odds are part of the paid plans; on a free key these two endpoints return 403.
$odds = goal_get("/fixtures/$fixtureId/odds");
foreach ($odds as $book) {
printf("[%s] Home %s | Draw %s | Away %s | BTTS %s | Over 2.5 %s\n",
$book['bookmaker'], $book['odd1'], $book['oddX'], $book['odd2'],
$book['btsYes'], $book['overUnder']['o+2.5'] ?? '-');
}
// In-play odds that update during the match:
$live = goal_get("/fixtures/$fixtureId/live-odds");Endpoints: GET /fixtures/:id/odds | GET /fixtures/:id/live-odds
The in-play odds endpoint updates during the match — useful for live dashboards. Which leagues are priced is listed on the coverage page.
6. Player Statistics
// Search, then pull stats
$results = goal_get('/players/search', ['q' => 'Erling Haaland']);
$id = $results[0]['id'];
$stats = goal_get("/players/$id/statistics")['performance'];
printf("Goals: %d | Assists: %d | Apps: %d\n",
$stats['goals'], $stats['assists'], $stats['matchPlayed']);
// League top scorers:
$topScorers = goal_get("/leagues/{$pl['id']}/top-scorers");
usort($topScorers, fn($a, $b) => (int) $a['playerPlace'] <=> (int) $b['playerPlace']);
foreach (array_slice($topScorers, 0, 5) as $p) {
printf("%s. %s (%s) %s\n", $p['playerPlace'], $p['playerName'], $p['teamName'], $p['goals']);
}Endpoints: GET /players/search?q= | GET /players/:id/statistics | GET /leagues/:id/top-scorers
7. Handling Rate Limits
Every response carries rate-limit headers. With Guzzle you can read them off the response object:
$res = $client->get('fixtures/live');
$remaining = $res->getHeaderLine('X-RateLimit-Remaining');
$limit = $res->getHeaderLine('X-RateLimit-Limit');
echo "Quota: $remaining / $limit\n";
// On a 429, check the error code in the body:
// QUOTA_EXCEEDED -> daily allowance gone, wait for reset
// BURST_LIMIT_EXCEEDED -> too many req/sec, back off ~1sThe rate limits documentation explains the two 429 types. Read X-RateLimit-Remaining and back off before you hit zero.
Using It in Laravel
Most PHP football projects are Laravel apps. Wrap the API in a service class so your controllers stay clean and you have one place to change if you ever swap providers.
A service class + facade
<?php
// app/Services/FootballService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class FootballService
{
private string $base = 'https://api.goal-api.com/v1';
private function get(string $path, array $query = []): array
{
return Http::withToken(config('services.goalapi.key'))
->baseUrl($this->base)
->get($path, $query)
->throw()
->json('data');
}
public function liveScores(): array
{
return $this->get('/fixtures/live');
}
public function standings(string $leagueId): array
{
return $this->get("/standings/$leagueId");
}
}Add the key to config/services.php and your .env, then inject FootballService into any controller. Laravel's built-in Http client means you don't even need Guzzle directly — it's already there.
What to Build Next
Live scoreboard — poll /fixtures/live on a cron or queue job, or move to the WebSocket connection WebSocket connection for instant updates. The polling vs WebSocket vs webhooks post shows the request math.
Goal notification bot — register a webhook endpoint so goal.scored events POST to a Laravel route. No polling loop to maintain.
Match preview page — combine /h2h/:t1/:t2/stats, /fixtures/:id/lineups, and /fixtures/:id/odds.
Endpoint Quick Reference

| Endpoint | Method | Returns |
|---|---|---|
| /fixtures/live | GET | All live matches |
| /fixtures/date/:date | GET | Fixtures on a date |
| /fixtures/:id/events | GET | Goals |
| /fixtures/:id/cards | GET | Yellow and red cards |
| /fixtures/:id/substitutions | GET | Substitutions |
| /fixtures/:id/odds | GET | Pre-match odds |
| /fixtures/:id/live-odds | GET | In-play odds |
| /fixtures/:id/lineups | GET | Lineups & formations |
| /standings/:leagueId | GET | League table |
| /leagues?search= | GET | Find a league + its ID |
| /players/search?q= | GET | Search players |
| /players/:id/statistics | GET | Player season stats |
| /leagues/:id/top-scorers | GET | League top scorers |
| /h2h/:team1Id/:team2Id | GET | Head-to-head |
Full reference at goal-api.com/documentation.
Start building free → goal-api.com/signup | No credit card | Every endpoint except odds and predictions


