<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arnaud POINTET</title>
    <description>The latest articles on DEV Community by Arnaud POINTET (@oipnet).</description>
    <link>https://dev.to/oipnet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F320559%2Fb68a6be2-7bc6-40a1-b192-e309933bd3f7.jpeg</url>
      <title>DEV Community: Arnaud POINTET</title>
      <link>https://dev.to/oipnet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oipnet"/>
    <language>en</language>
    <item>
      <title>Versioning a Symfony API without duplicating your classes</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/versioning-a-symfony-api-without-duplicating-your-classes-13ie</link>
      <guid>https://dev.to/oipnet/versioning-a-symfony-api-without-duplicating-your-classes-13ie</guid>
      <description>&lt;p&gt;An API that ships a new version cannot drop the old one overnight. Partners, a mobile app that hasn't been updated, a script written two years ago: they all still read v1. The data is the same, but the field names and the shape of the response have changed.&lt;/p&gt;

&lt;p&gt;With the Symfony Serializer, groups already chose &lt;strong&gt;which&lt;/strong&gt; properties are output. They said nothing about &lt;strong&gt;what&lt;/strong&gt; they are called. Symfony 8.2 (still in development) fills that gap: &lt;code&gt;#[SerializedName]&lt;/code&gt; and &lt;code&gt;#[SerializedPath]&lt;/code&gt; now accept a &lt;code&gt;groups&lt;/code&gt; argument, and a new context option, &lt;code&gt;ignored_groups&lt;/code&gt;, leaves a group out for a single response.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-groupes-serialisation?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=versioning-a-symfony-api-without-duplicating-classes" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt; that showcases these features is about deleting loops. This article takes a step back: how two versions used to be served, what the new approach changes, and the pitfalls to know before adopting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;A bookshop publishes its books in three places. v1, which partners still read, answers with a flat shape and its old names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isbn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"978-0-15-601219-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Little Prince"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price\_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v2 renamed the title and nests the price with its currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isbn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"978-0-15-601219-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Little Prince"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The back office reads the same shape as v2, plus the stock. The &lt;code&gt;Livre&lt;/code&gt; class carries &lt;code&gt;v1&lt;/code&gt;, &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;internal&lt;/code&gt; groups, but only one way of naming each property.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we did it until now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One class per version
&lt;/h3&gt;

&lt;p&gt;The most explicit solution: a &lt;code&gt;BookV1&lt;/code&gt; and a &lt;code&gt;BookV2&lt;/code&gt;, each with its own names, and a bit of code to map the entity to one or the other. Each version's contract can be read at a glance, and that is a real advantage.&lt;/p&gt;

&lt;p&gt;The cost shows up over time. Every field added to v2 raises the question for v1. Every format fix happens in two places. And a third version multiplies all of it. For versions that differ by three field names, that is a lot of code to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  A loop after the serializer
&lt;/h3&gt;

&lt;p&gt;The most common solution, and the exercise's before it is fixed: serialize with the version's group, then patch the array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$books&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'isbn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'isbn'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'price\_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'cents'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but the v1 contract now lives in a controller, far from the class it describes. The day someone adds a field to the &lt;code&gt;v1&lt;/code&gt; group, it doesn't come out: the loop doesn't know about it. And the loop only works one way. If v1 also accepts writes, you need a second loop, in reverse, for deserialization.&lt;/p&gt;

&lt;h3&gt;
  
  
  A custom normalizer or name converter
&lt;/h3&gt;

&lt;p&gt;The most "Symfony" solution: a normalizer that decorates &lt;code&gt;ObjectNormalizer&lt;/code&gt; for books in v1, or a &lt;em&gt;name converter&lt;/em&gt; that renames according to the context. Renaming then works both ways, and it stays out of the controller.&lt;/p&gt;

&lt;p&gt;In practice, I see these classes age badly. The name converter is global to the Serializer: it receives every property of every class, and has to check the class and the version itself. The decorated normalizer has to find its place among the others, and must not forget nested objects. That is a lot of infrastructure to express "in v1, &lt;code&gt;title&lt;/code&gt; is called &lt;code&gt;name&lt;/code&gt;".&lt;/p&gt;

&lt;h2&gt;
  
  
  What Symfony 8.2 changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One name per group
&lt;/h3&gt;

&lt;p&gt;Both attributes become repeatable and take a &lt;code&gt;groups&lt;/code&gt; argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#[Groups(['v1', 'v2'])]
#[SerializedName('name', groups: 'v1')]
public readonly string $title,

