<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged serialization</title>
<link>https://coderlegion.com/tag/serialization</link>
<description>Powered by Question2Answer</description>
<item>
<title>I Built a Serializer That Refuses to Own Your Data</title>
<link>https://coderlegion.com/28979/i-built-a-serializer-that-refuses-to-own-your-data</link>
<description>&lt;h2 id=&quot;i-built-a-serializer-that-refuses-to-own-your-data&quot;&gt;I Built a Serializer That Refuses to Own Your Data&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How The Singularity Workshop turned a recurring binary-IO problem into a tiny .NET boundary—and why the smallest part of the design may be the most important part.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is a particular kind of software problem that appears harmless at first.&lt;/p&gt;
&lt;p&gt;You have some state.&lt;/p&gt;
&lt;p&gt;You need to turn it into bytes.&lt;/p&gt;
&lt;p&gt;So you write a serializer.&lt;/p&gt;
&lt;p&gt;Then another part of the application needs bytes, so you add another serializer.&lt;/p&gt;
&lt;p&gt;Then a cache needs them.&lt;/p&gt;
&lt;p&gt;Then a manifest needs them.&lt;/p&gt;
&lt;p&gt;Then a transport layer needs them.&lt;/p&gt;
&lt;p&gt;Then somebody decides JSON is better here, binary is better there, and suddenly the architecture has six different opinions about what a piece of data &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That was the problem I was running into while building The Singularity Workshop.&lt;/p&gt;
&lt;p&gt;The interesting part is that I didn't actually need a giant serialization framework.&lt;/p&gt;
&lt;p&gt;I needed a &lt;strong&gt;boundary&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;So I built one.&lt;/p&gt;
&lt;p&gt;It is called:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TheSingularityWorkshop.FSM_Serialization&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And it is deliberately small.&lt;/p&gt;
&lt;p&gt;That is the point.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem-wasn-t-turning-objects-into-bytes&quot;&gt;The Problem Wasn't Turning Objects Into Bytes&lt;/h3&gt;
&lt;p&gt;Serialization is usually described as a mechanical operation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;object → serializer → bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That description hides something important.&lt;/p&gt;
&lt;p&gt;An object has meaning.&lt;/p&gt;
&lt;p&gt;Bytes do not.&lt;/p&gt;
&lt;p&gt;Bytes are a representation of meaning.&lt;/p&gt;
&lt;p&gt;That distinction becomes increasingly important as an architecture grows.&lt;/p&gt;
&lt;p&gt;The Workshop ecosystem is being built around several deliberately separated responsibilities:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FSM_API
    │
    │ behavior and state
    ▼
FSM_COS
    │
    │ composition and runtime assembly
    ▼
FSM_Serialization
    │
    │ representation
    ▼
