MyRugby.be — un livescore rugby multi-clubs en PHP, Tailwind et Firebase

Scores en direct, push notifications sur chaque essai, interface coach sur mobile, PWA installable. Un projet construit en decembre 2025 pour les clubs de rugby belges — sans framework JS, sans WebSocket, sans SaaS.

Ce projet a ete entierement construit avec l'IA en decembre 2025. PHP, JavaScript, SQL, Tailwind — tout le code a ete genere par Claude Code. Mais l'architecture, les choix techniques et chaque decision de design sont les miens. 25 ans de dev web, ca sert a savoir quoi demander et quand dire non.

Mon club de rugby avait besoin d'un livescore. Les solutions existantes — FederationRugby, SportEasy — sont soit limitees, soit payantes, soit pas adaptees au rugby belge. J'ai decide de construire le mien : myrugby.be.

Le cahier des charges etait simple : un coach sur le bord du terrain tape "Essai" sur son telephone, et les parents qui suivent de loin voient le score changer en direct. Avec une notification push sur chaque action.

MyRugby.be — Scores en direct de votre club
MyRugby.be — plateforme livescore pour clubs de rugby belges.

Vue d'ensemble

Le systeme tient en 4 couches :

  1. L'interface publique — un scoreboard temps reel par club, accessible sans login
  2. L'interface coach — une PWA installable pour gerer les matchs et scorer en direct
  3. Le backend PHP — un MVC maison avec MySQL et PDO
  4. Les push notifications — Firebase Cloud Messaging (FCM) avec Service Account
Spectateur (parent, fan) Coach (bord du terrain) myrugby.be/club?id=X myrugby.be/admin/ | | | polling /3s | POST score v v +------------------------------------------+ | PHP Backend (MVC) | | MySQL — PDO — Sessions — CSRF | +------------------------------------------+ | | | signature MD5 | sendScoreUpdate() v v Smart reload +---------------------+ (si state change) | FCM API v1 | | Service Account JWT | +---------------------+ | v Push notification sur le tel du parent

Le modele de donnees

Le schema MySQL est centre sur la relation Club → Team → Game → Score. Chaque club peut avoir plusieurs equipes (U16, U18, Seniors...), chaque equipe joue des matchs, et chaque match accumule des 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)

Le cycle de vie d'un match

Un match passe par 5 etats, geres par une constante PHP :

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

Le coach fait avancer le statut manuellement — pas de timer automatique. Au rugby, les arrets de jeu rendent tout chronometre automatique inutilisable. Le match peut aussi etre mis en pause (blessure, carton), et le temps de pause est soustrait du chrono affiche.

Les types de score

Trois actions possibles, chacune avec ses points :

5Essai 2Transformation 3Penalite

Ces cercles colores sont la signature visuelle de l'interface coach. En un coup d'oeil, on voit la decomposition du score — pas juste le total.

Le backend PHP : MVC sans framework

Pas de Laravel, pas de Symfony. Un MVC maison avec une classe Model abstraite qui fournit le CRUD de base :

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 { ... }
}

Chaque modele (Club, Team, Game, Score, User) herite de cette classe et ajoute ses methodes specifiques. Le Database utilise le pattern Singleton avec detection d'environnement automatique (local vs production).

Pourquoi pas un framework ?

Pour un projet de cette taille (~1 700 lignes de backend), un framework serait du over-engineering. Les besoins sont simples : routing basique (un fichier PHP par page), queries SQL directes, sessions natives. Le MVC maison fait le job sans les 50 Mo de vendor/.

Securite

  • CSRF — token genere par session, verifie sur chaque POST
  • Passwordsbcrypt via password_hash() / password_verify()
  • SQL injection — requetes preparees PDO partout
  • XSShtmlspecialchars() sur toutes les sorties
  • Remember Me — token persistant en DB avec expiration 1 an

Le temps reel sans WebSocket

C'est la partie la plus interessante techniquement. Comment afficher des scores "en direct" sans WebSocket, sans Firebase, sans serveur Node ?

Reponse : du polling intelligent avec signature MD5.

Le principe

Toutes les 3 secondes, le navigateur du spectateur appelle une API qui retourne l'etat actuel des matchs et un hash MD5 de cet etat :

// 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()
]);

Cote client : reload conditionnel

Le JavaScript compare la signature avec la precedente. Si elle a change, la page se recharge. Sinon, rien ne se passe :

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);

L'elegance de cette approche : le serveur ne maintient aucune connexion ouverte. Chaque requete est stateless. Et le MD5 garantit que la page ne se recharge que quand il y a un vrai changement — pas a chaque poll.

Pourquoi pas WebSocket ou SSE ?

Le projet inclut un endpoint SSE (/api/sse/updates.php) comme fallback, mais le polling reste la solution principale pour trois raisons :

  • Hebergement partage — O2Switch ne garantit pas les connexions longues
  • Resilience mobile — un poll qui echoue se relance au prochain interval. Un SSE qui perd sa connexion doit gerer la reconnexion
  • 3 secondes suffisent — on ne trade pas du Bitcoin, c'est du rugby. Un delai de 3 secondes est imperceptible

Push notifications : FCM avec Service Account

Le spectateur peut s'abonner aux notifications d'un match, d'une equipe ou d'un club entier. A chaque score, le backend envoie un push via Firebase Cloud Messaging.

L'authentification OAuth2

