Projet

MyRugby.be - a multi-club rugby livescore built with PHP, Tailwind and Firebase

Live scores, push notifications on every try, a mobile coach interface, installable PWA. A project built in December 2025 for Belgian rugby clubs - no JS framework, no WebSocket, no SaaS.

This project was built entirely with AI in December 2025. PHP, JavaScript, SQL, Tailwind - all the code was generated by Claude Code. But the architecture, the technical choices, and every design decision are mine. 25 years of web development teach you what to ask for and when to say no.

My rugby club needed a livescore system. Existing solutions - FederationRugby, SportEasy - are either limited, paid, or not suited to Belgian rugby. I decided to build my own: myrugby.be.

The brief was simple: a coach on the sideline taps "Try" on his phone, and parents watching from afar see the score change live. With a push notification on every action.

MyRugby.be - Live scores for your club
MyRugby.be - livescore platform for Belgian rugby clubs.

Overview

The system is built on 4 layers:

  1. The public interface - a real-time scoreboard per club, accessible without login
  2. The coach interface - an installable PWA to manage matches and score live
  3. The PHP backend - a custom MVC with MySQL and PDO
  4. Push notifications - Firebase Cloud Messaging (FCM) with Service Account
Spectator (parent, fan) Coach (sideline) myrugby.be/club?id=X myrugby.be/admin/ | | | polling /3s | POST score v v +------------------------------------------+ | PHP Backend (MVC) | | MySQL - PDO - Sessions - CSRF | +------------------------------------------+ | | | MD5 signature | sendScoreUpdate() v v Smart reload +---------------------+ (if state change) | FCM API v1 | | Service Account JWT | +---------------------+ | v Push notification on the parent's phone

The data model

The MySQL schema is centered on the relationship Club → Team → Game → Score. Each club can have multiple teams (U16, U18, Seniors...), each team plays matches, and each match accumulates scores:

Clubs (ClubID, ClubName, ClubSlug, LogoName, City)
  |-- Teams (TeamID, ClubID, TeamName, DisplayName)
  |     |-- Games (GameID, OwnerClubID, HomeTeamID, AwayClubID, GameStatus, Date)
  |           |-- Scores (ScoreID, GameID, IsOwnerTeam, ScoreType, Points, Timestamp)
  |
  |-- Users (UserID, ClubID, Username, Role: superadmin|admin|coach)
  |-- PushSubscriptions (FCMToken, ClubID, TeamID, GameID, DeviceType)

The lifecycle of a match

A match goes through 5 states, managed by a PHP constant:

define('GAME_STATUS', [
    'NOT_STARTED'  => 'Non commence',
    'FIRST_HALF'   => '1ere mi-temps',
    'HALF_TIME'    => 'Mi-temps',
    'SECOND_HALF'  => '2eme mi-temps',
    'FINISHED'     => 'Termine'
]);

The coach moves the status forward manually - no automatic timer. In rugby, stoppages make any automatic clock unusable. The match can also be put on pause (injury, card), and the pause time is subtracted from the displayed clock.

Score types

Three possible actions, each with its own points:

5Try 2Conversion 3Penalty

These colored circles are the visual signature of the coach interface. At a glance, you see the score breakdown - not just the total.

The PHP backend: MVC without a framework

No Laravel, no Symfony. A custom MVC with an abstract Model class that provides basic CRUD:

abstract class Model {
    protected PDO $db;
    protected string $table;

    public function __construct() {
        $this->db = Database::getInstance(); // Singleton PDO
    }

    public function findAll(string $orderBy = null): array { ... }
    public function findById(int $id): ?array { ... }
    public function findBy(array $conditions): array { ... }
    public function create(array $data): int { ... }
    public function update(int $id, array $data): bool { ... }
    public function delete(int $id): bool { ... }
}

Each model (Club, Team, Game, Score, User) inherits from this class and adds its specific methods. The Database uses the Singleton pattern with automatic environment detection (local vs production).

Why not a framework?

