<?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: Serhii</title>
    <description>The latest articles on DEV Community by Serhii (@serhii_fedorenko).</description>
    <link>https://dev.to/serhii_fedorenko</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%2F1048851%2Fa995e468-0ad1-45e7-9479-c3e9ffc971bb.jpeg</url>
      <title>DEV Community: Serhii</title>
      <link>https://dev.to/serhii_fedorenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serhii_fedorenko"/>
    <language>en</language>
    <item>
      <title>Git worktrees as agent isolation: what broke, with the code</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Tue, 22 Sep 2026 15:52:03 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/git-worktrees-as-agent-isolation-what-broke-with-the-code-55lh</link>
      <guid>https://dev.to/serhii_fedorenko/git-worktrees-as-agent-isolation-what-broke-with-the-code-55lh</guid>
      <description>&lt;p&gt;Giving a coding agent its own &lt;code&gt;git worktree&lt;/code&gt; has become the standard advice for letting it work without trampling your checkout. It's cheap, it's native to git, and it gives every task its own branch. I build &lt;a href="https://github.com/serhii-f8/vadd" rel="noopener noreferrer"&gt;VADD&lt;/a&gt;, a local dashboard that wraps Claude Code and Codex, and every objective there runs in its own worktree under &lt;code&gt;~/.vadd/worktrees/&amp;lt;projectId&amp;gt;/&amp;lt;objectiveId&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The advice is right. But nobody mentions the part where it goes wrong, which is the part that matters when you automate it. Here's what broke for me, roughly in the order I hit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;git worktree remove&lt;/code&gt; can succeed while the files stay on disk
&lt;/h2&gt;

&lt;p&gt;This was the worst one because it lied.&lt;/p&gt;