#[Groups(['v1', 'v2'])]
#[SerializedName('price\_cents', groups: 'v1')]
#[SerializedPath('[price][cents]', groups: 'v2')]
public readonly int $priceInCents,

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serialized with the &lt;code&gt;v1&lt;/code&gt; group, the title comes out as &lt;code&gt;name&lt;/code&gt;; with &lt;code&gt;v2&lt;/code&gt;, it keeps its PHP name. The price comes out flat as &lt;code&gt;price_cents&lt;/code&gt; in v1, and nested as &lt;code&gt;price.cents&lt;/code&gt; in v2. Each version's contract is back on the class, and the controller shrinks to one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renaming also applies to deserialization: a v1 body that sends &lt;code&gt;name&lt;/code&gt; does fill &lt;code&gt;$title&lt;/code&gt;, with no second loop. YAML and XML configuration follow suit, with a &lt;code&gt;serialized&lt;/code&gt; key and a &lt;code&gt;&amp;lt;serialized&amp;gt;&lt;/code&gt; element.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaving a group out
&lt;/h3&gt;

&lt;p&gt;The stock belongs to the &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;internal&lt;/code&gt; groups. The back office serializes &lt;code&gt;v2&lt;/code&gt; and sees it; the public API serializes &lt;code&gt;v2&lt;/code&gt; too, and must not see it. Before 8.2, you needed an &lt;code&gt;unset()&lt;/code&gt; afterwards, or an extra group on every other property. The &lt;code&gt;AbstractNormalizer::IGNORED_GROUPS&lt;/code&gt; context option settles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;AbstractNormalizer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;GROUPS&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;AbstractNormalizer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IGNORED\_GROUPS&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'internal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A property that belongs to an ignored group is not output, &lt;strong&gt;even if it also belongs to a requested group&lt;/strong&gt;. Exclusion wins. With a context builder, it's &lt;code&gt;withIgnoredGroups('internal')&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The ungrouped name is the fallback
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;#[SerializedName]&lt;/code&gt; without a &lt;code&gt;groups&lt;/code&gt; argument applies to every group. Combined with a per-group name, it becomes the fallback: the group's name wins when that group is requested, otherwise the ungrouped name applies.&lt;/p&gt;

&lt;p&gt;Handy, but it backfires quickly. In the exercise, the price started with an ungrouped &lt;code&gt;#[SerializedPath('[price][cents]')]&lt;/code&gt;. Add a &lt;code&gt;#[SerializedName('price_cents', groups: 'v1')]&lt;/code&gt; without touching the path, and v1 finds two answers: a name for its group, and a fallback path. When serializing, the path silently wins: the price stays under &lt;code&gt;price.cents&lt;/code&gt;, and &lt;code&gt;price_cents&lt;/code&gt; never appears. When deserializing, the Serializer refuses to choose and throws a &lt;code&gt;LogicException&lt;/code&gt; ("Found SerializedName and SerializedPath attributes"). Either way, the path has to be tied to the &lt;code&gt;v2&lt;/code&gt; group only.&lt;/p&gt;

&lt;p&gt;The silent case is the most dangerous one. My rule: as soon as a property has a name per group, I give a group to every one of its names and paths. The implicit fallback is the first thing you overlook when rereading the class.&lt;/p&gt;

&lt;h3&gt;
  
  
  Several groups requested: the declaration decides
&lt;/h3&gt;

&lt;p&gt;What happens if you request &lt;code&gt;['v1', 'v2']&lt;/code&gt; and the property has a name for each? The first &lt;strong&gt;declared&lt;/strong&gt; name whose group is requested wins. The order of the groups in the context doesn't matter. This is deliberate, so that two controllers listing the groups in a different order get the same response. But it means you can't pick a version by putting its group in front of the others. One response is one version, so one version group.&lt;/p&gt;

&lt;h3&gt;
  
  
  A property without a group disappears
&lt;/h3&gt;