For a project of this size (~1,700 lines of backend), a framework would be over-engineering. The needs are simple: basic routing (one PHP file per page), direct SQL queries, native sessions. The custom MVC does the job without the 50 MB of vendor/.

Security

  • CSRF - token generated per session, verified on every POST
  • Passwords - bcrypt via password_hash() / password_verify()
  • SQL injection - prepared PDO statements everywhere
  • XSS - htmlspecialchars() on all outputs
  • Remember Me - persistent token in DB with 1-year expiration

Real-time without WebSocket

This is the technically most interesting part. How do you display "live" scores without WebSocket, without Firebase, without a Node server?

Answer: smart polling with MD5 signature.

The principle

Every 3 seconds, the spectator's browser calls an API that returns the current state of the matches and an MD5 hash of that state:

// API /api/games/status.php
$state = [];
foreach ($games as $g) {
    $state[] = $g['GameID'] . ':' . $g['GameStatus']
             . ':' . $g['home_score'] . '-' . $g['away_score']
             . ':' . ($g['IsPaused'] ?? 0);
}
$signature = md5(implode('|', $state));

echo json_encode([
    'games'     => $games,
    'signature' => $signature,
    'timestamp' => time()
]);

Client side: conditional reload

The JavaScript compares the signature with the previous one. If it changed, the page reloads. Otherwise, nothing happens:

let lastSignature = null;

setInterval(() => {
    fetch('/api/games/status.php?club=' + clubId)
        .then(r => r.json())
        .then(data => {
            if (lastSignature && lastSignature !== data.signature) {
                location.reload();
            }
            lastSignature = data.signature;
        });
}, 3000);

The elegance of this approach: the server maintains no open connection. Every request is stateless. And the MD5 guarantees the page only reloads when there is a real change - not on every poll.

Why not WebSocket or SSE?

The project includes an SSE endpoint (/api/sse/updates.php) as a fallback, but polling remains the main solution for three reasons:

  • Shared hosting - O2Switch does not guarantee long-lived connections
  • Mobile resilience - a poll that fails simply retries on the next interval. An SSE that loses its connection has to handle reconnection
  • 3 seconds is enough - we're not trading Bitcoin, this is rugby. A 3-second delay is imperceptible

Push notifications: FCM with Service Account

The spectator can subscribe to notifications for a match, a team, or an entire club. On every score, the backend sends a push via Firebase Cloud Messaging.

OAuth2 authentication

FCM API v1 requires an OAuth2 access token, not a simple API key. The backend generates a JWT signed with the Service Account's private key, exchanges it for an access token, and caches it for 1 hour:

// PushNotificationService.php - JWT pour FCM
$header  = base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$payload = base64UrlEncode(json_encode([
    'iss'   => $serviceAccount['client_email'],
    'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
    'aud'   => 'https://oauth2.googleapis.com/token',
    'iat'   => time(),
    'exp'   => time() + 3600
]));

openssl_sign("$header.$payload", $signature, $privateKey, OPENSSL_ALGO_SHA256);
$jwt = "$header.$payload." . base64UrlEncode($signature);

// Echange JWT contre access token
curl_post('https://oauth2.googleapis.com/token', [
    'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    'assertion'  => $jwt
]);

The notification payload

Each notification includes the scores, the teams, and a deep link to the match:

$notification = [
    'title' => "🏉 Essai !",
    'body'  => "10 - 7 • RCB vs BRC"
];

$data = [
    'gameId'    => '123',
    'homeScore' => '10',
    'awayScore' => '7',
    'url'       => '/club.php?id=5&game=123'
];

Three-level subscriptions

The SQL query that retrieves the tokens is the key to the system. A single query covers all three subscription levels:

SELECT DISTINCT FCMToken FROM PushSubscriptions
WHERE IsActive = TRUE
AND (
    GameID = ?                                    -- abonne a CE match
    OR TeamID = ?                                 -- abonne a CETTE equipe
    OR (TeamID IS NULL AND (ClubID = ? OR ClubID = ?))  -- abonne au CLUB
)

A parent who subscribes to "Seniors" will receive notifications for all of that team's matches - no need to subscribe to each match individually.

