ranksource est une marketplace de contenu sponsorisé. D'un côté, des annonceurs achètent des articles et des liens ; de l'autre, des éditeurs de sites vendent des emplacements dans leurs pages. La plateforme prend une commission sur chaque publication vendue, et le catalogue annoncé par le client dépasse les 100 000 médias dans plus de 80 pays.
Nous l'avons conçue et développée, de la direction technique au code : cinq développeurs d'EpickOne, huit mois, on y revient plus bas. Plutôt que de dérouler la liste des fonctionnalités, on s'arrête ici sur les trois contraintes qui ont pesé le plus lourd dans nos choix, bien plus que le temps de réponse d'une page. L'acheteur et le vendeur ne doivent jamais se reconnaître. Ils ne voient jamais le même prix. Et la promesse commerciale doit pouvoir être vérifiée par une machine.
L'acheteur et le vendeur ne doivent jamais se reconnaître
Si l'acheteur et le vendeur s'identifient, ils traitent en direct la fois suivante et la plateforme s'évapore. Sur une place de marché, c'est le risque qui coûte le plus cher, et il ne déclenche aucune alerte de supervision. Personne ne voit une désintermédiation en cours ; on la lit dans les chiffres, six mois après.
On a donc pris cette peur commerciale et on en a fait une contrainte d'ingénierie écrite. Nous avons rédigé une spécification de 74 lignes, référencée depuis la documentation d'architecture et déclarée prioritaire sur toute autre convention du projet. Elle interdit que le prénom, le nom ou l'adresse de l'une des deux parties apparaisse dans un e-mail reçu par l'autre. Sa structure tient en six blocs : la règle, le pourquoi, comment l'appliquer (ce qui est autorisé, ce qui est interdit), les exemptions, une checklist de vérification et les commandes de contrôle.
Le cas difficile, c'est le message bidirectionnel, celui où le gabarit ignore qui écrit. Là, on a voulu une règle technique et pas une consigne. Le service passe au gabarit un booléen de sens, et le gabarit affiche « L'annonceur » ou « L'éditeur ». On s'interdit de lui passer l'entité de l'expéditeur, et la spécification dit pourquoi en une phrase : si elle est dans le scope, elle fuitera.
Avant d'écrire ce cas, nous avons rejoué les trois commandes de vérification que la spécification fournit : zéro occurrence de l'expéditeur, et les douze occurrences d'identité restantes sont des lignes de salutation adressées au destinataire lui-même, le cas explicitement exempté. La règle tient.
Il faut dire son périmètre exact, qui est celui des e-mails. Dans l'application, l'anonymat est partiel et asymétrique : l'annonceur voit le prénom et l'initiale de l'éditeur, l'éditeur ne voit rien. Et dans le service même qui applique la règle, l'entité annonceur est encore passée dans le contexte d'un gabarit destiné à l'éditeur, exactement ce que notre spécification interdit, pour la raison qu'elle énonce. Le gabarit ne l'utilise pas, il n'y a aujourd'hui aucune fuite au rendu, mais on le sait : la garantie est une discipline, pas une contrainte de portée.
Un modèle de langage en garde-fou contractuel
La même peur a une seconde porte, la messagerie de négociation. Si les deux parties s'accordent sur un montant dans le fil de discussion, elles concluent hors plateforme. Le contrat impose que le prix passe par le champ prévu à cet effet ; il fallait que le code le tienne. Nous avons écrit un service de 72 lignes qui soumet chaque message à un agent, via le composant IA de Symfony, avec une consigne de classification binaire, et qui bloque l'envoi si le message contient un montant chiffré, y compris déguisé. Parler de prix passe ; annoncer un chiffre est bloqué.
Le choix qui compte est ailleurs, et on l'assume. Le service échoue en mode fermé : une réponse inattendue du modèle ou une panne réseau bloquent le message, sur deux chemins que nous avons commentés « blocking by precaution ». Sur un classifieur probabiliste, c'est l'inverse du réflexe habituel. On a préféré le faux positif, un message à reformuler, au faux négatif, une commission perdue. Dans le fil d'une négociation existante, le détecteur ne s'exécute que si le champ de prix est vide : la plateforme offre un canal pour chiffrer une offre et interdit seulement de le faire en texte libre. Le détecteur reste une dépendance optionnelle ; sans agent branché, comme dans notre configuration de test, le message passe.
C'est le même arbitrage que sur notre agent IA qui traite une boîte mail administrative : la place d'un modèle se décide par le coût de son erreur, pas par sa précision moyenne.
Ils ne voient jamais le même prix
L'éditeur raisonne en montant perçu, l'annonceur en montant payé. Entre les deux, une commission résolue en cascade (un taux propre au média, sinon un taux propre à l'éditeur, sinon un palier, sinon une valeur par défaut), une remise liée au type d'offre et une conversion de devise. Le prix affiché à l'annonceur n'est donc stocké dans aucune colonne. Il se calcule à la lecture.
Le catalogue doit pourtant se trier par prix et se filtrer par fourchette, avec une pagination juste. On a d'abord envie de calculer en PHP après extraction, et ça casse dès la première page : impossible de trier un grand catalogue sur un prix calculé quand on n'en charge que vingt lignes. Nous avons donc réexprimé la règle de tarification en DQL. Une méthode construit à la volée une expression CASE/WHEN à partir des paliers lus en base, parcourus en ordre inverse pour que le palier le plus haut l'emporte ; le filtre de prix la compose avec la remise et le taux de change avant de la pousser dans la clause WHERE. Le même mécanisme de champ calculé caché nous sert à émuler un NULLS LAST sur les métriques SEO. Les champs triables sont sur liste blanche, et le résultat passe par le Paginator de Doctrine, parce que des jointures gauches compteraient des lignes et non des entités.
Ce qu'on y gagne est structurel. Un seul constructeur de requête sert la liste paginée, le comptage et les alertes e-mail des recherches sauvegardées. Un utilisateur ne peut pas recevoir une alerte pour un média qui n'apparaîtrait pas à l'écran, le piège classique des marketplaces qui entretiennent deux implémentations du filtre et les laissent diverger.
Une précision, et une ironie. La précision : l'expression de tri n'intègre pas la remise de type d'offre, seul le filtre le fait. L'ironie : le multiplicateur de remise est recodé en dur dans le dépôt de requêtes au lieu d'appeler la classe de tarification prévue, ce que notre propre spécification interdit. Une règle centralisée s'est fait recopier au point le plus chaud du produit. Ça arrive à toutes les règles centralisées, et c'est justement pour ça que nos spécifications embarquent leurs commandes de vérification.
La remise du client ne sort jamais de la poche du vendeur
Un annonceur peut négocier une remise, globale ou ciblée sur un éditeur précis. Nous l'avons prélevée sur la marge de la plateforme, jamais sur le gain de l'éditeur, et plafonnée pour que cette marge reste au-dessus de 10 % du prix de base. Le résolveur calcule le plancher, la marge courante, le maximum autorisé, puis applique le minimum entre la remise demandée et ce maximum.
Il renvoie un nombre et sa raison : sept clés, dont le motif de plafonnement, le motif de désactivation et l'origine de la remise retenue. Une négociation qui a déjà fait baisser le prix désactive la remise de compte, avec le motif correspondant, parce que deux gestes commerciaux ne se cumulent pas sur une même ligne. Entre remise globale et remise ciblée, la plus forte gagne, et le système dit laquelle.
C'est l'un des rares invariants du projet que nous avons testé des deux côtés, le résolveur seul puis son intégration au paiement, et le fichier de test porte en en-tête la date de l'audit et les tickets qu'il verrouille. Une implémentation naïve applique le pourcentage sur le prix final et rogne en silence le gain de l'éditeur, c'est-à-dire la confiance du côté le plus difficile à reconstruire sur une place de marché : l'offre.
La promesse doit être vérifiable par une machine
Dans le netlinking, le client paie un lien, pas une facture. Un lien retiré au bout de trois semaines ne vaut rien, et personne ne s'en aperçoit à l'œil nu.
Quand un éditeur déclare « c'est publié », la plateforme refuse de le croire sur parole. Deux gardes s'appliquent avant tout changement d'état : l'URL doit être sur le domaine du média (la variante www. est tolérée) et elle doit porter un vrai chemin d'article, pas la page d'accueil. La page est ensuite téléchargée, analysée avec DOMDocument, et nous construisons un index des liens vers leurs textes d'ancre après normalisation : schéma ignoré, www. retiré, slash final retiré, chaîne de requête ignorée parce que les éditeurs ajoutent des paramètres utm. Chaque lien commandé doit y figurer, avec la bonne ancre. En cas d'échec, l'éditeur reçoit une violation 422 sur le champ de l'URL, dans son formulaire et dans sa langue, avec le détail de ce qui manque.
Puis un contrôle quotidien, planifié à 2 h 30, rejoue la vérification. Trente contrôles réussis déclenchent le règlement automatique. Cinq échecs consécutifs, le compteur revenant à zéro à chaque succès, annulent la ligne et recréditent le portefeuille de l'annonceur, sans intervention humaine.
Cette rigueur, on ne l'avait pas au départ. Notre première vérification était un simple str_contains : elle acceptait une URL en texte brut et n'importe quelle ancre. Puis un éditeur a fait valider la page d'accueil de son site comme preuve de publication, alors qu'aucun article n'avait été posté. La documentation de la méthode de vérification cite ce cas de production, et c'est lui qui a produit les deux gardes et l'index des liens.
Deux limites à connaître. La comparaison d'ancre accepte l'égalité ou l'inclusion : « mon super site » est validé par « cliquez ici pour voir mon super site », tolérance assumée. Et le contrôle quotidien se limite à deux questions, la page répond-elle et les liens sont-ils présents ; pour une offre sans lien, il devient un simple contrôle de disponibilité. La promesse « le lien ne disparaît pas » vaut pour les offres avec lien.
Une publication automatique qui sait défaire ce qu'elle vient de faire
Pour les médias qui ont connecté leur WordPress, l'article est créé via l'API REST : image à la une, métadonnées SEO Yoast, RankMath ou AIOSEO, programmation native. Quand la publication est immédiate, la plateforme vérifie ensuite la page réellement publiée. Si la vérification échoue, l'article repasse en brouillon sur le site de l'éditeur, les résultats du contrôle sont conservés pour qu'il voie ce qui cloche, et la transition « publié » n'est jamais appliquée. Si le retour en brouillon échoue à son tour, le code le dit explicitement, manual_intervention_required, et un humain tranche.
C'est une action compensatoire sur un système tiers : l'effet de bord est déjà parti chez l'éditeur quand le problème apparaît. Beaucoup d'intégrations s'arrêtent au 201 Created. On a tenu à resynchroniser l'état de la plateforme et l'état du site distant, parce que le pire scénario commercial, c'est un article en ligne, facturé, sans le lien acheté. Une publication programmée à une date future n'entre pas dans ce contrôle au moment de sa création ; la commande reste en attente jusqu'au traitement planifié.
Qui a construit ranksource, et comment tenir la cohérence à cinq
Le projet est un projet EpickOne de bout en bout. Le dépôt vit dans notre organisation GitHub, la production tourne sur notre infrastructure, et l'équipe est la nôtre : cinq développeurs, 1 796 commits et 193 migrations de base sur les huit premiers mois, du 25 septembre 2025 au 20 mai 2026. Notre premier commit posait la pile, Symfony 7.3, API Platform 4.2 et PostgreSQL, puis React 19 et TypeScript côté interface. Nous avons expliqué ailleurs dans quels cas Symfony est le bon socle ; une place de marché qui manipule de l'argent réel en fait partie.
Tenir la cohérence à cinq personnes qui livrent vite, on l'a fait avec trois choses. Des règles écrites et versionnées, chacune avec sa checklist de vérification et ses commandes de contrôle : les trois spécifications transverses du projet, dont celle de l'anonymisation. Une convention structurante tenue à 100 % : aucune annotation d'API dans les entités, tout en YAML, vérifié sur 869 fichiers PHP. Et un poste d'intégrateur : sept Pull Requests sur dix ont été fusionnées par la direction technique, dernier filtre avant que le code entre sur la branche d'intégration.
Ce que nous avons choisi de ne pas faire
Un cas client qui ne dit que du bien ne se lit pas. Trois arbitrages, que nous assumons.
Nous avons livré vite et repoussé la vérification. L'intégration continue vérifie la syntaxe PHP, la validité des fichiers YAML qui pilotent la sérialisation de l'API, la compilation TypeScript stricte et les builds. Elle n'exécute pas les suites de tests : les 201 cas PHPUnit et les 42 scénarios de bout en bout existent, se lancent à la main, et ne gardent aucune porte. Nous avons même construit un workflow complet de tests de bout en bout, sans jamais le brancher sur les Pull Requests. C'est le premier poste que nous regardons dans un audit de dette technique, et il vaut aussi pour notre propre code.
Le schéma a bougé jusqu'au bout. Cent quatre-vingt-treize migrations en huit mois, c'est un signal de vélocité, pas de qualité : 72 tables ont été créées au fil de l'historique pour 66 vivantes aujourd'hui, une demi-douzaine ayant été supprimée ou renommée en route.
Nous ne publierons ni volume d'affaires ni résultat commercial. Ces chiffres appartiennent au client, et le seul ordre de grandeur cité ici, les 100 000 médias, est celui qu'il affiche lui-même sur son site.
Ce que ce cas dit d'un projet de marketplace
Trois enseignements que nous garderions pour la prochaine plateforme.
Les problèmes qui tuent une place de marché sont dans le modèle économique. Une panne se voit et se répare. Une désintermédiation silencieuse se lit dans le chiffre d'affaires, six mois plus tard.
Une règle métier ne survit que si elle embarque sa propre vérification. Notre spécification d'anonymisation tient parce que trois commandes permettent de le prouver ; notre règle de tarification s'est fait recopier là où personne ne relisait.
La place d'un modèle de langage se décide par le coût de son erreur. Ici, un faux positif coûte une reformulation et un faux négatif coûte une commission. Le modèle bloque dans le doute.
Vous construisez une plateforme, une marketplace ou un SaaS métier ? Ces trois contraintes existent dans votre projet avant la première ligne de code. On regarde votre modèle avec vous (qui paie qui, qui voit quoi, ce que vous promettez) et on vous dit ce qui doit être verrouillé dès le départ et ce qui peut attendre. Nous sommes à Escalquens, aux portes de Toulouse, et à Paris : échange de 30 minutes sur votre projet, diagnostic offert. Notre façon de travailler est décrite sur la page de l'agence de développement web.