&lt;p&gt;This isn't new, but it gets rediscovered every time groups are introduced: as soon as the context requests groups, a property without &lt;code&gt;#[Groups]&lt;/code&gt; is no longer output. Symfony 8.2 adds an option to get around this without annotating the whole class: with &lt;code&gt;AbstractNormalizer::ENABLE_DEFAULT_GROUPS&lt;/code&gt;, ungrouped properties join the &lt;code&gt;Default&lt;/code&gt; group and the class's short name (&lt;code&gt;Livre&lt;/code&gt;), provided you request one of them in the context, for example &lt;code&gt;['v1', 'Default']&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ignored_groups&lt;/code&gt; is not &lt;code&gt;#[Ignore]&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Both remove properties, but not in the same place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#[Ignore]&lt;/code&gt; sits on the property, in the class. The property is never output, anywhere, and never read in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignored_groups&lt;/code&gt; is passed in the context. It only applies to that call: the back office, which doesn't pass it, still sees the stock.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignored_attributes&lt;/code&gt;, which has been around for a long time, removes properties by name. It works, but every controller has to know the list, and a property added to the &lt;code&gt;internal&lt;/code&gt; group later won't be left out. With &lt;code&gt;ignored_groups&lt;/code&gt;, the decision stays on the class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that matters for an API: the exclusion also applies to deserialization. A &lt;code&gt;stock&lt;/code&gt; sent by a public API client is ignored, like an unknown key. Be careful if the property is a required constructor argument, as in the exercise: deserialization then fails for lack of a value. Give it a default value, or keep that property out of the constructor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the contract on the response
&lt;/h3&gt;

&lt;p&gt;The risk with these attributes is that a change to the class alters a version without anyone noticing. So I test each version against the exact response, not against the presence of a few keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'isbn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'978-0-15-601219-5'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The Little Prince'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price\_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/v1/books'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;assertSame&lt;/code&gt; on the whole array fails if a field appears, disappears or gets renamed. That is exactly what a v1 partner would notice. The exercise's tests do the same for v2 and the back office.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep two classes
&lt;/h2&gt;

&lt;p&gt;Per-group names fit when the versions share the same model and only differ in shape: names, nesting, a few fields more or less. That is the most common case, and it's where the new feature removes the most code.&lt;/p&gt;

&lt;p&gt;I keep separate classes when the versions diverge in substance: a field computed differently, a resource split in two, validation that is no longer the same. Stacking attributes for each version on a single property then becomes unreadable, and a dedicated class becomes the clear solution again. A good indicator: if a property carries more than two different names, the question is worth asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaway
&lt;/h2&gt;

&lt;p&gt;Symfony 8.2 doesn't change how to think about groups; it completes what they could do. A group already chose a version's fields; now it also gives their names and shape. And &lt;code&gt;ignored_groups&lt;/code&gt; replaces the &lt;code&gt;unset()&lt;/code&gt; calls lingering in controllers. Each version's contract goes back on the class it describes, and it holds both ways.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-groupes-serialisation?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=versioning-a-symfony-api-without-duplicating-classes" rel="noopener noreferrer"&gt;exercise&lt;/a&gt; takes a few minutes in the browser, on a development version of Symfony 8.2. The three requests in the "Requests" tab must respond exactly as before, without a single loop in the controller.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>api</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating files with zero downtime: what read-through does, and what it doesn't</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/migrating-files-with-zero-downtime-what-read-through-does-and-what-it-doesnt-4k8l</link>
      <guid>https://dev.to/oipnet/migrating-files-with-zero-downtime-what-read-through-does-and-what-it-doesnt-4k8l</guid>
      <description>&lt;p&gt;Laravel 13.26 added a &lt;code&gt;read-through&lt;/code&gt; disk driver. In &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-26-read-through?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=migrating-files-with-read-through" rel="noopener noreferrer"&gt;the Practice exercise&lt;/a&gt; that introduces it, a controller serves documents in the middle of a migration: it reads from the new storage, falls back to the old one, and copies files over along the way. The driver replaces those fifteen lines with three lines of configuration.&lt;/p&gt;

&lt;p&gt;That's good news, but a storage migration is more than that configuration. I've run several of them (local disk to S3, one bucket to another, one provider to another), and falling back on read is only one part of the job. This article starts from the exercise and goes all the way to the day you switch off the old storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exercise: three lines of configuration
&lt;/h2&gt;

&lt;p&gt;Before, the controller did everything itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Laravel 13.26, you declare a disk that combines the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/filesystems.php&lt;/span&gt;
&lt;span class="s1"&gt;'documents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'read-through'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'primary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'fallback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the controller only knows about that one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win goes beyond fewer lines of code: the migration becomes a configuration detail, invisible to the rest of the application. The day it's over, you replace &lt;code&gt;documents&lt;/code&gt; with a plain S3 disk, and not a single call to &lt;code&gt;Storage::disk('documents')&lt;/code&gt; has to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the driver actually does
&lt;/h2&gt;

