Comment j'ai failli dessiner le logo de PHP avec 300 téléphones
Publié le 28 octobre 2025
En 2024, j’ai rejoint l’équipe de bénévoles du Forum PHP. J’étais chargé d’animer un ice breaker juste avant la keynote d’ouverture. J’avais alors imaginé un jeu très simple : Patate vs. Raclette, qui avait plutôt bien fonctionné. Pour le Forum PHP 2025, une édition spéciale anniversaire, je voulais marquer le coup en renouvelant l’exercice, mais avec un défi technique supplémentaire : dessiner le logo PHP à l’aide de l’ensemble des personnes présentes dans la salle.
#1 téléphone = 1 pixel
L’idée était de considérer que chaque personne assise représentait un pixel avec l’aide de son téléphone. La difficulté consistait à positionner les téléphones les uns par rapport aux autres afin de reconstituer une grille de pixels.
La méthode la plus simple aurait été de placer un numéro sur chaque siège et de demander au participant·es de le saisir dans mon application. Mais comme l’objectif était de favoriser les échanges entre les participant·es, j’ai écarté cette option.
La géolocalisation n’était pas envisageable non plus, faute de précision suffisante. J’ai aussi envisagé un temps d’utiliser le Bluetooth pour le positionnement. Finalement, un positionnement relatif entre les personnes s’est révélé suffisant pour reconstruire la grille : l’application demandera simplement de renseigner ses voisin·es immédiats (devant, à droite, derrière et à gauche).