FCM API v1 exige un access token OAuth2, pas une simple API key. Le backend genere un JWT signe avec la cle privee du Service Account, l'echange contre un access token, et le met en cache pendant 1 heure :

// 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
]);

Le payload de notification

Chaque notification inclut les scores, les equipes et un deep link vers le match :

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

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

Abonnements a trois niveaux

La requete SQL qui recupere les tokens est la cle du systeme. Un seul query couvre les trois niveaux d'abonnement :

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
)

Un parent qui s'abonne aux "Seniors" recevra les notifications de tous les matchs de cette equipe — pas besoin de s'abonner a chaque match individuellement.

L'interface coach : PWA sur le bord du terrain

L'interface d'administration est pensee pour etre utilisee debout, sous la pluie, avec des gants. C'est une PWA (Progressive Web App) installable sur l'ecran d'accueil du telephone.

Gestion des equipes et matchs

Le coach (ou l'admin du club) peut :

  • Creer des equipes en bulk (un nom par ligne dans un textarea)
  • Programmer des matchs avec autocompletion de l'adversaire (recherche par nom de club, affichage du logo)
  • Demarrer le match et avancer les mi-temps

Scorer en direct

L'ecran de match actif affiche deux colonnes (equipe domicile / exterieur) avec trois boutons chacune : Essai (5 pts), Transformation (2 pts), Penalite (3 pts). Chaque tap :

  1. Ajoute le score en DB
  2. Recalcule le total
  3. Declenche le push notification vers tous les abonnes

Un bouton "Annuler la derniere action" permet de corriger une erreur — essentiel quand on est distrait par le match.

Multi-clubs : une seule codebase, N clubs

La V3 etait construite pour un seul club (BRC). La V4 est multi-tenant : chaque club a son espace, ses equipes, ses coachs et son URL publique.

Les roles

  • Superadmin — gere tous les clubs, approuve les inscriptions, cree les admins
  • Admin club — gere les equipes, les matchs et les coachs de son club
  • Coach — ne voit que son equipe, peut scorer en direct

L'onboarding

Un club qui veut rejoindre myrugby.be remplit un formulaire de proposition. Le superadmin recoit la demande, la valide, cree le club et genere les identifiants admin. Le club peut ensuite gerer tout en autonomie.

PWA : installable, offline-ready

L'app est installable sur iOS et Android via le manifest.json et un Service Worker qui gere le cache et le mode offline :

  • Manifest — icones, theme color, display standalone, orientation portrait
  • Service Worker — cache-first pour les assets statiques, network-first pour les API
  • Offline page — une page dediee s'affiche si le reseau tombe
  • FCM Service Worker — un worker separe gere la reception des push en background

Le resultat : le coach installe l'app sur son ecran d'accueil, et il a une experience native sans passer par l'App Store.

Structure du projet

myrugby.be/ | |-- config/ | |-- constants.php Statuts, scores, roles, Firebase | |-- database.php Singleton PDO + detection env | |-- src/ | |-- Models/ Club, Team, Game, Score, User | |-- Controllers/ AuthController (login, remember me) | |-- Services/ PushNotificationService (FCM) | |-- Utils/ Helpers (CSRF, escape, formatDate) | |-- public/ | |-- index.php Homepage (liste des clubs) | |-- club.php Scoreboard public + polling | |-- sw.js Service Worker (cache + offline) | |-- manifest.json PWA manifest | | | |-- admin/ Interface coach/admin | | |-- active_games.php Scoring en direct | | |-- games.php Creer/gerer les matchs | | |-- teams.php Gerer les equipes | | | |-- superadmin/ Gestion multi-clubs | | |-- clubs.php Tous les clubs | | |-- users.php Admins et coachs | | |-- proposals.php Demandes d'inscription | | | |-- api/ | |-- games/status.php Polling + signature MD5 | |-- sse/updates.php Server-Sent Events (fallback) | |-- notifications/ Subscribe/unsubscribe FCM

Stack technique

  • Backend — PHP 8.x, MVC maison, MySQL, PDO, sessions natives
  • Frontend — Tailwind CSS 4.1, vanilla JavaScript, pas de framework
  • Temps reel — polling 3s avec signature MD5 + SSE en fallback
  • Push — Firebase Cloud Messaging, API v1, Service Account avec JWT
  • PWA — Service Worker, manifest.json, offline page, FCM background worker
  • Hebergement — O2Switch (mutualize), deploy via script FTP
  • Cout — 0 euros/mois (hebergement existant + FCM gratuit)

Ce que j'ai appris

Quelques lecons tirees de ce projet :

  • Le polling est sous-estime. Avec une signature MD5, il devient quasi aussi reactif que du WebSocket pour ce cas d'usage — et infiniment plus simple a deployer sur un hebergement mutualise.
  • FCM API v1 est un parcours du combattant. La migration depuis l'ancienne API (simple API key) vers les Service Accounts avec JWT et OAuth2 est bien documentee mais pleine de subtilites. Le caching du token est essentiel pour ne pas re-generer un JWT a chaque notification.
  • PHP n'est pas mort. Pour un CRUD multi-tenant avec auth, CSRF, sessions et SQL, PHP natif reste redoutablement efficace. Zero build step, zero node_modules, deploiement instantane par FTP.
  • Les coachs ne sont pas des devs. L'interface doit marcher avec des doigts mouilles, sous la pluie, en plein match. Gros boutons, feedback visuel immediat, annulation facile.
Voir myrugby.be en direct Retour aux articles