<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Steven Fox</title>
        <link>https://steven-fox.com</link>
        <description>Steven's blog.</description>
        <lastBuildDate>Fri, 28 Aug 2026 06:22:52 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Steven Fox</title>
            <url>https://steven-fox.com/favicon.ico</url>
            <link>https://steven-fox.com</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <link>https://steven-fox.com/articles/a-hack-for-efficient-infinite-scrolling-in-your-livewire-app</link>
            <guid>https://steven-fox.com/articles/a-hack-for-efficient-infinite-scrolling-in-your-livewire-app</guid>
            <pubDate>Fri, 13 Sep 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Infinite scrolling with Livewire has <a href="https://codecourse.com/articles/laravel-livewire-infinite-scrolling">traditionally</a> meant starting with X records and when it comes time to "load more," you simply increase the query limit by X, re-fetch the records (resulting in duplication of what you've already queried), and then <em>replacing</em> your html with the new stuff. For example, at first you load 5 records, then to get the 6th thru 10th records, you increase the <code>limit</code> on your query to 10 and re-fetch (meaning this second query will include the 5 you grabbed originally). Scrolling deeply down the page, you can get to a point where you want the 95th thru 100th records, which means querying ALL 100, sending a huge payload back to the browser, and doing a massive morph-replace.</p>
<blockquote>
<p>We use this methodology at the time of writing for our feeds on <a href="https://pinkary.com">Pinkary</a>, and to combat the issue, we limit our <em>not-so-infinite</em> scrolling to 100 posts; hence, I set out to find if an alternate approach exists.</p>
</blockquote>
<p>In this article, I'll describe a way to do this more efficiently: fetching <em>only</em> the records we need to keep the page growing and <em>appending</em> those records to the existing DOM. Aka, how you'd normally do it when using a typical JS framework + api approach.</p>
<h3>Traditional Livewire Infinite Scrolling Recap</h3>
<p>Let's quickly go over the traditional setup to establish a baseline. We're going to infinitely load <code>Posts</code> into a feed (at least, until our server would run out of memory).</p>
<p>Here's an example Livewire feed component:</p>
<pre class="language-php"><code class="language-php"><span class="token php language-php"><span class="token delimiter important">&lt;?php</span>

<span class="token keyword">namespace</span> <span class="token package">App<span class="token punctuation">\</span>Livewire</span><span class="token punctuation">;</span>

<span class="token keyword">use</span> <span class="token package">App<span class="token punctuation">\</span>Models<span class="token punctuation">\</span>Posts</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Livewire<span class="token punctuation">\</span>Attributes<span class="token punctuation">\</span>Locked</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Livewire<span class="token punctuation">\</span>Component</span><span class="token punctuation">;</span>

<span class="token keyword">final</span> <span class="token keyword">class</span> <span class="token class-name-definition class-name">Feed</span> <span class="token keyword">extends</span> <span class="token class-name">Component</span>
<span class="token punctuation">{</span>
    <span class="token doc-comment comment">/**
     * The number of posts per "load more" cycle.
     */</span>
    <span class="token attribute"><span class="token delimiter punctuation">#[</span><span class="token attribute-content"><span class="token attribute-class-name class-name">Locked</span></span><span class="token delimiter punctuation">]</span></span>
    <span class="token keyword">public</span> <span class="token keyword type-declaration">int</span> <span class="token variable">$perPage</span> <span class="token operator">=</span> <span class="token number">5</span><span class="token punctuation">;</span>

    <span class="token doc-comment comment">/**
     * The total number of posts to load.
     */</span>
    <span class="token attribute"><span class="token delimiter punctuation">#[</span><span class="token attribute-content"><span class="token attribute-class-name class-name">Locked</span></span><span class="token delimiter punctuation">]</span></span>
    <span class="token keyword">public</span> <span class="token keyword type-declaration">int</span> <span class="token variable">$limit</span> <span class="token operator">=</span> <span class="token number">5</span><span class="token punctuation">;</span>

    <span class="token doc-comment comment">/**
     * Load more questions.
     */</span>
    <span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">loadMore</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token punctuation">{</span>
        <span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">limit</span> <span class="token operator">+=</span> <span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">perPage</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>

    <span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">render</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token punctuation">{</span>
        <span class="token variable">$posts</span> <span class="token operator">=</span> <span class="token scope">Posts<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">orderByDesc</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'created_at'</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">simplePaginate</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">limit</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token keyword">return</span> <span class="token function">view</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'livewire.feed'</span><span class="token punctuation">,</span> <span class="token punctuation">[</span>
            <span class="token string single-quoted-string">'posts'</span> <span class="token operator">=&gt;</span> <span class="token variable">$posts</span><span class="token punctuation">,</span>
        <span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</span></code></pre>
<p>And that <code>livewire.feed</code> view can be something like:</p>
<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>section</span><span class="token punctuation">&gt;</span></span>
    <span class="token comment">&lt;!-- Render all the posts --&gt;</span>
    @foreach ($posts as $post)
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token namespace">livewire:</span>posts.show</span>
            <span class="token attr-name">:postId</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$post-&gt;id<span class="token punctuation">"</span></span>
            <span class="token attr-name">:key</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">'</span>post-<span class="token punctuation">'</span> . $post-&gt;id<span class="token punctuation">"</span></span>
        <span class="token punctuation">/&gt;</span></span>
    @endforeach

    <span class="token comment">&lt;!-- Load more intersection --&gt;</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">x-intersect.margin.50%</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$wire.loadMore()<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>text-center text-slate-400<span class="token punctuation">"</span></span> <span class="token attr-name"><span class="token namespace">wire:</span>loading</span> <span class="token attr-name"><span class="token namespace">wire:</span>target</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>loadMore<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>
            <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>x-heroicon-o-arrow-path</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>w-5 h-5 animate-spin<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>section</span><span class="token punctuation">&gt;</span></span>
</code></pre>
<p>And we can assume that's wired together by a simple web route that does little more than return the view (which gets Livewire rolling).</p>
<p>As mentioned above, the problem with this setup is that the infinite scrolling works by increasing the <code>limit</code> on our query for each "load more" cycle, grabbing all the records (which includes the records from the prior loading cycle), creating new html for the entire <code>&lt;section&gt;</code>, sending that over the internet, and then <strong>morph-replacing</strong> the existing DOM with our new stuff. So, when we want the 91-95th posts, we just grab all 95 from the database and render + send that html. When the 96-100th posts are needed, we grab all 100, etc.</p>
<p>Each load more cycle means a larger query, larger html payload over the internet, and more DOM stuff to morph.</p>
<h3>What Would Be More Efficient?</h3>
<p>Doesn't take rocket science here... We've been doing this effectively in our api + JS apps for years now. Something like:</p>
<ol>
<li>Make a JS feed component that has a data prop for an array of posts.</li>
<li>Fetch an initial amount of posts from the server and hydrate those into the array, rendering them into the DOM (possibly as a SSR procedure).</li>
<li>When it's time to load more, ask the api to give you the limited subset of posts you need (ie. 6-10th posts, 11-15th, etc.).</li>
<li>Importantly, <strong>append</strong> those new posts to the array, which re-renders the DOM and appends the new post elements to our feed.</li>
<li>Rinse and repeat.</li>
</ol>
<p>Since we're not working with a JS frontend like Vue/React/etc., we'll need to approach this in a similar but slightly different way with Livewire.</p>
<p>What if we could set up a Livewire component to render a specific subset of posts (what we'd normally think of as a "page") as html and then <em>append</em> that html into the right location within the DOM? That'd be almost identical to the JS app methodology, except we get the server to convert an array of posts into html rather than doing that in the browser. Appending those new DOM elements, however, is the same concept.</p>
<p>Sounds like a plan.</p>
<h3>The Efficient Approach</h3>
<p>Let's do this.</p>
<p>First, we'll need a way to <em>append</em> html elements to the DOM in our Livewire app over <em>replacing</em>. Now, I'm a fan of the least-dependencies-possible approach, but for the sake of this article, I'm going to utilize a cool package by <a href="https://github.com/imacrayon">Christian Taylor (@imacrayon)</a> called <a href="https://alpine-ajax.js.org/">Alpine AJAX</a>.</p>
<p>With that installed, we can update our Livewire <code>Feed</code> component and view. Here's the best part: the changes are minimal and overall, we get to <em>reduce</em> our code.</p>
<blockquote>
<p>Note: the code examples are just rough examples, not meant to be copied verbatim; they don't represent the true source code of Pinkary.</p>
</blockquote>
<pre class="language-php"><code class="language-php"><span class="token php language-php"><span class="token delimiter important">&lt;?php</span>

<span class="token keyword">namespace</span> <span class="token package">App<span class="token punctuation">\</span>Livewire</span><span class="token punctuation">;</span>

<span class="token keyword">use</span> <span class="token package">App<span class="token punctuation">\</span>Models<span class="token punctuation">\</span>Posts</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Livewire<span class="token punctuation">\</span>Component</span><span class="token punctuation">;</span>

<span class="token keyword">final</span> <span class="token keyword">class</span> <span class="token class-name-definition class-name">Feed</span> <span class="token keyword">extends</span> <span class="token class-name">Component</span>
<span class="token punctuation">{</span>
    <span class="token doc-comment comment">/**
     * The number of posts per "load more" cycle.
     */</span>
    <span class="token attribute"><span class="token delimiter punctuation">#[</span><span class="token attribute-content"><span class="token attribute-class-name class-name">Locked</span></span><span class="token delimiter punctuation">]</span></span>
    <span class="token keyword">public</span> <span class="token keyword type-declaration">int</span> <span class="token variable">$perPage</span> <span class="token operator">=</span> <span class="token number">5</span><span class="token punctuation">;</span>

    <span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">render</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token punctuation">{</span>
        <span class="token variable">$posts</span> <span class="token operator">=</span> <span class="token scope">Posts<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">orderByDesc</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'created_at'</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">cursorPaginate</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">perPage</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// yay! cursor pagination!</span>

        <span class="token keyword">return</span> <span class="token function">view</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'livewire.feed'</span><span class="token punctuation">,</span> <span class="token punctuation">[</span>
            <span class="token string single-quoted-string">'posts'</span> <span class="token operator">=&gt;</span> <span class="token variable">$posts</span><span class="token punctuation">,</span>
        <span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</span></code></pre>
<p>To update the view, we just need to tell Alpine AJAX what to do (<code>$ajax('{{ $posts-&gt;nextPageUrl() }}', { target: 'feed pagination' })</code>), what to do with the html payload (<code>x-merge="append"</code>), and to which DOM elements (<code>id="feed'</code> and <code>id="pagination"</code>).</p>
<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>section</span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>feed<span class="token punctuation">"</span></span> <span class="token attr-name">x-merge</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>append<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>
    @foreach ($posts as $post)
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token namespace">livewire:</span>posts.show</span>
            <span class="token attr-name">:postId</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$post-&gt;id<span class="token punctuation">"</span></span>
            <span class="token attr-name">:key</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">'</span>post-<span class="token punctuation">'</span> . $post-&gt;id<span class="token punctuation">"</span></span>
        <span class="token punctuation">/&gt;</span></span>
    @endforeach

    @if($posts-&gt;hasMorePages())
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span>
            <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>pagination<span class="token punctuation">"</span></span>
            <span class="token attr-name">x-intersect.margin.600px</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$ajax(<span class="token punctuation">'</span>{{ $posts-&gt;nextPageUrl() }}<span class="token punctuation">'</span>, { target: <span class="token punctuation">'</span>feed pagination<span class="token punctuation">'</span> })<span class="token punctuation">"</span></span>
        <span class="token punctuation">&gt;</span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
    @endif
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>section</span><span class="token punctuation">&gt;</span></span>
</code></pre>
<p>Last piece is to update our web route handling for the feed so that the Alpine AJAX call is handled correctly.</p>
<p>This can be done in a controller like so:</p>
<pre class="language-php"><code class="language-php"><span class="token php language-php"><span class="token delimiter important">&lt;?php</span>

<span class="token keyword">namespace</span> <span class="token package">App<span class="token punctuation">\</span>Http<span class="token punctuation">\</span>Controllers</span><span class="token punctuation">;</span>

<span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>Http<span class="token punctuation">\</span>Request</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>View<span class="token punctuation">\</span>View</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Livewire<span class="token punctuation">\</span>Livewire</span><span class="token punctuation">;</span>

<span class="token keyword">final</span> <span class="token keyword">class</span> <span class="token class-name-definition class-name">FeedController</span>
<span class="token punctuation">{</span>
    <span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">__invoke</span><span class="token punctuation">(</span><span class="token class-name type-declaration">Request</span> <span class="token variable">$request</span><span class="token punctuation">)</span><span class="token punctuation">:</span> <span class="token class-name">View</span><span class="token operator">|</span><span class="token keyword type-declaration">string</span>
    <span class="token punctuation">{</span>
        <span class="token comment">// Determine if the request is a first page load or an infinite scroll "load more" request.</span>
        <span class="token variable">$isInfiniteScrollRequest</span> <span class="token operator">=</span> <span class="token variable">$request</span><span class="token operator">-&gt;</span><span class="token function">hasHeader</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'X-Alpine-Request'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token keyword">return</span> <span class="token variable">$isInfiniteScrollRequest</span>
            <span class="token operator">?</span> <span class="token scope">Livewire<span class="token punctuation">::</span></span><span class="token function">mount</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'feed'</span><span class="token punctuation">)</span> <span class="token comment">// render the component and return its html string fragment 👀</span>
            <span class="token punctuation">:</span> <span class="token function">view</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'feed'</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// do a regular full page load</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</span></code></pre>
<p>That's it. Let's break it down.</p>
<p>First of all, if we're dealing with a full page load (someone hitting our feed route and not attempting to "load more"), we can just return the <code>view('feed')</code> like normal.</p>
<p>If we're working with a "load more" request from Alpine AJAX, we get a little hacky (here's where the <em>hack</em> in the title comes from). We ask Livewire to <code>mount</code> our <code>Feed</code> component, and the returned value from that <code>Livewire::mount()</code> function call is the html string returned from the <code>render()</code> method of our <code>Feed</code> component after Livewire has performed some lifecycle events.</p>
<pre class="language-php"><code class="language-php"><span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">render</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token punctuation">{</span>
    <span class="token variable">$posts</span> <span class="token operator">=</span> <span class="token scope">Posts<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
        <span class="token operator">-&gt;</span><span class="token function">orderByDesc</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'created_at'</span><span class="token punctuation">)</span>
        <span class="token operator">-&gt;</span><span class="token function">cursorPaginate</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">perPage</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

    <span class="token keyword">return</span> <span class="token function">view</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'livewire.feed'</span><span class="token punctuation">,</span> <span class="token punctuation">[</span>
        <span class="token string single-quoted-string">'posts'</span> <span class="token operator">=&gt;</span> <span class="token variable">$posts</span><span class="token punctuation">,</span>
    <span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Through the ease provided to us from Laravel's cursor pagination implementation, our AJAX request will have the query params needed for Laravel to know which cursor we're working with and what page we want to query (you could manually handle this if you needed to for some reason).</p>
<p>Thus, on our infinte scrolling "load more" requests from Alpine AJAX, we will fetch only a subset of posts for the next cursor page (<code>-&gt;cursorPaginate(...)</code>), render a fragment of html with those posts (return value from <code>render()</code>), and respond with that html string back to the browser. Alpine AJAX handles that response, and because of our configuration above, it will append that new html of posts into our <code>&lt;section id="feed"...&gt;</code> element.</p>
<p>Easy peasy.</p>
<p>With this in place, we do a normal full page load (like any other Livewire app) and we get efficient infinite scrolling (appending a limited subset of new html elements to our feed). All of the appended elements retain all of their normal Livewire component functionality.</p>
<p>It's a little hacky, because we're no longer using Livewire <em>exclusively</em> (we now have that <code>FeedController</code> that handles our special request scenarios) and we're asking Livewire to render a component as a raw html string that we send back to the browser. But hey - it's not the worst hack I've seen for a production app. Plus, this concept of appending html (vs. replacing) from a Livewire request-&gt;response will be built into the package one day in the future.</p>
<h3>Bonus Example Using Blade Fragments</h3>
<p>If it seems silly to make the feed a Livewire component (our <code>Feed</code> component in this article), since it doesn't have any extra methods that are called for client-triggered async functionality (one of the big benefits of using Livewire), I'll show you how to accomplish this using Laravel's <code>@fragment</code> feature of its Blade engine.</p>
<p>Since you now know the concept, I'm just going to quickly dump the code.</p>
<pre class="language-php"><code class="language-php"><span class="token php language-php"><span class="token delimiter important">&lt;?php</span>

<span class="token keyword">namespace</span> <span class="token package">App<span class="token punctuation">\</span>Http<span class="token punctuation">\</span>Controllers</span><span class="token punctuation">;</span>

<span class="token keyword">use</span> <span class="token package">App<span class="token punctuation">\</span>Models<span class="token punctuation">\</span>Post</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>Http<span class="token punctuation">\</span>Request</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>View<span class="token punctuation">\</span>View</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Livewire<span class="token punctuation">\</span>Livewire</span><span class="token punctuation">;</span>

<span class="token keyword">final</span> <span class="token keyword">class</span> <span class="token class-name-definition class-name">FeedController</span>
<span class="token punctuation">{</span>
    <span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">__invoke</span><span class="token punctuation">(</span><span class="token class-name type-declaration">Request</span> <span class="token variable">$request</span><span class="token punctuation">)</span><span class="token punctuation">:</span> <span class="token class-name">View</span><span class="token operator">|</span><span class="token keyword type-declaration">string</span>
    <span class="token punctuation">{</span>
        <span class="token variable">$isInfiniteScrollRequest</span> <span class="token operator">=</span> <span class="token variable">$request</span><span class="token operator">-&gt;</span><span class="token function">hasHeader</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'X-Alpine-Request'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token comment">// Look familiar? 😉</span>
        <span class="token variable">$posts</span> <span class="token operator">=</span> <span class="token scope">Post<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">orderByDesc</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'created_at'</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">cursorPaginate</span><span class="token punctuation">(</span><span class="token variable">$perPage</span> <span class="token operator">=</span> <span class="token number">5</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token keyword">return</span> <span class="token function">view</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'feed'</span><span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token string single-quoted-string">'posts'</span> <span class="token operator">=&gt;</span> <span class="token variable">$posts</span><span class="token punctuation">]</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">fragmentIf</span><span class="token punctuation">(</span><span class="token variable">$isInfiniteScrollRequest</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'posts-list'</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// fragment magic! 👀</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</span></code></pre>
<p>Making a <code>posts.list</code> Blade component:</p>
<pre class="language-html"><code class="language-html">@props(['posts'])
@fragment('posts-list')
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">x-data</span><span class="token punctuation">&gt;</span></span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>section</span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>feed<span class="token punctuation">"</span></span> <span class="token attr-name">x-merge</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>append<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>
            @foreach ($posts as $post)
                <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token namespace">livewire:</span>posts.show</span>
                    <span class="token attr-name">:postId</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$post-&gt;id<span class="token punctuation">"</span></span>
                    <span class="token attr-name">:key</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">'</span>post-<span class="token punctuation">'</span> . $post-&gt;id<span class="token punctuation">"</span></span>
                <span class="token punctuation">/&gt;</span></span>
            @endforeach
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>section</span><span class="token punctuation">&gt;</span></span>
        @if($posts-&gt;hasMorePages())
            <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span>
                <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>pagination<span class="token punctuation">"</span></span>
                <span class="token attr-name">x-intersect.margin.600px</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$ajax(<span class="token punctuation">'</span>{{ $posts-&gt;nextPageUrl() }}<span class="token punctuation">'</span>, { target: <span class="token punctuation">'</span>feed pagination<span class="token punctuation">'</span> })<span class="token punctuation">"</span></span>
            <span class="token punctuation">&gt;</span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
        @endif
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
@endfragment
</code></pre>
<p>And that <code>posts.list</code> component can be called from a <code>feed</code> view.</p>
<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>x-app-layout</span><span class="token punctuation">&gt;</span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>x-home-menu</span><span class="token punctuation">&gt;</span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>x-home-menu</span><span class="token punctuation">&gt;</span></span>

    ...

    <span class="token comment">&lt;!-- This would have been the livewire:feed component earlier in the article --&gt;</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>x-posts.list</span> <span class="token attr-name">:posts</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>$posts<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>x-posts.list</span><span class="token punctuation">&gt;</span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>x-app-layout</span><span class="token punctuation">&gt;</span></span>
</code></pre>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <link>https://steven-fox.com/articles/a-performance-analysis-of-a-3-million-record-xml-import</link>
            <guid>https://steven-fox.com/articles/a-performance-analysis-of-a-3-million-record-xml-import</guid>
            <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>I recently needed to import large XML files into a MySQL database. The largest file was 11.3 GB containing over 3 million records. I thought this was going to be a straightforward streaming import, but it turned into an interesting debugging adventure that led me to discover some surprising limitations in PHP's XML handling<code>*</code>.</p>
<blockquote>
<p><code>*</code> Actually, these limitations likely extend beyond PHP, as the root cause may be in an underlying C library used by a number of other programming languages.</p>
</blockquote>
<p>Here's what I learned, the rabbit holes I went down, and the surprisingly simple solutions that gave me massive performance improvements.</p>
<h3>Context</h3>
<ul>
<li>The XML files I was importing contained a small intro/header section of data followed by an array of ~3 million nodes that all shared a known schema.</li>
<li>When importing, each element could be handled in isolation.</li>
</ul>
<h2>1. The Speed Problem</h2>
<p>I initially wrote a simple artisan command that would truncate the database table, take in the source xml file as a command argument, use the <code>XMLReader</code> class to iterate through the array's sibling nodes, and then bulk import the objects 5,000 records at a time.</p>
<pre class="language-php"><code class="language-php"><span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">handle</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token punctuation">{</span>
    <span class="token comment">// truncate, handle file argument...</span>

    <span class="token variable">$reader</span> <span class="token operator">=</span> <span class="token class-name class-name-fully-qualified static-context"><span class="token punctuation">\</span>XMLReader</span><span class="token operator">::</span><span class="token function">open</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">file</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

    <span class="token variable">$batch</span> <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
    <span class="token variable">$count</span> <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>
    <span class="token variable">$batchSize</span> <span class="token operator">=</span> <span class="token number">5000</span><span class="token punctuation">;</span>

    <span class="token keyword">foreach</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token function">iterateRecords</span><span class="token punctuation">(</span><span class="token variable">$reader</span><span class="token punctuation">,</span> <span class="token variable">$elementName</span><span class="token punctuation">)</span> <span class="token keyword">as</span> <span class="token variable">$xmlRecord</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$dto</span> <span class="token operator">=</span> <span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token function">createDto</span><span class="token punctuation">(</span><span class="token variable">$xmlRecord</span><span class="token punctuation">,</span> <span class="token variable">$type</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token variable">$batch</span><span class="token punctuation">[</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token punctuation">[</span>
            <span class="token operator">...</span><span class="token variable">$dto</span><span class="token operator">-&gt;</span><span class="token function">toModelAttributes</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span>
            <span class="token comment">// other static attributes</span>
        <span class="token punctuation">]</span><span class="token punctuation">;</span>

        <span class="token comment">// Bulk insert batch</span>
        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token function">count</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span> <span class="token operator">&gt;=</span> <span class="token variable">$batchSize</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            $<span class="token scope">modelClass<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">insert</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
            <span class="token variable">$count</span> <span class="token operator">+=</span> <span class="token function">count</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
            <span class="token variable">$batch</span> <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>

    <span class="token comment">// Insert remaining batch</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span> <span class="token keyword">empty</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        $<span class="token scope">modelClass<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">insert</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token variable">$count</span> <span class="token operator">+=</span> <span class="token function">count</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>

    <span class="token comment">// clean up...</span>
<span class="token punctuation">}</span>

<span class="token keyword">private</span> <span class="token keyword">function</span> <span class="token function-definition function">iterateRecords</span><span class="token punctuation">(</span><span class="token class-name type-declaration">XMLReader</span> <span class="token variable">$reader</span><span class="token punctuation">,</span> <span class="token keyword type-hint">string</span> <span class="token variable">$elementName</span><span class="token punctuation">)</span><span class="token punctuation">:</span> <span class="token class-name return-type">Generator</span>
<span class="token punctuation">{</span>
    <span class="token comment">// This isn't verbatim code but gets the point across...</span>

    <span class="token comment">// Get to the first instance of our element to import</span>
    <span class="token keyword">do</span> <span class="token punctuation">{</span>
        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$elementName</span> <span class="token operator">===</span> <span class="token variable">$reader</span><span class="token operator">-&gt;</span><span class="token property">localName</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token keyword">break</span><span class="token punctuation">;</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span> <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token variable">$reader</span><span class="token operator">-&gt;</span><span class="token function">read</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

    <span class="token comment">// Yield the outer xml for each element</span>
    <span class="token keyword">do</span> <span class="token punctuation">{</span>
        <span class="token keyword">yield</span> <span class="token variable">$reader</span><span class="token operator">-&gt;</span><span class="token function">readOuterXml</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span> <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">reader</span><span class="token operator">-&gt;</span><span class="token function">next</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>With this in place, my import process would start strong but <strong>degrade in speed over time:</strong></p>
<pre><code>Starting to import records...
Imported 5000 records. Current rate: 3557 records/s
Imported 10000 records. Current rate: 2273 records/s
Imported 15000 records. Current rate: 1931 records/s
...
Imported 125000 records. Current rate: 320 records/s
Imported 130000 records. Current rate: 307 records/s
</code></pre>
<p>Starting at ~3,500 records/second and dropping to ~300/s after just 130,000 records. At that rate, importing 3 million records would take forever—assuming PHP didn't run out of memory first.</p>
<p>That wasn't going to do.</p>
<h2>2. Noticing the Memory Leak</h2>
<p>To begin my investigation of the speed issue, I opened Activity Monitor and noticed two things: the PHP process was consuming an ever-growing amount of memory, and 2) the mysqld process was barely touching the cpu.</p>
<p>So I now had two symptoms to investigate:</p>
<ol>
<li><strong>Performance degradation</strong> - throughput dropping from 3,500/s to 300/s</li>
<li><strong>Memory leak</strong> - unbounded memory growth of the PHP process</li>
</ol>
<p>I assumed these were related. Spoiler: they weren't.</p>
<h2>3. Investigating the Leak</h2>
<p>My first instinct was that the memory leak was related to XML parsing or query logs. We were streaming gigabytes of data after all. All it would take is some distant reference messing up the garbage collector or keeping a hold of the xml strings and kaboom-big leak. So, I tried some of the simple and obvious options:</p>
<p><strong>Forced garbage collection:</strong></p>
<pre class="language-php"><code class="language-php"><span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token function">count</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span> <span class="token operator">&gt;=</span> <span class="token variable">$batchSize</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    $<span class="token scope">modelClass<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">insert</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">unset</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token function">gc_collect_cycles</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token function">gc_mem_caches</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// PHP 8.1+ - releases string interning cache</span>
<span class="token punctuation">}</span>
</code></pre>
<p><strong>Disabling Laravel's query log:</strong></p>
<pre class="language-php"><code class="language-php"><span class="token scope">DB<span class="token punctuation">::</span></span><span class="token function">disableQueryLog</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Neither of these attempts made a significant difference. The performance continued to degrade, and memory continued to grow.</p>
<p>I wrote a series of tinker tests to isolate the issue, testing raw XMLReader, XMLReader with DTO creation, batch building, and various combinations. The tests revealed something surprising: XMLReader itself wasn't leaking memory in isolation, but performance was degrading severely. We were onto something, at least for the speed issue...</p>
<h2>4. The Real Performance Culprit: XMLReader's Traversal</h2>
<p>After systematic testing, I discovered the root cause of the performance degradation. It wasn't memory, garbage collection, or the database—it was XMLReader's fundamental traversal behavior.</p>
<p>I ran performance tests comparing different approaches:</p>
<pre><code>===
  Raw fread() baseline
  How quickly can we buffer through the file?
===
Duration: 0.05s for 50000 records
Batch 1: 5000 records, 777586/s
Batch 2: 10000 records, 1099885/s
Batch 3: 15000 records, 1182893/s
Batch 4: 20000 records, 988756/s
Batch 5: 25000 records, 1094604/s
Batch 6: 30000 records, 948594/s
Batch 7: 35000 records, 1110367/s
Batch 8: 40000 records, 993863/s
Batch 9: 45000 records, 1118839/s
Batch 10: 50000 records, 951261/s
First batch rate: 777586/s
Last batch rate: 951261/s


===
  XMLReader::next()
  How quickly can the XMLReader traverse elements with no addt'l processing?
===
Duration: 36.29s for 50000 records
Batch 1: 5000 records, 10303/s
Batch 2: 10000 records, 3627/s
Batch 3: 15000 records, 2683/s
Batch 4: 20000 records, 1848/s
Batch 5: 25000 records, 1578/s
Batch 6: 30000 records, 1320/s
Batch 7: 35000 records, 1000/s
Batch 8: 40000 records, 950/s
Batch 9: 45000 records, 839/s
Batch 10: 50000 records, 746/s
First batch rate: 10303/s
Last batch rate: 746/s
</code></pre>
<p>The results were damning:</p>
<ul>
<li><strong>Raw fread()</strong>: Consistent ~1 million records/s with no degradation; disk reading &amp; buffering was plenty fast.</li>
<li><strong>XMLReader::next()</strong>: Starts at 10,303/s but degrades to 746/s—and this was <em>without even reading any content</em></li>
</ul>
<p>The degradation appears to be roughly quadratic as the number of nodes grows. Just calling <code>next()</code> to traverse to the next sibling gets progressively slower the further you are into the document.</p>
<h3>Why This Happens: libxml2?</h3>
<p><a href="https://github.com/php/php-src/blob/master/ext/xmlreader/php_xmlreader.c">PHP's XMLReader is a thin wrapper around libxml2.</a> Therefore, I believe the performance degradation originates in libxml2, not PHP itself. There are <a href="https://gitlab.gnome.org/GNOME/libxml2/-/issues?label_name%5B%5D=1.+Performance&amp;show=eyJpaWQiOiIxMzAiLCJmdWxsX3BhdGgiOiJHTk9NRS9saWJ4bWwyIiwiaWQiOjc1OTkwfQ%3D%3D">known performance issues in libxml2</a>, which may be causing this specific behavior seen during my imports. <a href="https://gitlab.gnome.org/GNOME/libxml2/-/issues/130">Here's a specific issue</a> from someone using a XML library in Ruby that showed a similar performance problem.</p>
<p>If I can find some spare time, I may write a C program to test libxml2 in a few ways to ensure that's truly the cause and not something else related to PHP's wrapper of the library.</p>
<h3>My Pragmatic Solution: String-Based Parsing</h3>
<p>For predictable XML structures where you know the element names you're parsing, simple string operations massively outperform XMLReader:</p>
<pre class="language-php"><code class="language-php"><span class="token keyword">public</span> <span class="token keyword">function</span> <span class="token function-definition function">iterate</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">:</span> <span class="token class-name return-type">Generator</span>
<span class="token punctuation">{</span>
    <span class="token variable">$handle</span> <span class="token operator">=</span> <span class="token function">fopen</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">xmlPath</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'r'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token variable">$buffer</span> <span class="token operator">=</span> <span class="token string single-quoted-string">''</span><span class="token punctuation">;</span>
    <span class="token variable">$bufferSize</span> <span class="token operator">=</span> <span class="token number">1024</span> <span class="token operator">*</span> <span class="token number">1024</span><span class="token punctuation">;</span> <span class="token comment">// 1MB chunks</span>

    <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token operator">!</span> <span class="token function">feof</span><span class="token punctuation">(</span><span class="token variable">$handle</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$buffer</span> <span class="token operator">.=</span> <span class="token function">fread</span><span class="token punctuation">(</span><span class="token variable">$handle</span><span class="token punctuation">,</span> <span class="token variable">$bufferSize</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token variable">$startPos</span> <span class="token operator">=</span> <span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token function">findStartTag</span><span class="token punctuation">(</span><span class="token variable">$buffer</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token operator">!==</span> <span class="token constant boolean">false</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token variable">$endPos</span> <span class="token operator">=</span> <span class="token function">strpos</span><span class="token punctuation">(</span><span class="token variable">$buffer</span><span class="token punctuation">,</span> <span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">endTag</span><span class="token punctuation">,</span> <span class="token variable">$startPos</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

            <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$endPos</span> <span class="token operator">===</span> <span class="token constant boolean">false</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token keyword">break</span><span class="token punctuation">;</span> <span class="token comment">// Need more data</span>
            <span class="token punctuation">}</span>

            <span class="token variable">$endPos</span> <span class="token operator">+=</span> <span class="token function">strlen</span><span class="token punctuation">(</span><span class="token this keyword">$this</span><span class="token operator">-&gt;</span><span class="token property">endTag</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

            <span class="token keyword">yield</span> <span class="token function">substr</span><span class="token punctuation">(</span><span class="token variable">$buffer</span><span class="token punctuation">,</span> <span class="token variable">$startPos</span><span class="token punctuation">,</span> <span class="token variable">$endPos</span> <span class="token operator">-</span> <span class="token variable">$startPos</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

            <span class="token variable">$buffer</span> <span class="token operator">=</span> <span class="token function">substr</span><span class="token punctuation">(</span><span class="token variable">$buffer</span><span class="token punctuation">,</span> <span class="token variable">$endPos</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>

    <span class="token function">fclose</span><span class="token punctuation">(</span><span class="token variable">$handle</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>This approach:</p>
<ul>
<li>Reads the file in 1MB chunks with <code>fread()</code></li>
<li>Uses <code>strpos()</code> to find record boundaries</li>
<li>Uses <code>substr()</code> to extract each record</li>
<li>Yields records one at a time for memory efficiency</li>
</ul>
<p>I ran a performance test like the ones above for this implementation:</p>
<pre><code>=== String-based (fread + strpos) ===
Duration: 0.64s for 50000 records
Batch 1: 5000 records, 80299/s
Batch 2: 10000 records, 76330/s
Batch 3: 15000 records, 79961/s
Batch 4: 20000 records, 75922/s
Batch 5: 25000 records, 76749/s
Batch 6: 30000 records, 76267/s
Batch 7: 35000 records, 79428/s
Batch 8: 40000 records, 79196/s
Batch 9: 45000 records, 81635/s
Batch 10: 50000 records, 80697/s
First batch rate: 80299/s
Last batch rate: 80697/s
</code></pre>
<p>Now we're talkin'. Obviously, it's much slower than just reading through the file, but the key is that the performance remains constant and that it's fast enough for my needs.</p>
<h2>5. The Memory Leak: Sentry's Query Tracking</h2>
<p>After switching to string-based parsing, throughput was excellent and consistent. But the memory leak persisted. The import would run beautifully at ~17,000 records/s with consistent performance, but eventually crash:</p>
<pre><code>Allowed memory size of 2147483648 bytes exhausted
at vendor/laravel/framework/src/Illuminate/Database/MySqlConnection.php:53
</code></pre>
<p>I initially suspected something in Laravel's query builder was holding a reference to the inserted data. Alas, I tested raw PDO statements (bypassing the query builder) and it had stable memory throughout the import. So something about using Laravel's query building and DB connection was causing the issue.</p>
<p>Since the source code between my raw PDO statements and Laravel's connection were so similar, my immediate next suspicion was the <code>QueryExecuted</code> event that is dispatched when running a statement in Laravel. Bazinga. I discovered the culprit: <strong>Sentry</strong>.</p>
<p>Sentry's Laravel integration listens to <code>QueryExecuted</code> events to capture queries for breadcrumbs and performance tracing. Each query's data was being retained in memory. With thousands of batch inserts, this accumulated until PHP ran out of memory.</p>
<table><thead><tr><th>Test</th><th>Memory Growth</th><th>Leaking?</th></tr></thead><tbody><tr><td>Raw PDO</td><td>0 MB</td><td>No</td></tr><tr><td>Laravel insert()</td><td>26 MB</td><td>Yes</td></tr><tr><td>Laravel + disableQueryLog()</td><td>26 MB</td><td>Yes</td></tr><tr><td><strong>Laravel + Event::forget(QueryExecuted)</strong></td><td><strong>0 MB</strong></td><td><strong>No</strong></td></tr></tbody></table>
<p>The fix is simple—disable Sentry's breadcrumbs &amp; tracing via config settings, or, as I chose to do, disable the QueryExecuted event listeners during bulk imports:</p>
<pre class="language-php"><code class="language-php"><span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>Database<span class="token punctuation">\</span>Events<span class="token punctuation">\</span>QueryExecuted</span><span class="token punctuation">;</span>
<span class="token keyword">use</span> <span class="token package">Illuminate<span class="token punctuation">\</span>Support<span class="token punctuation">\</span>Facades<span class="token punctuation">\</span>Event</span><span class="token punctuation">;</span>

<span class="token comment">// Disable query event listeners during bulk import</span>
<span class="token scope">Event<span class="token punctuation">::</span></span><span class="token function">forget</span><span class="token punctuation">(</span><span class="token scope">QueryExecuted<span class="token punctuation">::</span></span><span class="token keyword">class</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token comment">// Now Laravel's standard insert() works with stable memory</span>
<span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$batches</span> <span class="token keyword">as</span> <span class="token variable">$batch</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token scope">Model<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">insert</span><span class="token punctuation">(</span><span class="token variable">$batch</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h2>6. Index Optimization</h2>
<p>For even more processing throughput, I removed indexes from the database tables before the import and recreated them afterwards. This is a <a href="https://dev.mysql.com/doc/refman/8.0/en/optimizing-innodb-bulk-data-loading.html">recommended approach by MySQL</a> for bulk data loading.</p>
<p>This reduces how much processing the database has to do per insert, which was fairly significant in my case as some of the tables I was importing to had multiple unique indexes. Thus, the database had to not only update the B-tree structure but also validate uniqueness per insert.</p>
<h2>7. Overall Results</h2>
<p>After all optimizations:</p>
<pre><code>Starting to import records...
Imported 5000 records. Current rate: 19593 records/s
Imported 10000 records. Current rate: 19549 records/s
Imported 15000 records. Current rate: 19980 records/s
...
Imported 95000 records. Current rate: 20877 records/s
Imported 100000 records. Current rate: 21021 records/s
</code></pre>
<p>Consistent ~20,000 records/second throughout the entire import, with stable 80-100MB memory usage.</p>
<table><thead><tr><th>Metric</th><th>Before</th><th>After</th><th>Improvement</th></tr></thead><tbody><tr><td>Initial Rate</td><td>3,500/s</td><td>20,000/s</td><td>5.7x faster</td></tr><tr><td>Rate at 100k records</td><td>~300/s</td><td>20,000/s</td><td><strong>66x faster</strong></td></tr><tr><td>Memory Usage</td><td>Growing unbounded</td><td>Stable 80-100MB</td><td>Stable</td></tr><tr><td>PHP CPU</td><td>100%</td><td>30-40%</td><td>Balanced</td></tr><tr><td>MySQL CPU</td><td>~2%</td><td>60-70%</td><td>Properly utilized</td></tr></tbody></table>
<h2>Key Takeaways</h2>
<ol>
<li>
<p><strong>Don't trust XMLReader for very large files.</strong> Despite being marketed as a streaming parser, it has fundamental performance issues with multi-gigabyte files. The degradation is in traversal itself—even <code>next()</code> without reading content degrades ~14x over 50,000 records. This appears to be a libxml2 issue. Simple string operations can be 100x faster with zero degradation.</p>
</li>
<li>
<p><strong>Separate your symptoms.</strong> Performance degradation and memory leaks can have completely different causes.</p>
</li>
<li>
<p><strong>Watch your CPU distribution.</strong> When PHP is at 100% and MySQL is at 2%, your bottleneck isn't the database.</p>
</li>
<li>
<p><strong>APM tools can cause memory leaks in bulk operations.</strong> Sentry (and likely other APM tools) listen to query events and retain data for tracing. For bulk imports, disable query tracking with <code>Event::forget(QueryExecuted::class)</code>.</p>
</li>
<li>
<p><strong>Drop and recreate indexes for bulk imports.</strong> MySQL recommends this approach for good reason—the time complexity math works in your favor.</p>
</li>
<li>
<p><strong>Profile before optimizing.</strong> Systematic isolation testing found XMLReader and Sentry faster than guessing would have.</p>
</li>
</ol>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <link>https://steven-fox.com/articles/finding-children-records-where-parent-has-dynamic-number-of-children</link>
            <guid>https://steven-fox.com/articles/finding-children-records-where-parent-has-dynamic-number-of-children</guid>
            <pubDate>Wed, 10 Apr 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p><em>Whew</em>.</p>
<p>That title is a lot to take in. Let's break it down, cause this is actually a simple concept.</p>
<h3>Example Scenario</h3>
<p>Let's imagine we have 2 Eloquent models - a <code>Question</code> and an <code>Answer</code>. Those models are linked by a simple <code>BelongsTo</code> and <code>HasMany</code> relationship (a <code>Question-&gt;hasMany(Answer)</code> and an <code>Answer-&gt;belongsTo(Question)</code>).</p>
<p>Now let's imagine our business logic requires that <em>someone can set a maximum number of answers permitted for each question</em>. Ok, so our <code>Question</code> model will get an attribute called <code>max_answers</code>. If a particular <code>Question</code> has a count of <code>Answers</code> that matches the integer stored in the <code>max_answers</code> attribute, we will consider that <code>Question</code> "full". Perhaps in our imaginary app, we would only reveal answers to "full" questions and so we need to account for this in our API responses for the <code>/answers</code> endpoint.</p>
<blockquote>
<p>There are other scenarios where this could prove useful: polls with special logic around a user-configurable number of votes, an ecommerce product that requires a user-configurable number of orders before it goes into production, etc. The key here is "user-configurable". At the very least, this exercise will help you explore some advanced Eloquent concepts.</p>
</blockquote>
<p>Here's where the title of this article comes in...</p>
<h3>Rephrasing the question</h3>
<p>What if you wanted to <em>find a list of <code>Answers</code> that belong to a "full" <code>Question</code></em>?</p>
<p>You may know, <a href="https://laravel.com/docs/11.x/eloquent-relationships#querying-relationship-existence">from the Laravel docs</a>, that we can query for records based on the existence of a relation and include a count in that query. It looks like:</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Model<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">has</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'relation'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token punctuation">:</span>integer<span class="token punctuation">:</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token operator">...</span><span class="token punctuation">;</span>
</code></pre>
<p>Or, if you need to specify a constraint on the relation:</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Model<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">whereHas</span><span class="token punctuation">(</span>
    <span class="token string single-quoted-string">'relation'</span><span class="token punctuation">,</span>
    <span class="token keyword">function</span><span class="token punctuation">(</span><span class="token class-name type-declaration">Builder</span> <span class="token variable">$relationQuery</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$relationQuery</span><span class="token operator">-&gt;</span><span class="token function">where</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'foo'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'bar'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token operator">...</span>
    <span class="token punctuation">}</span>
    <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span>
    <span class="token punctuation">:</span>integer<span class="token punctuation">:</span>
<span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token operator">...</span><span class="token punctuation">;</span>
</code></pre>
<p>Using our <code>Question</code> and <code>Answer</code> example, we could find a list of questions that have exactly <code>5</code> answers like so:</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Question<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">has</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token number">5</span><span class="token punctuation">)</span><span class="token operator">-&gt;</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<blockquote>
<p>Don't forget the operator (<code>=</code> above) could be any database compliant operator (ex. <code>&lt;</code>, <code>&gt;</code>, <code>&lt;&gt;</code>, etc).</p>
</blockquote>
<p>However, all of these examples use a hard-coded <em>count</em> value (<code>:integer:</code>).</p>
<p>How do we do this with a count value <em>that is stored in the database</em> so that we can look for "full" <code>Questions</code>?</p>
<h3>Unlocking additional functionality</h3>
<p>It turns out it's possible to leverage the <code>has()</code> or <code>whereHas()</code> methods to accomplish this. At first, this will seem impossible because the Laravel <code>has()</code> method specifies the third parameter must be of <code>integer</code> type via docBlocks. Nonetheless, the following does work, but it's important to wrap our special <em>count</em> in a <code>Query\Expression</code>, otherwise Eloquent will use a literal string in the final query to the database (like <code>'questions.max_answers'</code> vs. <code>questions.max_answers</code>), which would cause a useless integer to string comparison by the database engine.</p>
<p>Let's first describe the query for a list of "full" <code>Questions</code> directly, since that's conceptually easier to grasp.</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Question<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">has</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token keyword">new</span> <span class="token class-name class-name-fully-qualified"><span class="token punctuation">\</span>Illuminate<span class="token punctuation">\</span>Database<span class="token punctuation">\</span>Query<span class="token punctuation">\</span>Expression</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'questions.max_answers'</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// or, using the DB Facade...</span>
<span class="token scope">Question<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">has</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token class-name class-name-fully-qualified static-context"><span class="token punctuation">\</span>Illuminate<span class="token punctuation">\</span>Support<span class="token punctuation">\</span>Facades<span class="token punctuation">\</span>DB</span><span class="token operator">::</span><span class="token function">raw</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'questions.max_answers'</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<blockquote>
<p>Side note: you can use <code>(new Question)-&gt;qualifyColumn('max_answers')</code> vs. <code>'questions.max_answers'</code> if you want Laravel to qualify the columns for you. You will see a version of this shortly, just using an existing query Builder instance instead of a new Model instance...</p>
</blockquote>
<p>Technically, one could perform a relation load on the <code>Questions</code> there to resolve the <code>Answers</code> to our original question (<code>Question::query()-&gt;with('answers')-&gt;has(...)-&gt;get()</code>), but the point of this article is to show you how to accomplish the Eloquent setup by looking up the <code>Answers</code> directly.</p>
<h3>Final Solution</h3>
<p>Ok. We're ready to take this one step further. Let's find <code>Answers</code> that belong to a "full" <code>Question</code>.</p>
<p>The final solution conceptually uses a combination of what we discussed above:</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Answer<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">whereHas</span><span class="token punctuation">(</span>
        <span class="token string single-quoted-string">'question'</span><span class="token punctuation">,</span>
        <span class="token keyword">fn</span> <span class="token punctuation">(</span><span class="token class-name type-declaration">Builder</span> <span class="token variable">$whereQuestion</span><span class="token punctuation">)</span> <span class="token operator">=&gt;</span> <span class="token variable">$whereQuestion</span>
            <span class="token operator">-&gt;</span><span class="token function">has</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token variable">$whereQuestion</span><span class="token operator">-&gt;</span><span class="token function">qualifyColumn</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'max_answers'</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>As you can see, it's possible to use the <code>whereHas()</code> method to easily say "give me answers that belong to a 'full' question as saved in the db."</p>
<blockquote>
<p>Hint: under the hood, the <code>whereHas()</code> method on the <code>\Illuminate\Database\Eloquent\Concerns\QueriesRelationships</code> trait is nothing more than an alias to the <code>has()</code> method. The <code>whereHas()</code> definition simply reorders the parameters so that the sub query Closure is in the second slot.</p>
</blockquote>
<h3>Bonus Round</h3>
<p>Want to see two other ways of doing this? See if you can figure out what's going on and how the database may handle each query (use of indexes, etc).</p>
<pre class="language-php"><code class="language-php"><span class="token scope">Answer<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">whereIn</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'question_id'</span><span class="token punctuation">,</span> <span class="token keyword">fn</span><span class="token punctuation">(</span><span class="token variable">$questionIdQuery</span><span class="token punctuation">)</span> <span class="token operator">=&gt;</span> <span class="token variable">$questionIdQuery</span>
        <span class="token operator">-&gt;</span><span class="token function">select</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'id'</span><span class="token punctuation">)</span>
        <span class="token operator">-&gt;</span><span class="token function">from</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'questions'</span><span class="token punctuation">)</span>
        <span class="token operator">-&gt;</span><span class="token function">where</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'questions.max_answers'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'='</span><span class="token punctuation">,</span> <span class="token keyword">fn</span><span class="token punctuation">(</span><span class="token variable">$subQuery</span><span class="token punctuation">)</span> <span class="token operator">=&gt;</span> <span class="token variable">$subQuery</span>
            <span class="token operator">-&gt;</span><span class="token function">selectRaw</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'count(*)'</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">from</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">)</span>
            <span class="token operator">-&gt;</span><span class="token function">whereColumn</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'questions.id'</span><span class="token punctuation">,</span> <span class="token string single-quoted-string">'answers.question_id'</span><span class="token punctuation">)</span>
        <span class="token punctuation">)</span>
    <span class="token punctuation">)</span>
    <span class="token operator">...</span>
</code></pre>
<blockquote>
<p>The query above is essentially an alternate version of a WHERE EXISTS (...) query and will perform similarly due to foreign key indexes.</p>
</blockquote>
<pre class="language-php"><code class="language-php"><span class="token scope">Answer<span class="token punctuation">::</span></span><span class="token function">query</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token operator">-&gt;</span><span class="token function">whereHas</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'question'</span><span class="token punctuation">,</span> <span class="token keyword">fn</span><span class="token punctuation">(</span><span class="token variable">$query</span><span class="token punctuation">)</span> <span class="token operator">=&gt;</span> <span class="token variable">$query</span>
	    <span class="token operator">-&gt;</span><span class="token function">withCount</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers'</span><span class="token punctuation">)</span>
	    <span class="token operator">-&gt;</span><span class="token function">groupBy</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'id'</span><span class="token punctuation">)</span>
	    <span class="token operator">-&gt;</span><span class="token function">havingRaw</span><span class="token punctuation">(</span><span class="token string single-quoted-string">'answers_count = questions.max_answers'</span><span class="token punctuation">)</span>
    <span class="token punctuation">)</span>
    <span class="token operator">...</span>
</code></pre>
<blockquote>
<p>Just in case you want to confuse someone by using a HAVING clause... 😅</p>
</blockquote>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <title><![CDATA[Q3 at a glance]]></title>
            <link>https://steven-fox.com/articles/introducing-supermark</link>
            <guid>https://steven-fox.com/articles/introducing-supermark</guid>
            <pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Like most devs today, I now build software using AI-first workflows, so I spend a good part of every day having conversations with Claude and reading what it writes. That second part is the whole reason Supermark exists. It isn't about building apps. It's about reading a conversation.</p>
<p>Want to see it in action? <a href="/supermark">Jump straight to the playground →</a></p>
<p>In an AI conversation (and for documentation), Markdown is the status quo, and for good reason: it's cheap to generate, it streams cleanly, and you can read the raw text with no renderer at all. It's also a language with a long habit of being extended when people want more. GitHub wanted more than base Markdown offered, so GitHub-Flavored Markdown added tables, task lists, strikethrough, and bare-URL autolinks, and the extensions were useful enough that GFM became the default dialect; models emit it without being asked. GitHub kept going with alerts, which gave Markdown a callout syntax. Mermaid solved diagrams with a small DSL inside a code fence, and enough renderers support it now that it's practically part of the dialect too. Stripe's Markdoc and MyST push further still, adding whole component systems through named tags and directives, though both are aimed at documentation sites and their tags are wordy enough that you wouldn't want to stream them into a chat. So the appetite is proven, and so is the pattern. What's missing is the conversation-shaped version: the moment a response wants to show a metric, a small chart, a status badge, or two stats side by side, there's still little to no way to do so, and you get a wall of prose where a couple of stat cards would have landed the point at a glance.</p>
<p>There's already a halfway answer to this, and it's spec'd into Markdown itself: when Markdown can't express something, you can drop into raw HTML and (some) renderers pass it through. And in certain cases, that solves the richness problem. But there's a few issues with that, in order of my concerns when it comes to AI conversations &amp; workflows:</p>
<ol>
<li>Most GUIs (and definitely the terminal) don't render inlined html from an agent.</li>
<li>Html is token hungry.</li>
<li>It's miserable to read raw html when any meaningful styling is applied.</li>
<li>It's a security threat.</li>
</ol>
<p>Plus, the power of html + css + js is overkill for a conversation and simple documentation. It doesn't need animated, interactive, on-brand interfaces for every response, and when that is needed, well... that's when I have AI write a full html page (or generate a pdf, powerpoint, etc.).</p>
<p><strong>I just want something a little richer than plain Markdown, at close to plain Markdown's cost.</strong></p>
<p>So the question became: why stop where GFM stopped? Do what GitHub did for tables and alerts, and keep going: give Markdown components for basic layout, information display, and input, and you get the richer response at a negligible token and legibility cost. In Supermark, that three-column layout is three characters: <code>|||</code>. The rule I kept coming back to, and eventually wrote at the top of the spec: if a feature can't be expressed in a handful of characters, it probably doesn't belong. A typical Supermark document lands around <strong>2–6× smaller</strong> than the equivalent HTML for the same interface (there's a counter in the playground), and when you're streaming to users or paying per token, that compounds fast.</p>
<p>That sketch turned into <strong>Supermark</strong>. It isn't trying to replace real UI (it would be bad at that). It's trying to make a slightly richer Markdown response possible: keep everything that makes Markdown good in a conversation, and add just enough vocabulary to show the handful of shapes a reply actually wants.</p>
<p>One caveat up front: Supermark is an <em>idea</em>, not a proposal, and certainly not a product. A real proposal would come with the follow-through: parsers in more than one language, renderers people can actually adopt, plugins for the tools where Markdown already lives. This is earlier than that. It's something I've noticed, something I've wanted, and a sketch of what it could look like, with a playground so you can poke at it. I'm writing it up because it already makes my own reading a little better, and because I'd like to know whether it makes sense to anyone else, or whether richer AI responses should come from a different direction entirely, before deciding it deserves the follow-through.</p>
<h2>The one-sentence version</h2>
<p>Supermark is a superset of Markdown (GFM included) where <strong>layout, data, and interactivity are first-class</strong>, expressed in the fewest characters that still read as plain text. Every valid Markdown document is already valid Supermark. You only reach for the extra syntax when you want something Markdown can't do (a metric, a chart, a card, a callout), and when you do, it stays terse and legible.</p>
<p>Here's the whole idea in one example. Ask an analytics agent how the quarter's going, and this is the reply you'd want back. On the left is what the model writes. On the right is what my renderer turns it into.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300"># Q3 at a glance
@crumbs Home / Reports / Q3

|||
@stat $128k "MRR" +18%
|||
@stat 2,410 "Active teams" +6%
|||

@bar Enterprise 62000
@bar Growth 41000
@bar Starter 25000

[!ok] Net revenue retention crossed 100% this quarter.</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><h1 class="font-semibold tracking-tight text-zinc-800 dark:text-zinc-100 text-2xl"><span>Q3 at a glance</span></h1><div class="flex flex-wrap items-center gap-1 text-sm text-zinc-500"><span class="flex items-center gap-1"><span class="">Home</span><span class="text-zinc-300">/</span></span><span class="flex items-center gap-1"><span class="">Reports</span><span class="text-zinc-300">/</span></span><span class="flex items-center gap-1"><span class="font-medium text-zinc-800 dark:text-zinc-200">Q3</span></span></div><div class="grid gap-4" style="grid-template-columns:repeat(auto-fit, minmax(min(100%, 11rem), 1fr))"><div class="min-w-0"><div class="space-y-4"><div class="inline-flex min-w-40 flex-col rounded-xl border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-800/40"><span class="text-2xl font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">$128k</span><span class="text-xs uppercase tracking-wide text-zinc-500">MRR</span><span class="mt-1 text-xs font-medium text-green-600 dark:text-green-400">▲<!-- --> <!-- -->+18%</span></div></div></div><div class="min-w-0"><div class="space-y-4"><div class="inline-flex min-w-40 flex-col rounded-xl border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-800/40"><span class="text-2xl font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">2,410</span><span class="text-xs uppercase tracking-wide text-zinc-500">Active teams</span><span class="mt-1 text-xs font-medium text-green-600 dark:text-green-400">▲<!-- --> <!-- -->+6%</span></div></div></div></div><div class="my-2 space-y-1.5"><div class="flex items-center gap-3 text-sm"><span class="w-28 flex-none truncate text-zinc-600 dark:text-zinc-400">Enterprise</span><span class="flex h-5 flex-1 items-center overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800"><span class="h-full rounded" style="width:100%;background:#14b8a6"></span></span><span class="w-16 flex-none whitespace-nowrap text-right text-xs font-medium tabular-nums text-zinc-700 dark:text-zinc-300">62000</span></div><div class="flex items-center gap-3 text-sm"><span class="w-28 flex-none truncate text-zinc-600 dark:text-zinc-400">Growth</span><span class="flex h-5 flex-1 items-center overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800"><span class="h-full rounded" style="width:66.12903225806451%;background:#3b82f6"></span></span><span class="w-16 flex-none whitespace-nowrap text-right text-xs font-medium tabular-nums text-zinc-700 dark:text-zinc-300">41000</span></div><div class="flex items-center gap-3 text-sm"><span class="w-28 flex-none truncate text-zinc-600 dark:text-zinc-400">Starter</span><span class="flex h-5 flex-1 items-center overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800"><span class="h-full rounded" style="width:40.32258064516129%;background:#f59e0b"></span></span><span class="w-16 flex-none whitespace-nowrap text-right text-xs font-medium tabular-nums text-zinc-700 dark:text-zinc-300">25000</span></div></div><div class="flex gap-2 rounded-lg border px-3 py-2 text-sm border-green-500/30 bg-green-500/10 text-green-800 dark:text-green-200"><span aria-hidden="true" class="font-semibold">✓</span><div><span>Net revenue retention crossed 100% this quarter.</span></div></div></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">57<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p>That whole interface is a handful of lines of text a model can generate as reliably as it generates a bulleted list, for about the token cost of writing it out as prose. That's the premise: richer responses, negligible cost. From there, the interesting decisions are in the syntax itself. Given what Supermark is for, I had four priorities, and then a payoff that fell out of meeting them.</p>
<h2>Why the syntax looks the way it does</h2>
<h3>1. It has to stream</h3>
<p>Supermark is meant to be read as it's written, token by token, because that's how models produce it. So the grammar is <strong>line-oriented</strong>: with very few exceptions, the meaning of a line is decided by how it starts, and no line needs to know about the lines after it. A parser never has to look ahead, which means it can render a document as it arrives. The stat card appears the instant its line is complete; you're not staring at a spinner waiting for a closing tag five hundred tokens away.</p>
<h3>2. It has to read well raw</h3>
<p>Every construct is designed so that, stripped of a renderer, it still reads as sensible plain text. <code>@stat $128k "MRR" +18%</code> isn't a cryptic macro; it's a line you can understand in a raw log, a git diff, or a terminal. This is the Markdown lineage showing through: the source is never hostage to the renderer.</p>
<p>That also happens to smooth the only realistic path to adoption. Nothing supports Supermark on day one, so most of the time a document will land in a plain Markdown renderer (GitHub, an editor, someone's inbox), and there it degrades to legible text instead of breaking. It reads as lightly-annotated Markdown, which is a far gentler failure than what a Markdown renderer does to a page of HTML.</p>
<h3>3. It has to be easy to remember</h3>
<p>Most Supermark will be written by models, with a person typing the odd line by hand, and the same property serves both: ambiguous, awkward syntax is syntax that gets produced wrong. The ergonomics of the <em>characters themselves</em> matter.</p>
<p>So the sigils are deliberate. Block-level things that own a whole line start with <code>@</code>: <code>@stat</code>, <code>@bar</code>, <code>@chart</code>. Inline things you drop into a sentence use square brackets, which your eye (and a model's training data) already associates with Markdown links: <code>[badge.green]Live[/]</code>. Variants chain with dots, borrowed straight from CSS and Pug (<code>[badge.green.lg.outline]</code>), because that's a pattern models have seen a million times.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300">Status: [badge.green]Passing[/] [badge.amber.outline]Flaky[/] [badge.red]Down[/]

Press [kbd]⌘K[/] to search. Rated [stars 4.5] by the team.

Deploying [spinner] please wait…</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><span>Status: </span><span class="mx-0.5 inline-flex items-center rounded-md font-medium ring-1 ring-inset text-xs px-2 py-0.5 bg-green-500/10 text-green-700 ring-green-600/20 dark:text-green-300 dark:ring-green-400/20"><span>Passing</span></span><span> </span><span class="mx-0.5 inline-flex items-center rounded-md font-medium ring-1 ring-inset text-xs px-2 py-0.5 bg-transparent bg-amber-500/10 text-amber-700 ring-amber-600/20 dark:text-amber-300 dark:ring-amber-400/20"><span>Flaky</span></span><span> </span><span class="mx-0.5 inline-flex items-center rounded-md font-medium ring-1 ring-inset text-xs px-2 py-0.5 bg-red-500/10 text-red-700 ring-red-600/20 dark:text-red-300 dark:ring-red-400/20"><span>Down</span></span></p><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><span>Press </span><kbd class="mx-0.5 rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700 shadow-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"><span>⌘K</span></kbd><span> to search. Rated </span><span class="relative inline-block whitespace-nowrap align-middle" aria-label="4.5 out of 5 stars"><span class="text-zinc-300 dark:text-zinc-600">★★★★★</span><span class="absolute left-0 top-0 overflow-hidden text-amber-500" style="width:90%">★★★★★</span></span><span> by the team.</span></p><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><span>Deploying </span><span class="mx-0.5 inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent align-middle"></span><span> please wait…</span></p></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">44<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p>None of that needs a manual. It reads as what it is.</p>
<h3>4. It has to be easy to render, and the renderer decides how it looks</h3>
<p>Supermark markup is <strong>semantic, not pixel-precise</strong>. A <code>@stat</code> says "this is a KPI with a label and a delta." It does <em>not</em> say what colour, font, or corner radius to use. That's the renderer's call.</p>
<p>I care about this one because it's why the examples on this page look like <em>my site</em> and not like a generic component kit. The exact same Supermark, handed to a different renderer, would wear that renderer's design language. The document describes intent; the host owns the aesthetics. It also keeps a renderer small: a line-oriented grammar with a fixed vocabulary is a weekend of parsing work, not a browser engine.</p>
<h3>5. The payoff: removing HTML entirely</h3>
<p>This is the part I'm proudest of, and it's a consequence rather than a goal I started with. Markdown supports raw HTML passthrough for a reason: the language is thin, so inline HTML is its designed escape hatch for everything it can't express. That escape hatch is also where the trouble lives, and it nags at me more now that a model is usually in the loop. Renderers need a sanitizer standing guard, and Markdown lets you <em>hide</em> things: an HTML comment, a <code>display:none</code> span, a zero-width character. A reader never sees them, but anything parsing the raw text does. That gap between what a person reads and what a machine reads is exactly where prompt injection lives, since a document that looks innocent to you can carry a second set of instructions to the next model that reads it. And models now read documents, skills, and plugins written by strangers all day.</p>
<p>But follow the chain back: the only justification for the escape hatch was the richness Markdown couldn't express, and for the shapes a conversation actually wants, Supermark covers that. So the escape hatch can simply go. Supermark has no raw-HTML passthrough, no comment syntax, no hidden-metadata block. Type <code>&lt;!-- … --&gt;</code> or <code>&lt;script&gt;</code> and it renders as its own literal text, in plain sight. And the language is <strong>logic-less by design</strong>: no variables, no expressions, no conditionals, no loops. Every value in a document is a literal the model already computed, so there's no machinery for untrusted output to smuggle behaviour through and nothing for a sanitizer to catch. (My renderer builds real DOM nodes and lets the framework escape text, so even a <code>&lt;script&gt;</code> you type into the playground is just... the text <code>&lt;script&gt;</code>.)</p>
<p>To be clear about the scope of the claim: none of this fixes the Markdown pipelines that already exist. GitHub will keep rendering HTML for compatibility forever. The claim is that a <em>new</em> surface (an agent UI, a chat renderer, a harness) that speaks Supermark never has to implement HTML handling at all, because the reason to implement it is gone. Source and rendered output carry the same information, so what you read is what the model actually said. That doesn't make any given document <em>trustworthy</em>, but it takes away the place you'd hide the untrustworthy part.</p>
<h2>A quick tour</h2>
<p>Beyond the basics, there's a component for most of the shapes a real interface needs. A few favourites:</p>
<p><strong>Callouts</strong>, for the four things you're always trying to say. These are GitHub's alerts in spirit, minus the blockquote wrapper:</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300">[!info] Your export is being prepared.
[!ok] Payment received, you're all set.
[!warn] This action can't be undone.
[!err] We couldn't reach the server.</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><div class="flex gap-2 rounded-lg border px-3 py-2 text-sm border-blue-500/30 bg-blue-500/10 text-blue-800 dark:text-blue-200"><span aria-hidden="true" class="font-semibold">ℹ</span><div><span>Your export is being prepared.</span></div></div><div class="flex gap-2 rounded-lg border px-3 py-2 text-sm border-green-500/30 bg-green-500/10 text-green-800 dark:text-green-200"><span aria-hidden="true" class="font-semibold">✓</span><div><span>Payment received, you're all set.</span></div></div><div class="flex gap-2 rounded-lg border px-3 py-2 text-sm border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200"><span aria-hidden="true" class="font-semibold">⚠</span><div><span>This action can't be undone.</span></div></div><div class="flex gap-2 rounded-lg border px-3 py-2 text-sm border-red-500/30 bg-red-500/10 text-red-800 dark:text-red-200"><span aria-hidden="true" class="font-semibold">✕</span><div><span>We couldn't reach the server.</span></div></div></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">38<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p><strong>Charts</strong> from plain CSV, no charting library, just the shape of the data:</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300">@chart:area title="Signups this week"
Mon,120
Tue,180
Wed,165
Thu,240
Fri,310</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><figure class="my-3 rounded-xl border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-800/40"><figcaption class="mb-1 text-sm font-medium text-zinc-700 dark:text-zinc-300">Signups this week</figcaption><svg viewBox="0 0 340 170" class="h-auto w-full" preserveAspectRatio="xMidYMid meet"><line x1="24" y1="146" x2="316" y2="146" stroke="currentColor" class="text-zinc-300 dark:text-zinc-600" stroke-width="1"></line><polygon points="24,146 24,98.7741935483871 97,75.16129032258064 170,81.06451612903226 243,51.54838709677419 316,24 316,146" fill="var(--sm-accent)" opacity="0.15"></polygon><polyline points="24,98.7741935483871 97,75.16129032258064 170,81.06451612903226 243,51.54838709677419 316,24" fill="none" stroke="var(--sm-accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"></polyline><circle cx="24" cy="98.7741935483871" r="3" fill="var(--sm-accent)"></circle><circle cx="97" cy="75.16129032258064" r="3" fill="var(--sm-accent)"></circle><circle cx="170" cy="81.06451612903226" r="3" fill="var(--sm-accent)"></circle><circle cx="243" cy="51.54838709677419" r="3" fill="var(--sm-accent)"></circle><circle cx="316" cy="24" r="3" fill="var(--sm-accent)"></circle></svg><div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.7rem] text-zinc-500 dark:text-zinc-400"><span class="inline-flex items-center gap-1">Mon</span><span class="inline-flex items-center gap-1">Tue</span><span class="inline-flex items-center gap-1">Wed</span><span class="inline-flex items-center gap-1">Thu</span><span class="inline-flex items-center gap-1">Fri</span></div></figure></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">20<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p><strong>Cards and layout</strong>, for composing the above into something that feels designed:</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300">&gt;&gt;&gt;
@avatar "RA" teal
**Review agent** found a race condition [badge.red.sm]publisher.ts[/]

Two workers can claim the same event before either marks it
in-flight, so the dedupe check reads stale state under load.

[btn.teal]Show the diff[/]  [btn.gray.outline]Dismiss[/]
&lt;&lt;&lt;</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><div class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-800/40"><div class="space-y-4"><div class="flex -space-x-2"><span class="flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold text-white ring-2 ring-white dark:ring-zinc-900" style="background:#14b8a6">RA</span></div><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><strong><span>Review agent</span></strong><span> found a race condition </span><span class="mx-0.5 inline-flex items-center rounded-md font-medium ring-1 ring-inset text-[0.65rem] px-1.5 py-0 bg-red-500/10 text-red-700 ring-red-600/20 dark:text-red-300 dark:ring-red-400/20"><span>publisher.ts</span></span></p><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><span>Two workers can claim the same event before either marks it in-flight, so the dedupe check reads stale state under load.</span></p><p class="text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-300"><span role="button" class="mx-0.5 inline-flex cursor-pointer items-center rounded-md px-2.5 py-1 text-xs font-semibold ring-1 ring-inset transition bg-teal-500/10 text-teal-700 ring-teal-600/20 dark:text-teal-300 dark:ring-teal-400/20"><span>Show the diff</span></span><span>  </span><span role="button" class="mx-0.5 inline-flex cursor-pointer items-center rounded-md px-2.5 py-1 text-xs font-semibold ring-1 ring-inset transition bg-zinc-500/10 text-zinc-700 ring-zinc-600/20 dark:text-zinc-300 dark:ring-zinc-400/20"><span>Dismiss</span></span></p></div></div></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">69<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p>And two flavours of control. <strong>UI controls</strong> (tabs and toggle groups) hold local view state, so they're useful the instant they render. <strong>Input controls</strong> (text and date fields, selects, sliders, switches) render as real widgets that an agent harness can wire up to collect a structured answer from the user; on their own they're presentational, and either way the document stays logic-less:</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><div class="grid sm:grid-cols-2"><div class="border-b border-zinc-200 sm:border-b-0 sm:border-r dark:border-zinc-700"><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Supermark</div><pre class="overflow-x-auto px-4 pb-4 pt-2 font-mono text-[0.78rem] leading-relaxed text-zinc-700 dark:text-zinc-300">@toggle-group [Chart] Table Raw

@slider "Confidence" 0 100 80
@switch "Email me the results" on</pre></div><div><div class="px-4 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-400">Renders as</div><div class="px-4 pb-4 pt-2"><div class="" style="--sm-accent:#14b8a6"><div class="space-y-4"><div class="my-2 inline-flex rounded-lg bg-zinc-100 p-0.5 dark:bg-zinc-800"><button class="rounded-md px-3 py-1 text-sm font-medium transition bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-white">Chart</button><button class="rounded-md px-3 py-1 text-sm font-medium transition text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">Table</button><button class="rounded-md px-3 py-1 text-sm font-medium transition text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">Raw</button></div><label class="my-1 flex items-center gap-3 text-sm text-zinc-700 dark:text-zinc-300"><span class="min-w-16">Confidence</span><input type="range" min="0" max="100" class="h-1 flex-1 cursor-pointer accent-teal-500" value="80"><span class="w-8 text-right tabular-nums text-zinc-500">80</span></label><label class="my-1 flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300"><button type="button" class="relative h-5 w-9 rounded-full transition" style="background:var(--sm-accent)"><span class="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all left-[1.125rem]"></span></button>Email me the results</label></div></div></div></div></div><div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"><span>Supermark ≈<!-- --> <span class="font-medium text-zinc-700 dark:text-zinc-200">24<!-- --> tokens</span></span><span class="ml-auto text-zinc-400">estimated</span></div></div>
<p>There's more (timelines, kanban boards, calendars, trees, diffs, progress bars), but the fastest way to get a feel for it is to type.</p>
<h2>What I changed along the way</h2>
<p>Building a real renderer is the fastest way to find the soft spots in a spec, and I hit a few worth calling out.</p>
<p>The original grammar had <strong>no way to escape a sigil</strong>, which meant you literally couldn't write an article <em>about</em> Supermark, because <code>[badge]</code> always tried to become one. So I added backslash escaping: <code>\[badge]</code> renders as the literal text. Small thing, but you're reading it work right now.</p>
<p>I also rounded out the <strong>Markdown superset</strong> claim so it's actually true. A first pass had the extras but was missing pieces of plain GitHub-Flavored Markdown a model emits without thinking: images, <code>_underscore_</code> emphasis, bare-URL autolinks, hard line breaks, and per-column table alignment. Those all work now, so a document that's <em>just</em> Markdown renders exactly as you'd expect, and you only notice Supermark when you reach for something Markdown can't do.</p>
<p>I also loosened the <strong>container blocks</strong> (cards, collapsibles, and columns), which used to terminate on the first blank line and so couldn't hold more than one paragraph. Now each closes on a matching tag (<code>&lt;&lt;&lt;</code> for a card, <code>+++</code> for a collapsible) or, for columns, its final <code>|||</code> fence, so a card or a column can hold real, multi-paragraph content. I decided that mattered more than the tidiness of auto-closing on a blank line.</p>
<p>That same choice, distinct open and close markers, is also what lets containers <strong>nest</strong>: a card inside a card, an accordion inside an accordion. The parser just depth-counts matched pairs, which is unambiguous and cheap in a line-oriented grammar (it's why every bracket in every language is mirrored rather than symmetric). Lists nest the ordinary Markdown way, by indentation. Flat fences that never nest, like code blocks and dividers, stay symmetric on purpose.</p>
<p>A couple of honest limitations remain. The input controls are display-only in this playground: by design they're meant for a host harness to read values from, and a bare renderer like this one has nothing wired up to collect them. And "streaming-first" is a property of the <em>grammar</em> today more than a feature of this particular renderer. The design supports it; I haven't wired incremental rendering into the playground yet. I'd rather tell you that than pretend.</p>
<h2>Prior art</h2>
<p>The Markdown-extension lineage at the top of this article is half the picture; the other half comes from the app side. <strong>A2UI</strong> (Google), <strong>Adaptive Cards</strong> (Microsoft), and <strong>Slack's Block Kit</strong> let an agent drive real UI by emitting JSON against a component catalog. They're solving application interfaces, and JSON is a fine call there, but it's expensive per token, unreadable raw, and useless the moment the output needs to live as text in a doc, a diff, or an inbox.</p>
<p>Nothing in either lineage sits where a conversation needs: Markdown-compatible, terse enough to stream cheaply, a fixed vocabulary a model can already half-guess, and readable with no renderer at all. That's the gap Supermark is sketching at. It also composes with what already works: Mermaid lives in a fenced code block, Supermark keeps fenced code blocks, so a renderer that wants rich diagrams can hand a <code>mermaid</code> fence to a Mermaid renderer unchanged.</p>
<h2>Where this could go</h2>
<p>I'd rather be straight about the state of this. Supermark is a syntax and one reference implementation, nothing more. For it to be useful to anyone but me, the interesting work is all still ahead: parser and renderer libraries in the languages people actually generate text from, plugins for the tools where Markdown already lives (Obsidian, VS Code, the GitHub pipeline), and enough agreement on the syntax that a document written for one renderer looks right in another.</p>
<p>None of that happens without adoption, and adoption isn't something I can will into being from a personal site. GFM proved Markdown grows by small, adoptable increments; it also had GitHub's distribution behind it, which is the part I can't replicate. But if something like this did settle into a convention, the Markdown-heavy surfaces we already stare at all day (agent UIs, docs, issues, chat) could carry a little more structure for a lot less effort, with nobody hand-writing HTML or trusting a model to. This is my sketch of that. If the idea is right and my particular syntax is wrong, I'd be glad to see a better one win.</p>
<h2>Try it</h2>
<p>The best way to understand Supermark is to break it. There's a live playground where you can type Supermark on one side and watch it render on the other, load a few presets, and watch the token counter tick as you go.</p>
<p><a href="/supermark">Open the Supermark playground →</a></p>
<p>I'd love to know what you build with it, where it falls short, or whether you'd solve this problem a different way entirely.</p>
<h2>The full syntax</h2>
<p>Everything Supermark understands, in one place: terse by design, so a person or
a model can write compliant Supermark without hunting through the prose above.
Since Supermark is a superset of Markdown, the ordinary Markdown you already know
(including GitHub-Flavored extras like tables, task lists, strikethrough, and
autolinks) works as-is; what follows adds the layout, data, and component syntax
on top.</p>
<div class="not-prose mt-8 text-zinc-700 dark:text-zinc-300"><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Document hints: first lines, one per line</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">^theme:dark | light</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Render on a dark or light surface.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">^accent:#14b8a6</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Accent color for charts, bars, progress, etc.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">^density:compact | default | spacious</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Vertical spacing between blocks.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Text &amp; inline (all of Markdown, plus a few)</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">**bold**  __bold__</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Bold.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">*italic*  _italic_</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Italic. Intra-word underscores stay literal.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">`code`</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Inline code.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">~~strike~~</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Strikethrough.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">==highlight==</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Highlighted (marked) text.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[text](url)</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Link.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">https://…   &lt;https://…&gt;</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Bare or angle-bracketed URLs autolink.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">![alt](src)</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Image. src must be http(s):, data:image/, or /path.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">\[  \@  \|  \*</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Backslash escapes any sigil to literal text.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">line ending in "\" or two spaces</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Hard line break within a paragraph.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Inline components: [name.mods]…[/]</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[badge.green.sm.outline]New[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Badge. Color + size (sm/lg) + outline, any order.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[btn.teal]Open[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Button (presentational).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[kbd]⌘K[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Keyboard key.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[tip "hover text"]term[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Tooltip on hover.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[color.blue]x[/]  [color #f00]x[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Colored text, named or hex.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[bg.amber]x[/]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Highlighted background.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[progress 73]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Inline progress bar, 0–100.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[spinner]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Loading spinner.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[stars 4.5]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Star rating, 0–5, halves allowed.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[swatch #7c6aff]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Color dot.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Headings, quotes, rules</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300"># H1 … ###### H6</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Headings, levels 1–6.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">&gt; quoted line</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Blockquote.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">---   ***</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Horizontal rule.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">--- Label ---</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Labeled section divider.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Lists</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">- item   * item   1. item</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Unordered or ordered list.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">  - nested</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Indent 2 spaces per level to nest.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">-[x] done   -[ ] todo</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Task list.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Code &amp; tables</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">```lang … ```</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Fenced code block (lang optional).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">| a | b |</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Table row.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">| :-- | :-: | --: |</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Separator row; colons set column alignment.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Callouts: [!kind] text</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">[!info] [!ok] [!warn] [!err]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Four callout styles.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Layout containers</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">||| … ||| … |||</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Columns. Flat (no nesting); final ||| closes.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">&gt;&gt;&gt; … &lt;&lt;&lt;</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Card. Nests by depth (&gt;&gt;&gt; opens, &lt;&lt;&lt; closes).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">+++ Title … +++</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Collapsible. Title required; nests by depth.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">::: center | right :::  …  ::: :::</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Align a block.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Stats &amp; charts</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@stat $48k "Revenue" +23%</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">KPI. Delta optional; a leading - reads as down/red.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@bar Label 24500</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Horizontal bar; consecutive @bar lines group.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@chart:TYPE title="…"</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Chart: line, area, bar, pie, scatter. Rows follow as CSV.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@metrics  then  Label :: value</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Label/value list.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Sequence &amp; structure: header line, then rows</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@steps  then  1. text</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Numbered walkthrough.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@timeline  then  ~ Date :: text</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Timeline entries.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@tree  then  indented names</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Any nested hierarchy; 2-space indent per level.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@diff  then  +add  -del   ctx</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Diff block.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@kanban  then  ## Column  - card</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Board columns and cards.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">UI bits</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@crumbs A / B / C</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Breadcrumbs.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@avatar "IN" teal "AK" purple</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Avatar group (initials + color).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@quote "Author" "Source"  then body</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Pull quote; both attributes optional.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@empty "No results" 🔍</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Empty state (icon optional).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@skeleton lines:3 | card | avatar</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Loading placeholder.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@pages 1 2 [3] 4</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Pagination; [n] marks the active page.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@calendar 2026-02 highlight=[3,7]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Month grid with highlighted days.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">UI controls: local view state, no data binding</h3><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@tabs A | B  then  @tab A / content</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Tabbed panels.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@toggle-group [A] B C</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Segmented control; [x] marks the active option.</dd></div></dl></section><section class="mb-7"><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Input controls</h3><p class="mb-2 rounded-lg border border-teal-500/20 bg-teal-500/5 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400">Presentational on their own. They’re meant to be wired by an agent harness that reads the values the user enters; the document itself stays logic-less and never submits.</p><dl class="divide-y divide-zinc-100 dark:divide-zinc-800"><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@input "Label" email "placeholder"</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Text field (any HTML input type).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@textarea "Label" 4 "placeholder"</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Multi-line field (rows count).</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@select "Label" [A (B) C]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Dropdown; (x) marks the default.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@radio "Label" [A (B)]</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Radio group; (x) marks the default.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@switch "Label" on | off</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Toggle.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@slider "Label" 0 100 80</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Slider: min, max, value.</dd></div><div class="grid grid-cols-1 gap-x-4 gap-y-0.5 py-1.5 sm:grid-cols-[minmax(0,22rem)_1fr]"><dt><code class="whitespace-pre-wrap break-words font-mono text-[0.8rem] text-teal-700 dark:text-teal-300">@date "Label" 2026-03-01</code></dt><dd class="text-sm text-zinc-600 dark:text-zinc-400">Date field.</dd></div></dl></section><section><h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Rules that aren’t obvious from the syntax</h3><ul class="space-y-1.5 text-sm text-zinc-600 dark:text-zinc-400"><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>Line-oriented: a block’s meaning is set by how its line starts. Block sigils (#, @, |||, &gt;&gt;&gt;, +++, :::) own the whole line; inline components live inside text.</span></li><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>Every valid Markdown (and GitHub-Flavored Markdown) document is already valid Supermark.</span></li><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>Containers close on a matching token, so they can hold multiple paragraphs. Cards and collapsibles nest by depth-counting; columns stay flat.</span></li><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>A collapsible’s opening +++ must carry a title; the bare +++ closes it.</span></li><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>Logic-less by design: every value is a literal, with no variables, expressions, or loops. UI controls (tabs, toggle-group) hold local view state; input controls are read by a host harness rather than submitting anywhere.</span></li><li class="flex gap-2"><span class="mt-2 h-1 w-1 flex-none rounded-full bg-teal-500"></span><span>No hidden content: there’s no comment, metadata, or raw-HTML syntax, so a document can’t carry anything that renders invisibly; the source and the rendered output say the same thing.</span></li></ul></section></div>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <link>https://steven-fox.com/articles/laravel-model-validation</link>
            <guid>https://steven-fox.com/articles/laravel-model-validation</guid>
            <pubDate>Sun, 09 Jun 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2>The TL;DR</h2>
<ul>
<li>I wrote <a href="https://github.com/steven-fox/laravel-model-validation">a package</a> that provides validation for Eloquent models.</li>
<li>It allows you to define the foundational rules, messages, etc. for each model directly within your model classes.</li>
<li>It has an opt-in feature to perform validation when saving.</li>
<li>If you need to perform special validation at some point, for example when handling a particular request payload, it's easy to retrieve, extend, or replace the validation logic for a model.</li>
<li>As of this moment, it's in an alpha release state. Feel free to give it a try, but note that the api may change slightly before it's v1.0 release.</li>
</ul>
<h2>Why use this package?</h2>
<p>For a few main reasons:</p>
<ol>
<li>To reduce code duplication</li>
<li>To reduce validation whoopsies</li>
<li>To ensure data integrity when you can't control all of the times when your model will be filled/saved</li>
</ol>
<h2>Shouldn't validation be handled in Controllers or FormRequests?</h2>
<p>Indeed, I think there <em>are</em> plenty of situations where validation needs to be performed in Controllers, FormRequests, and elsewhere (like Nova/Filament fields, console input, etc). However, after working with large applications containing over a hundred models and a ton of input vectors, I have found it's efficient and less buggy to define a foundational set of validation rules for each model and then handle extra validation concerns on a case by case basis wherever necessary.</p>
<p>Here are a few examples of putting that into practice.</p>
<ul>
<li>Sometimes, I will write a route to update a model record but I only want to permit the updating of certain attributes. In these situations, I will add the validation logic to my Controller or FormRequest class, but I will do so by grabbing the relevant rules from the model itself (<code>$requestRules = $model-&gt;validationRules('attribute_1', 'attribute_2', ...)</code>). By doing so, I can ensure that my <code>$request-&gt;validated()</code> array only contains the attributes I want to update and that the rules for each attribute is correct (at least, as correct as I had previously defined within the model).</li>
<li>There are times when model attributes get a little messy and it can be easy to mistakenly fill a model in a database-valid-yet-logically-invalid way. Consider a hypothetical <code>Order</code> model with <code>shipment_status</code>, <code>tracking_number</code>, <code>carrier</code>, <code>shipped_at</code>, and <code>delivered_at</code> attributes (ok ok - we'd almost certainly want to break this out into related models but go with me for a second). As you can imagine, we may need various implementations to integrate with <code>UPS</code>, <code>USPS</code>, <code>FedEx</code>, <code>DHL</code>, etc and each would fill those variables. Note that our code has nothing to do with user input. Now, would it be possible to write one of these implementations and forget to fill an attribute or parse a date from the carrier's API incorrectly? Sure. Remember: handling validation for user input is essential, but programmers make mistakes too. In an ideal world, you'd have a test to cover yourself, but perhaps you had to rush out a feature and 100% code/state coverage wasn't possible. By defining some model validation rules, you could ensure that you never save a record when the <code>delivered_at</code> is <em>before</em> the <code>shipped_at</code>, or have a record where the <code>tracking_number</code> is filled but the <code>carrier</code> was left <code>null</code>, or have a <code>cancelled</code> <code>shipment_status</code> that left the <code>shipped_at</code> filled.</li>
<li>When writing a package for others to use, you can't always control how the data will be filled for your packages' models. Thus, having validation at the model level helps to eliminate a footgun for the consumer of your package. If they write a custom admin form that deals with your models, you can be sure that they won't be able to (easily) save records that represent an invalid state.</li>
</ul>
<h2>Get started</h2>
<p>Check out the <a href="https://github.com/steven-fox/laravel-model-validation">repo</a> and if you like the features &amp; api described in the README, give it a try in your app.</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">composer</span> require steven-fox/laravel-model-validation
</code></pre>
<p>I'm open to feedback and improvements.</p>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <link>https://steven-fox.com/articles/my-neapolitan-pizza-dough-recipe</link>
            <guid>https://steven-fox.com/articles/my-neapolitan-pizza-dough-recipe</guid>
            <pubDate>Tue, 17 Sep 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>There are several aspects of my life where I push for perfection. Pizza is one of them. And the dough is the foundation for a fantastic pie. Here's my current recipe.</p>
<img alt="Steven making a pizza in a Gozney Dome" loading="lazy" width="1024" height="768" decoding="async" data-nimg="1" class="rounded-2xl object-cover" style="color:transparent" sizes="(min-width: 1024px) 32rem, 20rem" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=16&amp;q=75 16w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=32&amp;q=75 32w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=48&amp;q=75 48w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=64&amp;q=75 64w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=96&amp;q=75 96w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=128&amp;q=75 128w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=256&amp;q=75 256w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=384&amp;q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=640&amp;q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=750&amp;q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=828&amp;q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2FF9DD4FC8-AE55-492F-AAE7-5EA935BA1DEE_1_105_c.f6d7cdd8.jpeg&amp;w=3840&amp;q=75">
<h3>Heads Up</h3>
<p>With so many variables at play, one must develop some "tweaking judgement" through experience. Durations, ingredient amounts, folding counts of the dough, etc. are constantly being adjusted to suit the air temp, time constraints, flour, freshness of yeast, yada yada. So, I can’t provide a “this is guaranteed to work if you follow this to the minute/gram” but it should still serve as a helpful framework towards good pizza dough.</p>
<img alt="Pizza cooking in a Gozney Dome" loading="lazy" width="1024" height="768" decoding="async" data-nimg="1" class="rounded-2xl object-cover" style="color:transparent" sizes="(min-width: 1024px) 32rem, 20rem" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=16&amp;q=75 16w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=32&amp;q=75 32w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=48&amp;q=75 48w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=64&amp;q=75 64w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=96&amp;q=75 96w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=128&amp;q=75 128w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=256&amp;q=75 256w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=384&amp;q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=640&amp;q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=750&amp;q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=828&amp;q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2FE18A812A-F842-4AA0-8E60-81D2DFE87FC9_1_105_c.6d571490.jpeg&amp;w=3840&amp;q=75">
<h3>Notes</h3>
<ul>
<li>Although this is intended for wood-fired pizzas, I also use this dough for artisan and ciabatta bread frequently.</li>
<li>I make ~280g dough balls to produce ~12” pizzas.</li>
<li>Target hydration is 70-75% (mass of water / mass of flour).<!-- -->
<ul>
<li>75% is softer, fluffier, but HARD to work with (sticking, ripping, &amp; overstretching comes easily).</li>
<li>70% yields similar results but a little more forgiving.</li>
<li>If you struggle when working with the dough during pizza prep, you can try to go lower in hydration but anything below like 65% won’t give the epic “soft and crunchy” results.</li>
</ul>
</li>
<li>Times are <strong>very</strong> dependent on ambient temp. The poolish will ferment at widly different rates at 66F vs. 76F.</li>
<li>This recipe uses a <strong>poolish</strong> preferment. There is classic variation that uses a <strong>biga</strong> preferment instead, but unless you have a commercial style spiral dough mixer, you'll struggle to mix the biga and final dough.</li>
</ul>
<h3>Recipe</h3>
<p><strong>Ingredients</strong></p>
<p>The type of flour makes a notable difference. My go to is <a href="https://brickovenbaker.com/products/caputo-pizzeria-flour-tipo-00">Caputo Pizzeria</a>, but I’ve recently started to experiment with <a href="https://brickovenbaker.com/products/antimo-caputo-nuvola">Caputo Nuvola Super</a> (some people like this over the Pizzeria flour but I have found it to be stickier thus far) and <a href="https://www.amazon.com/dp/B08P7877J3?amp=&amp;crid=11BILL1ZVKW8C&amp;amp=&amp;sprefix=caputo+manit">Caputo Manitoba Oro</a> (very high strength flour that helps bring structure to the dough when using Pizzeria flour poolish preferments).</p>
<p>I use <a href="https://brickovenbaker.com/products/antimo-caputo-durum-wheat-semolina">Caputo Semolina</a> flour on my work surface/paddle when spreading/prepping the pizzas.</p>
<p>I use <a href="https://brickovenbaker.com/products/caputo-yeast">Caputo Lievito yeast</a> but any good instant yeast will work fine.</p>
<p>Use good water - we have a reverse osmosis setup at home and use that.</p>
<img alt="Finished pepperoni and prosciutto pizza on a pizza peel" loading="lazy" width="1024" height="768" decoding="async" data-nimg="1" class="rounded-2xl object-cover" style="color:transparent" sizes="(min-width: 1024px) 32rem, 20rem" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=16&amp;q=75 16w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=32&amp;q=75 32w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=48&amp;q=75 48w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=64&amp;q=75 64w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=96&amp;q=75 96w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=128&amp;q=75 128w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=256&amp;q=75 256w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=384&amp;q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=640&amp;q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=750&amp;q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=828&amp;q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F7E815D9D-7911-42E3-8FF2-FB03ECB3A809_1_105_c.6a3c7984.jpeg&amp;w=3840&amp;q=75">
<p><strong>Poolish Preferment</strong></p>
<p>Target hydration: <strong>100%</strong></p>
<ul>
<li>2 strategies:<!-- -->
<ul>
<li>Very little yeast with 12-16 hour rest at room temp (68-72*F) overnight.</li>
<li>Much more yeast with 1hr at room temp and then 24 hours in fridge.</li>
</ul>
</li>
</ul>
<p><strong>Example ingredient amounts PER PIZZA</strong></p>
<ul>
<li>81.3g room temp water</li>
<li>81.3g Pizzeria flour</li>
<li>Yeast<!-- -->
<ul>
<li>1/12 teaspoon for room temp / 12hr version</li>
<li>0.5g for 1hr room temp + 24hr fridge version</li>
</ul>
</li>
<li>1.6g honey</li>
<li>No salt</li>
</ul>
<p>Steps</p>
<ol>
<li>Combine ingredients in a container that has an airtight lid.</li>
<li>Mix by hand/spatula until smooth (1 min), but you are not trying to develop gluten structure.</li>
<li>Follow resting strategy based on the amount of yeast you used.</li>
<li>The poolish is ready when:<!-- -->
<ol>
<li>Room temp rest: it has doubled/tripled in volume and there are bubbles popping every few seconds at the surface.</li>
<li>Fridge rest: 24 hrs have passed (with this strategy, it will not have tripled in volume but you should still see some gluten development and bubbles throughout).</li>
</ol>
</li>
</ol>
<img alt="Finished pepperoni pizza on a pizza peel" loading="lazy" width="768" height="1024" decoding="async" data-nimg="1" class="rounded-2xl object-cover" style="color:transparent" sizes="(min-width: 1024px) 32rem, 20rem" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=16&amp;q=75 16w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=32&amp;q=75 32w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=48&amp;q=75 48w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=64&amp;q=75 64w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=96&amp;q=75 96w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=128&amp;q=75 128w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=256&amp;q=75 256w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=384&amp;q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=640&amp;q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=750&amp;q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=828&amp;q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F24533A01-8576-4224-A308-24A078E63E41_1_105_c.78a6e60d.jpeg&amp;w=3840&amp;q=75">
<p><strong>Mixing the Final Dough</strong></p>
<ul>
<li>Target hydration: my go to is <strong>70%</strong>, but as described above, somewhere between 65% and 75% will do depending on your level of dedication.</li>
<li>Flour used: either more Pizzeria or this is where I’m experimenting with the Manitoba flour to have more dough strength… most of the time, I just use more Pizzeria flour.</li>
</ul>
<p><strong>Example ingredient amounts PER PIZZA</strong></p>
<ul>
<li>32.5g room temp water</li>
<li>81.3g flour</li>
<li>3.3g fine sea salt</li>
<li>Ideally, NO yeast. The poolish activity will develop the final dough. However, if you struggle with rise or are in a time crunch, you can add a little bit of yeast (like 1/12tsp per pizza) to the final dough to speed things along.</li>
</ul>
<p>Steps</p>
<ol>
<li>Pour the water into the poolish to help it separate from the container.</li>
<li>Add the flour.</li>
<li>(Autolyse 20 min) Mix until combined but don’t worry about developing gluten yet. Cover and rest for 20 min.</li>
<li>(Develop) Work the dough. Add the salt during this process (if by hand, immediately; if by mixer, at the halfway point). I have 2 strategies for this:<!-- -->
<ol>
<li>By hand. Use the pinch-pull-fold technique.</li>
<li>Mixer. Start on a slower speed (2-3?) until the dough is kinda uniform in texture (3-4min). Then speed up (4-6?) to get the dough separating and slapping around the bowl.</li>
</ol>
</li>
<li>After the first round of development (maybe 8min), the dough should hold a shape momentarily (when tensioned), by smooth in texture, and have a good amount of strength.</li>
<li>Rest for 20 mins.</li>
<li>Assess the dough and if needed, work the dough again (common if doing this by hand). Doesn’t have to be long, but you are looking to add tension to the dough so that it will hold a ball longer and longer.</li>
<li>Depending on how the dough is forming, you may need to rest and then develop again, especially when working by hand. If using a mixer, you’re probably good.</li>
</ol>
<p><strong>Shaping and Proofing</strong></p>
<p>At this point, we once again have a couple strategies which differ in time:</p>
<ol>
<li>You intend to make the pizza same day.</li>
<li>You intend to wait 24-48hrs before making pizza.</li>
</ol>
<p><strong>For same day:</strong></p>
<ol>
<li>Rest the final dough for ~1 hr.</li>
<li>Divide into ~280g balls.</li>
<li>Allow to proof until doubled in size. Time will vary significantly depending on air temp and whether you added additional yeast for the final dough. At 75*F and no addt’l yeast, maybe 4-6hrs.</li>
</ol>
<p><strong>For long proof</strong></p>
<ol>
<li>Rest the final dough for ~1 hr.</li>
<li>Divide into ~280g balls.</li>
<li>Immediately put into fridge and allow to rest for 24-48hrs.</li>
<li>On the day of pizzas, remove from the fridge about 1-2hrs ahead of time.</li>
</ol>
<img alt="Picture of a pizza slice, showing the wonderful gluten structure and airyness of the crust." loading="lazy" width="1024" height="768" decoding="async" data-nimg="1" class="rounded-2xl object-cover" style="color:transparent" sizes="(min-width: 1024px) 32rem, 20rem" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=16&amp;q=75 16w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=32&amp;q=75 32w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=48&amp;q=75 48w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=64&amp;q=75 64w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=96&amp;q=75 96w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=128&amp;q=75 128w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=256&amp;q=75 256w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=384&amp;q=75 384w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=640&amp;q=75 640w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=750&amp;q=75 750w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=828&amp;q=75 828w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F057EB055-E935-4AEC-B54B-3F2F8128534D_1_105_c.444c30d8.jpeg&amp;w=3840&amp;q=75">]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
        <item>
            <link>https://steven-fox.com/articles/new-personal-site</link>
            <guid>https://steven-fox.com/articles/new-personal-site</guid>
            <pubDate>Mon, 01 Apr 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>I created the first version of my personal website back in 2016, a time that preceded my present day web development knowledge. Hence, it was a Squarespace buildout. 😅</p>
<p>This week felt like a good time to give it an update.</p>
<p>Fun fact: I don't know React or Next.js. So, this particular project seemed like a great opportunity to learn something new. After a few days learning the basics of Next.js &amp; React (still feeling partial to Vue) and spending what felt like forever to get the logos sorted out for the home page, I'm ready to publish the first version. I hope you like it!</p>
<p>Along with this update, I also hope to begin publishing a few articles from time to time. Not entirely sure what my focus will be just yet, but that's something we can hone in the coming months.</p>
<p>For those interested, here are a few facts about the new site:</p>
<ul>
<li>Based on the <a href="https://tailwindui.com/templates/spotlight">Spotlight template</a> from TailwindUI</li>
<li><a href="https://nextjs.org/">Next.js</a> v14</li>
<li><a href="https://tailwindcss.com">Tailwind</a> v3</li>
<li>A few inspirations from <a href="https://flowbite.com/">Flowbite</a></li>
</ul>]]></content:encoded>
            <author>steven@steven-fox.com (Steven Fox)</author>
        </item>
    </channel>
</rss>