&lt;p&gt;My original teardown looked like the obvious thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worktree&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--force&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worktree&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prune&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// post-condition: is it gone?&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listWorktrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;still registered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One day I checked disk usage and found &lt;strong&gt;about 1.7 GB across five directories&lt;/strong&gt; under the worktrees root. Two of them came from a cleanup run that had reported &lt;strong&gt;26/26 success&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git worktree remove --force&lt;/code&gt; deletes the worktree's &lt;code&gt;.git&lt;/code&gt; link file &lt;strong&gt;first&lt;/strong&gt;, then recursively deletes the rest.&lt;/li&gt;
&lt;li&gt;The recursive delete failed partway. The repo was a Laravel app, and an in-container &lt;code&gt;composer install&lt;/code&gt; had written &lt;code&gt;backend/vendor&lt;/code&gt; as &lt;code&gt;root:root&lt;/code&gt;. My user can't unlink files in a directory it doesn't own.&lt;/li&gt;
&lt;li&gt;git had already removed its admin entry. And the missing &lt;code&gt;.git&lt;/code&gt; link file is exactly what causes &lt;code&gt;git worktree prune&lt;/code&gt; to deregister a worktree.&lt;/li&gt;
&lt;li&gt;So my post-condition asked git "is this worktree still registered?", git truthfully said no, and the function returned success while the directory stayed on disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Git-level success and filesystem-level success are two different facts.&lt;/strong&gt; Check both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worktreePath&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listWorktrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repoPath&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&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;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Worktree still registered after removal: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;worktreePath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EGIT&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`Worktree directory still on disk after removal: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;worktreePath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Git has deregistered it, so nothing will retry this. Most likely it &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;holds files this user cannot unlink (a root-owned vendor/ or &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/ written by an in-container install).&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EGIT&lt;/span&gt;&lt;span class="dl"&gt;'&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;You can reproduce it without root. Unlinking a file is authorised by the directory that contains it, not by the file itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; wt/locked &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;touch &lt;/span&gt;wt/locked/f &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;555 wt/locked
&lt;span class="c"&gt;# now `git worktree remove --force` on wt fails halfway, and prune hides it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A follow-up lesson: &lt;code&gt;git worktree list&lt;/code&gt; can't show you this kind of leak at all, because git no longer knows the directory exists. My UI listed worktrees from git and couldn't display the leak it was meant to prevent. The fix was to also &lt;code&gt;readdir&lt;/code&gt; the worktrees root and report anything no database row or git entry claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A fresh worktree can't run the project's tests
&lt;/h2&gt;

&lt;p&gt;An agent that's asked to work test-first needs to run the tests. A new worktree has no &lt;code&gt;node_modules&lt;/code&gt;, no &lt;code&gt;vendor&lt;/code&gt;, no &lt;code&gt;.env&lt;/code&gt;: only tracked files.&lt;/p&gt;

&lt;p&gt;Symlinking the root &lt;code&gt;node_modules&lt;/code&gt; isn't enough with pnpm workspaces. I tried it on my own repo and still got &lt;code&gt;Cannot find package 'zod'&lt;/code&gt;, because pnpm resolves through &lt;strong&gt;per-package&lt;/strong&gt; &lt;code&gt;node_modules&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;What I ended up with is a per-repo &lt;code&gt;setup&lt;/code&gt; step in &lt;code&gt;.vadd/config.json&lt;/code&gt; that runs &lt;strong&gt;once, when the worktree is created&lt;/strong&gt;. It used to run before the first verification, and that was too late, since the agent needs to run tests in its very first task.&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;"verify"&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;"setup"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm install --frozen-lockfile"&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;"commands"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;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;If setup fails, the objective stops in &lt;code&gt;setup_failed&lt;/code&gt; with the log kept as evidence, instead of letting the agent try to work in a worktree that can't build.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Hard-linking dependencies silently skips files
&lt;/h2&gt;

&lt;p&gt;For big PHP &lt;code&gt;vendor/&lt;/code&gt; directories I tried &lt;code&gt;cp -al&lt;/code&gt; (hard-link copy) to avoid reinstalling. It looked like it worked. It hadn't.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;fs.protected_hardlinks=1&lt;/code&gt;, which is the default on most distros, the kernel refuses to hard-link a file you don't own. &lt;strong&gt;&lt;code&gt;cp -al&lt;/code&gt; doesn't fail loudly when that happens.&lt;/strong&gt; About 1,800 root-owned files in &lt;code&gt;vendor/&lt;/code&gt; were just missing, and the worktree looked complete.&lt;/p&gt;

&lt;p&gt;If you do this, follow the link pass with a copy pass that fills in the gaps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-al&lt;/span&gt;  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIN&lt;/span&gt;&lt;span class="s2"&gt;/backend/vendor"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WT&lt;/span&gt;&lt;span class="s2"&gt;/backend/vendor"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--no-clobber&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIN&lt;/span&gt;&lt;span class="s2"&gt;/backend/vendor/."&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WT&lt;/span&gt;&lt;span class="s2"&gt;/backend/vendor/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A broader point: hard-linking root-owned trees into every worktree is also how you create the un-removable directories from trap #1. Link only what the commands actually need, and prove the need by running the commands without it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;docker compose exec&lt;/code&gt; works on the wrong checkout
&lt;/h2&gt;

&lt;p&gt;Many projects bind-mount the &lt;strong&gt;main checkout&lt;/strong&gt; into their containers. An agent working in a worktree that runs &lt;code&gt;docker compose exec app php artisan test&lt;/code&gt; is testing code it didn't write, from the main checkout, and gets a result that has nothing to do with its changes.&lt;/p&gt;

&lt;p&gt;There's no clever fix for this inside git. What worked for me was a setup step that points a copied &lt;code&gt;.env&lt;/code&gt; at the containers' host-exposed ports (database, cache), so the test runner runs on the host inside the worktree, and Docker only provides services, not the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Protected files leaked into the squash commit
&lt;/h2&gt;

&lt;p&gt;That setup step had a side effect. It rewrote a &lt;strong&gt;tracked&lt;/strong&gt; file (&lt;code&gt;backend/.env.testing&lt;/code&gt;) to remap ports. The agent's checkpoint commits use &lt;code&gt;git add -A&lt;/code&gt;, so the change went into every checkpoint, and the final squash would have carried it onto the branch and broken every other developer's Docker setup.&lt;/p&gt;

&lt;p&gt;I already had a &lt;code&gt;protectedGlobs&lt;/code&gt; config field. It was resolved, merged, persisted, and &lt;strong&gt;read by nothing&lt;/strong&gt;. The lesson: a config field with no reader isn't a policy, it's a comment that costs a migration.&lt;/p&gt;

&lt;p&gt;The enforcement point ended up in the squash, not in setup. "Arbitrary shell must never touch tracked files" is a rule I can't enforce. "This squash contains no protected path" is a rule I can check at exactly the right moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git reset &lt;span class="nt"&gt;--soft&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# staged paths matching a protected glob, from `git diff --cached --name-only -z`:&lt;/span&gt;
git reset &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;reset&lt;/code&gt; puts each protected path's index entry back to how it was at base. A protected file the agent &lt;em&gt;created&lt;/em&gt; didn't exist at base, so it drops out of the index entirely.&lt;/p&gt;

&lt;p&gt;VADD then emits an event naming every path it excluded. A squash that silently leaves out a file the agent believed it changed is the same kind of lie as a dropped log line.&lt;/p&gt;

&lt;p&gt;One more ordering bug turned up here. The first version refused to integrate when it couldn't read the policy, but it made that check &lt;em&gt;after&lt;/em&gt; &lt;code&gt;reset --soft&lt;/code&gt; had already moved the branch. &lt;strong&gt;A refusal that happens after the first mutating step isn't a refusal.&lt;/strong&gt; Read everything a guard needs before you touch the worktree.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Two worktrees, one ID
&lt;/h2&gt;

&lt;p&gt;The last one isn't really git's fault. My plan-task table used the task's ordinal (&lt;code&gt;'0'&lt;/code&gt;, &lt;code&gt;'1'&lt;/code&gt;, &lt;code&gt;'2'&lt;/code&gt;) as a global primary key. That's unique within one objective and not unique anywhere else. The second objective to reach planning hit a &lt;code&gt;UNIQUE&lt;/code&gt; violation, the error was swallowed, and the user was shown a plan with no rows behind it.&lt;/p&gt;

&lt;p&gt;Every test used one objective at a time. &lt;strong&gt;A fixture with one of the thing under test can't show you a collision between two of them.&lt;/strong&gt; If you're isolating parallel work, make sure your tests run parallel work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If you're building on worktrees for agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After removal, assert &lt;strong&gt;both&lt;/strong&gt; that git no longer lists the path &lt;strong&gt;and&lt;/strong&gt; that it's gone from disk.&lt;/li&gt;
&lt;li&gt;Scan the worktree root yourself. &lt;code&gt;git worktree list&lt;/code&gt; can't see a directory git has already forgotten.&lt;/li&gt;
&lt;li&gt;Run dependency setup at worktree creation, and treat a setup failure as a hard stop.&lt;/li&gt;
&lt;li&gt;Don't trust &lt;code&gt;cp -al&lt;/code&gt; with files owned by another user.&lt;/li&gt;
&lt;li&gt;Check whether anything in the project is bind-mounted to the main checkout.&lt;/li&gt;
&lt;li&gt;Enforce "don't commit this" where the commit happens, and report what you excluded.&lt;/li&gt;
&lt;li&gt;Test with two or more concurrent worktrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this is in &lt;a href="https://github.com/serhii-f8/vadd" rel="noopener noreferrer"&gt;VADD's repo&lt;/a&gt; under &lt;code&gt;packages/server/src/git/&lt;/code&gt;, and the full list of traps is in &lt;code&gt;CLAUDE.md&lt;/code&gt;. Next post: the process-management side of running an agent's test suite, where a 1-second timeout took 5 seconds and left the process running.&lt;/p&gt;

</description>
      <category>git</category>
      <category>ai</category>
      <category>node</category>
      <category>devtools</category>
    </item>
    <item>
      <title>About 1,800 tests passed. Running the real app found 14 bugs</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Tue, 22 Sep 2026 15:35:47 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/about-1800-tests-passed-running-the-real-app-found-14-bugs-l46</link>
      <guid>https://dev.to/serhii_fedorenko/about-1800-tests-passed-running-the-real-app-found-14-bugs-l46</guid>
      <description>&lt;p&gt;My last post about Filament Studio was about &lt;code&gt;v1.2.0&lt;/code&gt; and multilingual content, back in April.&lt;/p&gt;

&lt;p&gt;Since then I have shipped two larger things: an MCP server, so AI agents can manage collections and records, and &lt;strong&gt;Flows&lt;/strong&gt;, an automation engine inside the plugin. Flows has a visual designer, four trigger types (manual, webhook, collection event, cron), a set of operations (create/update records, HTTP requests, email, conditions, calling another flow), draft/publish versioning, and a step-through debugger.&lt;/p&gt;

&lt;p&gt;By the end of August the test suite was at about 1,800 tests. I run mutation testing on top of that, with an 80% MSI target per module. By the numbers I had, Flows was in good shape.&lt;/p&gt;

&lt;p&gt;In September I stopped trusting those numbers and did something I should have done earlier. I installed the package into a separate Laravel app and used it the way a user would:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;real HTTPS webhook deliveries&lt;/li&gt;
&lt;li&gt;a real queue worker&lt;/li&gt;
&lt;li&gt;MySQL instead of in-memory SQLite&lt;/li&gt;
&lt;li&gt;the admin panel, clicked through in a browser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That found fourteen defects in one release (&lt;code&gt;v1.8.0&lt;/code&gt;), plus one more the day before (&lt;code&gt;v1.7.1&lt;/code&gt;). The existing suite covered none of them.&lt;/p&gt;

&lt;p&gt;This post goes through the interesting ones. What I took from it: almost every bug had the same shape. &lt;strong&gt;The system reported success while doing nothing, or doing the wrong thing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A run that completed in 0 ms
&lt;/h2&gt;

&lt;p&gt;The first bug showed up before I had even started on webhooks.&lt;/p&gt;

&lt;p&gt;I opened a seeded "Welcome Email" flow, triggered it, and the run page said &lt;strong&gt;Completed&lt;/strong&gt;. Duration: 0 ms. Steps: none.&lt;/p&gt;

&lt;p&gt;Nothing had run, and the engine said everything was fine.&lt;/p&gt;

&lt;p&gt;The engine walks the flow as a graph. After each node it asks for the successors on the &lt;code&gt;success&lt;/code&gt; branch (or &lt;code&gt;failure&lt;/code&gt;). The lookup matched the edge's &lt;code&gt;sourceHandle&lt;/code&gt; exactly:&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;// Before&lt;/span&gt;
&lt;span class="nv"&gt;$matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sourceHandle'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visual designer always sets &lt;code&gt;sourceHandle&lt;/code&gt;, because its nodes have named &lt;code&gt;success&lt;/code&gt; and &lt;code&gt;failure&lt;/code&gt; handles. But a graph written by hand, by a seeder, or through the REST API usually looks like this:&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trigger"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"send_email"&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;No handle. So the walk stopped at the trigger, found nothing to do, and finished. "Finished with nothing to do" was recorded as success.&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;// After&lt;/span&gt;
&lt;span class="nv"&gt;$matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$edge&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$edge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sourceHandle'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$branch&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;Every graph in my tests had the shape the designer produces. The engine had only ever been tested on input from its own UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A webhook that looked signed but accepted anyone
&lt;/h2&gt;

&lt;p&gt;This is the one I am least proud of.&lt;/p&gt;

&lt;p&gt;Flows can be triggered by a webhook, and the default auth mode is HMAC: the sender signs &lt;code&gt;"{timestamp}.{body}"&lt;/code&gt; with a shared secret, and the endpoint checks the signature.&lt;/p&gt;

&lt;p&gt;A few things combined here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The flow table has a &lt;code&gt;webhook_auth_mode&lt;/code&gt; column that defaults to &lt;code&gt;hmac&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The secret was generated only when the &lt;strong&gt;trigger node's config&lt;/strong&gt; contained &lt;code&gt;auth_mode: hmac&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The trigger's config schema never declared an &lt;code&gt;auth_mode&lt;/code&gt; field, so the designer never showed it and nobody could set it.&lt;/li&gt;
&lt;li&gt;So on the normal path, &lt;code&gt;webhook_secret&lt;/code&gt; stayed &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The verifier took the secret as a string. &lt;code&gt;(string) null&lt;/code&gt; is &lt;code&gt;''&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The endpoint said it required a signature, but anyone who could compute &lt;code&gt;hash_hmac('sha256', "{ts}.{body}", '')&lt;/code&gt; could pass the check. That is anyone.&lt;/p&gt;

&lt;p&gt;The fix has two parts. First, the verifier refuses to verify against an empty secret:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Without this guard an unconfigured flow verifies against hash_hmac(..., ''),&lt;/span&gt;
    &lt;span class="c1"&gt;// which any caller can compute — an unauthenticated endpoint that looks signed.&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidWebhookSignatureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Webhook secret is not configured for this flow.'&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;Second, the trigger now reads the &lt;code&gt;webhook_auth_mode&lt;/code&gt; column instead of node config. The webhook trigger node has no config at all anymore. Auth mode, secret, API-key allowlist and redaction paths all live on the flow record, so there is one source of truth.&lt;/p&gt;

&lt;p&gt;The real bug was having two places to set one security setting. Each had its own tests, and nothing checked that they agreed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run Flows with a reachable webhook:&lt;/strong&gt; check for flows where &lt;code&gt;webhook_auth_mode = 'hmac'&lt;/code&gt; and &lt;code&gt;webhook_secret IS NULL&lt;/code&gt;. Those endpoints were effectively public. After upgrading, publish the flow again and a secret will be generated. Until then it returns 401.&lt;/p&gt;

&lt;p&gt;In the same area, &lt;code&gt;webhook_redact_paths&lt;/code&gt; redacted the parsed body but not the raw body stored next to it, so a value scrubbed from &lt;code&gt;body&lt;/code&gt; still sat in &lt;code&gt;trigger_payload.raw&lt;/code&gt;. The raw body is now re-encoded from the sanitized one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;Cache::forever&lt;/code&gt; is not forever
&lt;/h2&gt;

&lt;p&gt;Collection-event triggers ("run this flow when a record in &lt;code&gt;orders&lt;/code&gt; is created") need a fast lookup: given a collection and an event, which flows care?&lt;/p&gt;

&lt;p&gt;I kept that index in the cache:&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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'studio.flows.collection_event_subscriptions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$subscriptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a flow was published, it subscribed. When unpublished, it unsubscribed. The tests passed.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;code&gt;php artisan optimize:clear&lt;/code&gt;, which plenty of deploy scripts run. Every collection-event flow stopped firing. Nothing was logged and nothing showed in the UI. The only way to recover was to publish every flow again.&lt;/p&gt;

&lt;p&gt;"Forever" in a cache means "until something clears it": a deploy, an eviction, a Redis restart. The cache was the only record of the subscriptions, so losing it lost the data.&lt;/p&gt;

&lt;p&gt;The fix makes the cache a read-through index over what is actually published:&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&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;CACHE_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// An explicitly empty map is a real answer — every flow unsubscribed.&lt;/span&gt;
    &lt;span class="c1"&gt;// Only a missing key means the index was lost and must be rebuilt.&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;$cached&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&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;$cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$rebuilt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rebuildFromPublishedVersions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forever&lt;/span&gt;&lt;span class="p"&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;CACHE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rebuilt&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;$rebuilt&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 comment covers a detail I almost got wrong: &lt;code&gt;[]&lt;/code&gt; (nobody subscribed) and &lt;code&gt;null&lt;/code&gt; (the index is gone) have to be treated differently. Otherwise an install with no subscriptions would query the database on every record save.&lt;/p&gt;

&lt;p&gt;Since then, whenever I put something in the cache, I ask: &lt;em&gt;if this key disappears right now, what breaks, and would anyone notice?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Config schemas that disagreed with the code reading them
&lt;/h2&gt;

&lt;p&gt;Each trigger declares a config schema. The designer renders a form from it and the API validates against it. Separately, the trigger's runtime code reads config keys.&lt;/p&gt;

&lt;p&gt;In three of the four triggers, these didn't match:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Schema declared&lt;/th&gt;
&lt;th&gt;Runtime read&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Collection event&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;collection_id&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;collection&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Flow never subscribed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedule&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cron_expression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cron&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flow &lt;strong&gt;failed to publish&lt;/strong&gt;: &lt;code&gt;Invalid cron expression:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;secret_key&lt;/code&gt;, &lt;code&gt;expected_method&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;Fields that did nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A schedule flow configured exactly as its own schema described could not be published.&lt;/p&gt;

&lt;p&gt;There were tests for the schemas and tests for the runtime. None of them built a config from the schema and gave it to the runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Secrets that were never injected
&lt;/h2&gt;

&lt;p&gt;Flows support per-flow encrypted secrets, used in configs as &lt;code&gt;{{ $secrets.API_TOKEN }}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The table existed. Encryption at rest worked. &lt;code&gt;FlowContext&lt;/code&gt; had a &lt;code&gt;$secrets&lt;/code&gt; slot, and the template engine knew how to resolve &lt;code&gt;$secrets.*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing ever filled the slot. Every &lt;code&gt;{{ $secrets.X }}&lt;/code&gt; rendered as an empty string, so any flow using one was sending an empty credential.&lt;/p&gt;

&lt;p&gt;Wiring it up was the easy part. The harder part was what the secrets must not leak into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step logs.&lt;/strong&gt; Values are already masked by key name (&lt;code&gt;/token/i&lt;/code&gt;, &lt;code&gt;/password/i&lt;/code&gt;, …), but a secret interpolated into a URL or into a field called &lt;code&gt;note&lt;/code&gt; gets past key-name matching. Resolved secret values are now scrubbed from every string before a step is saved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The step-through cache.&lt;/strong&gt; A paused debugging session stores its context in the cache store. Secrets are left out of that blob and loaded from the database again when the session resumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-flows.&lt;/strong&gt; A flow that triggers another flow doesn't pass its secrets along. The child loads its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. The quick ones
&lt;/h2&gt;

&lt;p&gt;Each of these deserves a paragraph, but the pattern is clear by now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form-encoded webhooks wrote blank records&lt;/strong&gt;, and the run showed as successful. The body was parsed only when &lt;code&gt;$request-&amp;gt;isJson()&lt;/code&gt;, so &lt;code&gt;{{ $trigger.body.email }}&lt;/code&gt; rendered as &lt;code&gt;''&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The run timeline listed steps alphabetically.&lt;/strong&gt; Sorting used &lt;code&gt;started_at&lt;/code&gt;, which has second precision. In a fast run every step ties, and MySQL fell back to index order, which was alphabetical by operation key. It now tie-breaks on the UUIDv7 primary key, which increases monotonically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every fast step showed &lt;code&gt;0ms&lt;/code&gt;&lt;/strong&gt; for the same reason. There is now a &lt;code&gt;duration_ms&lt;/code&gt; column measured by the engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A synchronous "trigger another flow" step reported success when the child flow failed.&lt;/strong&gt; It now takes the &lt;code&gt;failure&lt;/code&gt; branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Studio Dashboard page returned 403 for everyone&lt;/strong&gt; using &lt;code&gt;spatie/laravel-permission&lt;/code&gt;. It checked a permission name that the package never registers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit log recorded &lt;code&gt;updated&lt;/code&gt; for every lifecycle change&lt;/strong&gt;: draft save, publish, rollback. Now they are &lt;code&gt;draft_saved&lt;/code&gt;, &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;rolled_back&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which API answered depended on service-provider boot order.&lt;/strong&gt; Collection routes are a catch-all, &lt;code&gt;api/studio/{collection_slug}&lt;/code&gt;, so &lt;code&gt;GET /api/studio/flows&lt;/code&gt; went to the collection controller when it was registered first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The route fix gave me a small lesson of its own. My first version excluded reserved segments with a lookahead ending in &lt;code&gt;$&lt;/code&gt;:&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="s1"&gt;'(?!(?:flows|webhooks)$)[^/]+'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regression test failed. Laravel splices the parameter pattern into the regex for the whole path, so &lt;code&gt;$&lt;/code&gt; doesn't mean the end of the segment. &lt;code&gt;/api/studio/webhooks/my-flow&lt;/code&gt; still matched. The working version ends the lookahead at a segment boundary:&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="s1"&gt;'(?!(?:flows|webhooks)(?:/|$))[^/]+'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I only caught that because I wrote the test first. That was the rule for this release: every one of the fourteen fixes started with a test that failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two older bugs from the same family
&lt;/h2&gt;

&lt;p&gt;Looking back, I had seen this pattern before and hadn't named it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search that returned nothing.&lt;/strong&gt; In &lt;code&gt;v1.5.0&lt;/code&gt; I fixed record search, which matched nothing for any term. Filament's default &lt;code&gt;searchable()&lt;/code&gt; generated &lt;code&gt;where "column" like ?&lt;/code&gt; against the records table. But in an EAV model, the values live in another table, and on the record query they exist only as subquery aliases, which a &lt;code&gt;WHERE&lt;/code&gt; clause can't reference. On MySQL that throws &lt;code&gt;Unknown column&lt;/code&gt;. On SQLite, which my tests used, an unresolved double-quoted identifier quietly turns into a string literal. So the query compared the literal string &lt;code&gt;'title'&lt;/code&gt; to the search term and returned zero rows, and the tests passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dependency I never used.&lt;/strong&gt; The &lt;code&gt;v1.4.0&lt;/code&gt; changelog said Flows was "built on &lt;code&gt;durable-workflow/workflow&lt;/code&gt;". It wasn't. Nothing in the package referenced it. The engine had always walked its own graph and used plain Laravel queued jobs. I removed it in &lt;code&gt;v1.7.0&lt;/code&gt; (three fewer packages and about thirty fewer migrations on every install) and corrected the changelog. It wasn't a runtime bug, but it was the same problem in documentation: a claim nobody had checked against reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Grouped together, the fifteen or so bugs fall into three categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Silent success.&lt;/strong&gt; The system's default outcome was "success", so doing nothing looked the same as doing the job. A 0 ms run, a blank record, a swallowed child failure, an empty credential, a subscription index that disappeared, a search with no results. None of them threw an exception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two things that should agree, tested separately.&lt;/strong&gt; Schema vs. runtime. Flow column vs. node config. Designer-shaped graphs vs. API-shaped graphs. Collection routes vs. flow routes. A policy's permission name vs. the permissions that exist. Each side had tests, and the gap between them had none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A test environment gentler than production.&lt;/strong&gt; SQLite that forgives bad SQL. A cache that never gets flushed mid-test. Requests that are always JSON.&lt;/p&gt;

&lt;p&gt;A green suite showed that the code did what the tests said. It said much less about whether the product worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An end-to-end pass in a real host app before releases&lt;/strong&gt;: real queue, real database engine, real HTTP. It's slow and manual, and it found more in a week than the suite had in months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failing test first for every bug&lt;/strong&gt;, so each bug from this list is now permanently covered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking where "success" comes from.&lt;/strong&gt; For each code path that reports success, I ask what it reports when nothing happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking what happens if the cache key disappears&lt;/strong&gt;, every time I write one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;v1.8.0&lt;/code&gt; is out with all of this. Upgrading is &lt;code&gt;php artisan migrate&lt;/code&gt; (for &lt;code&gt;duration_ms&lt;/code&gt;). There are two behavior changes: a collection with the slug &lt;code&gt;flows&lt;/code&gt; or &lt;code&gt;webhooks&lt;/code&gt; is no longer reachable through the collection API, and HMAC webhooks without a secret now return 401. The &lt;a href="https://github.com/serhii-f8/filament-studio/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; has the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like to hear
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you run an end-to-end pass against a real host app for your Laravel packages? How much of it is automated?&lt;/li&gt;
&lt;li&gt;Has &lt;code&gt;Cache::forever&lt;/code&gt; bitten you, or have you been using the cache as an index from the start?&lt;/li&gt;
&lt;li&gt;For "silent success": do you have a pattern for making "nothing happened" look different from "it worked"?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/serhii-f8/filament-studio" rel="noopener noreferrer"&gt;https://github.com/serhii-f8/filament-studio&lt;/a&gt;&lt;br&gt;
Packagist: &lt;a href="https://packagist.org/packages/serhii-f8/filament-studio" rel="noopener noreferrer"&gt;https://packagist.org/packages/serhii-f8/filament-studio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a good way to catch "two things that should agree" bugs before a user does, I would like to hear it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped Filament Studio 1.3.0, and it is the first version that feels AI-ready</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Sun, 10 May 2026 16:20:20 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/i-shipped-filament-studio-130-and-it-is-the-first-version-that-feels-ai-ready-2c37</link>
      <guid>https://dev.to/serhii_fedorenko/i-shipped-filament-studio-130-and-it-is-the-first-version-that-feels-ai-ready-2c37</guid>
      <description>&lt;p&gt;I have been building &lt;code&gt;Filament Studio&lt;/code&gt; as a dynamic data model manager for &lt;code&gt;Filament v5&lt;/code&gt; and &lt;code&gt;Laravel 12&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The original value prop was runtime-defined collections and fields on top of an EAV model, so you can build flexible admin/data systems without creating a new migration for every content type.&lt;/p&gt;

&lt;p&gt;That part worked.&lt;/p&gt;

&lt;p&gt;But while working on the project, I kept coming back to a different question:&lt;/p&gt;

&lt;p&gt;If this data layer is dynamic, how should an AI agent interact with it?&lt;/p&gt;

&lt;p&gt;My answer in &lt;code&gt;v1.3.0&lt;/code&gt; was to add the MCP foundation instead of hacking together one-off AI endpoints.&lt;/p&gt;

&lt;p&gt;So this release makes Filament Studio usable as an AI-facing layer, not just an admin-facing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I added in &lt;code&gt;v1.3.0&lt;/code&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MCP server foundation under &lt;code&gt;src/Mcp/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HTTP/SSE&lt;/code&gt; transport mounted at &lt;code&gt;/ai/studio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stdio&lt;/code&gt; transport via &lt;code&gt;php artisan mcp:start studio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reuse of &lt;code&gt;StudioApiKey&lt;/code&gt; for MCP auth&lt;/li&gt;
&lt;li&gt;New management scope namespace for MCP operations&lt;/li&gt;
&lt;li&gt;Capability discovery resources like:

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;studio://info&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;studio://field-types&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;studio://panel-types&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;studio://operators&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;12&lt;/code&gt; schema-management MCP tools for collections and fields&lt;/li&gt;

&lt;li&gt;Confirm-token flow for destructive operations&lt;/li&gt;

&lt;li&gt;Canonical serializer + exception handling for tool responses&lt;/li&gt;

&lt;li&gt;Tenant-aware behavior aligned with the rest of Studio&lt;/li&gt;

&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I think this matters
&lt;/h2&gt;

&lt;p&gt;A lot of AI integration work feels shallow to me.&lt;/p&gt;

&lt;p&gt;Either there is a chat UI bolted on top, or there is an agent wired straight into places it probably should not be touching.&lt;/p&gt;

&lt;p&gt;For a system like Filament Studio, neither approach feels right.&lt;/p&gt;

&lt;p&gt;If the schema is dynamic, the agent needs a real contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;discover what exists&lt;/li&gt;
&lt;li&gt;inspect schema safely&lt;/li&gt;
&lt;li&gt;mutate collections and fields through structured tools&lt;/li&gt;
&lt;li&gt;handle destructive actions with explicit confirmation tokens&lt;/li&gt;
&lt;li&gt;work through auth scopes instead of bypassing application boundaries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the part I wanted to build first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for me conceptually
&lt;/h2&gt;

&lt;p&gt;Before this release, Filament Studio was mainly a human-operated system.&lt;/p&gt;

&lt;p&gt;After this release, it is starting to become an agent-addressable one.&lt;/p&gt;

&lt;p&gt;That is a much more interesting direction than sprinkling AI on top of the UI.&lt;/p&gt;

&lt;p&gt;I want the data model, schema rules, and operational boundaries of a Laravel app to be available to agents in a structured way.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;v1.3.0&lt;/code&gt; is my first serious pass at that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this release is not
&lt;/h2&gt;

&lt;p&gt;This is not the final AI story for the project.&lt;/p&gt;

&lt;p&gt;It is the foundation.&lt;/p&gt;

&lt;p&gt;I started with schema management because if that layer is weak, everything above it becomes fragile.&lt;/p&gt;

&lt;p&gt;So I would rather get the contract right first, then expand the workflows on top of it.&lt;/p&gt;

&lt;p&gt;If you are building with Filament and thinking about AI agents beyond demo-level features, this is the direction I am building toward.&lt;/p&gt;

&lt;p&gt;Follow the project if you want to see where the MCP roadmap goes next.&lt;/p&gt;

&lt;p&gt;Feedback I would like&lt;br&gt;
Filament Studio v1.3.0 is available now.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/flexpik/filament-studio" rel="noopener noreferrer"&gt;https://github.com/flexpik/filament-studio&lt;/a&gt;&lt;br&gt;
Packagist: &lt;a href="https://packagist.org/packages/flexpik/filament-studio" rel="noopener noreferrer"&gt;https://packagist.org/packages/flexpik/filament-studio&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>opensource</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building multilingual runtime collections in a Filament plugin</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Wed, 15 Apr 2026 20:46:01 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/building-multilingual-runtime-collections-in-a-filament-plugin-27cm</link>
      <guid>https://dev.to/serhii_fedorenko/building-multilingual-runtime-collections-in-a-filament-plugin-27cm</guid>
      <description>&lt;h1&gt;
  
  
  Building multilingual runtime collections in a Filament plugin
&lt;/h1&gt;

&lt;p&gt;I just released &lt;code&gt;v1.2.0&lt;/code&gt; of &lt;strong&gt;Filament Studio&lt;/strong&gt;, a Filament v5 plugin I am building for runtime collections, custom fields, dashboards, filters, and APIs.&lt;/p&gt;

&lt;p&gt;The short version: this release adds multilingual support.&lt;/p&gt;

&lt;p&gt;The longer version: adding multilingual support to a runtime data model is not the same as adding a &lt;code&gt;translations&lt;/code&gt; JSON column and moving on.&lt;/p&gt;

&lt;p&gt;Filament Studio is built for admin panels where the data model changes after the app is already running. A user can create a collection, add fields, manage records, build dashboards, and expose API endpoints without writing a new migration and Filament resource for each new content type.&lt;/p&gt;

&lt;p&gt;That flexibility creates a specific problem for translations.&lt;/p&gt;

&lt;p&gt;If the fields are defined at runtime, translation behavior also has to be defined at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I wanted to solve
&lt;/h2&gt;

&lt;p&gt;In real admin systems, multilingual content is rarely all-or-nothing.&lt;/p&gt;

&lt;p&gt;A collection might support English and French.&lt;/p&gt;

&lt;p&gt;Inside that collection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt; should be translated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;description&lt;/code&gt; should be translated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;price&lt;/code&gt; should stay shared&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;published_at&lt;/code&gt; should stay shared&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_featured&lt;/code&gt; should stay shared&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means translation behavior belongs at the field level, not only at the record or collection level.&lt;/p&gt;

&lt;p&gt;I also wanted the feature to work across the whole plugin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generated Filament forms&lt;/li&gt;
&lt;li&gt;EAV storage&lt;/li&gt;
&lt;li&gt;query builder reads and writes&lt;/li&gt;
&lt;li&gt;REST API responses&lt;/li&gt;
&lt;li&gt;OpenAPI documentation&lt;/li&gt;
&lt;li&gt;fallback behavior&lt;/li&gt;
&lt;li&gt;version snapshots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise it would feel like a partial feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global and collection config
&lt;/h2&gt;

&lt;p&gt;Multilingual support is opt-in.&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/filament-studio.php&lt;/span&gt;
&lt;span class="s1"&gt;'locales'&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;'enabled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'available'&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;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'de'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'en'&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;You can also enable it through the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STUDIO_LOCALES_ENABLED=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each collection can then define its own supported locales and default locale:&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;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'supported_locales'&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;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'default_locale'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'en'&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;If a collection does not define supported locales, it uses the global list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-field translation
&lt;/h2&gt;

&lt;p&gt;Each field now has a translatable flag.&lt;/p&gt;

&lt;p&gt;That lets a collection mix localized and shared data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;title        translatable
description  translatable
price        shared
published_at shared
is_featured  shared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part of the design I care about most. In multilingual admin work, not every value is content. Some values are facts, flags, prices, dates, or relationships. Treating all of them as translatable creates noise and sometimes bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage model
&lt;/h2&gt;

&lt;p&gt;Filament Studio uses an EAV model because fields are created at runtime.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;v1.2.0&lt;/code&gt;, the &lt;code&gt;studio_values&lt;/code&gt; table now includes a &lt;code&gt;locale&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;For translatable fields, values are stored per locale.&lt;/p&gt;

&lt;p&gt;For shared fields, one value is used regardless of active locale.&lt;/p&gt;

&lt;p&gt;The uniqueness boundary is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record_id + field_id + locale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That allows a record to have different localized values for the same runtime field without creating new database columns for every translated field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locale resolution
&lt;/h2&gt;

&lt;p&gt;I added a dedicated &lt;code&gt;LocaleResolver&lt;/code&gt; service so the rules live in one place.&lt;/p&gt;

&lt;p&gt;The active locale is resolved in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;?locale=&lt;/code&gt; query parameter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-Locale&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;session&lt;/li&gt;
&lt;li&gt;collection default&lt;/li&gt;
&lt;li&gt;global default&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives API consumers a direct way to request a locale, while the admin panel can remember the selected locale in the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query builder API
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;EavQueryBuilder&lt;/code&gt; now supports a &lt;code&gt;locale()&lt;/code&gt; method:&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;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EavQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&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;locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fr'&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;getRecordData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create and update operations can also target a locale:&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;$record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EavQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&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;locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fr'&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;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mon Titre'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;EavQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&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;locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fr'&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Titre mis a jour'&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;There is also &lt;code&gt;getAllLocaleData()&lt;/code&gt; for retrieving every translation at once:&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;$allData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EavQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&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;getAllLocaleData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example result:&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="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'title'&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;'en'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'My Title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'fr'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mon Titre'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;29.99&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;Translatable fields return locale maps. Shared fields stay plain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback metadata
&lt;/h2&gt;

&lt;p&gt;I did not want fallback behavior to be silent.&lt;/p&gt;

&lt;p&gt;If an API client asks for French and a field falls back to English, the client should be able to detect that.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;getRecordDataWithMeta()&lt;/code&gt; returns fallback information:&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;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EavQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&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;locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fr'&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;getRecordDataWithMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&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="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'data'&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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mon Titre'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'my-slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'fallbacks'&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;'slug'&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;That tells the caller that &lt;code&gt;slug&lt;/code&gt; came from the default locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  REST API support
&lt;/h2&gt;

&lt;p&gt;API endpoints now accept locale selection through query params:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: your-key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="s2"&gt;"https://your-app.com/api/studio/posts?locale=fr"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or through headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: your-key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Locale: fr"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="s2"&gt;"https://your-app.com/api/studio/posts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Responses include metadata:&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;"data"&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;"uuid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"550e8400-e29b-41d4-a716-446655440000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&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;"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;"Mon Titre"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-slug"&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="mf"&gt;29.99&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;span class="nl"&gt;"_meta"&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;"locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fallbacks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"slug"&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="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;For single-record reads, &lt;code&gt;all_locales=true&lt;/code&gt; returns every locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: your-key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="s2"&gt;"https://your-app.com/api/studio/posts/550e8400?all_locales=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  OpenAPI docs
&lt;/h2&gt;

&lt;p&gt;When multilingual support is enabled, the generated OpenAPI docs include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;locale&lt;/code&gt; query parameter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-Locale&lt;/code&gt; header parameter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;all_locales&lt;/code&gt; query parameter&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_meta.locale&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_meta.fallbacks&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This felt worth doing because API features are easy to misunderstand when the docs do not show the request and response shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Admin UI and versioning
&lt;/h2&gt;

&lt;p&gt;In the Filament admin, multilingual collections now get a locale switcher on record pages.&lt;/p&gt;

&lt;p&gt;Editors can switch locale and edit only the fields that should change for that locale.&lt;/p&gt;

&lt;p&gt;Version snapshots also store all locale values for translatable fields:&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;"title"&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;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mon Titre"&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;"slug"&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;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-slug"&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;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;29.99&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;That way restoring a version does not only restore whichever locale happened to be active at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am sharing this
&lt;/h2&gt;

&lt;p&gt;This was one of those updates that made the package feel more practical.&lt;/p&gt;

&lt;p&gt;The first version of a dynamic admin builder can look good with just runtime fields and generated forms.&lt;/p&gt;

&lt;p&gt;But real projects keep asking for the less glamorous pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;permissions&lt;/li&gt;
&lt;li&gt;APIs&lt;/li&gt;
&lt;li&gt;versioning&lt;/li&gt;
&lt;li&gt;tenancy&lt;/li&gt;
&lt;li&gt;filtering&lt;/li&gt;
&lt;li&gt;multilingual content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the direction I am trying to push Filament Studio.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;code&gt;https://github.com/flexpik/filament-studio&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Packagist: &lt;code&gt;https://packagist.org/packages/flexpik/filament-studio&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you have built multilingual admin panels in Laravel, I would like feedback on the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;should translation behavior live per field?&lt;/li&gt;
&lt;li&gt;is fallback metadata useful in practice?&lt;/li&gt;
&lt;li&gt;would &lt;code&gt;all_locales=true&lt;/code&gt; work for your API use cases?&lt;/li&gt;
&lt;li&gt;what edge cases am I missing?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>opensource</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I built a Filament plugin for runtime collections, custom fields, dashboards, and APIs</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Mon, 06 Apr 2026 10:54:30 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/i-built-a-filament-plugin-for-runtime-collections-custom-fields-dashboards-and-apis-3g5f</link>
      <guid>https://dev.to/serhii_fedorenko/i-built-a-filament-plugin-for-runtime-collections-custom-fields-dashboards-and-apis-3g5f</guid>
      <description>&lt;h1&gt;
  
  
  I built a Filament plugin for runtime collections, custom fields, dashboards, and APIs
&lt;/h1&gt;

&lt;p&gt;I built &lt;strong&gt;Filament Studio&lt;/strong&gt; because I got tired of the same cycle in Laravel projects.&lt;/p&gt;

&lt;p&gt;The admin panel starts simple.&lt;/p&gt;

&lt;p&gt;Then requirements change.&lt;/p&gt;

&lt;p&gt;A client wants a new content type.&lt;br&gt;&lt;br&gt;
Then custom fields.&lt;br&gt;&lt;br&gt;
Then better filtering.&lt;br&gt;&lt;br&gt;
Then dashboards.&lt;br&gt;&lt;br&gt;
Then API access.&lt;br&gt;&lt;br&gt;
Then tenant-specific data.&lt;br&gt;&lt;br&gt;
Then version history.&lt;br&gt;&lt;br&gt;
Then another special case nobody planned for.&lt;/p&gt;

&lt;p&gt;At that point, what looked like a straightforward Filament setup turns into more migrations, more models, more resources, more filters, and more maintenance.&lt;/p&gt;

&lt;p&gt;That gets old fast.&lt;/p&gt;

&lt;p&gt;So I built a plugin for &lt;strong&gt;Filament v5&lt;/strong&gt; that lets you create and manage data collections at runtime, define fields through the UI, manage records, build dashboards, and expose API endpoints without creating a new table and Filament resource every time the model changes.&lt;/p&gt;

&lt;p&gt;The plugin is called &lt;strong&gt;Filament Studio&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I wanted to solve
&lt;/h2&gt;

&lt;p&gt;Filament is excellent when the data model is already clear and relatively stable.&lt;/p&gt;

&lt;p&gt;But some projects are not like that at all.&lt;/p&gt;

&lt;p&gt;You might be building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a flexible internal CMS&lt;/li&gt;
&lt;li&gt;a client-specific admin panel&lt;/li&gt;
&lt;li&gt;an operations dashboard&lt;/li&gt;
&lt;li&gt;a multi-tenant back office&lt;/li&gt;
&lt;li&gt;a content platform where structure changes over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those projects, the data model is usually the part that keeps moving.&lt;/p&gt;

&lt;p&gt;And every change has a cost.&lt;/p&gt;

&lt;p&gt;A new content type is not just "add a few fields."&lt;br&gt;&lt;br&gt;
It usually means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;another migration&lt;/li&gt;
&lt;li&gt;another model&lt;/li&gt;
&lt;li&gt;another Filament resource&lt;/li&gt;
&lt;li&gt;another set of forms and table columns&lt;/li&gt;
&lt;li&gt;another round of filters and policies&lt;/li&gt;
&lt;li&gt;another layer of maintenance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted a way to stay inside Filament while making the admin side much more flexible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Filament Studio does
&lt;/h2&gt;

&lt;p&gt;Filament Studio turns Filament into a runtime-configurable data platform.&lt;/p&gt;

&lt;p&gt;With it, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create collections from the admin UI&lt;/li&gt;
&lt;li&gt;define custom fields without writing migrations for each collection&lt;/li&gt;
&lt;li&gt;manage records with generated forms, tables, and filters&lt;/li&gt;
&lt;li&gt;build dashboards with metrics and chart panels&lt;/li&gt;
&lt;li&gt;expose REST API endpoints with API key authentication&lt;/li&gt;
&lt;li&gt;enable versioning and soft deletes&lt;/li&gt;
&lt;li&gt;scope everything by tenant&lt;/li&gt;
&lt;li&gt;control access with policies and permissions&lt;/li&gt;
&lt;li&gt;extend the system with custom field types, panel types, hooks, and schema modifiers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was not trying to build a toy form builder.&lt;/p&gt;

&lt;p&gt;I wanted something that still feels useful when the project becomes real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I chose an EAV-based approach
&lt;/h2&gt;

&lt;p&gt;The core of Filament Studio is an &lt;strong&gt;EAV storage model&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of creating a new database table for every collection, the plugin stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;collection definitions&lt;/li&gt;
&lt;li&gt;field definitions&lt;/li&gt;
&lt;li&gt;records&lt;/li&gt;
&lt;li&gt;typed values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives the plugin room to support runtime schema changes without forcing a migration every time someone adds or edits a field.&lt;/p&gt;

&lt;p&gt;I know EAV has a reputation.&lt;/p&gt;

&lt;p&gt;Sometimes that reputation is deserved.&lt;/p&gt;

&lt;p&gt;But in this case, the tradeoff made sense because I was not trying to model one stable domain perfectly.&lt;br&gt;&lt;br&gt;
I was trying to give teams a flexible way to build evolving admin-managed data systems inside Filament.&lt;/p&gt;

&lt;p&gt;The important part was making it practical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;typed storage columns&lt;/li&gt;
&lt;li&gt;generated Filament components&lt;/li&gt;
&lt;li&gt;useful filtering&lt;/li&gt;
&lt;li&gt;dashboards&lt;/li&gt;
&lt;li&gt;API support&lt;/li&gt;
&lt;li&gt;extensibility hooks&lt;/li&gt;
&lt;li&gt;production-oriented features like tenancy and authorization&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's built in
&lt;/h2&gt;

&lt;p&gt;Filament Studio already includes a lot of what I usually end up rebuilding by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic collections
&lt;/h3&gt;

&lt;p&gt;You can create collections in the admin panel and define fields visually.&lt;/p&gt;

&lt;p&gt;The plugin currently includes &lt;strong&gt;33 built-in field types&lt;/strong&gt; across categories like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;text&lt;/li&gt;
&lt;li&gt;numeric&lt;/li&gt;
&lt;li&gt;boolean&lt;/li&gt;
&lt;li&gt;selection&lt;/li&gt;
&lt;li&gt;date and time&lt;/li&gt;
&lt;li&gt;file&lt;/li&gt;
&lt;li&gt;relational&lt;/li&gt;
&lt;li&gt;structured&lt;/li&gt;
&lt;li&gt;presentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes it useful for more than simple text-entry forms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dashboard builder
&lt;/h3&gt;

&lt;p&gt;There is also a dashboard builder with &lt;strong&gt;9 panel types&lt;/strong&gt;, including metrics, lists, time-series charts, bar charts, pie charts, and more.&lt;/p&gt;

&lt;p&gt;That matters because once teams have data, the next thing they ask for is visibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Advanced filtering
&lt;/h3&gt;

&lt;p&gt;I also wanted filtering to be good enough for real admin use, not just "search one text column."&lt;/p&gt;

&lt;p&gt;So the plugin includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a visual filter builder&lt;/li&gt;
&lt;li&gt;nested AND/OR logic&lt;/li&gt;
&lt;li&gt;dynamic variables&lt;/li&gt;
&lt;li&gt;saved presets&lt;/li&gt;
&lt;li&gt;type-aware operators&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  REST API
&lt;/h3&gt;

&lt;p&gt;Filament Studio can generate REST API endpoints with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API key authentication&lt;/li&gt;
&lt;li&gt;per-collection permissions&lt;/li&gt;
&lt;li&gt;rate limiting&lt;/li&gt;
&lt;li&gt;OpenAPI documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Production-oriented features
&lt;/h3&gt;

&lt;p&gt;A lot of packages stop at the demo layer.&lt;/p&gt;

&lt;p&gt;I wanted this one to go further, so it also supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multi-tenancy&lt;/li&gt;
&lt;li&gt;authorization&lt;/li&gt;
&lt;li&gt;record versioning&lt;/li&gt;
&lt;li&gt;soft deletes&lt;/li&gt;
&lt;li&gt;lifecycle hooks&lt;/li&gt;
&lt;li&gt;schema modification callbacks&lt;/li&gt;
&lt;li&gt;custom field and panel extensions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who I think this is useful for
&lt;/h2&gt;

&lt;p&gt;I think Filament Studio is most useful for three groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Laravel and Filament developers
&lt;/h3&gt;

&lt;p&gt;If you like building with Filament but do not want every new admin requirement to trigger another round of scaffolding, this gives you a more flexible starting point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agencies
&lt;/h3&gt;

&lt;p&gt;If you build custom admin panels for clients, this can reduce the amount of repetitive work involved in creating content structures, dashboards, and internal workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Startup teams
&lt;/h3&gt;

&lt;p&gt;If your internal systems and content models are still evolving, runtime collections can be easier to live with than constantly reshaping your app around new admin requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted the plugin to feel like
&lt;/h2&gt;

&lt;p&gt;I did not want this to feel like a disconnected "builder product" bolted onto Laravel.&lt;/p&gt;

&lt;p&gt;I wanted it to still feel like it belongs in a Filament application.&lt;/p&gt;

&lt;p&gt;That meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;native-looking forms and tables&lt;/li&gt;
&lt;li&gt;sensible extension points&lt;/li&gt;
&lt;li&gt;support for real authorization models&lt;/li&gt;
&lt;li&gt;room for custom field types and custom panels&lt;/li&gt;
&lt;li&gt;documentation good enough that people can actually adopt it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also spent time on screenshots and docs because I think open-source packages need to earn trust quickly.&lt;/p&gt;

&lt;p&gt;If someone lands on the repository, they should immediately understand what the plugin does, what it looks like, and whether it is serious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm sharing it now
&lt;/h2&gt;

&lt;p&gt;I am sharing Filament Studio because I think it fills a real gap in a certain kind of Laravel project.&lt;/p&gt;

&lt;p&gt;Not every app needs runtime-defined collections.&lt;/p&gt;

&lt;p&gt;But when a project does need flexibility, the usual alternatives are messy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep hardcoding every change&lt;/li&gt;
&lt;li&gt;overbuild a custom internal CMS&lt;/li&gt;
&lt;li&gt;stitch together several tools&lt;/li&gt;
&lt;li&gt;give up on having a clean workflow inside Filament&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted a better option.&lt;/p&gt;

&lt;h2&gt;
  
  
  If this sounds useful, I'd love feedback
&lt;/h2&gt;

&lt;p&gt;If you work with Laravel and Filament and this solves a problem you've run into, take a look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;code&gt;https://github.com/flexpik/filament-studio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Packagist: &lt;code&gt;https://packagist.org/packages/flexpik/filament-studio&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it looks useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;star the repo&lt;/li&gt;
&lt;li&gt;try it in a project&lt;/li&gt;
&lt;li&gt;tell me what you'd build with it&lt;/li&gt;
&lt;li&gt;tell me what feels missing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most useful feedback at this stage is not just "looks nice."&lt;/p&gt;

&lt;p&gt;It is "this would help me build X" or "I would need Y before using it."&lt;/p&gt;

&lt;p&gt;That kind of feedback is what makes open-source tools better.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>opensource</category>
    </item>
    <item>
      <title>10 Reasons Why PrestaShop Is the Best E-commerce Platform for Your Business</title>
      <dc:creator>Serhii</dc:creator>
      <pubDate>Tue, 21 Mar 2023 10:01:02 +0000</pubDate>
      <link>https://dev.to/serhii_fedorenko/10-reasons-why-prestashop-is-the-best-e-commerce-platform-for-your-business-51j5</link>
      <guid>https://dev.to/serhii_fedorenko/10-reasons-why-prestashop-is-the-best-e-commerce-platform-for-your-business-51j5</guid>
      <description>&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--QbgjcFRk--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cbyut6ptuxhxydtg9pqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--QbgjcFRk--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cbyut6ptuxhxydtg9pqa.png" alt="PrestaShop" width="880" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Are you looking to start or expand your e-commerce business? With so many platforms available, it can be challenging to determine which one is the best fit for your specific needs. In this article, we will delve into 10 reasons why PrestaShop is the best e-commerce platform for your business, and why it should be at the top of your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. User-friendly Interface
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Intuitive dashboard&lt;/strong&gt;&lt;br&gt;
PrestaShop boasts a user-friendly interface with an intuitive dashboard that even beginners can easily navigate. The admin panel is designed with simplicity in mind, making it easy to manage your online store with minimal effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easy customization&lt;/strong&gt;&lt;br&gt;
With PrestaShop, customizing your online store is a breeze. You can easily modify the look and feel of your site, add new features, and make changes to your store's layout without any coding knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Wide Range of Features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built-in SEO tools&lt;/strong&gt;&lt;br&gt;
PrestaShop comes with a set of built-in SEO tools that help you optimize your online store for search engines. These tools include customizable meta titles, descriptions, and URLs, making it easy to improve your store's search engine rankings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Powerful analytics&lt;/strong&gt;&lt;br&gt;
The platform offers robust analytics features that allow you to track your store's performance and make data-driven decisions. You can monitor sales, visitor behavior, and other key metrics with ease, helping you optimize your business strategy and grow your revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Extensive Theme Selection
&lt;/h2&gt;

&lt;p&gt;PrestaShop offers a vast selection of professionally designed themes, ensuring that your online store looks attractive and engaging. You can choose from a wide variety of free and premium themes, making it easy to find one that matches your brand identity and appeals to your target audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scalability
&lt;/h2&gt;

&lt;p&gt;PrestaShop is a highly scalable e-commerce platform, making it an ideal choice for businesses of all sizes. As your business grows, you can easily add new features, products, and categories without experiencing any significant slowdowns or performance issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Multilingual and Multi-currency Support
&lt;/h2&gt;

&lt;p&gt;Operating an international e-commerce business? PrestaShop has you covered with its multilingual and multi-currency support. The platform allows you to offer your products in multiple languages and accept payments in various currencies, making it easier for you to cater to a global audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Large Community and Support Network
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Active forums&lt;/strong&gt;&lt;br&gt;
PrestaShop has a large and active community of users, developers, and experts who are always ready to help. The platform's forums are filled with knowledgeable individuals who can offer advice, tips,and troubleshooting assistance whenever you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation and tutorials&lt;/strong&gt;&lt;br&gt;
PrestaShop also provides extensive documentation and tutorials, ensuring that you have all the resources you need to manage your online store effectively. These resources cover a wide range of topics, from basic setup to advanced customization and optimization techniques.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Cost-effective Solution
&lt;/h2&gt;

&lt;p&gt;PrestaShop is an open-source platform, which means it's free to download and use. This makes it an attractive option for small businesses and startups on a tight budget. While there are some premium features and themes available for purchase, the core functionality of the platform is available at no cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. High-level Security
&lt;/h2&gt;

&lt;p&gt;Security is a top priority for any e-commerce business. PrestaShop takes this concern seriously by providing a secure platform that is regularly updated to address potential vulnerabilities. The platform also offers features like SSL support and PCI compliance, ensuring that your customers' sensitive data is protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Seamless Third-party Integrations
&lt;/h2&gt;

&lt;p&gt;PrestaShop is highly compatible with various third-party services and applications, making it easy to integrate your online store with other tools and platforms. From payment gateways to shipping providers and marketing tools, PrestaShop's extensive ecosystem allows you to build a comprehensive e-commerce solution tailored to your specific needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Mobile-friendliness
&lt;/h2&gt;

&lt;p&gt;With the ever-increasing importance of mobile commerce, having a mobile-friendly online store is essential. PrestaShop's responsive design ensures that your website looks great and functions smoothly on all devices, providing a seamless shopping experience for your customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In conclusion, PrestaShop is an excellent e-commerce platform that offers a wide range of features, scalability, and a user-friendly interface, making it an ideal choice for businesses of all sizes. By choosing PrestaShop, you can build a powerful online store that is easy to manage, secure, and ready to scale as your business grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q1: Can I migrate my existing online store to PrestaShop?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, PrestaShop offers tools and resources to help you migrate your existing online store from another platform to PrestaShop. You can also seek assistance from the community or hire a professional to handle the migration process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q2: Does PrestaShop offer hosting services?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PrestaShop does not provide hosting services directly. However, the platform is compatible with various hosting providers, allowing you to choose the best hosting solution for your specific needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q3: How customizable is PrestaShop?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PrestaShop is highly customizable, allowing you to tailor the appearance and functionality of your online store to suit your preferences. With a wide range of themes and modules available, you can create a unique shopping experience for your customers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q4: Is PrestaShop suitable for large-scale e-commerce businesses?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, PrestaShop is designed to accommodate businesses of all sizes, including large-scale enterprises. Its scalability and extensive feature set make it an excellent choice for businesses with a large product catalog and high sales volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q5: Can I use my own domain name with PrestaShop?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, you can use your own domain name with PrestaShop. The platform allows you to connect your custom domain to your online store, ensuring that your branding remains consistent across all channels.&lt;/p&gt;

</description>
      <category>prestashop</category>
      <category>shop</category>
      <category>cms</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