&lt;p&gt;The documentation is brief. The source code tells you more, and these details matter for what follows (the main operations, as of Laravel 13.26; &lt;code&gt;move()&lt;/code&gt; and &lt;code&gt;copy()&lt;/code&gt; on a file still sitting on the old disk only gained support in 13.27):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;readStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;reads &lt;code&gt;primary&lt;/code&gt;; otherwise reads &lt;code&gt;fallback&lt;/code&gt;, then &lt;strong&gt;writes a copy&lt;/strong&gt; to &lt;code&gt;primary&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exists()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true if the file is on &lt;strong&gt;either&lt;/strong&gt; disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;writeStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;writes to &lt;code&gt;primary&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deletes from &lt;strong&gt;both&lt;/strong&gt; disks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;files()&lt;/code&gt;, &lt;code&gt;allFiles()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;list &lt;code&gt;primary&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;url()&lt;/code&gt;, &lt;code&gt;size()&lt;/code&gt;, &lt;code&gt;mimeType()&lt;/code&gt;, &lt;code&gt;lastModified()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;read from whichever disk has the file, &lt;strong&gt;without copying it&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two design choices are particularly well thought out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes hit both disks.&lt;/strong&gt; Otherwise, a file deleted from the new storage would linger on the old one, and the next read would bring it back. It's the classic bug in hand-rolled fallbacks, and I've seen it reach production: documents deleted at a customer's request (including GDPR erasure requests) that were back the next day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The copy re-checks before writing.&lt;/strong&gt; Between the moment the driver finds the file missing from &lt;code&gt;primary&lt;/code&gt; and the moment it copies it over, another request may have written a newer version. The driver checks again just before copying so it doesn't overwrite it. The race window doesn't disappear entirely (two operations are never atomic on object storage), but it becomes very narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What read-through doesn't migrate
&lt;/h2&gt;

&lt;p&gt;The driver migrates what the application &lt;strong&gt;reads&lt;/strong&gt;. Everything else stays on the old disk, and that's where migrations get bogged down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files nobody ever reads
&lt;/h3&gt;

&lt;p&gt;An invoice from 2019, an inactive user's avatar, a forgotten attachment: most of an application's files are almost never read again. Read-through quickly migrates the hot files, then plateaus. It can happen that after three months only 20% of the files have moved, and the old storage is still indispensable.&lt;/p&gt;

&lt;p&gt;So you &lt;strong&gt;always&lt;/strong&gt; need a bulk backfill (see below). Read-through doesn't replace the copy: it lets you do it at your own pace, with no maintenance window, while the application is already running on the new storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files served by URL
&lt;/h3&gt;

&lt;p&gt;This is the most common trap. On S3, you rarely serve a file by reading it with PHP: you generate a URL, possibly a signed one, and the browser downloads straight from the bucket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;temporaryUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver does return a working URL: the one for the disk that holds the file. But it doesn't read the file, so &lt;strong&gt;it never copies it&lt;/strong&gt;. An application that serves all its files by URL migrates nothing at all with read-through, even though everything appears to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listings
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;files()&lt;/code&gt; and &lt;code&gt;allFiles()&lt;/code&gt; only list the new disk. So during the migration, an "All your documents" page that lists a folder only shows the documents already migrated. An export, a disk-usage calculation, a cleanup of orphaned files: anything that enumerates instead of reading a known path gives the wrong result.&lt;/p&gt;

&lt;p&gt;If the application lists files, the right answer often isn't a technical one: the database should know which files exist, not the storage. A migration is when that debt comes due.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata
&lt;/h3&gt;

&lt;p&gt;The copy is a plain &lt;code&gt;write()&lt;/code&gt;. For &lt;code&gt;readStream()&lt;/code&gt;, the driver first buffers the whole file (in memory up to 2 MB, then in a temporary file) before writing it to the new disk and handing it back to you: so the first &lt;code&gt;Storage::download()&lt;/code&gt; of a large unmigrated file waits for the copy to finish before sending the first byte. The modification date becomes the date of the copy. Visibility (&lt;code&gt;public&lt;/code&gt; or &lt;code&gt;private&lt;/code&gt;) and HTTP headers (&lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Cache-Control&lt;/code&gt;, &lt;code&gt;Content-Disposition&lt;/code&gt;) take the new disk's defaults. If your application relies on &lt;code&gt;lastModified()&lt;/code&gt; for HTTP caching, or on S3 headers set at upload time, check this before you migrate. It's not a flaw in the driver: it's true of almost any file copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failed copies
&lt;/h3&gt;

&lt;p&gt;By default, if copying to the new disk fails (insufficient permissions, quota reached), the driver still returns the content it read from the old one. That's the right call for the user, who gets their file. But it means a migration can stall &lt;strong&gt;without making a sound&lt;/strong&gt;. The driver accepts a &lt;code&gt;throw_on_promotion_failure&lt;/code&gt; option to throw an exception in that case. Failing that, keep an eye on the volume of reads served by the old disk: if it isn't going down, something is stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the migration through
&lt;/h2&gt;