The coach interface: a PWA on the sideline

The admin interface is designed to be used standing up, in the rain, with gloves on. It's a PWA (Progressive Web App) installable on the phone's home screen.

Team and match management

The coach (or the club admin) can:

  • Create teams in bulk (one name per line in a textarea)
  • Schedule matches with opponent autocomplete (search by club name, logo display)
  • Start the match and advance the halves

Score live

The active match screen shows two columns (home team / away team) with three buttons each: Try (5 pts), Conversion (2 pts), Penalty (3 pts). Each tap:

  1. Adds the score to the DB
  2. Recalculates the total
  3. Triggers the push notification to all subscribers

An "Undo last action" button lets you correct a mistake - essential when you're distracted by the match.

Multi-club: one codebase, N clubs

V3 was built for a single club (BRC). V4 is multi-tenant : each club has its own space, teams, coaches, and public URL.

Roles

  • Superadmin - manages all clubs, approves registrations, creates admins
  • Club admin - manages the teams, matches, and coaches of their club
  • Coach - only sees their team, can score live

Onboarding

A club that wants to join myrugby.be fills out a proposal form. The superadmin receives the request, approves it, creates the club, and generates the admin credentials. The club can then manage everything independently.

PWA: installable, offline-ready

The app is installable on iOS and Android via the manifest.json and a Service Worker that manages caching and offline mode:

  • Manifest - icons, theme color, standalone display, portrait orientation
  • Service Worker - cache-first for static assets, network-first for APIs
  • Offline page - a dedicated page displays if the network drops
  • FCM Service Worker - a separate worker manages receiving push notifications in the background

The result: the coach installs the app on their home screen, and gets a native experience without going through the App Store.

Project structure

myrugby.be/ | |-- config/ | |-- constants.php Statuses, scores, roles, Firebase | |-- database.php PDO Singleton + env detection | |-- src/ | |-- Models/ Club, Team, Game, Score, User | |-- Controllers/ AuthController (login, remember me) | |-- Services/ PushNotificationService (FCM) | |-- Utils/ Helpers (CSRF, escape, formatDate) | |-- public/ | |-- index.php Homepage (club list) | |-- club.php Public scoreboard + polling | |-- sw.js Service Worker (cache + offline) | |-- manifest.json PWA manifest | | | |-- admin/ Coach/admin interface | | |-- active_games.php Live scoring | | |-- games.php Create/manage matches | | |-- teams.php Manage teams | | | |-- superadmin/ Multi-club management | | |-- clubs.php All clubs | | |-- users.php Admins and coaches | | |-- proposals.php Registration requests | | | |-- api/ | |-- games/status.php Polling + MD5 signature | |-- sse/updates.php Server-Sent Events (fallback) | |-- notifications/ Subscribe/unsubscribe FCM

Tech stack

  • Backend - PHP 8.x, custom MVC, MySQL, PDO, native sessions
  • Frontend - Tailwind CSS 4.1, vanilla JavaScript, no framework
  • Real-time - 3s polling with MD5 signature + SSE as fallback
  • Push - Firebase Cloud Messaging, API v1, Service Account with JWT
  • PWA - Service Worker, manifest.json, offline page, FCM background worker
  • Hosting - O2Switch (shared), deploy via FTP script
  • Cost - 0 euros/month (existing hosting + free FCM)

What I learned

A few lessons from this project:

  • Polling is underrated. With an MD5 signature, it becomes almost as responsive as WebSocket for this use case - and infinitely simpler to deploy on shared hosting.
  • FCM API v1 is a real ordeal. The migration from the old API (simple API key) to Service Accounts with JWT and OAuth2 is well documented but full of subtleties. Caching the token is essential to avoid regenerating a JWT on every notification.
  • PHP is not dead. For multi-tenant CRUD with auth, CSRF, sessions, and SQL, native PHP remains remarkably effective. Zero build step, zero node_modules, instant deployment via FTP.
  • Coaches are not developers. The interface has to work with wet fingers, in the rain, during a match. Big buttons, immediate visual feedback, easy undo.
See myrugby.be live