On stocke alors l’ensemble des joueur·ses en base de données. Puis en bouclant dessus, on les relie entre eux.
public function link(): void
{
foreach ($this->players as $player) {
if (!empty($player->getTop()) && isset($this->players[$player->getTop()])) {
$player->setPlayerTop($this->players[$player->getTop()]);
}
if (!empty($player->getRight()) && isset($this->players[$player->getRight()])) {
$player->setPlayerRight($this->players[$player->getRight()]);
}
if (!empty($player->getBottom()) && isset($this->players[$player->getBottom()])) {
$player->setPlayerBottom($this->players[$player->getBottom()]);
}
if (!empty($player->getLeft()) && isset($this->players[$player->getLeft()])) {
$player->setPlayerLeft($this->players[$player->getLeft()]);
}
}
}L’algorithme de reconstruction de la grille n’a pas été efficace parce que je n’ai pas réussi à obtenir un résultat concluant dans des conditions réelles. Il y a sans doute eu davantage de “trous” que ce que j’avais imaginé et peut-être des erreurs de saisie de la part des participant·es. En tout cas, l’algorithme a manqué de robustesse et de tests. Je ne vais pas détailler cette partie, car elle n’est pas concluante.
En revanche, en partant d’une grille presque parfaite, avec seulement quelques manques, le principe fonctionne très bien. J’ai créé plusieurs tests unitaires : ils consistent à mélanger une grille de référence, puis à tenter de la reconstruire.
A1 A2 A3 A4
B1 B3 B4
C1 C2 C4
D1 D2 D3 D4
Exemple de test :
public function testGrid(string $grid): void
{
$expected = self::buildGrid($grid);
$players = self::flatAndShuffle($expected);
$positionner = new Positionner();
foreach ($players as $player) {
$positionner->add($player);
}
$positionner->link();
$players = $positionner->getFullGridPlayers();
self::assertCount(count($expected), $players);
foreach($players as $i => $cols) {
self::assertCount(count($expected[$i]), $cols);
foreach($cols as $j => $player) {
self::assertSame($expected[$i][$j], $player, sprintf('"%s" must be "%s"', $i, $j));
}
}
}Envoyer la couleur sur les téléphones
Pour afficher les couleurs sur les téléphones, j’ai utilisé des événements via Server-Sent Events (SSE), en tirant parti du protocole Mercure et de son implémentation dans FrankenPHP. L’image Docker contient tout le nécessaire pour faire tourner le projet : un serveur web, Mercure et PHP.
J’y ai simplement ajouté une base de données PostgreSQL pour le stockage des joueur·ses.
Côté conception, j’ai défini des Events en PHP afin de structurer les données échangées avec une équivalence côté front. Une interface PHP garantit notamment le nom de chaque événement.
final class Visual extends AbstractEvent
{
public function __construct(
#[Groups(["public"])]
public string $color = '',
#[Groups(["public"])]
public string $effect = '',
#[Groups(["public"])]
public ?array $points = []
){}
public function name(): string
{
return 'visual';
}
}Un service Broadcaster est chargé d’envoyer les événements via le Hub Mercure. Ici, l’information est diffusée sur un topic nommé game écouté par l’ensemble des joueur·ses. Ce service applique également les groupes de sérialisation, afin de ne transmettre que les propriétés marquées avec le groupe public.
public function send(EventInterface $event): void
{
$jsonContent = $this->serializer->serialize($event, 'json', [
'groups' => 'public'
]);
$update = new Update([self::TOPIC], $jsonContent);
$this->hub->publish($update);
}Côté client, une simple souscription à l’EventSource du topic game permet de réagir aux événements envoyés via PHP :
useEffect(() => {
const eventSource = new EventSource(eventSourceUrl);
eventSource.addEventListener("message", (e) => {
const event = JSON.parse(e.data) as MessageEvent;
if (event.name === "visual") {
setEventVisual(event as EventVisual);
}
// ...
});
return () => {
eventSource?.close();
};
}, []);À ce stade, on est capable d’envoyer des couleurs grâce à l’EventVisual sur chaque téléphone.
Dessiner le logo de PHP
Pour dessiner le logo, je suis parti de l’image du logo PHP en version PNG. J'imaginais que les joueur·ses seraient rangé·es à peu près sur 24 colonnes et 8 rangées. Pour ne pas déformer le logo, j’ai calculé la taille maximale qu’il peut occuper par rapport à la grille de participant·es. Comme le logo est un PNG transparent, on ajoute ensuite la couleur de fond, puis on compose l’image finale en centrant le logo.
public function resize(string $src, int $width, int $height): \Imagick
{
$final = new \Imagick();
$final->newImage($width, $height, new \ImagickPixel('#4F5991'));
$final->setImageFormat('png');
$logo = new \Imagick(realpath($src));
[$w, $h] = $this->maxWithAndHeight($logo, $width, $height);
$logo->resizeImage($w, $h, \Imagick::FILTER_POINT, 0);
[$x, $y] = $this->computePosition($final, $logo);
$final->compositeImage($logo, \Imagick::COMPOSITE_DEFAULT, $x, $y);
return $final;
}Une fois cette image générée, il faut récupérer la couleur de chaque pixel pour faire la correspondance avec la position de chaque joueur·se. Ici, on retourne l’image (flip/flop) car ce sera vu en face des participant·es et que l’on souhaite voir le logo à l’endroit depuis ce point de vue.
public function buildPoints(string $logo, array $grid): array
{
$points = [];
$rows = count($grid);
$cols = count($grid[0]);
$image = $this->resize($logo, $cols, $rows);
$image->flipImage();
$image->flopImage();
for ($y = 0; $y < $rows; $y++) {
for ($x = 0; $x < $cols; $x++) {
$pixel = $image->getImagePixelColor($x, $y);
if (!isset($grid[$y][$x])) {
continue;
}
$points[$y][$x] = new Point(
$grid[$y][$x]->key,
new Color($pixel->getColor())
);
}
}
return $points;
}Grâce à ce système, il est possible de mapper n’importe quelle image sur la grille des joueur·ses. J’avais prévu une petite animation à partir d’une image d’arc-en-ciel, en envoyant une nouvelle image toutes les 500 ms avec un léger décalage.
Le simulateur
Lors de la phase de test, j’ai eu besoin de lancer un grand nombre de joueur·ses et de voir ce que cela donne. J’ai conçu alors une page HTML qui permet de lancer des fenêtres de navigateur et de contrôler sommairement l’interface.
const start = () => {
COLS = document.getElementById('cols').value;
ROWS = document.getElementById('rows').value;
const width = 100;
const height = 100;
let time = 0;
for (let i = 0; i < COLS; i++) {
windows[i] = [];
for (let j = 0; j < ROWS; j++) {
const left = i * width;
const top = j * (height+40);
const windowFeatures = `left=${left},top=${top},width=${width},height=${height}`;
time += 250;
setTimeout(() => {
windows[i].push(window.open(
"/?simulate=1",
`player${i}_${j}`,
windowFeatures,
));
}, time);
}
}
}Un bouton permet de remplir les emplacements des joueur·ses entre eux. Voici un aperçu de la simulation, on remarque que l'image est bien retournée :
#Le déploiement
J’ai choisi d’utiliser Clever Cloud car ils ont une application FrankenPHP prête à l’emploi. En revanche, pour utiliser Mercure il est nécessaire d’utiliser un Caddyfile personnalisé. Voici celui que j’ai utilisé, il est très proche de celui disponible dans l’image Docker de base :
{
frankenphp {
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}
}
}
:8080 {
log {
# Redact the authorization query parameter that can be set by Mercure
format filter {
request>uri query {
replace authorization REDACTED
}
}
}
root ./public
encode zstd br gzip
mercure {
debug
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
anonymous
subscriptions
}
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
@phpRoute {
not path /.well-known/mercure*
not file {path}
}
rewrite @phpRoute index.php
@frontController path index.php
php @frontController
file_server {
hide *.php
}
}Pour que ce Caddyfile soit pris en compte, une simple variable d’environnement suffit à l’activer :
CC_RUN_COMMAND="frankenphp run --config ./clevercloud/Caddyfile"Ensuite grâce à la variable d’environnement CC_RUN_COMMAND j’ai pu activer la compilation de React au déploiement.
Le jour J
Le jour J, l’affichage du logo n’a malheureusement pas fonctionné. Cependant, le Hub Mercure sur FrankenPHP a tenu sans problème malgré 300 connexions simultanées. Mon algorithme de positionnement des joueur·ses n’était pas assez robuste ni suffisamment testé, mais le plus important était de briser la glace et de détendre les participant·es avant le démarrage des talks, et je crois que cela a fonctionné.

Et puis, cette belle erreur 500 affichée sur l’écran géant a bien fait rire la salle… annonçant une édition mémorable !



