<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Grégoire Pineau</title><link>https://blog.lyrixx.info/</link><description>A geek’s herbarium.</description><language>en</language><lastBuildDate>Thu, 05 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.lyrixx.info/index.xml" rel="self" type="application/rss+xml"/><item><title>The Symfony Workflow demo got a new look</title><link>https://blog.lyrixx.info/posts/the-symfony-workflow-demo-got-a-new-look/</link><guid>https://blog.lyrixx.info/posts/the-symfony-workflow-demo-got-a-new-look/</guid><pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;In April 2016, I created a small application for SymfonyLive Paris, to show the new Workflow component. Ten years later, the &lt;a href="https://demo-symfony-workflow.cleverapps.io/"&gt;demo&lt;/a&gt; is still online. And it just got a new look.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/the-symfony-workflow-demo-got-a-new-look/screenshot.jpg" alt="The new homepage of the Symfony Workflow demo"&gt;&lt;/p&gt;&#10;&lt;p&gt;Why now? I was fed up with the ugly old style.&lt;/p&gt;&#10;&lt;h2 id="what-about-the-style"&gt;What about the style?&lt;/h2&gt;&#10;&lt;p&gt;The new theme is green, with dark bands. And the diagrams now look like Petri nets, the model behind the Workflow component: black transitions, and marked places in lime.&lt;/p&gt;&#10;&lt;p&gt;Bootstrap 5.3.8 and Mermaid come from AssetMapper instead of a CDN. But Mermaid has a funny side effect: its single-file build is not an ES module, so it adds about 125 entries to &lt;code&gt;importmap.php&lt;/code&gt; (d3, cytoscape…).&lt;/p&gt;&#10;&lt;aside&gt;125 entries. For a diagram.&lt;/aside&gt;&#10;&#10;&lt;p&gt;That&amp;rsquo;s why it only loads on the pages that show a diagram.&lt;/p&gt;&#10;&lt;h2 id="whats-new-in-the-demo"&gt;What&amp;rsquo;s new in the demo?&lt;/h2&gt;&#10;&lt;p&gt;Applying a transition doesn&amp;rsquo;t reload the page anymore. Turbo morphs it and keeps the scroll. And a small Stimulus controller keeps the diagram on screen until the new one is drawn, so it never goes blank.&lt;/p&gt;&#10;&lt;p&gt;But Turbo prefetches the links you hover. And in this demo, logging in and out are GET requests. So just hovering a link would log you in 😅. That&amp;rsquo;s why Turbo doesn&amp;rsquo;t prefetch these two links.&lt;/p&gt;&#10;&lt;aside&gt;Logged out by your mouse.&lt;/aside&gt;&#10;&#10;&lt;p&gt;The show pages now list the transitions with the guards that block them. And the history shows the last 5 transitions, instead of the first 5.&lt;/p&gt;&#10;&lt;h2 id="what-about-symfony-82"&gt;What about Symfony 8.2?&lt;/h2&gt;&#10;&lt;p&gt;The demo runs on Symfony 8.2.0-BETA1, and it uses what&amp;rsquo;s new.&lt;/p&gt;&#10;&lt;p&gt;You can now define a workflow in a class, with PHP attributes. Here is a simplified version of the article workflow:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;#[&lt;/span&gt;&lt;span class="nd"&gt;AsWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="na"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;supports&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleWorkflow&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nx"&gt;WorkflowTrait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;#[&lt;/span&gt;&lt;span class="nd"&gt;Place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="nx"&gt;DRAFT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;draft&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;#[&lt;/span&gt;&lt;span class="nd"&gt;Transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="na"&gt;DRAFT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="na"&gt;WAITING_FOR_JOURNALIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="na"&gt;WAIT_FOR_SPELLCHECKER&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;guard&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;is_fully_authenticated()&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="nx"&gt;REQUEST_REVIEW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;request review&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The demo also has five small workflows in YAML, with no class behind them. But a workflow needed a &lt;code&gt;supports&lt;/code&gt; key, so I had to write this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;framework&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;straight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;workflow&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;stdClass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# Just a hack&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With 8.2, &lt;code&gt;supports&lt;/code&gt; is optional, and workflows have their own root key:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;straight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;workflow&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The homepage lost its controller too, thanks to the new &lt;code&gt;template&lt;/code&gt; key of the routes:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;homepage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;homepage/index.html.twig&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And &lt;code&gt;bin/console lint:expressions&lt;/code&gt; now checks the guards of the workflows in the CI.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;Go &lt;a href="https://demo-symfony-workflow.cleverapps.io/"&gt;play with the demo&lt;/a&gt;. Connect as a journalist, apply a transition, and see what happens. And the code is &lt;a href="https://github.com/lyrixx/SFLive-Paris2016-Workflow"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>What's new in private-stuff, my emergency website</title><link>https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/</link><guid>https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/</guid><pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;If I lose my phone while traveling, I still need my 2FA recovery codes, my documents and my contacts. That&amp;rsquo;s why I built &lt;a href="https://github.com/lyrixx/private-stuff"&gt;private-stuff&lt;/a&gt; in 2024: a static website with everything I may need in an emergency.&lt;/p&gt;&#10;&lt;aside&gt;Passport, driving license, bank, insurance…&lt;/aside&gt;&#10;&#10;&lt;p&gt;It got a big update this autumn: a new theme, a safer deployment, and above all a way to share a secret with people you trust.&lt;/p&gt;&#10;&lt;p&gt;The whole site sits behind a password, and the recovery codes are also encrypted with &lt;a href="https://github.com/robinmoisson/staticrypt"&gt;staticrypt&lt;/a&gt;. You can see &lt;a href="https://lyrixx.github.io/private-stuff/"&gt;a demo&lt;/a&gt; with dummy data.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/screenshot.jpg" alt="The demo: emergency contacts with big Call and Email buttons"&gt;&lt;/p&gt;&#10;&lt;h2 id="what-changed-on-the-website"&gt;What changed on the website?&lt;/h2&gt;&#10;&lt;p&gt;The site has a new &amp;ldquo;night light&amp;rdquo; theme: always dark and warmer, with big Call and Email buttons on the emergency contacts.&lt;/p&gt;&#10;&lt;p&gt;The project now has two modes. &lt;code&gt;dev&lt;/code&gt;, the default, uses dummy data and forbids the deployment. &lt;code&gt;prod&lt;/code&gt; uses my real data, and the secrets live in a file which is only loaded in this mode. So they can&amp;rsquo;t leak into a &lt;code&gt;dev&lt;/code&gt; build.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;encrypt&lt;/code&gt; and &lt;code&gt;decrypt&lt;/code&gt; tasks now use the native functions of Castor, instead of my hand-written libsodium code.&lt;/p&gt;&#10;&lt;h2 id="what-went-wrong-with-the-deployment"&gt;What went wrong with the deployment?&lt;/h2&gt;&#10;&lt;p&gt;The real site is on Cloudflare Pages, with a password in front of it. But deploying from another git branch created a &lt;em&gt;preview&lt;/em&gt; deployment, which doesn&amp;rsquo;t get the production secrets. So the password was empty, and the middleware let everyone in.&lt;/p&gt;&#10;&lt;aside&gt;Oups!&lt;/aside&gt;&#10;&#10;&lt;p&gt;Now &lt;code&gt;castor deploy&lt;/code&gt; always deploys to the production branch. And after the deployment, it checks that the site really asks for a password. If not, the task fails.&lt;/p&gt;&#10;&lt;p&gt;The password itself was also sent with a shell &lt;code&gt;echo&lt;/code&gt;, which exposed it on the command line and in the error output of Castor. It now goes on stdin.&lt;/p&gt;&#10;&lt;h2 id="how-to-share-a-secret"&gt;How to share a secret?&lt;/h2&gt;&#10;&lt;p&gt;Some things are too sensitive for this website, like a main password or bank accounts. But the people close to you must be able to reach them if something really bad happens.&lt;/p&gt;&#10;&lt;p&gt;You could give the password to one person. But then you trust one person, and everything that can happen to this person&amp;rsquo;s laptop. You could also cut it in two halves. But each half reveals a part of the password, and if one half is lost, the password is lost too.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s what &lt;a href="https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing"&gt;Shamir&amp;rsquo;s secret sharing&lt;/a&gt; solves. You split a secret into N pieces, and you choose a threshold K. Any K pieces give the secret, and fewer give nothing.&lt;/p&gt;&#10;&lt;aside&gt;Adi Shamir, the S of RSA. &amp;ldquo;How to Share a Secret&amp;rdquo; is the title of his 1979 paper.&lt;/aside&gt;&#10;&#10;&lt;h3 id="how-can-a-piece-reveal-nothing"&gt;How can a piece reveal nothing?&lt;/h3&gt;&#10;&lt;p&gt;Let&amp;rsquo;s take a threshold of 2. Draw a line on a graph, and put your secret where the line crosses the vertical axis. Then give each person a different point of this line.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/shamir-line.svg" alt="A line crossing the vertical axis at the secret, with three points on it for John, Jane and Bob"&gt;&lt;/p&gt;&#10;&lt;aside&gt;John + Jane, John + Bob or Jane + Bob: any pair works.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Two points define a line. So two people can draw it, and read the secret. But one point is not enough: an infinity of lines go through it, and they can cross the axis anywhere. So one piece says nothing about the secret. Not &amp;ldquo;almost nothing&amp;rdquo;: nothing.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/shamir-one-point.svg" alt="Many lines going through the single point of Jane, each one crossing the vertical axis somewhere else"&gt;&lt;/p&gt;&#10;&lt;p&gt;With a threshold of 3, you use a parabola, which needs three points. And so on.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/shamir-parabola.svg" alt="A parabola crossing the vertical axis at the secret, with three points on it"&gt;&lt;/p&gt;&#10;&lt;aside&gt;A threshold of K: a curve of degree K − 1.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Note: the real implementation works on bytes, in a finite field (GF(2⁸)), not on a graph. But the idea is the same.&lt;/p&gt;&#10;&lt;h3 id="how-do-you-seal-a-document"&gt;How do you seal a document?&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/eljojo/rememory"&gt;ReMemory&lt;/a&gt; does the crypto, and private-stuff wraps it in Castor tasks.&lt;/p&gt;&#10;&lt;p&gt;First, you write your document in Markdown, in &lt;code&gt;data/shamir.md&lt;/code&gt;. Then you list the people in &lt;code&gt;data/shamir.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;fr&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;holders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;John Doe&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;+1234567890&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Jane Smith&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;janesmith@example.com&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Bob&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You check the document, and you seal it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;castor shamir:preview&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;castor shamir:seal&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The task renders the document as an HTML page with no external assets, because it may be opened offline, years later. Then ReMemory encrypts it with &lt;a href="https://github.com/FiloSottile/age"&gt;age&lt;/a&gt; and a random passphrase, and splits this passphrase with Shamir:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;document ──► encrypted with age ──► the same in every bundle&#10;passphrase ──► split in N pieces ──► one piece per bundle&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Each person gets a ZIP bundle, with a &lt;code&gt;recover.html&lt;/code&gt; page, and a printable &lt;code&gt;README.pdf&lt;/code&gt; with the instructions, their piece and the contact list.&lt;/p&gt;&#10;&lt;p&gt;You send each bundle to its holder, then you delete &lt;code&gt;var/shamir/&lt;/code&gt;. Together, these bundles open the document, so they must not stay in one place.&lt;/p&gt;&#10;&lt;p&gt;If the threshold equals the number of people, the task warns you: a single lost bundle, and nobody can open the document.&lt;/p&gt;&#10;&lt;h3 id="how-do-they-open-it"&gt;How do they open it?&lt;/h3&gt;&#10;&lt;p&gt;A holder opens &lt;code&gt;recover.html&lt;/code&gt; in any browser, offline, with nothing to install. Their own piece is already loaded. They add the bundles of the other holders, until the threshold is reached, and the document opens.&lt;/p&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t depend on ReMemory anymore: the page is self-contained, and it still works if the project disappears.&lt;/p&gt;&#10;&lt;p&gt;But any K holders can open the document, without asking you. So choose the people and the threshold carefully.&lt;/p&gt;&#10;&lt;aside&gt;Choose your friends well.&lt;/aside&gt;&#10;&#10;&lt;p&gt;And if you seal the document again, it creates a new key. The old bundles can&amp;rsquo;t open the new document, so you have to send the new bundles to everyone.&lt;/p&gt;&#10;&lt;h3 id="is-rememory-safe"&gt;Is ReMemory safe?&lt;/h3&gt;&#10;&lt;p&gt;I ran a security audit of ReMemory, with &lt;a href="https://github.com/cloudflare/security-audit-skill"&gt;the security audit skill from Cloudflare&lt;/a&gt;. It took about 4 hours. Then I shared the results with José Albornoz, its author, and he &lt;a href="https://github.com/eljojo/rememory/pull/186"&gt;patched the library&lt;/a&gt; quickly. Thanks José!&lt;/p&gt;&#10;&lt;p&gt;There was nothing serious. But one finding is a nice illustration of the line above. The secret is the point at x = 0, so nobody ever gets a piece there. And the recovery didn&amp;rsquo;t check it. With a fake piece at x = 0, the line goes through this point, whatever the other pieces are, so whoever wrote the fake piece chooses the result. They can&amp;rsquo;t learn your real secret, but they could make someone open their files instead of yours.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/what-s-new-in-private-stuff-my-emergency-website/shamir-fake-piece.svg" alt="A fake piece on the vertical axis and the real piece of Jane give a line that crosses the axis at the fake value, instead of the real secret"&gt;&lt;/p&gt;&#10;&lt;aside&gt;The real piece changes nothing.&lt;/aside&gt;&#10;&#10;&lt;p&gt;ReMemory now refuses these pieces, and the existing bundles keep working.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;The code is &lt;a href="https://github.com/lyrixx/private-stuff"&gt;on GitHub&lt;/a&gt;, and the README explains how to build your own, with your own data. And you can try the recovery with &lt;a href="https://github.com/eljojo/rememory/releases/latest/download/demo-bundles.zip"&gt;the demo bundles of ReMemory&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>The Messenger and Mercure demo got a big update</title><link>https://blog.lyrixx.info/posts/the-messenger-and-mercure-demo-got-a-big-update/</link><guid>https://blog.lyrixx.info/posts/the-messenger-and-mercure-demo-got-a-big-update/</guid><pubDate>Wed, 21 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;For SymfonyLive Paris 2025, I gave &lt;a href="https://speakerdeck.com/lyrixx/asynchrone-avec-symfony-messenger-et-mercure"&gt;a talk about async with Messenger and Mercure&lt;/a&gt;, and I built &lt;a href="https://github.com/lyrixx/async-messenger-mercure"&gt;a demo&lt;/a&gt; for it. A year and a half later, this demo got a big update: Symfony 8.1, FrankenPHP and Mercure 1.0.&lt;/p&gt;&#10;&lt;p&gt;Why now? I was fed up with the ugly UI, the demo was getting old, and Mercure 1.0 had just come out.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/the-messenger-and-mercure-demo-got-a-big-update/home.jpg" alt="The new home page of the demo, with its three modes"&gt;&lt;/p&gt;&#10;&lt;h2 id="what-does-the-demo-do"&gt;What does the demo do?&lt;/h2&gt;&#10;&lt;p&gt;You upload a CSV of first names, and you compare three ways to import it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Sync: the import runs during the HTTP request, and the browser waits&lt;/li&gt;&#10;&lt;li&gt;Async, no feedback: a worker does the job, the response is instant, but you have no clue about the progress&lt;/li&gt;&#10;&lt;li&gt;Async, with feedback: same thing, but the worker publishes its progress on Mercure, and the page is updated in real time&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/the-messenger-and-mercure-demo-got-a-big-update/demo.gif" alt="The progress bar of an import, updated in real time up to Done!"&gt;&lt;/p&gt;&#10;&lt;p&gt;No polling involved. The worker renders a Twig block, and it publishes the result:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;twig&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;csv/async.html.twig&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;renderBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;status&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;mercurePublisher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;csv:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$importId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And the page subscribes to it with one line:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-twig" data-lang="twig"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;turbo_stream_from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;csv:&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="nv"&gt;importId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="x"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is new too. &lt;code&gt;turbo_stream_listen()&lt;/code&gt; is deprecated since UX Turbo 3.1, so I moved to &lt;code&gt;turbo_stream_from()&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Note: a Messenger worker keeps the code it started with. So restart it after a &lt;code&gt;composer install&lt;/code&gt;, or your imports will fail with &lt;code&gt;Unknown &amp;quot;turbo_stream_from&amp;quot; function&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="what-changed"&gt;What changed?&lt;/h2&gt;&#10;&lt;p&gt;The demo now has a home page, with the three modes, and it runs on Symfony 8.1. The Docker stack comes from docker-starter, with FrankenPHP, which embeds the Mercure hub.&lt;/p&gt;&#10;&lt;p&gt;It also got a PHPUnit test suite and a CI. And I fixed two bugs along the way: submitting the form without a file was a 500 error, and a service was named &lt;code&gt;deabug.log_processor&lt;/code&gt;.&lt;/p&gt;&#10;&lt;aside&gt;Yes, deabug.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Cherry on top: &lt;code&gt;castor media&lt;/code&gt; drives a real headless Chromium, with &lt;a href="https://github.com/playwright-php/playwright"&gt;Playwright PHP&lt;/a&gt;, to regenerate the screenshot and the GIF of this article.&lt;/p&gt;&#10;&lt;h2 id="what-about-mercure-10"&gt;What about Mercure 1.0?&lt;/h2&gt;&#10;&lt;p&gt;FrankenPHP 1.13 embeds the Mercure 1.0 hub, and mercure-bundle 0.6 speaks protocol 1.0 by default. So both sides must move together.&lt;/p&gt;&#10;&lt;p&gt;The main change is about the JWT tokens: the hub now only trusts the tokens of a known issuer. So the &lt;code&gt;Caddyfile&lt;/code&gt;, which configures the hub, looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-caddyfile" data-lang="caddyfile"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gh"&gt;issuer&lt;/span&gt; &lt;span class="se"&gt;{$MERCURE_JWT_ISSUER}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;publisher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;jwt&lt;/span&gt; &lt;span class="se"&gt;{env.MERCURE_PUBLISHER_JWT_KEY}&lt;/span&gt; &lt;span class="se"&gt;{env.MERCURE_PUBLISHER_JWT_ALG}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;subscriber&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;jwt&lt;/span&gt; &lt;span class="se"&gt;{env.MERCURE_SUBSCRIBER_JWT_KEY}&lt;/span&gt; &lt;span class="se"&gt;{env.MERCURE_SUBSCRIBER_JWT_ALG}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These are environment variables, set in the &lt;code&gt;docker-compose.yml&lt;/code&gt; of the demo:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;MERCURE_JWT_ISSUER&lt;/code&gt; is a name you choose for the issuer of your tokens. The demo uses its own URL, &lt;code&gt;https://async-messenger-mercure.test&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;MERCURE_PUBLISHER_JWT_KEY&lt;/code&gt; and &lt;code&gt;MERCURE_SUBSCRIBER_JWT_KEY&lt;/code&gt; are the secrets used to sign the tokens. Use your own secrets, not the ones of the demo&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;MERCURE_PUBLISHER_JWT_ALG&lt;/code&gt; and &lt;code&gt;MERCURE_SUBSCRIBER_JWT_ALG&lt;/code&gt; are the signing algorithms. The demo doesn&amp;rsquo;t set them, so the hub uses its default, &lt;code&gt;HS256&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;aside&gt;In the demo, the key starts with !ChangeThis.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Symfony signs the tokens, so it needs the same issuer, in the &lt;code&gt;iss&lt;/code&gt; claim:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;mercure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hubs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;%env(MERCURE_JWT_ISSUER)%&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But wait, there is a trap. The &lt;code&gt;demo&lt;/code&gt; and &lt;code&gt;ui&lt;/code&gt; options of the &lt;code&gt;Caddyfile&lt;/code&gt;, which enabled the debug interface of the hub, are gone. So I replaced them with &lt;code&gt;playground&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The problem is that FrankenPHP serves the hub and the application on the same site. And the playground redirects &lt;code&gt;/&lt;/code&gt; to the debug interface. So when I opened the demo, I no longer saw the home page of the application, but the debug interface of Mercure 😅.&lt;/p&gt;&#10;&lt;p&gt;The fix is to use &lt;code&gt;debugger&lt;/code&gt; instead. It serves the debug interface at &lt;code&gt;/.well-known/mercure/debug/&lt;/code&gt;, and it leaves &lt;code&gt;/&lt;/code&gt; to the application.&lt;/p&gt;&#10;&lt;aside&gt;And unlike playground, it&amp;rsquo;s safe in production.&lt;/aside&gt;&#10;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;The demo runs on your machine, you only need Docker and Castor:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;castor start&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The code is &lt;a href="https://github.com/lyrixx/async-messenger-mercure"&gt;on GitHub&lt;/a&gt;, and the slides are &lt;a href="https://speakerdeck.com/lyrixx/asynchrone-avec-symfony-messenger-et-mercure"&gt;on Speaker Deck&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>How to convert emoji with PHP, in your browser</title><link>https://blog.lyrixx.info/posts/how-to-convert-emoji-with-php-in-your-browser/</link><guid>https://blog.lyrixx.info/posts/how-to-convert-emoji-with-php-in-your-browser/</guid><pubDate>Wed, 21 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;I maintain symfony/emoji, and I thought it would be hilarious to build something with it in WebAssembly. So here is &lt;a href="https://jolicode.github.io/emoji-converter/"&gt;Emoji Converter&lt;/a&gt;. It turns 🐘 into &lt;code&gt;:elephant:&lt;/code&gt; and back, with a whole PHP interpreter running in your browser.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/how-to-convert-emoji-with-php-in-your-browser/screenshot.jpg" alt="Emoji on the left, GitHub shortcodes on the right, and a PHP elephant in the middle"&gt;&lt;/p&gt;&#10;&lt;h2 id="is-it-a-joke"&gt;Is it a joke?&lt;/h2&gt;&#10;&lt;p&gt;Mostly 😁. symfony/emoji converts your text with a good old &lt;code&gt;strtr()&lt;/code&gt;, and I ship 8 MB of WebAssembly to call it.&lt;/p&gt;&#10;&lt;aside&gt;A whole interpreter to call strtr().&lt;/aside&gt;&#10;&#10;&lt;p&gt;But it works. And it&amp;rsquo;s fast: a few milliseconds per conversion, PHP boot included.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/how-to-convert-emoji-with-php-in-your-browser/metrics.jpg" alt="Very serious metrics: the PHP version, the requests served by the browser, the duration of a conversion and the size of the WebAssembly"&gt;&lt;/p&gt;&#10;&lt;h2 id="what-does-it-do"&gt;What does it do?&lt;/h2&gt;&#10;&lt;p&gt;It converts emoji to shortcodes (GitHub, GitLab, Slack, text), to their names in 136 languages, or to nothing at all. And it converts them back. There is no server, so your text never leaves the tab.&lt;/p&gt;&#10;&lt;aside&gt;100% serverless: the server is your browser.&lt;/aside&gt;&#10;&#10;&lt;p&gt;The PHP part is a thin layer over symfony/emoji:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;EmojiConverter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;fromEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;github&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Hello 👋 🐘&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Hello :wave: :elephant:&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;fromEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;fr&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Hello 👋 🐘&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Hello signe de la main éléphant&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;toEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;slack&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Hello :wave: :elephant:&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Hello 👋 🐘&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But symfony/emoji only converts shortcodes back to emoji. Names are ordinary words, so a simple &lt;code&gt;strtr()&lt;/code&gt; would also replace them inside other words. In French, &amp;ldquo;chat&amp;rdquo; (cat) is also in &amp;ldquo;achat&amp;rdquo; (purchase).&lt;/p&gt;&#10;&lt;p&gt;So the converter replaces whole words only, and it prefers the longest name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;toEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;fr&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Un chat noir, pas un achat&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Un 🐈‍⬛, pas un achat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="how-does-it-work"&gt;How does it work?&lt;/h2&gt;&#10;&lt;p&gt;PHP 8.5 is compiled with Emscripten by &lt;a href="https://github.com/lyrixx/php-wasm-builder"&gt;php-wasm-builder&lt;/a&gt;, and the application is embedded in the build.&lt;/p&gt;&#10;&lt;p&gt;For each conversion, the page runs a small PHP script. The request goes on stdin, and the result comes back on stdout, as JSON:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;php://stdin&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;EmojiConverter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nx"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;PHP is started and shut down on each conversion. Yes, like CGI.&lt;/p&gt;&#10;&lt;h2 id="what-was-the-hard-part"&gt;What was the hard part?&lt;/h2&gt;&#10;&lt;p&gt;Not the emoji. The hard part was the intl extension, because symfony/emoji needs the &lt;code&gt;\Transliterator&lt;/code&gt; class.&lt;/p&gt;&#10;&lt;p&gt;You can&amp;rsquo;t just load an &lt;code&gt;intl.so&lt;/code&gt;: &lt;code&gt;dlopen()&lt;/code&gt; compiles WebAssembly synchronously, and Chrome refuses that on the main thread. So ICU is statically linked in the binary.&lt;/p&gt;&#10;&lt;p&gt;Then there is the size. The ICU data weighs about 30 MB, and the catalogs of symfony/emoji about 37 MB. So I don&amp;rsquo;t ship the ICU data at all: everything else that needs it (&lt;code&gt;Collator&lt;/code&gt;, &lt;code&gt;NumberFormatter&lt;/code&gt;…) fails. And the page only fetches the catalogs it needs.&lt;/p&gt;&#10;&lt;aside&gt;The 30 MB of ICU data stayed home.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Note: Fibers and &lt;code&gt;proc_open()&lt;/code&gt; don&amp;rsquo;t work either. Emscripten does not provide the functions they rely on.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;Is it useful? Not really. But I had fun, and it was a good excuse to build PHP with intl for the browser. &lt;a href="https://jolicode.github.io/emoji-converter/"&gt;Try it&lt;/a&gt;, and the code is &lt;a href="https://github.com/jolicode/emoji-converter"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>La raclette est-elle de gauche ou de droite ?</title><link>https://blog.lyrixx.info/posts/la-raclette-est-elle-de-gauche-ou-de-droite/</link><guid>https://blog.lyrixx.info/posts/la-raclette-est-elle-de-gauche-ou-de-droite/</guid><pubDate>Thu, 08 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;Vous tapez n&amp;rsquo;importe quoi (« la raclette », « le vélo », « Chateaubriand »), et une IA vous dit si c&amp;rsquo;est de gauche ou de droite. C&amp;rsquo;est &lt;a href="https://lyrixx.github.io/gauche-ou-droite/"&gt;De gauche ou de droite ?&lt;/a&gt;, un clone du &lt;a href="https://degaucheoudedroite.delemazure.fr/"&gt;site de Théo Delemazure&lt;/a&gt;, avec une différence : le modèle tourne entièrement dans votre navigateur. Il n&amp;rsquo;y a ni serveur ni API.&lt;/p&gt;&#10;&lt;p&gt;L&amp;rsquo;IA a été entraînée sur des clichés, donc à ne pas prendre au sérieux.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/la-raclette-est-elle-de-gauche-ou-de-droite/screenshot.jpg" alt="Le site : un champ de texte, et un hémicycle avec une aiguille entre la gauche et la droite"&gt;&lt;/p&gt;&#10;&lt;h2 id="comment-cest-parti-"&gt;Comment c&amp;rsquo;est parti ?&lt;/h2&gt;&#10;&lt;p&gt;Je voulais tester Jev, un nouveau modèle de TypeSafe AI, dans une page statique. Jev ne génère pas de texte : il renvoie une décision et un score de confiance. Exactement ce qu&amp;rsquo;il me fallait.&lt;/p&gt;&#10;&lt;p&gt;Mais Jev est propriétaire, et accessible seulement par API. Impossible de le faire tourner dans un navigateur.&lt;/p&gt;&#10;&lt;aside&gt;Pas de poids ouverts, pas de chocolat.&lt;/aside&gt;&#10;&#10;&lt;p&gt;J&amp;rsquo;ai donc gardé l&amp;rsquo;idée et la page statique, et j&amp;rsquo;ai remplacé Jev par un classifieur fait maison.&lt;/p&gt;&#10;&lt;h2 id="pourquoi-pas-un-petit-llm-"&gt;Pourquoi pas un petit LLM ?&lt;/h2&gt;&#10;&lt;p&gt;La première idée, c&amp;rsquo;est un petit LLM open source dans le navigateur. Mais c&amp;rsquo;est un giga ou plus à télécharger, il est médiocre en français à cette taille, et surtout, un LLM, ça discute. On lui demande « gauche ou droite ? », il répond « alors, c&amp;rsquo;est une question complexe… ». Moi, je voulais un modèle qui décide.&lt;/p&gt;&#10;&lt;p&gt;Un modèle d&amp;rsquo;embeddings fait un seul passage sur la phrase, là où un LLM génère sa réponse mot par mot :&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;#34;le vélo&amp;#34; ──► modèle d&amp;#39;embeddings ──► vecteur ──► régression logistique ──► 72 % gauche&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Le modèle d&amp;rsquo;embeddings ne sait rien de la politique, il représente seulement le sens d&amp;rsquo;un texte. Ensuite, une régression logistique, entraînée sur 972 exemples étiquetés, trouve quelle direction veut dire gauche, et laquelle veut dire droite.&lt;/p&gt;&#10;&lt;aside&gt;768 poids et un biais, dans un JSON de quelques Ko.&lt;/aside&gt;&#10;&#10;&lt;h2 id="est-ce-que-plus-dexemples-ça-aide-"&gt;Est-ce que plus d&amp;rsquo;exemples, ça aide ?&lt;/h2&gt;&#10;&lt;p&gt;Pas vraiment. Je suis passé de 253 exemples à 952, et la précision n&amp;rsquo;est passée que de 66-68 % à 68,7 %. J&amp;rsquo;ai tout mesuré avec une validation croisée : on cache 1/5 des exemples, on entraîne sur le reste, et on mesure sur la partie cachée.&lt;/p&gt;&#10;&lt;p&gt;Les nouveaux exemples ont quand même corrigé des cas gênants. Avant, « facho » était de gauche à 84 %.&lt;/p&gt;&#10;&lt;aside&gt;Et « Marine Le Pen » à 86 %.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Le vrai goulot d&amp;rsquo;étranglement, c&amp;rsquo;était le modèle. J&amp;rsquo;ai donc monté un banc d&amp;rsquo;essai avec 11 modèles d&amp;rsquo;embeddings. EmbeddingGemma (197 Mo) atteint 81-82 %, contre 70,6 % pour la première version avec multilingual-e5-small. Dans le vrai pipeline, il donne 79 %.&lt;/p&gt;&#10;&lt;p&gt;Le résultat le plus drôle vient de Qwen3-Embedding. Son vecteur est calculé à partir du dernier mot de la phrase. Si le texte se termine par « votent pour », la précision passe donc de 80 % à 89 %. Du prompt engineering, pour un embedding ! Mais il pèse 614 Mo.&lt;/p&gt;&#10;&lt;p&gt;Et le zero-shot, qui compare le texte à « de gauche » et « de droite » sans aucun entraînement, donne 48 à 60 %. Pas mieux que le hasard. Il faut dire que « de droite », c&amp;rsquo;est aussi… le côté droit.&lt;/p&gt;&#10;&lt;aside&gt;Une pièce de monnaie fait 50 %.&lt;/aside&gt;&#10;&#10;&lt;h2 id="quel-classifieur-choisir-"&gt;Quel classifieur choisir ?&lt;/h2&gt;&#10;&lt;p&gt;Une fois le modèle d&amp;rsquo;embeddings choisi, il reste à décider qui tranche. J&amp;rsquo;ai donc monté &lt;a href="https://lyrixx.github.io/gauche-ou-droite/media/bench/"&gt;un second banc d&amp;rsquo;essai&lt;/a&gt; : 12 classifieurs entraînés sur les embeddings des 972 exemples, avec les mêmes découpages pour tous.&lt;/p&gt;&#10;&lt;p&gt;Le SVM à noyau RBF arrive en tête avec 81,8 %, contre 78,8 % pour la régression logistique du site. Aucun autre ne gagne 3 points.&lt;/p&gt;&#10;&lt;p&gt;Les modèles à arbres ne battent pas un modèle linéaire : Random Forest, XGBoost, CatBoost et LightGBM restent entre 77,6 % et 79,4 %. Dans un embedding, l&amp;rsquo;information est répartie sur des centaines de dimensions à la fois, alors qu&amp;rsquo;un arbre découpe une variable à la fois.&lt;/p&gt;&#10;&lt;p&gt;Réduire la dimension n&amp;rsquo;apporte rien non plus. La précision augmente avec le nombre de composantes gardées, et rejoint celle des 768 dimensions vers 512.&lt;/p&gt;&#10;&lt;p&gt;Le gain le moins cher vient de la régularisation. La régression du site apprend ses exemples par cœur : 99,9 % sur ceux qu&amp;rsquo;elle a vus, 78,8 % sur les autres. Avec une régularisation environ 40 fois plus forte (λ ≈ 0,4 au lieu de 0,01), elle passe à 80,0 %.&lt;/p&gt;&#10;&lt;p&gt;Mais attention à ne pas trop lire dans ces chiffres. Avec 972 exemples, la marge d&amp;rsquo;erreur sur une précision de 80 % est d&amp;rsquo;environ ±2,5 points. Un écart d&amp;rsquo;un point entre deux modèles ne veut donc rien dire.&lt;/p&gt;&#10;&lt;p&gt;Et le site ? Il garde sa régression logistique, avec λ = 0,01. Pourquoi ? Flemme de changer 😅.&lt;/p&gt;&#10;&lt;h2 id="quest-ce-qui-a-cassé-dans-le-navigateur-"&gt;Qu&amp;rsquo;est-ce qui a cassé dans le navigateur ?&lt;/h2&gt;&#10;&lt;p&gt;La version q4 standard du modèle fonctionne dans Node, mais pas dans le navigateur. Elle utilise une opération (&lt;code&gt;GatherBlockQuantized&lt;/code&gt;) que le moteur WebAssembly d&amp;rsquo;onnxruntime ne sait pas exécuter. L&amp;rsquo;entraînement et le site utilisent donc la variante &lt;code&gt;model_no_gather_q4&lt;/code&gt;, pour obtenir les mêmes vecteurs des deux côtés.&lt;/p&gt;&#10;&lt;p&gt;Ensuite, la page gelait. Avec EmbeddingGemma, un calcul prend environ 1 seconde dans le navigateur, parce que GitHub Pages ne peut pas envoyer les en-têtes COOP/COEP qui autorisent plusieurs cœurs. Une option déplace ce calcul dans un Web Worker :&lt;/p&gt;&#10;&lt;aside&gt;1 seconde, contre 10 ms avec le premier modèle.&lt;/aside&gt;&#10;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backends&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onnx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Et envoyer le formulaire pendant le téléchargement rechargeait la page, ce qui relançait les 200 Mo 😅. Le formulaire est donc verrouillé tant que le modèle n&amp;rsquo;est pas prêt.&lt;/p&gt;&#10;&lt;h2 id="qua-appris-le-modèle-"&gt;Qu&amp;rsquo;a appris le modèle ?&lt;/h2&gt;&#10;&lt;p&gt;Le &lt;a href="https://lyrixx.github.io/gauche-ou-droite/media/nuage/"&gt;nuage des exemples&lt;/a&gt; permet de le voir. Chaque point est un des 972 exemples : EmbeddingGemma le transforme en vecteur de 768 nombres, puis t-SNE ramène ces vecteurs sur un plan, en gardant ensemble les textes de sens proche.&lt;/p&gt;&#10;&lt;p&gt;La question est la suivante : avant même qu&amp;rsquo;on lui apprenne quoi que ce soit, le modèle range-t-il la gauche d&amp;rsquo;un côté et la droite de l&amp;rsquo;autre ?&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/la-raclette-est-elle-de-gauche-ou-de-droite/nuage.jpg" alt="Le nuage des exemples : des points rouges et bleus mélangés, regroupés par thèmes comme la nourriture, les personnalités ou les lieux"&gt;&lt;/p&gt;&#10;&lt;p&gt;Pas vraiment. La carte est rangée par thème, pas par camp. Les personnalités sont ensemble, les lieux ensemble, la nourriture ensemble, et on trouve les deux couleurs dans chaque thème. Le modèle a d&amp;rsquo;abord appris de quoi parle un texte. Le camp n&amp;rsquo;arrive qu&amp;rsquo;en second.&lt;/p&gt;&#10;&lt;p&gt;Mais dans un thème, le camp se voit parfois très bien. Les 8 plus proches voisins de « la Rolex » sont tous étiquetés à droite : montre suisse, Porsche, sac Louis Vuitton… Ceux des « vêtements de seconde main » sont surtout à gauche : friperie, Vinted, tote bag. Sur les modes de vie, les clichés sont déjà dans les vecteurs.&lt;/p&gt;&#10;&lt;p&gt;Ça coince sur les prises de position. Le modèle se trompe sur 30 % des exemples de fiscalité et d&amp;rsquo;immigration, contre 10 % pour les partis et les étiquettes. Un embedding dit de quoi on parle, pas si on est pour ou contre : le plus proche voisin de « la loi anti-squat », c&amp;rsquo;est « le squat ».&lt;/p&gt;&#10;&lt;aside&gt;Pour un embedding, c&amp;rsquo;est pareil.&lt;/aside&gt;&#10;&#10;&lt;p&gt;C&amp;rsquo;est aussi pour ça que le nuage paraît mélangé, malgré 79 % de bonnes réponses. Parmi les 10 plus proches voisins d&amp;rsquo;un exemple, seuls 65 % ont la même étiquette. La régression, elle, cherche une seule direction « gauche → droite » qui traverse tous les thèmes. C&amp;rsquo;est elle qui fait la différence.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://lyrixx.github.io/gauche-ou-droite/"&gt;Essayez-le&lt;/a&gt;. La première visite télécharge environ 200 Mo, ensuite le modèle reste en cache. Tout est expliqué dans &lt;a href="https://github.com/lyrixx/gauche-ou-droite"&gt;le README&lt;/a&gt; et dans &lt;a href="https://lyrixx.github.io/gauche-ou-droite/media/slides/"&gt;les slides de mon lightning talk&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>Faut-il emprunter sur 15 ou 25 ans ?</title><link>https://blog.lyrixx.info/posts/faut-il-emprunter-sur-15-ou-25-ans/</link><guid>https://blog.lyrixx.info/posts/faut-il-emprunter-sur-15-ou-25-ans/</guid><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;Avec un pote, on a le même débat à chaque fois : pour acheter un logement en France, faut-il emprunter sur la durée la plus courte possible, ou la plus longue ? J&amp;rsquo;ai donc demandé à Claude de répondre avec de vrais chiffres : &lt;a href="https://lyrixx.github.io/duree-de-pret/"&gt;Court ou long ?&lt;/a&gt;. L&amp;rsquo;étude est livrée avec un simulateur.&lt;/p&gt;&#10;&lt;p&gt;Pour être honnête, cette étude sert à 90 % à ce débat, et à 10 % à jouer avec Claude, pour montrer qu&amp;rsquo;il sait bien compter. C&amp;rsquo;est Claude qui a tout construit.&lt;/p&gt;&#10;&lt;aside&gt;Oui, même les 34 sources.&lt;/aside&gt;&#10;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/faut-il-emprunter-sur-15-ou-25-ans/screenshot.jpg" alt="Le haut de l’étude : allonger un prêt de 15 à 25 ans coûte réellement 4,7 % par an"&gt;&lt;/p&gt;&#10;&lt;p&gt;Note : ce n&amp;rsquo;est pas un conseil financier personnalisé. Les chiffres utilisent les taux moyens d&amp;rsquo;octobre 2026.&lt;/p&gt;&#10;&lt;h2 id="combien-coûte-vraiment-un-prêt-long-"&gt;Combien coûte vraiment un prêt long ?&lt;/h2&gt;&#10;&lt;p&gt;Prenons 250 000 € sur 15 ou 25 ans, à 3,50 % et 3,75 %, avec une assurance à 0,30 % sur le capital initial.&lt;/p&gt;&#10;&lt;p&gt;Le prêt sur 25 ans affiche 3,75 %. Mais passer de 15 à 25 ans, c&amp;rsquo;est comme recevoir 502 € par mois pendant 15 ans, puis rembourser 1 348 € par mois pendant 10 ans de plus. Et ce prêt caché a un vrai taux : &lt;strong&gt;4,68 % par an&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;aside&gt;Emprunter à son futur soi, à 4,7 %.&lt;/aside&gt;&#10;&#10;&lt;p&gt;C&amp;rsquo;est plus que 3,75 %, pour deux raisons. Le taux sur 25 ans s&amp;rsquo;applique à tout le capital, et l&amp;rsquo;assurance court dix ans de plus.&lt;/p&gt;&#10;&lt;p&gt;La vraie question, c&amp;rsquo;est donc : que deviennent les 502 € libérés chaque mois ?&lt;/p&gt;&#10;&lt;h2 id="et-si-on-place-la-différence-"&gt;Et si on place la différence ?&lt;/h2&gt;&#10;&lt;p&gt;Vous avez sûrement déjà entendu cet argument : « Avec un prêt long, on garde plus d&amp;rsquo;argent, et on peut le placer à 8 %. »&lt;/p&gt;&#10;&lt;p&gt;Avec 8 % garantis dans un PEA, le prêt long gagne : après 25 ans, vous avez 70 408 € de plus, frais et prélèvements sociaux compris.&lt;/p&gt;&#10;&lt;aside&gt;Garantis. Bien sûr.&lt;/aside&gt;&#10;&#10;&lt;p&gt;Mais 8 %, ce n&amp;rsquo;est qu&amp;rsquo;une moyenne. L&amp;rsquo;étude simule donc 4 000 marchés, avec une volatilité de 15 %. Le prêt long gagne dans 74 % des cas. Dans 1 cas sur 10, il perd plus de 48 000 €. Dans 1 cas sur 10, il gagne plus de 366 000 €.&lt;/p&gt;&#10;&lt;p&gt;Et ça ne marche que si vous gardez le logement. Si vous revendez au bout de 8 ans, c&amp;rsquo;est presque pile ou face : 52 %. Et avec le rendement du MSCI World en euros depuis 2000 (6,67 % par an), le prêt long ne gagne que dans 62 % des cas après 25 ans.&lt;/p&gt;&#10;&lt;aside&gt;Les logements anciens revendus en 2021 avaient été détenus 12,2 ans en moyenne.&lt;/aside&gt;&#10;&#10;&lt;h2 id="alors-court-ou-long-"&gt;Alors, court ou long ?&lt;/h2&gt;&#10;&lt;p&gt;Le court par défaut, le long si vous investissez vraiment.&lt;/p&gt;&#10;&lt;p&gt;Le court gagne si l&amp;rsquo;argent libéré irait sur un livret ou un fonds euros, ou s&amp;rsquo;il serait dépensé. Il gagne aussi si vous risquez de revendre dans les 10 ans, ou si votre assurance est chère.&lt;/p&gt;&#10;&lt;p&gt;Le long gagne si vous placez réellement la différence en actions chaque mois, pendant toute la durée, sans vendre pendant les krachs. Il gagne aussi en investissement locatif, où les intérêts se déduisent, ou quand c&amp;rsquo;est la seule durée qui rend l&amp;rsquo;achat possible.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;L&amp;rsquo;étude parle aussi de fiscalité, d&amp;rsquo;inflation, de renégociation et de retraite, avec 34 sources. Le &lt;a href="https://lyrixx.github.io/duree-de-pret/"&gt;simulateur&lt;/a&gt; permet de tester votre propre cas, et les réglages sont enregistrés dans l&amp;rsquo;URL.&lt;/p&gt;&#10;&lt;p&gt;Tout tient dans un seul fichier HTML, et tous les calculs se font dans votre navigateur. Si vous trouvez un chiffre faux, &lt;a href="https://github.com/lyrixx/duree-de-pret/issues"&gt;ouvrez une issue&lt;/a&gt;, avec une source si possible.&lt;/p&gt;&#10;</description></item><item><title>consul-php-sdk 5.4 covers the whole Consul API</title><link>https://blog.lyrixx.info/posts/consul-php-sdk-5-4-covers-the-whole-consul-api/</link><guid>https://blog.lyrixx.info/posts/consul-php-sdk-5-4-covers-the-whole-consul-api/</guid><pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;I started consul-php-sdk in November 2014. Almost twelve years later, version 5.4 finally covers the whole Consul CE HTTP API: it went from 6 services to 17, and from 34 public methods to 141.&lt;/p&gt;&#10;&lt;p&gt;Why now? I don&amp;rsquo;t use Consul in production anymore, but I have an Ultra Claude plan to do open source.&lt;/p&gt;&#10;&lt;aside&gt;Yes, an SDK for a tool I don&amp;rsquo;t run anymore.&lt;/aside&gt;&#10;&#10;&lt;h2 id="what-is-consul-php-sdk"&gt;What is consul-php-sdk?&lt;/h2&gt;&#10;&lt;p&gt;It&amp;rsquo;s a thin PHP wrapper around the Consul HTTP API, built on top of Symfony HttpClient. No magic here:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\KV&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$kv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;config/feature-flag&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;enabled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$kv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;config/feature-flag&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;raw&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// enabled&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Until now, it covered &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Catalog&lt;/code&gt;, &lt;code&gt;Health&lt;/code&gt;, &lt;code&gt;KV&lt;/code&gt;, &lt;code&gt;Session&lt;/code&gt; and &lt;code&gt;TXN&lt;/code&gt;. So you had the key/value store and the service discovery. For anything else, you were on your own.&lt;/p&gt;&#10;&lt;p&gt;Version 5.4 adds 11 services. Let&amp;rsquo;s see some of them.&lt;/p&gt;&#10;&lt;h2 id="how-to-back-up-a-cluster"&gt;How to back up a cluster?&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Snapshot&lt;/code&gt; saves the whole state of your cluster, and restores it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\Snapshot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;backup.snap&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Later...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;backup.snap&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="how-to-manage-acls"&gt;How to manage ACLs?&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;ACL&lt;/code&gt; handles the tokens, the policies and the roles. For example, to create a token that can only read your configuration:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$acl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\ACL&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$acl&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;createPolicy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Name&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;read-config&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Rules&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;key_prefix &amp;#34;config/&amp;#34; { policy = &amp;#34;read&amp;#34; }&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$acl&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Description&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Read the configuration&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Policies&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ID&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ID&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;SecretID&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="how-to-query-your-services"&gt;How to query your services?&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;PreparedQuery&lt;/code&gt; stores a query in Consul, and you run it by its name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\PreparedQuery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Name&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;Service&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Service&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;OnlyPassing&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Nodes&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-about-the-service-mesh"&gt;What about the service mesh?&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Connect&lt;/code&gt; manages the intentions, and &lt;code&gt;Config&lt;/code&gt; the configuration entries:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$connect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\Connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$connect&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;upsertIntention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;web&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Action&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;allow&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$connect&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;checkIntention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;web&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// [&amp;#39;Allowed&amp;#39; =&amp;gt; true]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\Config&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Kind&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;service-defaults&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Name&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Protocol&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;http&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="and-the-rest"&gt;And the rest?&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Event&lt;/code&gt; fires custom events through your cluster:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\Event&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;deploy&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;v1.2.3&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Status&lt;/code&gt; gives you the leader and the peers, and &lt;code&gt;Operator&lt;/code&gt; covers Raft, Autopilot and the keyring. &lt;code&gt;Coordinate&lt;/code&gt;, &lt;code&gt;DiscoveryChain&lt;/code&gt; and &lt;code&gt;Peering&lt;/code&gt; complete the list.&lt;/p&gt;&#10;&lt;p&gt;And &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Catalog&lt;/code&gt; and &lt;code&gt;Health&lt;/code&gt; got all their missing endpoints.&lt;/p&gt;&#10;&lt;h2 id="what-else-changed"&gt;What else changed?&lt;/h2&gt;&#10;&lt;p&gt;All the documented options are now allowed: blocking queries, consistency modes, filtering, and so on. Before, an option missing from the list of a method was silently dropped. And you can pass many values for an option:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$health&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Consul\Services\Health&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$health&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;api&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;tag&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;v1&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;primary&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;dc&lt;/code&gt; option now works on all endpoints that are not local to the agent. And the ACL token can be read from the &lt;code&gt;CONSUL_HTTP_TOKEN&lt;/code&gt; environment variable, like the Consul CLI does.&lt;/p&gt;&#10;&lt;p&gt;I also fixed a few bugs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;MultiSemaphore&lt;/code&gt; reused the metadata of a previous resource when acquiring many resources&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Session::create()&lt;/code&gt;, and so &lt;code&gt;LockHandler&lt;/code&gt;, failed when called without arguments&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Agent::join()&lt;/code&gt; and &lt;code&gt;Agent::forceLeave()&lt;/code&gt; used &lt;code&gt;GET&lt;/code&gt; instead of &lt;code&gt;PUT&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="how-to-upgrade"&gt;How to upgrade?&lt;/h2&gt;&#10;&lt;p&gt;Version 5.4 requires PHP 8.2, Symfony HttpClient 6.4, 7.4 or 8.1+, and psr/log 2 or 3.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;The library stays thin: it still only depends on &lt;code&gt;symfony/http-client&lt;/code&gt; and &lt;code&gt;psr/log&lt;/code&gt;. But now, if Consul can do it, you can do it from PHP.&lt;/p&gt;&#10;</description></item><item><title>The Symfony observability demo got a refresh</title><link>https://blog.lyrixx.info/posts/the-symfony-observability-demo-got-a-refresh/</link><guid>https://blog.lyrixx.info/posts/the-symfony-observability-demo-got-a-refresh/</guid><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><description>&lt;p&gt;I just wanted to give my &lt;a href="https://github.com/lyrixx/symfony-observability-demo"&gt;observability demo&lt;/a&gt; a little refresh. It ended up with 84 files changed, 6,743 new lines, and Symfony going from 7.0 to 8.1 😅.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/the-symfony-observability-demo-got-a-refresh/homepage.jpg" alt="The new homepage of the demo, with links to Kibana, Grafana, Redash and Vector"&gt;&lt;/p&gt;&#10;&lt;h2 id="what-is-this-demo"&gt;What is this demo?&lt;/h2&gt;&#10;&lt;p&gt;It&amp;rsquo;s a small Symfony application I built for &lt;a href="https://s.lyrixx.info/log"&gt;a talk about logs&lt;/a&gt; (in French). It shows how to configure Monolog, and what a real observability stack looks like around it.&lt;/p&gt;&#10;&lt;p&gt;Every log record goes to &lt;a href="https://vector.dev/"&gt;Vector&lt;/a&gt;, which fans it out to ClickHouse, Elasticsearch and Loki. And each one comes with its own tool: Redash, Kibana and Grafana.&lt;/p&gt;&#10;&lt;p&gt;The Monolog part is really short:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;monolog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;syslog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;syslogudp&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;vector&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9000&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;formatter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;monolog.formatter.json&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;!event&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-changed"&gt;What changed?&lt;/h2&gt;&#10;&lt;p&gt;The application now runs on Symfony 8.1, and the Docker stack comes from docker-starter. It got a Bootstrap frontend with AssetMapper, so no Node. It also got tests and a CI.&lt;/p&gt;&#10;&lt;p&gt;And &lt;code&gt;castor start&lt;/code&gt; now provisions a dashboard in Kibana, Grafana and Redash. So the links of the homepage show real data, instead of an empty tool.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/the-symfony-observability-demo-got-a-refresh/grafana.jpg" alt="The Grafana dashboard, with the volume of logs by level and by channel"&gt;&lt;/p&gt;&#10;&lt;h2 id="how-to-fill-a-dashboard"&gt;How to fill a dashboard?&lt;/h2&gt;&#10;&lt;p&gt;A dashboard needs data. But if you generate 600 logs with one click, they all land at the same time, and you get a single spike. Not very realistic.&lt;/p&gt;&#10;&lt;p&gt;So the &amp;ldquo;Generate logs&amp;rdquo; button now asks for a tempo: right now, the last 30 minutes, the last hour, or the last 6 hours. And the logs are backdated, with a small Monolog processor:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="fm"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LogRecord&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LogRecord&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$backdateTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;backdate_to&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$backdateTo&lt;/span&gt; &lt;span class="nx"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;\DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;backdate_to&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;datetime&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$backdateTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But wait. Vector has to use the date of the record, instead of the time it received it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;.timestamp = parse_timestamp(.datetime, format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;%+&amp;#34;&lt;/span&gt;&lt;span class="l"&gt;) ?? now()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And Loki doesn&amp;rsquo;t like out-of-order entries. So the generator emits the oldest logs first. And Vector must let them go, instead of silently dropping them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;out_of_order_action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;accept&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;aside&gt;A lot of work for fake logs.&lt;/aside&gt;&#10;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;The demo runs on your machine, you only need Docker and Castor:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;castor start&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first start takes a few minutes. Then click on &amp;ldquo;Generate logs&amp;rdquo;, and open a dashboard.&lt;/p&gt;&#10;&lt;p&gt;The code is &lt;a href="https://github.com/lyrixx/symfony-observability-demo"&gt;on GitHub&lt;/a&gt;, and the slides are &lt;a href="https://s.lyrixx.info/log"&gt;on Speaker Deck&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>How to compare two composer.lock files</title><link>https://blog.lyrixx.info/posts/how-to-compare-two-composer-lock-files/</link><guid>https://blog.lyrixx.info/posts/how-to-compare-two-composer-lock-files/</guid><pubDate>Wed, 09 Apr 2025 00:00:00 +0000</pubDate><description>&lt;p&gt;I was upgrading an application, and keeping track of the dependencies was a real mess. The raw diff of a big JSON file is not the best way to see what really changed between two &lt;code&gt;composer.lock&lt;/code&gt; files.&lt;/p&gt;&#10;&lt;p&gt;So I built &lt;a href="https://lyrixx.github.io/composer-diff/"&gt;Composer Lock Comparison&lt;/a&gt;. You paste two &lt;code&gt;composer.lock&lt;/code&gt;, and you get a beautiful diff.&lt;/p&gt;&#10;&lt;p&gt;&lt;img src="https://blog.lyrixx.info/posts/how-to-compare-two-composer-lock-files/screenshot.jpg" alt="The result of a comparison: each package with its operation, its versions, a link to the diff and its license"&gt;&lt;/p&gt;&#10;&lt;h2 id="how-does-it-work"&gt;How does it work?&lt;/h2&gt;&#10;&lt;p&gt;Go to &lt;a href="https://lyrixx.github.io/composer-diff/"&gt;lyrixx.github.io/composer-diff&lt;/a&gt;. Paste the old file on the left, the new one on the right, and click on &amp;ldquo;Compare Files&amp;rdquo;. No file at hand? Click on &amp;ldquo;Load examples&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;You get one table for your packages, and another one for your dev packages. For each package, you can see the operation (upgrade, downgrade, install or remove), the versions, the license, and a link to the diff of the package between the two versions.&lt;/p&gt;&#10;&lt;p&gt;And there is a Markdown version of the result, so you can paste it in a pull request:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| Prod Packages | Operation | Base | Target |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;|-----------------------------------------------------------|-----------|-------|--------|&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| [&lt;span class="nt"&gt;composer/composer&lt;/span&gt;](&lt;span class="na"&gt;https://github.com/composer/composer&lt;/span&gt;) | Upgraded | 2.8.0 | 2.8.8 |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| [&lt;span class="nt"&gt;marc-mabe/php-enum&lt;/span&gt;](&lt;span class="na"&gt;https://github.com/marc-mabe/php-enum&lt;/span&gt;) | New | - | v4.7.1 |&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="where-does-my-composerlock-go"&gt;Where does my composer.lock go?&lt;/h2&gt;&#10;&lt;p&gt;Nowhere. The website is a static page on GitHub Pages, and there is no server behind it.&lt;/p&gt;&#10;&lt;aside&gt;Yes, this is PHP. In your browser.&lt;/aside&gt;&#10;&#10;&lt;p&gt;PHP is compiled to WebAssembly and runs in your browser. So your &lt;code&gt;composer.lock&lt;/code&gt; never leaves the tab, and you can use it with your private packages.&lt;/p&gt;&#10;&lt;h2 id="how-is-it-made"&gt;How is it made?&lt;/h2&gt;&#10;&lt;p&gt;The code is really small, because the hard work is done by others. The comparison comes from &lt;a href="https://github.com/IonBazan/composer-diff"&gt;IonBazan/composer-diff&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-php" data-lang="php"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$prodOperations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;packageDiff&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;getDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;packageDiff&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;loadPackagesFromArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$oldLock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;packageDiff&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="na"&gt;loadPackagesFromArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newLock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And &lt;a href="https://github.com/soyuka/php-wasm"&gt;soyuka/php-wasm&lt;/a&gt; showed the way to run PHP in the browser. Thanks to both of them!&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;Next time you review a &lt;code&gt;composer update&lt;/code&gt;, &lt;a href="https://lyrixx.github.io/composer-diff/"&gt;give it a try&lt;/a&gt;. And the code is &lt;a href="https://github.com/lyrixx/composer-diff"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;&#10;</description></item></channel></rss>