&lt;p&gt;Here is the plan I follow, and read-through is only one step of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Write to the new disk, read from both
&lt;/h3&gt;

&lt;p&gt;You switch the application over to the &lt;code&gt;read-through&lt;/code&gt; disk. From that point on, every new or modified file goes straight to the new storage, and the old one no longer receives any writes. &lt;strong&gt;That's the real cutover&lt;/strong&gt; , and it happens with zero downtime.&lt;/p&gt;

&lt;p&gt;If several applications or several servers write to the old storage, they all have to switch before you move on to the next step. Otherwise, files keep landing on the old disk after the backfill has already gone past.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Backfill the rest in the background
&lt;/h3&gt;

&lt;p&gt;A command walks the old disk and copies whatever is missing. It must be able to stop and resume without starting over, and never overwrite a newer version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BackfillDocuments&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'documents:backfill'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$copied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allFiles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already migrated, or rewritten since: leave it alone&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\is_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Failed: &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$failures&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$copied&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$copied&lt;/span&gt;&lt;span class="s2"&gt; document(s) copied, &lt;/span&gt;&lt;span class="nv"&gt;$failures&lt;/span&gt;&lt;span class="s2"&gt; failure(s)."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$failures&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FAILURE&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;exists()&lt;/code&gt; check before each copy makes the command &lt;strong&gt;idempotent&lt;/strong&gt; : rerunning it after an interruption only copies what's missing. Streaming (&lt;code&gt;readStream()&lt;/code&gt;) keeps memory usage flat, even for files of several hundred megabytes. Failures are counted separately: without the &lt;code&gt;'throw' =&amp;gt; true&lt;/code&gt; option on the disks, &lt;code&gt;readStream()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;writeStream()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, with no exception, and a command that doesn't check them would report success.&lt;/p&gt;

&lt;p&gt;With millions of files, &lt;code&gt;allFiles()&lt;/code&gt; becomes a problem in its own right: it loads the entire list into memory. At that point you're better off walking the table that references the files, in batches (&lt;code&gt;chunkById()&lt;/code&gt;), or spreading the work across a queue, one job per prefix or per batch of IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Verify
&lt;/h3&gt;

&lt;p&gt;Before switching anything off, I check that the old storage is &lt;strong&gt;contained&lt;/strong&gt; in the new one: every file from the old storage exists on the new one, with the same size, and for a sample, the same checksum (&lt;code&gt;md5&lt;/code&gt;, or the ETag on S3 for files uploaded in a single part). Comparing total file counts would be meaningless: the new disk has received every file written since the migration started, and a delete removed the file from both sides. This is also the time to check the metadata that matters to the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Unplug the fallback, keep the old storage
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;documents&lt;/code&gt; disk goes back to being a plain S3 disk. If you want to play it safe, there's an intermediate step: keep the read-through disk with the &lt;code&gt;'copy' =&amp;gt; false&lt;/code&gt; option. The fallback still serves the odd forgotten file but no longer writes anything, and a rise in reads from the old storage points to a gap in the backfill. The old storage itself stays in place for a few weeks, &lt;strong&gt;read-only&lt;/strong&gt;. If a file is missing, you can still recover it. Keep a close eye on 404 logs for documents during this period.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Switch it off
&lt;/h3&gt;

&lt;p&gt;Only now. It's often the step people forget, and the old bucket is still being billed two years later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of 404s
&lt;/h2&gt;

&lt;p&gt;One last detail, which matters on storage billed per request: a &lt;strong&gt;missing&lt;/strong&gt; file now costs two checks, one on each disk. If your application gets a lot of requests for files that don't exist (bots, old links, thumbnails not generated yet), the number of requests to your storage doubles for the entire migration. One more reason not to let it drag on.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaways
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;read-through&lt;/code&gt; driver cleanly solves the trickiest part of a migration: cutting over with zero downtime, without any business code knowing a migration is underway. Its design choices (deleting from both disks, re-checking before copying) show that its authors have done this kind of migration before.&lt;/p&gt;

&lt;p&gt;But a migration doesn't finish on its own. Read-through is step 1 of 5. What makes the difference is the idempotent backfill, the verification, and the discipline to actually switch off the old storage.&lt;/p&gt;

&lt;p&gt;To see the driver in action, the &lt;a href="https://apprendre.forelse.fr/pratique/laravel-13-26-read-through?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=migrating-files-with-read-through" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt; runs in the browser, with nothing to install: a real Laravel 13, two disks, and tests that check every behavior.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