storage / transport / host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I didn't want FSM_COS to become a serializer.&lt;/p&gt;
&lt;p&gt;I didn't want a MicroBundle to have to know how a filesystem works.&lt;/p&gt;
&lt;p&gt;I didn't want a database abstraction hiding inside a representation layer.&lt;/p&gt;
&lt;p&gt;And I definitely didn't want every higher-level system inventing its own binary stream interface.&lt;/p&gt;
&lt;p&gt;So I extracted the smallest reusable boundary I could identify.&lt;/p&gt;
&lt;p&gt;That boundary became FSM_Serialization.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-first-design-decision&quot;&gt;The First Design Decision&lt;/h2&gt;
&lt;p&gt;The most important rule in the package is also the simplest:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The type that owns the meaning should own the representation rules.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=16908214559991722359&quot; alt=&quot;Ai rendering of the serialization as a process&quot;&gt;&lt;/p&gt;
&lt;p&gt;FSM_Serialization provides the machinery for crossing the byte boundary.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; decide what your data means.&lt;/p&gt;
&lt;p&gt;That means the package doesn't contain some enormous reflection-based object graph engine that looks at your class and decides what should be serialized.&lt;/p&gt;
&lt;p&gt;Instead, the type explicitly says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryPackable
{
    void Pack(IBinaryStream stream);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the reverse direction is equally explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryUnpackable
{
    void Unpack(IBinaryStream stream);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a type needs both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinarySerializable :
    IBinaryPackable,
    IBinaryUnpackable
{
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the entire conceptual model.&lt;/p&gt;
&lt;p&gt;No magic.&lt;/p&gt;
&lt;p&gt;No global serializer registry.&lt;/p&gt;
&lt;p&gt;No reflection-based guesswork.&lt;/p&gt;
&lt;p&gt;No framework deciding that a private field suddenly became part of your persistent format.&lt;/p&gt;
&lt;p&gt;The domain owns that decision.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-i-didn-t-just-use-system-io-stream&quot;&gt;Why I Didn't Just Use &lt;code&gt;System.IO.Stream&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This is probably the first question a .NET developer will ask.&lt;/p&gt;
&lt;p&gt;And honestly, it is a good question.&lt;/p&gt;
&lt;p&gt;.NET already has &lt;code&gt;System.IO.Stream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why introduce another interface?&lt;/p&gt;
&lt;p&gt;Because I wasn't trying to replace &lt;code&gt;Stream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I was trying to establish a &lt;strong&gt;package-owned contract&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The current abstraction is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryStream : IDisposable
{
    bool CanRead { get; }
    bool CanWrite { get; }

    long Position { get; set; }
    long Length { get; }

    int Read(Span&amp;lt;byte&amp;gt; buffer);
    void Write(ReadOnlySpan&amp;lt;byte&amp;gt; buffer);
    void Flush();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what isn't there.&lt;/p&gt;
&lt;p&gt;There is no:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;filename;&lt;/li&gt;
&lt;li&gt;filesystem;&lt;/li&gt;
&lt;li&gt;database;&lt;/li&gt;
&lt;li&gt;socket;&lt;/li&gt;
&lt;li&gt;HTTP request;&lt;/li&gt;
&lt;li&gt;serializer registry;&lt;/li&gt;
&lt;li&gt;schema;&lt;/li&gt;
&lt;li&gt;domain metadata;&lt;/li&gt;
&lt;li&gt;application configuration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The interface only knows about the byte boundary.&lt;/p&gt;
&lt;p&gt;That makes it possible to write serialization code against one very small abstraction while letting the host decide what is actually underneath it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-two-directions-matter&quot;&gt;The Two Directions Matter&lt;/h2&gt;
&lt;p&gt;There is another small decision here that I think becomes important in larger systems.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1800093087783163459&quot; alt=&quot;Ai generated image showing data flow in both directions&quot;&gt;&lt;/p&gt;
&lt;p&gt;Packing and unpacking are separate capabilities.&lt;/p&gt;
&lt;p&gt;A type that produces a representation does not necessarily need to consume one.&lt;/p&gt;
&lt;p&gt;Likewise, a type may consume a representation without being responsible for producing it.&lt;/p&gt;
&lt;p&gt;So the package doesn't force everything into one giant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Serialize()
Deserialize()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;method.&lt;/p&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 DOMAIN TYPE
                     │
          ┌──────────┴──────────┐
          │                     │
        Pack                  Unpack
          │                     │
          ▼                     ▲
     representation          representation
          │                     │
          └──────────┬──────────┘
                     ▼
                byte stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That separation costs almost nothing.&lt;/p&gt;
&lt;p&gt;But architecturally, it keeps the ownership visible.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-smallest-possible-example&quot;&gt;The Smallest Possible Example&lt;/h2&gt;
&lt;p&gt;Let's make this concrete.&lt;/p&gt;
&lt;p&gt;Imagine a domain object containing one integer.&lt;/p&gt;
&lt;p&gt;That's intentionally boring.&lt;/p&gt;
&lt;p&gt;Boring is useful here because it lets us see exactly where the responsibility lives.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using System.Buffers.Binary;
using TheSingularityWorkshop.FSM_Serialization;

public sealed class ExampleState : IBinarySerializable
{
    public int Value { get; private set; }

    public ExampleState()
    {
    }

    public ExampleState(int value)
    {
        Value = value;
    }

    public void Pack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        BinaryPrimitives.WriteInt32LittleEndian(
            buffer,
            Value);

        stream.Write(buffer);
    }

    public void Unpack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        if (stream.Read(buffer) != buffer.Length)
            throw new EndOfStreamException();

        Value = BinaryPrimitives.ReadInt32LittleEndian(buffer);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no serializer object.&lt;/p&gt;
&lt;p&gt;There is no configuration object.&lt;/p&gt;
&lt;p&gt;There is no registration step.&lt;/p&gt;
&lt;p&gt;The object itself defines its representation.&lt;/p&gt;
&lt;p&gt;That is intentional.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;now-let-s-actually-serialize-something&quot;&gt;Now Let's Actually Serialize Something&lt;/h2&gt;
&lt;p&gt;The package includes &lt;code&gt;MemoryBinaryStream&lt;/code&gt;, which is exactly what it sounds like: an in-memory implementation of &lt;code&gt;IBinaryStream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That makes testing and temporary representations extremely simple.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var stream = new MemoryBinaryStream();

var original = new ExampleState(42);

original.Pack(stream);

stream.Position = 0;

var restored = new ExampleState();

restored.Unpack(stream);

Console.WriteLine(restored.Value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it.&lt;/p&gt;
&lt;p&gt;The complete flow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     │ Pack
     ▼
MemoryBinaryStream
     │
     │ bytes
     ▼
Position = 0
     │
     │ Unpack
     ▼
ExampleState
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The package supplied the boundary.&lt;/p&gt;
&lt;p&gt;The domain supplied the meaning.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;installation&quot;&gt;Installation&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=9594257162961760962&quot; alt=&quot;Ai generated image showing how easy it is to install&quot;&gt;&lt;/p&gt;
&lt;p&gt;If you want to try it yourself, the current published alpha is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package TheSingularityWorkshop.FSM_Serialization --version 0.1.0-alpha.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;PackageReference
    Include=&quot;TheSingularityWorkshop.FSM_Serialization&quot;
    Version=&quot;0.1.0-alpha.2&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The current package targets .NET 8.&lt;/p&gt;
&lt;p&gt;This is intentionally an alpha release.&lt;/p&gt;
&lt;p&gt;That doesn't mean the contracts are casual.&lt;/p&gt;
&lt;p&gt;Quite the opposite.&lt;/p&gt;
&lt;p&gt;A binary representation is one of those things where small assumptions can become enormous compatibility problems later.&lt;/p&gt;
&lt;p&gt;So I'm treating the package as alpha while treating its architectural boundaries seriously.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-about-real-files-&quot;&gt;What About Real Files?&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5919042162865156260&quot; alt=&quot;Ai generated image showing that we are now going to talk about real files&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is where &lt;code&gt;StreamBinaryStream&lt;/code&gt; enters.&lt;/p&gt;
&lt;p&gt;The package includes an adapter around an ordinary .NET &lt;code&gt;Stream&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var file = File.OpenRead(&quot;state.bin&quot;);
using var stream = new StreamBinaryStream(file);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the same domain serialization code can operate against the package abstraction without knowing that a file exists.&lt;/p&gt;
&lt;p&gt;The relationship is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     ▼
IBinaryStream
     │
     ▼
StreamBinaryStream
     │
     ▼
System.IO.Stream
     │
     ▼
file / memory / whatever the host provides
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is a very different architecture from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     ▼
ExampleStateSerializer
     │
     ▼
FileSystemSerializerManager
     │
     ▼
ApplicationStorageService
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The latter might be useful somewhere.&lt;/p&gt;
&lt;p&gt;It just doesn't belong in this package.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-stream-adapter-is-supposed-to-be-boring&quot;&gt;The Stream Adapter Is Supposed to Be Boring&lt;/h2&gt;
&lt;p&gt;This is one of those cases where boring code is a compliment.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StreamBinaryStream&lt;/code&gt; essentially delegates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public int Read(Span&amp;lt;byte&amp;gt; buffer)
    =&amp;gt; _stream.Read(buffer);

public void Write(ReadOnlySpan&amp;lt;byte&amp;gt; buffer)
    =&amp;gt; _stream.Write(buffer);

public void Flush()
    =&amp;gt; _stream.Flush();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's not a weakness.&lt;/p&gt;
&lt;p&gt;It's the point.&lt;/p&gt;
&lt;p&gt;The adapter is not supposed to invent behavior.&lt;/p&gt;
&lt;p&gt;It establishes a stable contract between two layers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;then-i-ran-into-a-very-important-detail&quot;&gt;Then I Ran Into a Very Important Detail&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11766274087907501333&quot; alt=&quot;Ai generated image illustrating a very important detail&quot;&gt;&lt;/p&gt;
&lt;p&gt;A byte stream is not the same thing as an array.&lt;/p&gt;
&lt;p&gt;This matters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int count = stream.Read(buffer);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You cannot blindly assume:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;count == buffer.Length
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a fixed-width decoder in FSM_Serialization explicitly checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;if (stream.Read(buffer) != buffer.Length)
    throw new EndOfStreamException();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That may look like a tiny implementation detail.&lt;/p&gt;
&lt;p&gt;It isn't.&lt;/p&gt;
&lt;p&gt;It establishes an architectural rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Never interpret an incomplete representation as a complete value.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The serializer shouldn't quietly manufacture missing data.&lt;/p&gt;
&lt;p&gt;If the representation is incomplete, the domain implementation gets to decide what that means.&lt;/p&gt;
&lt;p&gt;In the current examples, that means rejecting it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;serialization-is-not-storage&quot;&gt;Serialization Is Not Storage&lt;/h2&gt;
&lt;p&gt;This distinction became increasingly important as the Workshop architecture grew.&lt;/p&gt;
&lt;p&gt;Consider these questions:&lt;/p&gt;
&lt;h4 id=&quot;serialization&quot;&gt;Serialization&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;How are these values represented as bytes?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;storage&quot;&gt;Storage&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;Where do those bytes live?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;transport&quot;&gt;Transport&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;How do those bytes move between boundaries?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14879660735867442002&quot; alt=&quot;Ai generated image showing a difference between Transport and Storage&quot;&gt;&lt;/p&gt;
&lt;p&gt;Those are three different questions.&lt;/p&gt;
&lt;p&gt;So:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 SEMANTIC DATA
                      │
                      ▼
                representation
                      │
                      ▼
              FSM_Serialization
                      │
              ┌───────┴───────┐
              ▼               ▼
           storage         transport
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A database is not a serializer.&lt;/p&gt;
&lt;p&gt;A filesystem is not a serializer.&lt;/p&gt;
&lt;p&gt;An HTTP endpoint is not a serializer.&lt;/p&gt;
&lt;p&gt;A cache is not a serializer.&lt;/p&gt;
&lt;p&gt;They may all consume serialized data.&lt;/p&gt;
&lt;p&gt;That doesn't make them the same architectural layer.&lt;/p&gt;
&lt;p&gt;This is exactly the kind of distinction that seems unnecessary when an application is small and becomes incredibly valuable when an ecosystem starts growing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-binary-&quot;&gt;Why Binary?&lt;/h2&gt;
&lt;p&gt;I am not claiming that binary is universally superior to JSON.&lt;/p&gt;
&lt;p&gt;It isn't.&lt;/p&gt;
&lt;p&gt;JSON is fantastic for many things.&lt;/p&gt;
&lt;p&gt;Text is fantastic for many things.&lt;/p&gt;
&lt;p&gt;Human-readable configuration is valuable.&lt;/p&gt;
&lt;p&gt;Debugging matters.&lt;/p&gt;
&lt;p&gt;Authoring matters.&lt;/p&gt;
&lt;p&gt;Interoperability with external systems matters.&lt;/p&gt;
&lt;p&gt;The reason I created this package is narrower.&lt;/p&gt;
&lt;p&gt;There are situations where I want an explicit, compact, byte-oriented representation.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;runtime state;&lt;/li&gt;
&lt;li&gt;manifests;&lt;/li&gt;
&lt;li&gt;dependency metadata;&lt;/li&gt;
&lt;li&gt;compact configuration;&lt;/li&gt;
&lt;li&gt;cache entries;&lt;/li&gt;
&lt;li&gt;identity records;&lt;/li&gt;
&lt;li&gt;version records;&lt;/li&gt;
&lt;li&gt;lineage metadata;&lt;/li&gt;
&lt;li&gt;persistence;&lt;/li&gt;
&lt;li&gt;transport payloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The important thing is that the semantic model shouldn't have to become identical to its wire representation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;deterministic-does-not-mean-magical&quot;&gt;Deterministic Does Not Mean Magical&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=2047862534182526687&quot; alt=&quot;Ai generated image showing this isn't magical&quot;&gt;&lt;/p&gt;
&lt;p&gt;The package's NuGet description talks about deterministic binary serialization.&lt;/p&gt;
&lt;p&gt;There is an important qualification.&lt;/p&gt;
&lt;p&gt;Implementing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;IBinaryPackable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;does not magically make your representation deterministic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You&lt;/strong&gt; have to define deterministic rules.&lt;/p&gt;
&lt;p&gt;For example, suppose your object contains a collection that is semantically unordered.&lt;/p&gt;
&lt;p&gt;If you serialize it in whatever order happens to be present at runtime, two semantically identical objects could produce different byte sequences.&lt;/p&gt;
&lt;p&gt;If deterministic bytes matter, the domain needs something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic collection
        │
        ▼
canonical ordering
        │
        ▼
explicit representation
        │
        ▼
deterministic bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That distinction matters if bytes are later used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;caching;&lt;/li&gt;
&lt;li&gt;content identity;&lt;/li&gt;
&lt;li&gt;synchronization;&lt;/li&gt;
&lt;li&gt;comparison;&lt;/li&gt;
&lt;li&gt;reproducible builds;&lt;/li&gt;
&lt;li&gt;lineage;&lt;/li&gt;
&lt;li&gt;integrity checks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Again, the package supplies the boundary.&lt;/p&gt;
&lt;p&gt;It does not pretend to know your domain's canonical ordering rules.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;versioning-gets-interesting&quot;&gt;Versioning Gets Interesting&lt;/h2&gt;
&lt;p&gt;This is another reason I didn't want a magical object serializer.&lt;/p&gt;
&lt;p&gt;Once bytes leave your process, they can outlive the code that created them.&lt;/p&gt;
&lt;p&gt;Imagine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Application v1
      │
      ▼
    bytes
      │
      │ time passes
      ▼
Application v2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What happens?&lt;/p&gt;
&lt;p&gt;That is a representation contract question.&lt;/p&gt;
&lt;p&gt;A durable binary format may need to distinguish between:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic object version
format version
dependency version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those are not necessarily the same thing.&lt;/p&gt;
&lt;p&gt;Your semantic object can change while the representation remains compatible.&lt;/p&gt;
&lt;p&gt;Your representation can change while the semantic concept remains the same.&lt;/p&gt;
&lt;p&gt;A dependency can change independently of both.&lt;/p&gt;
&lt;p&gt;FSM_Serialization intentionally doesn't impose one universal versioning strategy.&lt;/p&gt;
&lt;p&gt;Different domains need different compatibility policies.&lt;/p&gt;
&lt;p&gt;What it does is make the boundary explicit enough that you can actually have that conversation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-package-came-from-a-real-application&quot;&gt;The Package Came From a Real Application&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14666232440140458644&quot; alt=&quot;Ai generate image showing that the FSM_Serialization came from a real application&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is probably the most important part of the story.&lt;/p&gt;
&lt;p&gt;FSM_Serialization wasn't created because I thought:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;The world needs another serialization library.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are already plenty of serialization libraries.&lt;/p&gt;
&lt;p&gt;The package emerged from building the Workshop itself.&lt;/p&gt;
&lt;p&gt;The development pattern was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;working application
       │
       │ repeated infrastructure appears
       ▼
explicit contract
       │
       │ extract
       ▼
reusable package
       │
       │ consume again
       ▼
working application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;p&gt;I wasn't trying to abstract everything before I had a reason to abstract it.&lt;/p&gt;
&lt;p&gt;The application became the proving ground.&lt;/p&gt;
&lt;p&gt;Once the boundary became clear, I extracted it.&lt;/p&gt;
&lt;p&gt;That is a much healthier way to create infrastructure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;and-this-is-where-fsm-cos-enters-the-story&quot;&gt;And This Is Where FSM_COS Enters the Story&lt;/h2&gt;
&lt;p&gt;The larger ecosystem is becoming increasingly compositional.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10200316983152966329&quot; alt=&quot;Ai generated image showing that FSM_COS has arrived&quot;&gt;&lt;/p&gt;
&lt;p&gt;FSM_COS is concerned with questions like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What runtime composition was requested?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;Which MicroBundles are required?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;What dependencies are reachable?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;How do those capabilities reconcile?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;When is the composition stable enough to hand to a host?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;FSM_Serialization answers a different question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;How do we represent the values that need to cross a byte boundary?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That means a future conceptual flow can look something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Runtime Manifest
      │
      ▼
    FSM_COS
      │
      │ composition
      ▼
Runtime Assembly
      │
      │ representation
      ▼
FSM_Serialization
      │
      ▼
     bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then those bytes might eventually go somewhere else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 FSM_Serialization
                        │
             ┌──────────┼──────────┐
             ▼          ▼          ▼
          storage    transport    cache
             │          │          │
             └──────────┼──────────┘
                        ▼
                      host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The serializer doesn't become FSM_COS.&lt;/p&gt;
&lt;p&gt;FSM_COS doesn't become a serializer.&lt;/p&gt;
&lt;p&gt;That separation is exactly what I'm trying to preserve.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-tests-tell-the-same-story&quot;&gt;The Tests Tell the Same Story&lt;/h2&gt;
&lt;p&gt;The package currently has a deliberately small test surface.&lt;/p&gt;
&lt;p&gt;It verifies things such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an empty &lt;code&gt;MemoryBinaryStream&lt;/code&gt; is readable and writable;&lt;/li&gt;
&lt;li&gt;bytes survive a write/read round trip;&lt;/li&gt;
&lt;li&gt;existing byte arrays can initialize a memory stream;&lt;/li&gt;
&lt;li&gt;null input is rejected;&lt;/li&gt;
&lt;li&gt;end-of-input returns zero;&lt;/li&gt;
&lt;li&gt;positioning and overwriting work;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;StreamBinaryStream&lt;/code&gt; delegates correctly;&lt;/li&gt;
&lt;li&gt;null wrapped streams are rejected;&lt;/li&gt;
&lt;li&gt;writes reach the underlying stream;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IBinaryPackable&lt;/code&gt; and &lt;code&gt;IBinaryUnpackable&lt;/code&gt; can round-trip;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IBinarySerializable&lt;/code&gt; combines both contracts;&lt;/li&gt;
&lt;li&gt;incomplete binary input is rejected.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of the tests is essentially the entire architectural idea in executable form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var original = new TestValue(123456789);

using var stream = new MemoryBinaryStream();

original.Pack(stream);

stream.Position = 0;

var restored = new TestValue();

restored.Unpack(stream);

Assert.Equal(original.Value, restored.Value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tests aren't merely protecting the implementation here.&lt;/p&gt;
&lt;p&gt;They are defining the boundary.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;i-also-benchmarked-it&quot;&gt;I Also Benchmarked It&lt;/h2&gt;
&lt;p&gt;Because eventually somebody is going to ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Okay, but what does that abstraction cost?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Good.&lt;/p&gt;
&lt;p&gt;Let's measure it.&lt;/p&gt;
&lt;p&gt;The companion benchmark project uses BenchmarkDotNet against .NET 8 and exercises payloads from 16 bytes through 100,000 bytes.&lt;/p&gt;
&lt;p&gt;The interesting result isn't that FSM_Serialization magically beats &lt;code&gt;MemoryStream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It doesn't.&lt;/p&gt;
&lt;p&gt;That's not what I was trying to prove.&lt;/p&gt;
&lt;p&gt;The useful result is that the abstraction can remain extremely close to the underlying primitive for substantial workloads.&lt;/p&gt;
&lt;p&gt;At 100,000 bytes, the benchmark measured approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MemoryStream write:
39,894.85 ns

MemoryBinaryStream write:
40,005.59 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's effectively the same scale.&lt;/p&gt;
&lt;p&gt;Likewise, the adapter path was approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MemoryStream read:
2,272.47 ns

StreamBinaryStream read:
2,298.40 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Again, very close.&lt;/p&gt;
&lt;p&gt;And the end-to-end &lt;code&gt;IBinarySerializable&lt;/code&gt; benchmark measured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;16 bytes       64.08 ns
1,024 bytes   207.47 ns
10,000 bytes  1,481.61 ns
100,000 bytes 60,152.74 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there is an important qualification.&lt;/p&gt;
&lt;p&gt;The benchmark measures the &lt;em&gt;whole operation&lt;/em&gt;, including things like creating the test value, creating the stream, packing, rewinding, allocating the destination payload, and unpacking.&lt;/p&gt;
&lt;p&gt;So I'm not going to pretend those numbers represent some universal &quot;serialization speed.&quot;&lt;/p&gt;
&lt;p&gt;They represent a measured workload.&lt;/p&gt;
&lt;p&gt;That's much more useful.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-benchmark-question-i-actually-care-about&quot;&gt;The Benchmark Question I Actually Care About&lt;/h2&gt;
&lt;p&gt;The interesting engineering question isn't:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Is my interface faster than MemoryStream?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The interface isn't supposed to be the star of the show.&lt;/p&gt;
&lt;p&gt;The question is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Can I establish a useful architectural boundary without making the underlying operation prohibitively expensive?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The current measurements suggest that, for these workloads, the answer is yes.&lt;/p&gt;
&lt;p&gt;And that's exactly where I want infrastructure to live.&lt;/p&gt;
&lt;p&gt;The abstraction should be visible architecturally and nearly invisible computationally.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-deliberately-did-not-build&quot;&gt;What I Deliberately Did Not Build&lt;/h2&gt;
&lt;p&gt;This may be the most important section of the entire package.&lt;/p&gt;
&lt;p&gt;FSM_Serialization should not become:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an FSM engine;&lt;/li&gt;
&lt;li&gt;a game framework;&lt;/li&gt;
&lt;li&gt;a GUI framework;&lt;/li&gt;
&lt;li&gt;a browser framework;&lt;/li&gt;
&lt;li&gt;a Unity integration layer;&lt;/li&gt;
&lt;li&gt;a database abstraction;&lt;/li&gt;
&lt;li&gt;a filesystem framework;&lt;/li&gt;
&lt;li&gt;an HTTP client;&lt;/li&gt;
&lt;li&gt;an application configuration system;&lt;/li&gt;
&lt;li&gt;a MicroBundle implementation;&lt;/li&gt;
&lt;li&gt;an Experience runtime;&lt;/li&gt;
&lt;li&gt;a reflection-driven object graph serializer;&lt;/li&gt;
&lt;li&gt;a global serializer registry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those things may all be useful.&lt;/p&gt;
&lt;p&gt;They just aren't this thing.&lt;/p&gt;
&lt;p&gt;That restraint is part of the architecture.&lt;/p&gt;
&lt;p&gt;A small package becomes powerful when other systems can depend on it without having to inherit the systems it was never supposed to understand.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;so-what-would-you-actually-use-it-for-&quot;&gt;So What Would You Actually Use It For?&lt;/h2&gt;
&lt;p&gt;If you're building a .NET application and you have a domain type whose state needs to cross a byte-oriented boundary, the starting point is incredibly small.&lt;/p&gt;
&lt;p&gt;Install the package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package TheSingularityWorkshop.FSM_Serialization --version 0.1.0-alpha.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Define your representation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class MyState : IBinarySerializable
{
    public int Id { get; set; }

    public void Pack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        BinaryPrimitives.WriteInt32LittleEndian(
            buffer,
            Id);

        stream.Write(buffer);
    }

    public void Unpack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        if (stream.Read(buffer) != buffer.Length)
            throw new EndOfStreamException();

        Id = BinaryPrimitives.ReadInt32LittleEndian(buffer);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use memory when you need a temporary representation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var stream = new MemoryBinaryStream();

state.Pack(stream);

byte[] bytes = stream.ToArray();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use a normal .NET stream when the host provides one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var file = File.Create(&quot;state.bin&quot;);
using var stream = new StreamBinaryStream(file);

state.Pack(stream);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that's basically the entire learning curve.&lt;/p&gt;
&lt;p&gt;The difficult part isn't learning the API.&lt;/p&gt;
&lt;p&gt;The difficult part is deciding what your domain actually means by its representation.&lt;/p&gt;
&lt;p&gt;I think that's exactly where the difficult part belongs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-serializer-shouldn-t-become-your-architecture&quot;&gt;A Serializer Shouldn't Become Your Architecture&lt;/h2&gt;
&lt;p&gt;This is the idea that kept resurfacing while I built the package.&lt;/p&gt;
&lt;p&gt;It's very easy for infrastructure to become the thing it was supposed to merely support.&lt;/p&gt;
&lt;p&gt;A serializer starts storing metadata.&lt;/p&gt;
&lt;p&gt;Then it starts deciding schemas.&lt;/p&gt;
&lt;p&gt;Then it starts managing versions.&lt;/p&gt;
&lt;p&gt;Then it starts talking to databases.&lt;/p&gt;
&lt;p&gt;Then it starts managing transports.&lt;/p&gt;
&lt;p&gt;Then it starts discovering domain types.&lt;/p&gt;
&lt;p&gt;Eventually your &quot;serialization library&quot; is quietly running your architecture.&lt;/p&gt;
&lt;p&gt;I wanted the opposite.&lt;/p&gt;
&lt;p&gt;I wanted something I could put underneath the architecture and forget about until I actually needed to cross the boundary.&lt;/p&gt;
&lt;p&gt;So the package can be reduced to one equation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic state
      +
explicit representation rules
      +
byte boundary
      =
portable representation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;object
  +
magic serializer
  =
architecture
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the difference.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-boundary-i-want-to-preserve&quot;&gt;The Boundary I Want to Preserve&lt;/h2&gt;
&lt;p&gt;The entire package can ultimately be reduced to this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 DOMAIN
                   │
          owns meaning and rules
                   │
                   ▼
          Pack / Unpack contract
                   │
                   ▼
          FSM_Serialization
                   │
              byte boundary
                   │
        ┌──────────┴──────────┐
        ▼                     ▼
     storage               transport
        │                     │
        └──────────┬──────────┘
                   ▼
                  HOST
                   │
             manifestation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The serializer owns the boundary.&lt;/p&gt;
&lt;p&gt;The domain owns the meaning.&lt;/p&gt;
&lt;p&gt;The host owns the destination.&lt;/p&gt;
&lt;p&gt;That is the architecture.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-i-think-small-infrastructure-is-worth-talking-about&quot;&gt;Why I Think Small Infrastructure Is Worth Talking About&lt;/h2&gt;
&lt;p&gt;There is a tendency in software writing to focus on the enormous systems.&lt;/p&gt;
&lt;p&gt;The massive framework.&lt;/p&gt;
&lt;p&gt;The clever algorithm.&lt;/p&gt;
&lt;p&gt;The huge benchmark.&lt;/p&gt;
&lt;p&gt;The complicated application.&lt;/p&gt;
&lt;p&gt;But some of the most consequential architectural decisions are tiny.&lt;/p&gt;
&lt;p&gt;An interface.&lt;/p&gt;
&lt;p&gt;A boundary.&lt;/p&gt;
&lt;p&gt;A decision about ownership.&lt;/p&gt;
&lt;p&gt;A decision about what a component &lt;strong&gt;doesn't&lt;/strong&gt; know.&lt;/p&gt;
&lt;p&gt;FSM_Serialization is one of those experiments.&lt;/p&gt;
&lt;p&gt;It isn't trying to replace every serialization technology in .NET.&lt;/p&gt;
&lt;p&gt;It isn't trying to declare that binary is the future.&lt;/p&gt;
&lt;p&gt;It is much more modest:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I needed a clean byte boundary for a growing compositional architecture, so I built the smallest one I could.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Then I tested it.&lt;/p&gt;
&lt;p&gt;Then I benchmarked it.&lt;/p&gt;
&lt;p&gt;Then I packaged it.&lt;/p&gt;
&lt;p&gt;And now I'm putting it out there so somebody else can kick it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;you-don-t-have-to-take-my-word-for-it&quot;&gt;You Don't Have to Take My Word for It&lt;/h2&gt;
&lt;p&gt;That's ultimately why I publish these projects.&lt;/p&gt;
&lt;p&gt;Install it.&lt;/p&gt;
&lt;p&gt;Implement one type.&lt;/p&gt;
&lt;p&gt;Serialize something you actually care about.&lt;/p&gt;
&lt;p&gt;Put it under your own workload.&lt;/p&gt;
&lt;p&gt;Decide whether the ownership model makes sense to you.&lt;/p&gt;
&lt;p&gt;Measure it.&lt;/p&gt;
&lt;p&gt;Break it.&lt;/p&gt;
&lt;p&gt;Tell me where the boundary doesn't hold.&lt;/p&gt;
&lt;p&gt;That's much more interesting to me than somebody simply agreeing with the architecture.&lt;/p&gt;
&lt;p&gt;The package is available now as an alpha.&lt;/p&gt;
&lt;p&gt;And if you find a place where this tiny boundary lets you remove a surprisingly large amount of architectural mess, I'd genuinely like to hear about it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;resources-amp-code-&quot;&gt;Resources &amp;amp; Code:&lt;/h3&gt;
&lt;p&gt;The FSM Package (Unity Asset Store):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://assetstore.unity.com/packages/slug/332450&quot;&gt;https://assetstore.unity.com/packages/slug/332450&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;nuget-packages-&quot;&gt;NuGet Packages:&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&quot;&gt;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_Serialization/&quot;&gt;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_Serialization/&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;github-repositories-&quot;&gt;GitHub Repositories:&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/FSM_API&quot;&gt;https://github.com/TrentBest/FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.FSM_Serialization&quot;&gt;https://github.com/TrentBest/TheSingularityWorkshop.FSM_Serialization&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Support Our Work:&lt;/p&gt;
&lt;p&gt;Patreon Page:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/c/TheSingularityWorkshop&quot;&gt;https://www.patreon.com/c/TheSingularityWorkshop&lt;/a&gt;&lt;/p&gt;
&lt;p&gt; Support Us (PayPal Donation):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&quot;&gt;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We'd love to hear your thoughts! Please Like this post, Love the code, and Share your feedback in the comments.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14669829065118869161&quot; alt=&quot;A Hyper-Detailed, Architecturally Impossible Synthesis of Consciousness and Digital Matter. The image is a frenetic, deeply complex digital vista where a central Luminous Eye dominates a vast, glowing circuit landscape. This eye, suspended mid-air, is a sphere of intense, fractal energy—a pulsating vortex of pink, violet, and electric blue light—that powerfully suggests an emergence of digital consciousness or a Technological Singularity itself. The core is a bottomless black aperture, ringed by a white-hot plasma disc. Below this ocular energy source, the light dissipates into an intricate, copper-gold and neon-blue Circuit Board Megastructure that stretches to the horizon, impossibly dense with exaggerated microchips, glowing resistor arrays, and power conduits that form deep, glowing canyons. The background is a dark, holographic projection field displaying complex schematics, mathematical models, and flowing data streams, reinforcing the theme of Absolute Digital Engineering. The entire scene is bathed in a dramatic, opposing light source—a warm, orange-gold glow from the left and an ice-cold, electric-blue glow from the right—creating a maximalist, high-contrast visual experience that definitively rejects the minimalist simplicity of conventional design and announces: Technology is intricate, overwhelming, and infinitely complex.&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28979/i-built-a-serializer-that-refuses-to-own-your-data</guid>
<pubDate>Mon, 28 Sep 2026 23:36:42 +0000</pubDate>
</item>
<item>
<title>phpser: a fast, secure binary serializer for PHP cache workloads</title>
<link>https://coderlegion.com/19532/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads</link>
<description>&lt;p&gt;I've reached for igbinary on nearly every PHP project I've shipped in the last decade. It's smaller and faster than PHP's native &lt;code&gt;serialize()&lt;/code&gt;, it's stable, and it has been the obvious default for so long that reaching for it stopped being a decision.&lt;/p&gt;
&lt;p&gt;So phpser started as curiosity, not a complaint. igbinary is good. Could a serializer built specifically for cache workloads do better?&lt;/p&gt;
&lt;p&gt;I wanted two things from it. It should be fast on the shapes a cache actually holds, where a value is decoded far more often than it's encoded. And it should be safe to decode bytes from a store an attacker might reach, because &lt;code&gt;unserialize()&lt;/code&gt; on untrusted input is one of PHP's oldest exploit primitives. igbinary gives you the speed; the safety you bolt on yourself. phpser builds in both.&lt;/p&gt;
&lt;p&gt;On the shapes that matter for caches it encodes 10 to 70% faster than igbinary and decodes 12 to 75% faster, with packed numeric data also 65% smaller on the wire. Its signed mode refuses to decode any payload that wasn't produced with your key, so a poisoned cache entry never reaches the code that builds objects. The rest of this post is how it gets both.&lt;/p&gt;
&lt;h3 id=&quot;why-a-serializer-built-for-caches-&quot;&gt;Why a serializer built for caches?&lt;/h3&gt;
&lt;p&gt;Because igbinary optimizes for the general case, and a cache is not the general case, on two axes.&lt;/p&gt;
&lt;p&gt;The first is the read/write asymmetry. A PHP cache pays decode cost on every single read. Encode happens once, when you write the value; decode happens every time anything reads it back. For a read-heavy cache that ratio is easily 100 to 1. igbinary, like most general serializers, balances the two sides. A cache serializer shouldn't.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-asymmetry.svg&quot; alt=&quot;Encode runs once per write; decode runs on every read&quot;&gt;&lt;/p&gt;
&lt;p&gt;The second is trust. The thing reading those bytes back is often reading from redis, memcached, a file, or a cookie, any of which an attacker may be able to write to. A general serializer treats decode as a pure data operation. A cache serializer has to treat it as a trust boundary.&lt;/p&gt;
&lt;p&gt;igbinary is still the right default for general use. I went looking for the specific shapes where a cache-focused design could pull ahead, and there are three that show up everywhere in real PHP backends:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Packed numeric arrays. &lt;code&gt;range(0, 999)&lt;/code&gt;, ID lists, analytics buckets, sensor readings.&lt;/li&gt;
&lt;li&gt;Deep-nested structures. Trees, recursive config, nested document structures.&lt;/li&gt;
&lt;li&gt;Same-class object batches. Laravel queue payloads, cached Eloquent models, any array of a few hundred identical-shape DTOs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;designing-the-format-for-the-reader&quot;&gt;Designing the format for the reader&lt;/h3&gt;
&lt;p&gt;The performance half of phpser borrows an instinct from Rust's rkyv. rkyv's pitch is that deserialization should be nearly free, because the writer already laid the bytes out the way the reader needs them. You don't parse an rkyv archive so much as point at it.&lt;/p&gt;
&lt;p&gt;phpser isn't zero-copy, and I want to be precise about that before the comparison runs away. PHP values are refcounted &lt;code&gt;zval&lt;/code&gt;s with owned hashtables; you can't hand PHP a pointer into a cache buffer and call it an array. phpser does a real decode pass and builds real &lt;code&gt;zval&lt;/code&gt;s. It's not rkyv.&lt;/p&gt;
&lt;p&gt;What transferred is the instinct, not the mechanism. rkyv made me stop thinking about the wire format as a neutral container and start thinking about it as a set of instructions to the reader. If the writer knows something that saves the reader work, the writer should record it, even when that makes encoding a little more complex. Once you adopt that lens, a set of concrete decisions falls out of it.&lt;/p&gt;
&lt;h3 id=&quot;a-string-dictionary-and-an-intern-that-survives-decode&quot;&gt;A string dictionary, and an intern that survives decode&lt;/h3&gt;
&lt;p&gt;The honest starting point: a front-loaded string dictionary isn't novel. igbinary already does this, it calls them &lt;code&gt;compact_strings&lt;/code&gt;. Both serializers emit each distinct string once and reference it afterward, so the property name &lt;code&gt;&quot;created_at&quot;&lt;/code&gt; repeated across a thousand cached rows costs one copy, not a thousand.&lt;/p&gt;
&lt;p&gt;The dictionary isn't where the win is. The win is on the decode side, and it's the most direct application of the design-for-the-reader rule.&lt;/p&gt;
&lt;p&gt;When phpser decodes a dictionary string the first time, it allocates a &lt;code&gt;zend_string&lt;/code&gt;. Every later reference to that same dictionary index doesn't allocate; it bumps the refcount on the one already built. A thousand rows that all carry the key &lt;code&gt;&quot;user_id&quot;&lt;/code&gt; produce exactly one string allocation and 999 refcount increments. PHP's own machinery is built for exactly this, interned strings are shared by refcount throughout the engine, so phpser isn't fighting the runtime, it's leaning on it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-wire-format.svg&quot; alt=&quot;The dictionary is emitted once at the head; values reference it by varint index, and repeated strings reuse one interned zend_string by refcount&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-to-encode-too&quot;&gt;Fast to encode, too&lt;/h3&gt;
&lt;p&gt;Designing for the reader could have meant a slow writer. It doesn't, because of two encoder choices, and the result is that phpser encodes faster than igbinary on every shape I test.&lt;/p&gt;
&lt;p&gt;The first is the intern cache. phpser keeps an open-addressed &lt;code&gt;zend_string*&lt;/code&gt;-to-slot hash, grown without eviction. Before hashing a string's bytes, it checks pointer identity: PHP interns string literals, so the &lt;code&gt;&quot;id&quot;&lt;/code&gt; in row 1 and the &lt;code&gt;&quot;id&quot;&lt;/code&gt; in row 900 are usually the same pointer and resolve with no byte work at all. Just as important, a unique value string, a name, an email, a SKU, takes a single-probe miss instead of a linear scan. The per-value dedup lookup stays off the critical path even on payloads full of strings that never repeat.&lt;/p&gt;
&lt;p&gt;The second is objects. Encoding a PHP object the obvious way calls &lt;code&gt;get_properties&lt;/code&gt;, which materializes a properties hashtable even for a plain object whose layout is fixed and known. For a batch of a few hundred DTOs that's hundreds of throwaway hashtables. phpser serializes a plain object straight from its declared property slots and skips the hashtable, the way native &lt;code&gt;serialize()&lt;/code&gt; does. PHP 8.4 lazy objects fall back to &lt;code&gt;get_properties&lt;/code&gt;, because their initializer has to run first.&lt;/p&gt;
&lt;h3 id=&quot;tagged-scalar-runs-and-building-the-array-in-place&quot;&gt;Tagged scalar runs, and building the array in place&lt;/h3&gt;
&lt;p&gt;Two more decisions, on the decode side, are what make the packed-numeric numbers as large as they are.&lt;/p&gt;
&lt;p&gt;The first is tagged scalar runs. igbinary encodes &lt;code&gt;[1, 2, 3, ...]&lt;/code&gt; as a sequence of tagged values: a type tag and a varint, a thousand times over. phpser detects a uniform run and emits one &lt;code&gt;PACKED_LONGS&lt;/code&gt; header plus the thousand integers as raw zigzag varints, no per-element tag. Decode becomes one tight loop with zero tag dispatch.&lt;/p&gt;
&lt;p&gt;The second is building the hashtable in place. When the wire format says &lt;code&gt;PACKED_LONGS&lt;/code&gt; of length N, the decoder knows the final size before it reads a single element. So it allocates the array once with &lt;code&gt;zend_new_array(N)&lt;/code&gt; and writes the values directly into PHP 8's packed &lt;code&gt;arPacked&lt;/code&gt; storage with &lt;code&gt;ZVAL_*&lt;/code&gt; macros. That skips N calls to &lt;code&gt;zend_hash_next_index_insert&lt;/code&gt;, and with them N hash computations, N capacity checks, and the incremental table growth that a naive decoder pays as it discovers the array's size one element at a time. The writer recorded the size so the reader could allocate once and fill, which is the rkyv instinct applied as far as a non-zero-copy format can take it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-build-in-place.svg&quot; alt=&quot;A naive decoder hashes and grows the table per element; phpser allocates once from the header count and writes slots directly&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-benchmarks&quot;&gt;The benchmarks&lt;/h3&gt;
&lt;p&gt;Here is the full shape-by-shape comparison, run on my machine, against igbinary. The bench harness (&lt;code&gt;bench.php&lt;/code&gt; in the repo) round-trips every shape for correctness first, then times encode and decode separately, because decode is the number that matters for a cache.&lt;/p&gt;
&lt;p&gt;Methodology: phpser 0.1.2, PHP 8.4.22-dev NTS, release build (not a debug or ASan build, which would inflate everything 2 to 5x), igbinary 3.2.17RC1, Intel Core i9-13950HX. 1,000 iterations per shape, median of 9 runs after a discarded warm-up.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Shape &lt;/th&gt;&lt;th&gt;Size: igbinary → phpser &lt;/th&gt;&lt;th&gt;Encode: igbinary → phpser &lt;/th&gt;&lt;th&gt;Decode: igbinary → phpser&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;packed_1k &lt;/td&gt;&lt;td&gt;5,495 → 1,941 B (&lt;strong&gt;-65%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;4.6 → 1.4 µs (&lt;strong&gt;-70%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;7.3 → 1.8 µs (&lt;strong&gt;-75%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;packed_10k &lt;/td&gt;&lt;td&gt;59,495 → 21,749 B (&lt;strong&gt;-63%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;46.4 → 13.7 µs (&lt;strong&gt;-70%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;74.0 → 18.9 µs (&lt;strong&gt;-74%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;deep_50 &lt;/td&gt;&lt;td&gt;419 → 424 B (+1%) &lt;/td&gt;&lt;td&gt;1.3 → 0.62 µs (&lt;strong&gt;-54%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;1.8 → 1.6 µs (&lt;strong&gt;-15%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_100 &lt;/td&gt;&lt;td&gt;7,083 → 6,362 B (&lt;strong&gt;-10%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;15.5 → 13.9 µs (&lt;strong&gt;-10%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;26.9 → 23.5 µs (&lt;strong&gt;-13%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_1000 &lt;/td&gt;&lt;td&gt;73,372 → 64,863 B (&lt;strong&gt;-12%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;194 → 165 µs (&lt;strong&gt;-15%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;275 → 227 µs (&lt;strong&gt;-18%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;rowset_100 &lt;/td&gt;&lt;td&gt;4,570 → 4,771 B (+4%) &lt;/td&gt;&lt;td&gt;10.0 → 7.3 µs (&lt;strong&gt;-27%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;10.7 → 10.8 µs (+1%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;rowset_1000 &lt;/td&gt;&lt;td&gt;47,459 → 47,972 B (+1%) &lt;/td&gt;&lt;td&gt;157 → 71 µs (&lt;strong&gt;-55%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;104 → 107 µs (+4%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_mixed &lt;/td&gt;&lt;td&gt;21,644 → 17,927 B (&lt;strong&gt;-17%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;58.8 → 39.8 µs (&lt;strong&gt;-32%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;112 → 81 µs (&lt;strong&gt;-28%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The packed rows are the ones that jump out: roughly two-thirds smaller and three-quarters faster to decode, on a real shape, not a synthetic micro-case. &lt;code&gt;packed_1k&lt;/code&gt; is &lt;code&gt;range(0, 999)&lt;/code&gt;, which is what an ID list or an analytics bucket looks like.&lt;/p&gt;
&lt;p&gt;The DTO rows are the relatable ones. &lt;code&gt;dto_1000&lt;/code&gt; is a thousand small typed objects of one class, the shape a Laravel queue batch or a page of cached models actually has. 12% smaller, 18% faster to decode, 15% faster to encode, from the dictionary dedup on property names and a class-entry lookup cache that amortizes &lt;code&gt;zend_lookup_class_ex&lt;/code&gt; across the batch. Encode is faster than igbinary on every row; the largest margins are on the object-heavy &lt;code&gt;dto_mixed&lt;/code&gt; (32% faster, 17% smaller) and the mixed &lt;code&gt;rowset_1000&lt;/code&gt; (55% faster).&lt;/p&gt;
&lt;h3 id=&quot;where-it-gives-a-little-back&quot;&gt;Where it gives a little back&lt;/h3&gt;
&lt;p&gt;The one row where phpser loses is the mixed associative rowset. &lt;code&gt;rowset_1000&lt;/code&gt; decodes about 4% slower than igbinary, and the rowset payloads run 1 to 4% larger. That's the front-loaded dictionary showing its one downside: the decoder walks the dictionary header before it touches values, and on a heterogeneous rowset with few repeated strings that header walk doesn't buy back its cost. It's a small tax, and it's on the exact axis I chose to de-prioritize, but it's real and measured, so there it is.&lt;/p&gt;
&lt;p&gt;The structural limit is the same decision seen from another angle: phpser isn't streamable. The dictionary lives at the head of the payload and values reference it by index, so you can't decode the stream incrementally as it arrives. The front-loaded dictionary is what makes the other decodes fast and what makes streaming impossible. You don't get to keep both. If you need a streaming parser, this is the wrong format.&lt;/p&gt;
&lt;p&gt;I also cross-checked the whole suite on arm64 to make sure none of this was an x86 quirk. Same direction on every shape, with narrower encode margins on the object cases. The decode wins and the single &lt;code&gt;rowset_1000&lt;/code&gt; decode tax both reproduce.&lt;/p&gt;
&lt;h3 id=&quot;signed-payloads-safe-to-decode-from-an-untrusted-cache&quot;&gt;Signed payloads: safe to decode from an untrusted cache&lt;/h3&gt;
&lt;p&gt;The performance half is only one reason a cache serializer is its own problem. The other is that decoding attacker-controlled bytes is dangerous. Native &lt;code&gt;unserialize()&lt;/code&gt; on untrusted input lets a crafted payload instantiate any allowed class and drive its &lt;code&gt;__wakeup&lt;/code&gt;, &lt;code&gt;__destruct&lt;/code&gt;, or other magic methods into a state the code never anticipated. That's the mechanism behind object-injection and gadget-chain attacks, and a cache is the soft spot: a redis instance, a memcached pool, a file cache, or a cookie is exactly the kind of store an attacker reaches in a real incident, and whatever sits there gets decoded on the next read.&lt;/p&gt;
&lt;p&gt;phpser's answer is a signed mode built on HMAC-SHA256. You serialize with a secret key, and you refuse to decode anything that wasn't signed with that same key. Verification is constant-time and runs before any decoding work, so a tampered or foreign-keyed payload never reaches the part of the decoder that builds values or constructs objects.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-signed-trust-boundary.svg&quot; alt=&quot;Native unserialize decodes attacker bytes before checking anything; the signed path verifies the HMAC first and returns null on mismatch, so nothing is decoded&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$key = random_bytes(32);            // generate once, keep it in app config or a secrets manager

// on write
$blob = phpser_serialize_signed($cacheValue, $key);
$redis-&amp;gt;set('user:42', $blob);

// on read
$blob  = $redis-&amp;gt;get('user:42');
$value = phpser_unserialize_signed($blob, $key);

if ($value === null) {
    // tampered, truncated, or signed with a different key.
    // nothing was decoded; treat it as a cache miss and rebuild.
    $value = rebuild_user(42);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The contract is deliberately blunt. &lt;code&gt;phpser_unserialize_signed()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; on any signature failure rather than throwing, so a poisoned cache entry degrades to a miss instead of an exception in a hot read path. The decode only proceeds once the MAC matches. This is authentication, not encryption: the bytes are still readable, but they can't be forged without the key, and that's the property that keeps a crafted object graph out of your decoder.&lt;/p&gt;
&lt;p&gt;Signed mode also refuses an empty key. An empty key would reduce HMAC-SHA256 to a fixed, keyless tag anyone can recompute, so a caller writing &lt;code&gt;phpser_serialize_signed($v, getenv('SECRET') ?: '')&lt;/code&gt; with the variable unset would be shipping forgeable payloads without knowing it. Both signed entry points throw on an empty key before doing any work, so that misconfiguration fails loudly instead of silently defeating the signature.&lt;/p&gt;
&lt;p&gt;If you genuinely can't sign, because you're decoding bytes from a source you don't control and can't key, the second line of defense is &lt;code&gt;allowed_classes&lt;/code&gt;, with the same shape as PHP's native &lt;code&gt;unserialize()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// reject every class: unknown objects decode as __PHP_Incomplete_Class, never instantiated
$value = phpser_unserialize($blob, ['allowed_classes' =&amp;gt; false]);

// or allowlist only the classes you actually expect to read back
$value = phpser_unserialize($blob, ['allowed_classes' =&amp;gt; [UserDto::class, OrderDto::class]]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same option works on &lt;code&gt;phpser_unserialize_signed()&lt;/code&gt; too, so you can combine a valid signature with a class allowlist for defense in depth. Underneath both paths, the decoder is hardened on its own: a recursion depth cap of 512 bounds stack use against deliberately deep payloads (decode returns &lt;code&gt;null&lt;/code&gt;, encode throws), and crafted payloads naming a missing enum case or a non-serializable class like &lt;code&gt;Closure&lt;/code&gt; are rejected rather than crashing, matching what PHP's own &lt;code&gt;unserialize()&lt;/code&gt; refuses.&lt;/p&gt;
&lt;h3 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h3&gt;
&lt;p&gt;igbinary is still the serializer I'd reach for on a general-purpose workload, and I'll keep using it. It's mature, it's everywhere, and on a mixed rowset it's still a hair ahead on decode.&lt;/p&gt;
&lt;p&gt;For a read-heavy cache, phpser gives me two things at once. It's faster on the shapes caches actually hold, encode and decode both, because the wire format is designed around the reader rather than balanced between reader and writer. And signed mode means I can decode from redis without treating every read as a potential injection. Speed and trust are the two things a cache serializer has to get right, and they're the two things a general serializer leaves half-finished. Building both in was the point.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pie install iliaal/phpser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source, wire-format spec, and the bench harness: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19532/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads</guid>
<pubDate>Tue, 02 Jun 2026 19:01:31 +0000</pubDate>
</item>
</channel>
</rss>