<?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>Forem: Ian Johnson</title>
    <description>The latest articles on Forem by Ian Johnson (@tacoda).</description>
    <link>https://forem.com/tacoda</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F171498%2Fb1207a6e-f740-43c4-bb64-c675e3b3ce1d.jpeg</url>
      <title>Forem: Ian Johnson</title>
      <link>https://forem.com/tacoda</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tacoda"/>
    <language>en</language>
    <item>
      <title>Last post in this series! This post talks about how to apply the lessons learned and the agent harness to any stack, with examples of different popular web technologies.</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 18:23:35 +0000</pubDate>
      <link>https://forem.com/tacoda/last-post-in-this-series-this-post-talks-about-how-to-apply-the-lessons-learned-and-the-agent-4omi</link>
      <guid>https://forem.com/tacoda/last-post-in-this-series-this-post-talks-about-how-to-apply-the-lessons-learned-and-the-agent-4omi</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf" class="crayons-story__hidden-navigation-link"&gt;Beyond Laravel: Applying the Agent Harness to Any Stack&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/tacoda" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F171498%2Fb1207a6e-f740-43c4-bb64-c675e3b3ce1d.jpeg" alt="tacoda profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/tacoda" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ian Johnson
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ian Johnson
                
              
              &lt;div id="story-author-preview-content-3467649" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/tacoda" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F171498%2Fb1207a6e-f740-43c4-bb64-c675e3b3ce1d.jpeg" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ian Johnson&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 8&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf" id="article-link-3467649"&gt;
          Beyond Laravel: Applying the Agent Harness to Any Stack
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tdd"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tdd&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt; reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            9 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Beyond Laravel: Applying the Agent Harness to Any Stack</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 17:31:33 +0000</pubDate>
      <link>https://forem.com/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf</link>
      <guid>https://forem.com/tacoda/beyond-laravel-applying-the-agent-harness-to-any-stack-2gaf</guid>
      <description>&lt;h2&gt;
  
  
  The Strategy Is the Point
&lt;/h2&gt;

&lt;p&gt;This series followed a Laravel + React codebase. But if you've been reading for the strategy and not the syntax, you already know: &lt;em&gt;none of this is Laravel-specific&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Tests before agents. Linting as machine-checkable standards. Clean architecture so the agent follows patterns instead of inventing them. Trunk-based development for fast feedback. Harness files that scope guidance to where the agent is working. Custom skills that turn your workflow into structure.&lt;/p&gt;

&lt;p&gt;Every step has an equivalent in whatever stack you're using. The tools change. The progression doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Seven Steps
&lt;/h2&gt;

&lt;p&gt;Here's the agent harness approach distilled to its language-agnostic core. Each step builds on the ones before it. You cannot skip ahead: the entire system is load-bearing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Test Infrastructure
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Wrapping the existing codebase in tests that run against real dependencies (the same database engine, the same cache, the same queue) so you have a machine-checkable safety net before the agent touches anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Characterization tests first.&lt;/strong&gt; Lock in what the code &lt;em&gt;does&lt;/em&gt; before you change what it &lt;em&gt;should&lt;/em&gt; do. These aren't aspirational tests. They're documentation of current behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real dependencies, not fakes.&lt;/strong&gt; If production runs Postgres, your tests run Postgres. SQLite-in-memory is a lie that will catch up with you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One command to run everything.&lt;/strong&gt; &lt;code&gt;make test&lt;/code&gt;, &lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;./gradlew test&lt;/code&gt; — the agent needs a single entry point. If running tests requires tribal knowledge, the agent will get it wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test factories that are hard to misuse.&lt;/strong&gt; Give the agent a discoverable API for creating test data. Fluent builders, factory patterns, fixtures with clear names. Design for the dumbest correct user, because that's how the agent will use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Linting and Static Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Adding machine-checkable standards for code style, type safety, and structural quality. Each tool eliminates an entire category of wrong output from the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format, lint, and type-check — all three.&lt;/strong&gt; Formatting removes style arguments. Linting catches structural problems. Type checking catches logic errors. Together they narrow the space the agent can operate in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One command to check everything.&lt;/strong&gt; &lt;code&gt;make lint&lt;/code&gt;, &lt;code&gt;npm run lint&lt;/code&gt;, a Makefile target that runs the full stack. The agent runs this before every commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-commit hooks that block and explain.&lt;/strong&gt; The hook should fail with a message the agent can read and act on. "Run &lt;code&gt;npx prettier --write .&lt;/code&gt; to fix" is better than "formatting error on line 47."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI as the gate that cannot be skipped.&lt;/strong&gt; Pre-commit hooks are the first check. CI is the final one. The agent cannot merge without green CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Architecture and Boundaries
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Refactoring toward clean boundaries (interfaces, services, clear separation of concerns) so the agent can work within a bounded area without needing to understand the whole system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contracts before implementations.&lt;/strong&gt; Define interfaces first. The agent can implement an interface without understanding the rest of the system. It cannot safely modify a God class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One responsibility per unit.&lt;/strong&gt; Whether it's a service class, a module, a use case — the agent works best when each unit does one thing and the boundaries are obvious.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture as documentation.&lt;/strong&gt; If the codebase has a clear pattern (actions, services, repositories, commands), the agent follows it. If every file is a snowflake, the agent improvises. You don't want improvisation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small, safe steps.&lt;/strong&gt; One extraction per PR. Keep the app running in production throughout. Never refactor and change behavior in the same commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Explicit Patterns for Business Logic
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Establishing the patterns the agent should follow for new work: how business logic is structured, how authorization works, and how data flows through the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single pattern for business logic.&lt;/strong&gt; Actions, use cases, commands, interactors — the name doesn't matter. What matters is that there's &lt;em&gt;one&lt;/em&gt; pattern, it's consistent, and the agent can see ten examples in the codebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized authorization.&lt;/strong&gt; Scattered permission checks are a security risk with human developers. With an agent, they're a guarantee of inconsistency. Use your framework's policy/guard/permission system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed inputs and outputs.&lt;/strong&gt; Form objects, request validators, result types, DTOs — whatever your stack calls them. The agent needs to know what goes in and what comes out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 5: Migration Strategy (If Applicable)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; If you're migrating frontends, databases, or major subsystems: running old and new in parallel, migrating incrementally, never doing a big-bang rewrite, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both systems run simultaneously.&lt;/strong&gt; The old system serves production. The new system is gated behind environment flags or feature toggles until proven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page by page, feature by feature.&lt;/strong&gt; Each migration is a small PR. Each small PR goes through the full test/lint/CI pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear scoping rules.&lt;/strong&gt; The agent needs to know: does this work go in the old system, the new system, or both? Make the rules explicit in the harness.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 6: Trunk-Based Development and CI/CD
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Establishing the delivery cadence that makes AI-assisted development practical: short-lived branches, small PRs, fast CI, and automated deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Branches live for hours, not days.&lt;/strong&gt; The longer a branch lives, the more the agent's assumptions go stale. Small batches, fast merges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI runs the full pipeline.&lt;/strong&gt; Build, lint, type-check, test, deploy. If any step fails, the PR doesn't merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventional commits.&lt;/strong&gt; A machine-readable commit history helps the agent understand what changed and why. It also helps you when you're reviewing 145 PRs in three months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated deployment.&lt;/strong&gt; Push to main, deploy to staging. The feedback loop from code change to running software should be minutes, not hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 7: The Harness and Skills
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What you're doing:&lt;/strong&gt; Writing scoped guidance files that tell the agent how to work in each area of the codebase, then codifying your workflow into repeatable skills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scoped guidance, not one big file.&lt;/strong&gt; One harness file per major area. The agent loads what's relevant to where it's working. Keep the signal-to-noise ratio high.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patterns with examples, not just rules.&lt;/strong&gt; Show the agent a code example of the pattern you want. "Do it like this" beats "follow these principles" every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-patterns are explicit fences.&lt;/strong&gt; Tell the agent what &lt;em&gt;not&lt;/em&gt; to do. "Never put HTTP concerns in an Action" is more useful than "keep Actions pure."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The feedback protocol.&lt;/strong&gt; When the agent drifts, ask: is this a harness gap? If yes, update the harness first, then re-apply. Corrections become permanent rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills codify the sequence.&lt;/strong&gt; Automate the ceremony (read ticket, write tests, implement, lint, commit, push, PR). Keep the judgment calls at checkpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Stack Table
&lt;/h2&gt;

&lt;p&gt;Here's how each step maps to tools across popular web framework stacks. The rows are the steps. The columns are the stacks. Every cell answers: "What would I use here?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Infrastructure
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;th&gt;DB Strategy&lt;/th&gt;
&lt;th&gt;Factories / Fixtures&lt;/th&gt;
&lt;th&gt;One Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Laravel (PHP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PHPUnit / Pest&lt;/td&gt;
&lt;td&gt;MySQL in Docker (tmpfs)&lt;/td&gt;
&lt;td&gt;Model Factories&lt;/td&gt;
&lt;td&gt;&lt;code&gt;php artisan test&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rails (Ruby)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSpec / Minitest&lt;/td&gt;
&lt;td&gt;Postgres in Docker&lt;/td&gt;
&lt;td&gt;FactoryBot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bundle exec rspec&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Django (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pytest-django&lt;/td&gt;
&lt;td&gt;Postgres in Docker&lt;/td&gt;
&lt;td&gt;factory_boy / Model Bakery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python -m pytest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js (TypeScript)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vitest / Jest&lt;/td&gt;
&lt;td&gt;Postgres via Testcontainers&lt;/td&gt;
&lt;td&gt;Prisma seed scripts / custom builders&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm test&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spring Boot (Java)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JUnit 5&lt;/td&gt;
&lt;td&gt;Testcontainers (Postgres/MySQL)&lt;/td&gt;
&lt;td&gt;TestEntityManager / custom builders&lt;/td&gt;
&lt;td&gt;&lt;code&gt;./gradlew test&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET (C#)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;xUnit / NUnit&lt;/td&gt;
&lt;td&gt;Testcontainers or LocalDB&lt;/td&gt;
&lt;td&gt;Bogus + custom builders&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dotnet test&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;testing&lt;/code&gt; + testify&lt;/td&gt;
&lt;td&gt;Testcontainers or dockertest&lt;/td&gt;
&lt;td&gt;Custom factory functions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;go test ./...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phoenix (Elixir)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ExUnit&lt;/td&gt;
&lt;td&gt;Postgres sandbox&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ex_machina&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mix test&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recommendation:&lt;/strong&gt; Wrap your test command in a &lt;code&gt;make test&lt;/code&gt; target. It gives the agent (and your team) a single, stack-agnostic entry point that hides flags, environment setup, and Docker orchestration behind one command. When every project starts with &lt;code&gt;make test&lt;/code&gt;, nobody needs to remember whether it's &lt;code&gt;php artisan test&lt;/code&gt;, &lt;code&gt;go test ./...&lt;/code&gt;, or &lt;code&gt;bundle exec rspec&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Linting and Static Analysis
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Formatter&lt;/th&gt;
&lt;th&gt;Linter&lt;/th&gt;
&lt;th&gt;Type Checker&lt;/th&gt;
&lt;th&gt;One Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Laravel (PHP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pint&lt;/td&gt;
&lt;td&gt;Psalm / PHPStan&lt;/td&gt;
&lt;td&gt;Psalm (level)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;./vendor/bin/pint --test &amp;amp;&amp;amp; ./vendor/bin/phpstan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rails (Ruby)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RuboCop (formatting)&lt;/td&gt;
&lt;td&gt;RuboCop (style/lint)&lt;/td&gt;
&lt;td&gt;Sorbet / Steep&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bundle exec rubocop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Django (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Black / Ruff format&lt;/td&gt;
&lt;td&gt;Ruff / Flake8&lt;/td&gt;
&lt;td&gt;mypy / pyright&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ruff check . &amp;amp;&amp;amp; mypy .&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js (TypeScript)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prettier&lt;/td&gt;
&lt;td&gt;ESLint&lt;/td&gt;
&lt;td&gt;TypeScript (&lt;code&gt;tsc --noEmit&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm run lint &amp;amp;&amp;amp; npx tsc --noEmit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spring Boot (Java)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;google-java-format / Spotless&lt;/td&gt;
&lt;td&gt;Checkstyle / SpotBugs&lt;/td&gt;
&lt;td&gt;javac (compile-time)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;./gradlew check&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET (C#)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;dotnet format&lt;/td&gt;
&lt;td&gt;Roslyn analyzers / StyleCop&lt;/td&gt;
&lt;td&gt;C# compiler + nullable refs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dotnet format --verify-no-changes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gofmt&lt;/code&gt; / &lt;code&gt;goimports&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;golangci-lint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go compiler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;golangci-lint run&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phoenix (Elixir)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mix format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credo&lt;/td&gt;
&lt;td&gt;Dialyxir&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mix format --check-formatted &amp;amp;&amp;amp; mix credo &amp;amp;&amp;amp; mix dialyzer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recommendation:&lt;/strong&gt; Wrap your lint pipeline in a &lt;code&gt;make lint&lt;/code&gt; target. Most stacks need multiple tools chained together — formatter, linter, type checker — and the exact flags change over time. A &lt;code&gt;make lint&lt;/code&gt; target keeps the agent from needing to know whether your project runs &lt;code&gt;ruff check . &amp;amp;&amp;amp; mypy .&lt;/code&gt; or &lt;code&gt;golangci-lint run&lt;/code&gt;. One target, full coverage, zero tribal knowledge.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Architecture Patterns
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Service Layer&lt;/th&gt;
&lt;th&gt;Business Logic Unit&lt;/th&gt;
&lt;th&gt;Authorization&lt;/th&gt;
&lt;th&gt;Request Validation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Laravel (PHP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service classes + contracts&lt;/td&gt;
&lt;td&gt;Action classes&lt;/td&gt;
&lt;td&gt;Policies&lt;/td&gt;
&lt;td&gt;Form Requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rails (Ruby)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service objects / POROs&lt;/td&gt;
&lt;td&gt;Command / Interactor&lt;/td&gt;
&lt;td&gt;Pundit / Action Policy&lt;/td&gt;
&lt;td&gt;Strong Parameters + dry-validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Django (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service layer (manual)&lt;/td&gt;
&lt;td&gt;Service functions / Command pattern&lt;/td&gt;
&lt;td&gt;django-rules / permissions&lt;/td&gt;
&lt;td&gt;Serializers / Pydantic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js (TypeScript)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server actions / service modules&lt;/td&gt;
&lt;td&gt;Use case functions&lt;/td&gt;
&lt;td&gt;Middleware + CASL / next-auth&lt;/td&gt;
&lt;td&gt;Zod schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spring Boot (Java)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Service&lt;/code&gt; classes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Service&lt;/code&gt; or Command pattern&lt;/td&gt;
&lt;td&gt;Spring Security + &lt;code&gt;@PreAuthorize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Valid&lt;/code&gt; + Bean Validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET (C#)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service classes via DI&lt;/td&gt;
&lt;td&gt;MediatR handlers / Command pattern&lt;/td&gt;
&lt;td&gt;Authorization policies + &lt;code&gt;[Authorize]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;FluentValidation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Package-level service structs&lt;/td&gt;
&lt;td&gt;Handler / Use case functions&lt;/td&gt;
&lt;td&gt;Middleware + Casbin&lt;/td&gt;
&lt;td&gt;Struct validation (go-playground)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phoenix (Elixir)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Context modules&lt;/td&gt;
&lt;td&gt;Context functions / Command pattern&lt;/td&gt;
&lt;td&gt;Bodyguard&lt;/td&gt;
&lt;td&gt;Ecto changesets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  CI/CD and Delivery
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;CI Platform&lt;/th&gt;
&lt;th&gt;Deploy Tool&lt;/th&gt;
&lt;th&gt;Branch Strategy&lt;/th&gt;
&lt;th&gt;Commit Convention&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Laravel (PHP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;Forge / Envoyer&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rails (Ruby)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions / CircleCI&lt;/td&gt;
&lt;td&gt;Kamal / Capistrano / Heroku&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Django (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions / GitLab CI&lt;/td&gt;
&lt;td&gt;Gunicorn + systemd / Docker + ECS&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js (TypeScript)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions / Vercel CI&lt;/td&gt;
&lt;td&gt;Vercel / Docker + ECS&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spring Boot (Java)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions / Jenkins&lt;/td&gt;
&lt;td&gt;Docker + Kubernetes / AWS ECS&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET (C#)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions / Azure DevOps&lt;/td&gt;
&lt;td&gt;Azure App Service / Docker + ECS&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;Docker + Kubernetes / systemd&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phoenix (Elixir)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;Fly.io / Docker + release&lt;/td&gt;
&lt;td&gt;Trunk-based, short-lived branches&lt;/td&gt;
&lt;td&gt;Conventional Commits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Harness and Skills
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Harness Format&lt;/th&gt;
&lt;th&gt;Scoped Files&lt;/th&gt;
&lt;th&gt;Skill Definition&lt;/th&gt;
&lt;th&gt;Agent Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All Stacks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Markdown (CLAUDE.md)&lt;/td&gt;
&lt;td&gt;One per major directory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/skills/SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The harness and skills layer is entirely stack-agnostic. It's Markdown files in your repo. The content changes (your patterns, your anti-patterns, your architectural rules) but the mechanism is the same regardless of language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start
&lt;/h2&gt;

&lt;p&gt;If you're looking at this table and wondering where to begin, here's the priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tests.&lt;/strong&gt; If you have nothing else, start here. Get a test runner working against real dependencies with a single command. Write characterization tests for the most critical paths. This alone makes the agent dramatically safer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Linting.&lt;/strong&gt; Add a formatter and a linter. Wire them into a pre-commit hook. This takes an afternoon and eliminates an entire category of bad output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. CI.&lt;/strong&gt; Connect your test and lint commands to your CI platform. Make it block merges. Now the agent cannot ship broken code even if it tries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Architecture.&lt;/strong&gt; This is the long game. You don't need perfect architecture to start using an agent. But every boundary you create, every interface you extract, every consistent pattern you establish makes the agent more reliable in that area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Harness files.&lt;/strong&gt; Start with a root CLAUDE.md that describes the project, the tech stack, and the top-level patterns. Add subdirectory files as you notice the agent drifting in specific areas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Skills.&lt;/strong&gt; Only after everything else is working. Skills automate a workflow that already works manually. If the underlying steps aren't solid, automating them just produces bad output faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern Behind the Pattern
&lt;/h2&gt;

&lt;p&gt;Every step in this series followed the same logic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify a category of error&lt;/strong&gt; the agent can make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a machine-checkable constraint&lt;/strong&gt; that eliminates it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the agent a single command&lt;/strong&gt; to verify compliance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the harness&lt;/strong&gt; when a new failure mode appears.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tests eliminate behavioral errors. Linting eliminates structural errors. Architecture eliminates design errors. CI eliminates delivery errors. The harness eliminates context errors. Skills eliminate process errors.&lt;/p&gt;

&lt;p&gt;The tools in the table will change. New frameworks will appear. New linters will ship. New CI platforms will launch. But this progression &lt;strong&gt;constrain, verify, scope, automate&lt;/strong&gt; will remain the &lt;em&gt;same&lt;/em&gt;, because it's not about the tools. It's about narrowing the space where the agent can be wrong until the only thing left is the judgment calls that require a human.&lt;/p&gt;

&lt;p&gt;That's the harness. Build it in whatever language you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The strategy is language-agnostic.&lt;/strong&gt; Tests, linting, architecture, CI, harness, skills — every stack has equivalents. The progression is what matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with tests and linting.&lt;/strong&gt; These two steps alone eliminate more bad agent output than any amount of prompt engineering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture is a force multiplier.&lt;/strong&gt; Clear patterns make the agent predictable. Unclear patterns make it creative. You don't want creative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The harness content is yours.&lt;/strong&gt; The table gives you the tools, but the rules inside your harness files come from &lt;em&gt;your&lt;/em&gt; engineering judgment about &lt;em&gt;your&lt;/em&gt; codebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain, verify, scope, automate.&lt;/strong&gt; That's the whole series in four words.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent didn't get smarter across the previous ten posts. The environment got smarter. That's the insight that generalizes to every stack, every language, and every team. Build the environment, and the agent follows.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tdd</category>
      <category>programming</category>
    </item>
    <item>
      <title>Custom Skills: The End-to-End Workflow Made Executable</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 16:53:17 +0000</pubDate>
      <link>https://forem.com/tacoda/custom-skills-the-end-to-end-workflow-made-executable-i8i</link>
      <guid>https://forem.com/tacoda/custom-skills-the-end-to-end-workflow-made-executable-i8i</guid>
      <description>&lt;h2&gt;
  
  
  I Was Repeating Myself
&lt;/h2&gt;

&lt;p&gt;By the time the harness was working and I'd moved to on-the-loop development, my sessions with Claude had a rhythm. Pick up a Jira ticket. Read the requirements. Decide which part of the codebase it touches. Write failing tests. Get them approved. Implement. Run lint and tests. Commit. Open a PR. Watch CI. Review the diff. Maybe refactor.&lt;/p&gt;

&lt;p&gt;Every time, I typed the same instructions. "Here's a Jira ticket. Pull the requirements with &lt;code&gt;jira issue view&lt;/code&gt;. Write tests first. Follow the Action pattern. Run &lt;code&gt;make lint&lt;/code&gt; and &lt;code&gt;make test&lt;/code&gt; before committing."&lt;/p&gt;

&lt;p&gt;It worked. But I was the ceremony. I was the one remembering the steps, enforcing the order, making sure the harness feedback loop happened. If I forgot to say "write tests first," Claude might skip straight to implementation. If I forgot to say "run lint," it might commit without checking.&lt;/p&gt;

&lt;p&gt;The workflow was good. But it lived in my head, not in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills: Slash Commands for Claude Code
&lt;/h2&gt;

&lt;p&gt;Claude Code supports custom skills: reusable prompts you invoke with a slash command. They live in &lt;code&gt;.claude/skills/&lt;/code&gt; as Markdown files with a bit of frontmatter. When you type &lt;code&gt;/implement-jira-card PROJ-123&lt;/code&gt;, Claude reads the skill definition and executes the workflow described in it.&lt;/p&gt;

&lt;p&gt;The skill file is just a &lt;code&gt;SKILL.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude/skills/
  implement-jira-card/
    SKILL.md
  implement-change/
    SKILL.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each skill defines the argument it expects, describes the workflow in phases, and lists the rules the agent must follow. It's the same kind of guidance as a &lt;code&gt;CLAUDE.md&lt;/code&gt; harness file (plain Markdown, checked into the repo, version-controlled) except instead of scoping guidance to a directory, it scopes guidance to a &lt;em&gt;workflow&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Skills
&lt;/h2&gt;

&lt;p&gt;I built two skills that cover the two ways work enters the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/implement-jira-card [PROJ-123]&lt;/code&gt;&lt;/strong&gt; — for work that starts with a Jira ticket. The skill pulls the issue details from Jira, walks through requirements, planning, TDD, and delivery. It knows how to use &lt;code&gt;jira&lt;/code&gt; for issue details and &lt;code&gt;gh&lt;/code&gt; for GitHub operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/implement-change [description]&lt;/code&gt;&lt;/strong&gt; — for everything else. Bug fixes that don't have a ticket. Follow-up tasks from code review. Small improvements. Ad-hoc work. Same workflow, but the requirements come from the user's description instead of Jira.&lt;/p&gt;

&lt;p&gt;Both skills follow the same eight-phase workflow. The difference is where the requirements come from — Jira or the user's own words.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Skill Needs from Jira
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/implement-jira-card&lt;/code&gt; skill pulls issue details with &lt;code&gt;jira issue view&lt;/code&gt;, but not every field matters equally. The agent needs specific information to draft a requirements plan, and our Jira structure is set up to provide it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Epics are projects.&lt;/strong&gt; An epic groups all the tasks for a single initiative. When the agent reads a task, the parent epic gives it the broader goal: why this work exists, what it's part of, and what other tasks sit alongside it. Without that context, the agent treats every task as isolated. With it, the agent understands where the task fits and can make better scoping decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tasks are implementation units.&lt;/strong&gt; Each task maps to one piece of work and typically produces one PR. We don't use stories. A task is specific enough that the agent can read it and know what to build, what to test, and when it's done.&lt;/p&gt;

&lt;p&gt;Three fields on each task do the heavy lifting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Description&lt;/strong&gt; — the problem statement and context. This tells the agent &lt;em&gt;what&lt;/em&gt; needs to change and &lt;em&gt;why&lt;/em&gt;. A good description includes enough domain context that the agent doesn't invent assumptions. "Users should not be able to approve their own orders" is better than "fix approval logic." The description feeds directly into Phase 1's requirements document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acceptance criteria&lt;/strong&gt; — the conditions that define done. These translate almost directly into test cases during Phase 4. "Given a user who created an order, when they attempt to approve it, then the request should be rejected with a 403" becomes a failing test before any implementation exists. The more precise the acceptance criteria, the better the test coverage. Vague criteria produce vague tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots and attachments&lt;/strong&gt; — the visual reference. For UI work, screenshots show what the result should look like. The agent uses these during implementation to match layout, placement, and content. During Phase 5, if the skill runs visual verification through Puppeteer, the screenshots serve as the expected state.&lt;/p&gt;

&lt;p&gt;The skill pulls all three, drafts a requirements plan from them, and asks for feedback before moving on. If the card is thin (missing acceptance criteria, vague description) that becomes obvious at the first checkpoint. I either flesh out the card or fill in the gaps in conversation. Either way, the agent doesn't start writing code until the requirements are clear.&lt;/p&gt;

&lt;p&gt;This is why card quality matters more with an agent than without one. A developer can fill in gaps from tribal knowledge and hallway conversations. An agent works with what the card gives it. Good cards produce good requirements plans. Bad cards produce a longer Phase 1 conversation, or worse, confident code that solves the wrong problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eight Phases
&lt;/h2&gt;

&lt;p&gt;Here's the full workflow, phase by phase, as I actually experience it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 0: Scope the Target
&lt;/h3&gt;

&lt;p&gt;The first thing the skill does is ask a question: does this change apply to the &lt;strong&gt;legacy application&lt;/strong&gt; (Blade CRUD), the &lt;strong&gt;React SPA&lt;/strong&gt;, or &lt;strong&gt;both&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;This matters because the project is in a transitional state. The legacy app is typical CRUD with Blade views. The SPA is event-driven and personalized. They have different controllers, different test patterns, different harness files. Getting this wrong means the agent writes code in the wrong layer.&lt;/p&gt;

&lt;p&gt;I answer "SPA" or "legacy" or "both," and the skill knows which harness files to consult, which layers to touch, and how to test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Requirements
&lt;/h3&gt;

&lt;p&gt;For &lt;code&gt;/implement-jira-card&lt;/code&gt;, the skill runs &lt;code&gt;jira issue view PROJ-123&lt;/code&gt; to pull the issue details. For &lt;code&gt;/implement-change&lt;/code&gt;, it asks me to describe the problem if the argument wasn't clear enough.&lt;/p&gt;

&lt;p&gt;Then it creates a requirements document: the problem, acceptance criteria, and scope. And it asks for feedback.&lt;/p&gt;

&lt;p&gt;This is the first checkpoint. I read the requirements. If something's off, I correct it. If my feedback is about code quality or patterns, such as "we don't do it that way, use the notification service instead of calling the webhook directly", then the skill does something specific: it updates the relevant &lt;code&gt;CLAUDE.md&lt;/code&gt; harness file &lt;em&gt;first&lt;/em&gt;, reloads it, and &lt;em&gt;then&lt;/em&gt; revises the requirements.&lt;/p&gt;

&lt;p&gt;That's the harness feedback loop, baked into the workflow. My correction doesn't just fix this instance. It fixes all future instances.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Implementation Plan
&lt;/h3&gt;

&lt;p&gt;The skill creates an implementation plan: files to change, new files to create, and the testing strategy.&lt;/p&gt;

&lt;p&gt;Another feedback checkpoint. Same rules: if my feedback is about patterns, the harness gets updated before the plan gets revised.&lt;/p&gt;

&lt;p&gt;This phase catches architectural missteps early. If Claude plans to put business logic in a controller instead of an Action, I catch it here, not after 200 lines of implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Branch Setup
&lt;/h3&gt;

&lt;p&gt;For Jira cards, the branch is prefixed with the issue key: &lt;code&gt;PROJ-123-short-description&lt;/code&gt;. For ad-hoc changes, it's descriptive: &lt;code&gt;fix-order-approval&lt;/code&gt; or &lt;code&gt;followup-ticket-validation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The branch is always created from the latest &lt;code&gt;origin/main&lt;/code&gt;. No stale branches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 4: TDD Implementation
&lt;/h3&gt;

&lt;p&gt;This is the core of the workflow, and it's where TDD stops being a philosophy and becomes a protocol.&lt;/p&gt;

&lt;p&gt;The skill writes failing tests first. PHP tests, JavaScript tests, or both — whatever the change requires. Then it presents me with a list of test descriptions and asks for feedback.&lt;/p&gt;

&lt;p&gt;This is the second critical checkpoint. I'm reviewing &lt;em&gt;what the code should do&lt;/em&gt; before any implementation exists. The test descriptions are the spec. If they're wrong, the implementation will be wrong no matter how clean it is.&lt;/p&gt;

&lt;p&gt;Once I approve the tests, the skill implements the smallest changes to make them pass. It presents a description of what changed. Another checkpoint.&lt;/p&gt;

&lt;p&gt;If at any point my feedback references patterns or code quality, the harness gets updated first, then the tests or implementation get regenerated. The ratchet turns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 5: Change Approval and Commit
&lt;/h3&gt;

&lt;p&gt;The skill shows a diff of all changes and asks for approval. If I'm happy, it runs the full pre-commit checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make lint      &lt;span class="c"&gt;# Pint, Psalm, Prettier, ESLint, TypeScript&lt;/span&gt;
make &lt;span class="nb"&gt;test&lt;/span&gt;      &lt;span class="c"&gt;# 2,700+ PHP tests&lt;/span&gt;
make test-js   &lt;span class="c"&gt;# Vitest suite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the change has UI, it runs &lt;code&gt;make migrate-e2e&lt;/code&gt; and &lt;code&gt;make seed-e2e&lt;/code&gt;, then uses the Puppeteer MCP server to visually verify the page. It asks whether to run the full E2E suite.&lt;/p&gt;

&lt;p&gt;Everything passes? Commit, push, and create a PR (or update the existing one).&lt;/p&gt;

&lt;p&gt;Everything fails? Fix it first. No exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 6: CI and Code Review
&lt;/h3&gt;

&lt;p&gt;The skill watches the GitHub Actions workflow in the background and fixes any CI failures. Then it runs a code review of its own changes looking for logic errors, security issues, and pattern violations.&lt;/p&gt;

&lt;p&gt;It presents all findings at once and asks which ones to address. For each approved finding, same rules: if it's a pattern issue, update the harness first. For denied findings, skip them.&lt;/p&gt;

&lt;p&gt;This is the agent reviewing its own work. It catches things the automated checks miss: subtle logic issues, naming inconsistencies, missing edge cases. I decide which findings are worth addressing and which are noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 7: Refactoring
&lt;/h3&gt;

&lt;p&gt;After the feature works and the code review is addressed, the skill identifies refactoring opportunities and security concerns. Same presentation format: all suggestions at once, I pick which to address.&lt;/p&gt;

&lt;p&gt;This is where the rule of three applies. If the skill identifies duplicated code that's only duplicated twice, I skip it. If it's the third time, we refactor.&lt;/p&gt;

&lt;p&gt;Refactoring commits are separate from feature commits. The skill follows conventional commits: &lt;code&gt;feat:&lt;/code&gt; for the feature, &lt;code&gt;refactor:&lt;/code&gt; for the cleanup. Separate concerns, separate commits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 8: Done
&lt;/h3&gt;

&lt;p&gt;CI is green. PR is up. Code review is addressed. Refactoring is committed.&lt;/p&gt;

&lt;p&gt;The skill confirms everything passed and asks if there's follow-up work. If there is, we start the next skill invocation. If not, we're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;A real session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;me:     /implement-jira-card PROJ-456
claude: [reads Jira issue — "Add order approval workflow to admin dashboard"]
claude: Does this apply to the legacy app, the SPA, or both?
me:     SPA
claude: [creates requirements document]
claude: Here are the requirements. Feedback?
me:     Looks good.
claude: [creates implementation plan — new API endpoint, React component, tests]
claude: Here's the plan. Feedback?
me:     Use the existing DashboardDataService instead of a new service.
claude: [updates app/Services/CLAUDE.md to note DashboardDataService as the
         preferred service for dashboard data, revises plan]
claude: Updated plan. Feedback?
me:     Good.
claude: [creates branch PROJ-456-order-approval-dashboard]
claude: [writes failing tests]
claude: Here are the test descriptions:
        - admin can view pending orders on dashboard
        - order approval updates status and records approver
        - unauthenticated user gets 401
        - user without dashboard permission gets 403
        - user cannot approve their own order
        Feedback?
me:     Add a test for orders that have already been approved.
claude: [adds test, presents updated list]
me:     Good.
claude: [implements — API endpoint, React component, service method]
claude: Here's what changed. Feedback?
me:     Looks right.
claude: [shows diff]
me:     Ship it.
claude: [runs make lint, make test, make test-js — all pass]
claude: [commits, pushes, creates PR]
claude: [watches CI — green]
claude: [runs code review — 2 findings]
claude: Finding 1: The order type enum could use a TypeScript const assertion.
        Finding 2: Missing null check on allocation end date.
        Which to address?
me:     Both.
claude: [fixes both, commits, pushes]
claude: All green. Anything else?
me:     Nope.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. From Jira ticket to merged PR. I typed maybe 30 words of direction. The skill handled the ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Consistency
&lt;/h3&gt;

&lt;p&gt;Without the skill, the workflow depends on me remembering the steps. Some sessions I'd forget to ask for E2E verification. Some sessions I'd skip the code review phase because I was in a hurry. The skill doesn't forget. It runs the same eight phases every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Harness Feedback Is Built In
&lt;/h3&gt;

&lt;p&gt;The most important line in both skill files:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;If feedback references code quality or patterns, update the relevant CLAUDE.md harness file FIRST, reload it, then apply.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This rule appears at every feedback checkpoint. It means the harness feedback loop isn't something I have to remember to invoke; it's structural. Every piece of feedback I give either confirms the harness is working or improves it. The skill enforces this.&lt;/p&gt;

&lt;h3&gt;
  
  
  TDD Is Non-Negotiable
&lt;/h3&gt;

&lt;p&gt;The skill writes tests before implementation. Not as a suggestion. As a phase that happens before the implementation phase exists. There's no way to skip it without aborting the skill entirely.&lt;/p&gt;

&lt;p&gt;This is important because TDD is easy to skip when you're in a hurry. "I'll write the tests after" is the most common lie in software engineering. The skill makes the lie structurally impossible.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Agent Reviews Its Own Work
&lt;/h3&gt;

&lt;p&gt;Phases 6 and 7, code review and refactoring, are the agent auditing itself. It's not perfect. It misses things. But it catches enough that my review time drops significantly. And I get to choose which findings to act on, so it never runs away with unnecessary changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separation of Concerns in Commits
&lt;/h3&gt;

&lt;p&gt;The skill produces separate commits for features, fixes, and refactoring. This isn't cosmetic. When something breaks in production and you're scanning &lt;code&gt;git log&lt;/code&gt;, the difference between &lt;code&gt;feat: add order approval to dashboard&lt;/code&gt; and &lt;code&gt;refactor: extract order calculation helper&lt;/code&gt; tells you instantly which commit to investigate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Skill File Anatomy
&lt;/h2&gt;

&lt;p&gt;Both skills are plain Markdown with YAML frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;implement-jira-card&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Analyze and implement a Jira issue using TDD — from requirements through to a PR&lt;/span&gt;
&lt;span class="na"&gt;argument-hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[Jira&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;issue&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;key,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;e.g.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PROJ-123]"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

Implement the Jira issue: $ARGUMENTS

&lt;span class="gu"&gt;## Workflow&lt;/span&gt;

&lt;span class="gu"&gt;### Phase 0: Scope the Target&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$ARGUMENTS&lt;/code&gt; placeholder gets replaced with whatever you pass after the slash command. The description shows up in Claude Code's skill list. The argument hint tells you what to pass.&lt;/p&gt;

&lt;p&gt;The workflow section is the actual prompt the agent follows. It's specific, phased, and full of checkpoints. The key rules section at the bottom handles edge cases and priorities.&lt;/p&gt;

&lt;p&gt;That's it. No plugin system. No SDK. No custom tooling. A Markdown file with a workflow written in plain English.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;/implement-jira-card&lt;/code&gt; vs &lt;code&gt;/implement-change&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The two skills are nearly identical. The differences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/implement-jira-card&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/implement-change&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Jira issue key&lt;/td&gt;
&lt;td&gt;Text description&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Requirements source&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jira issue view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User conversation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Branch naming&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PROJ-123-description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fix-description&lt;/code&gt; or &lt;code&gt;followup-description&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jira&lt;/code&gt; + &lt;code&gt;gh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gh&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything else — the phases, the checkpoints, the harness feedback loop, the TDD workflow, the CI watching, the code review — is the same.&lt;/p&gt;

&lt;p&gt;I considered making one skill with a flag, but two separate skills is clearer. When I type &lt;code&gt;/implement-jira-card&lt;/code&gt;, I know I'm starting from a ticket. When I type &lt;code&gt;/implement-change&lt;/code&gt;, I know I'm describing the work myself. The intent is obvious from the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Feedback Checkpoints
&lt;/h2&gt;

&lt;p&gt;Count the checkpoints in a single skill run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scope the target (legacy, SPA, or both)&lt;/li&gt;
&lt;li&gt;Requirements review&lt;/li&gt;
&lt;li&gt;Implementation plan review&lt;/li&gt;
&lt;li&gt;Test descriptions review&lt;/li&gt;
&lt;li&gt;Implementation review&lt;/li&gt;
&lt;li&gt;Diff review&lt;/li&gt;
&lt;li&gt;Code review findings — which to address&lt;/li&gt;
&lt;li&gt;Refactoring suggestions — which to address&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Eight checkpoints. Eight moments where I'm on-the-loop: reviewing output, giving direction, and making judgment calls. Between those checkpoints, the agent operates autonomously. It writes code, runs tests, fixes failures, manages git, and creates PRs without asking.&lt;/p&gt;

&lt;p&gt;This is the on-the-loop workflow from post 7, made concrete and repeatable. I'm not directing input. I'm reviewing output at predetermined checkpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Skills Don't Do
&lt;/h2&gt;

&lt;p&gt;The skills don't replace judgment. They automate ceremony.&lt;/p&gt;

&lt;p&gt;I still decide &lt;em&gt;what&lt;/em&gt; to build. I still decide &lt;em&gt;which layer&lt;/em&gt; it belongs in. I still review test descriptions to make sure they capture the right behavior. I still read the diff. I still choose which code review findings matter.&lt;/p&gt;

&lt;p&gt;The skills handle the &lt;em&gt;sequence&lt;/em&gt;: read the ticket, write tests first, implement, run checks, commit, push, create PR, watch CI, review, refactor. That sequence is the same every time. It doesn't need my attention. The judgment calls at each checkpoint do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your Own
&lt;/h2&gt;

&lt;p&gt;If you want to build skills for your project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start by noticing repetition.&lt;/strong&gt; What instructions do you give the agent every session? That's your first skill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the phases.&lt;/strong&gt; What's the sequence? Where are the checkpoints?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build in the harness feedback loop.&lt;/strong&gt; Every checkpoint should have the rule: if feedback is about patterns, update the harness first, then re-apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make TDD structural.&lt;/strong&gt; Tests before implementation. Not as guidance, but as a phase that must complete before the next phase starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include self-review.&lt;/strong&gt; Have the agent audit its own work before you see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it simple.&lt;/strong&gt; A Markdown file. Plain English. No tooling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The skill doesn't need to be clever. It needs to be consistent. The value isn't in any single phase; it's in the guarantee that all eight phases happen, in order, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Custom skills are the on-the-loop workflow made executable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Codify your workflow, not just your patterns.&lt;/strong&gt; The harness tells the agent &lt;em&gt;how&lt;/em&gt; to write code. Skills tell it &lt;em&gt;when&lt;/em&gt; to write code, &lt;em&gt;when&lt;/em&gt; to test, &lt;em&gt;when&lt;/em&gt; to ask for feedback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every feedback checkpoint is a harness improvement opportunity.&lt;/strong&gt; The skill enforces this. Corrections become rules before they become code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TDD as a phase, not a preference.&lt;/strong&gt; The skill makes it structural. Tests come first because that's what Phase 4 says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the ceremony from the judgment.&lt;/strong&gt; Automate the sequence. Keep the checkpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two skills cover most work.&lt;/strong&gt; Jira ticket or ad-hoc description. Everything else is the same workflow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The skills turned a workflow I was repeating from memory into a workflow the system enforces. Same eight phases. Same checkpoints. Same harness feedback loop. Every time, without fail.&lt;/p&gt;

&lt;p&gt;That's not a minor convenience. That's the difference between a process that depends on discipline and a process that depends on structure. Structure scales. Discipline doesn't.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tdd</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Curator's Role: Managing a Codebase With an Agent</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 16:01:46 +0000</pubDate>
      <link>https://forem.com/tacoda/the-curators-role-managing-a-codebase-with-an-agent-2jlb</link>
      <guid>https://forem.com/tacoda/the-curators-role-managing-a-codebase-with-an-agent-2jlb</guid>
      <description>&lt;h2&gt;
  
  
  The Simplest Thing That Could Work
&lt;/h2&gt;

&lt;p&gt;There's a temptation, when you decide to "use AI for software development," to build something complicated. A custom orchestration layer. A RAG pipeline over your codebase. A fine-tuned model trained on your conventions. A plugin ecosystem.&lt;/p&gt;

&lt;p&gt;I used Markdown files.&lt;/p&gt;

&lt;p&gt;The entire agent harness for this project is plain Markdown, checked into the repo, loaded automatically by Claude Code based on which directory you're working in. No custom tooling. No infrastructure. No maintenance burden.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLAUDE.md                           ← Root guidance
app/Actions/CLAUDE.md               ← Action patterns
app/Services/CLAUDE.md              ← Service patterns
tests/CLAUDE.md                     ← Test patterns
resources/js/spa/CLAUDE.md          ← SPA patterns
...9 files total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Nine Markdown files. The agent reads them, follows them, and produces code that matches the project's conventions.&lt;/p&gt;

&lt;p&gt;I want to be very clear about this because the industry is drowning in complexity around AI tooling: &lt;strong&gt;the simplest approach worked.&lt;/strong&gt; Not as a starting point. Not as a minimum viable product. As the actual, final, production approach that I use every day.&lt;/p&gt;

&lt;p&gt;When in doubt, choose the boring solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails First. Always.
&lt;/h2&gt;

&lt;p&gt;If there's one message in this entire series, it's this: &lt;strong&gt;do the guardrails work up front.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I know. It's not the fun part. Writing tests for existing code is tedious. Setting up linting is yak-shaving. CI pipelines are plumbing. Nobody got excited about a pre-commit hook.&lt;/p&gt;

&lt;p&gt;But here's what happens when you skip the guardrails and go straight to "AI agent writes my code":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent writes code that looks correct&lt;/li&gt;
&lt;li&gt;You deploy it&lt;/li&gt;
&lt;li&gt;Something breaks in production&lt;/li&gt;
&lt;li&gt;You debug it&lt;/li&gt;
&lt;li&gt;You realize the agent made an assumption that your tests would have caught — if you had tests&lt;/li&gt;
&lt;li&gt;You fix the bug and add a test&lt;/li&gt;
&lt;li&gt;You repeat this for every bug&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the expensive path. You're paying the cost of guardrails &lt;em&gt;anyway&lt;/em&gt;, but you're paying it in production bugs, debugging time, and lost confidence. You're paying retail instead of wholesale.&lt;/p&gt;

&lt;p&gt;The cheap path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write tests&lt;/li&gt;
&lt;li&gt;Add linting&lt;/li&gt;
&lt;li&gt;Set up CI&lt;/li&gt;
&lt;li&gt;Establish patterns&lt;/li&gt;
&lt;li&gt;Build the harness&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; let the agent write code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the agent's output is verified before it ships. Tests catch behavioral bugs. Linting catches structural issues. CI catches everything else. The harness guides the agent toward correct patterns from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every hour spent on guardrails saves ten hours of debugging.&lt;/strong&gt; I can't prove that number, but I believe it in my bones after watching this codebase evolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compound Effect of Simple Rules
&lt;/h2&gt;

&lt;p&gt;Each guardrail is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tests: does the code do what it should?&lt;/li&gt;
&lt;li&gt;Pint: is the PHP formatted consistently?&lt;/li&gt;
&lt;li&gt;Psalm: are the types correct?&lt;/li&gt;
&lt;li&gt;Prettier: is the JS/CSS formatted?&lt;/li&gt;
&lt;li&gt;ESLint: are the React patterns correct?&lt;/li&gt;
&lt;li&gt;TypeScript: are the frontend types sound?&lt;/li&gt;
&lt;li&gt;Pre-commit: did we check before committing?&lt;/li&gt;
&lt;li&gt;CI: did everything pass together?&lt;/li&gt;
&lt;li&gt;Harness: did we follow the project's patterns?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are individually impressive. But together they create a &lt;strong&gt;narrowing funnel&lt;/strong&gt;. The space of "code the agent could produce" starts enormous. Each guardrail eliminates a category of wrong answers. By the time code passes all of them, the remaining space is almost entirely correct code.&lt;/p&gt;

&lt;p&gt;This is why the approach scales. I didn't build a sophisticated AI system. I built a series of simple filters. The AI writes whatever it writes, and the filters ensure only good code survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modern Software Engineering and Agents
&lt;/h2&gt;

&lt;p&gt;Dave Farley's &lt;em&gt;Modern Software Engineering&lt;/em&gt; argues that software engineering is the application of empiricism and the scientific method to building software. The core practices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Work in small batches&lt;/strong&gt; — small commits, small PRs, fast integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize for fast feedback&lt;/strong&gt; — tests, CI, trunk-based development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Experimentation&lt;/strong&gt; — try things, measure results, adapt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous delivery&lt;/strong&gt; — always releasable, deploy when ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these maps directly to AI-assisted development:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small batches&lt;/strong&gt; = small PRs the agent can produce and you can review in minutes. Not 2,000-line diffs. Not week-long branches. One feature. One fix. One refactoring. Merge and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast feedback&lt;/strong&gt; = &lt;code&gt;make lint &amp;amp;&amp;amp; make test&lt;/code&gt; gives you a definitive answer in minutes. The agent runs these checks. If they pass, the code is good. If they fail, the agent fixes and tries again. The feedback loop is tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimentation&lt;/strong&gt; = the harness is a hypothesis. "If I give the agent these patterns, it will produce code like this." Update the harness when the hypothesis is wrong. Run the experiment again. This is the scientific method applied to AI collaboration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery&lt;/strong&gt; = trunk-based development with CI means every merge is deployable. The agent produces code that's always ready to ship, not code that needs a cleanup pass before release.&lt;/p&gt;

&lt;p&gt;Farley couldn't have predicted AI agents when he wrote the book, but he described exactly the practices that make them work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Harness Optimizes Feedback — For You and the Agent
&lt;/h2&gt;

&lt;p&gt;The harness has two audiences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the agent:&lt;/strong&gt; "Here's how to write an Action class. Here's the pattern. Here are the anti-patterns. Follow this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For you:&lt;/strong&gt; "Here's what I expect the agent to produce. If it doesn't match, either the agent drifted or the harness needs updating."&lt;/p&gt;

&lt;p&gt;The feedback protocol makes this bidirectional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You review output → Agent drifted? → Update harness → Agent re-reads → Better output
                   → Harness gap? → Update harness → Agent re-reads → Better output
                   → Looks good?  → Commit and ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every review either confirms the harness is working or improves it. The harness gets better over time. The agent's first-attempt accuracy improves over time. Your review time decreases over time.&lt;/p&gt;

&lt;p&gt;This is the ratchet effect. The system improves in one direction. It doesn't degrade. Each lesson is captured. Each correction is permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  You're Codifying Yourself
&lt;/h2&gt;

&lt;p&gt;Here's something I didn't expect: building the harness forced me to articulate decisions I'd been making unconsciously for years.&lt;/p&gt;

&lt;p&gt;Why do I prefer constructor injection over facades? Why do I insist on Result DTOs instead of returning models? Why do Actions have one public method? Why does the SPA component own the logic while the interim wrapper is just plumbing?&lt;/p&gt;

&lt;p&gt;I had reasons for all of these. They were informed by years of experience, books I'd read, projects I'd worked on, mistakes I'd made. But they lived in my head. When I was writing every line of code, they came out through my fingers. When an agent writes the code, they need to come out through the harness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The harness is a codification of your engineering judgment.&lt;/strong&gt; Your preferences. Your project's specific constraints. Your team's conventions. Your domain's requirements.&lt;/p&gt;

&lt;p&gt;And every project's harness will be different. A fintech codebase has different concerns than a social media app. A Go microservice has different patterns than a Laravel monolith. A greenfield project has different rules than a legacy migration.&lt;/p&gt;

&lt;p&gt;This is why "just use AI to write code" is incomplete advice. The AI doesn't know your project. It doesn't know your domain. It doesn't know why you chose contracts over concrete classes, or why authorization goes through Policies instead of middleware, or why the SPA is gated to non-production environments.&lt;/p&gt;

&lt;p&gt;You know. And your job is to make that knowledge explicit, machine-readable, and automatically loaded at the right time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Engineer's Role in the Age of Agents
&lt;/h2&gt;

&lt;p&gt;If agents can write code, what do engineers do?&lt;/p&gt;

&lt;p&gt;Three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Curator of Design.&lt;/strong&gt;&lt;br&gt;
You decide the architecture. Actions, Services, Policies, query builders. These are design decisions that shape how every feature gets built. The agent follows design. It doesn't create it. Your taste, your judgment, your experience with tradeoffs, and &lt;em&gt;that's&lt;/em&gt; irreplaceable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Curator of Guardrails.&lt;/strong&gt;&lt;br&gt;
You build and maintain the system that verifies output: tests, linting, CI, pre-commit hooks. Without guardrails, agent output is unchecked. The guardrails are your engineering standards made executable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Curator of Documentation.&lt;/strong&gt;&lt;br&gt;
Not READMEs for humans; guidance for agents. Harness files that encode patterns, constraints, and anti-patterns. Documentation that's loaded in context, not filed in a wiki nobody reads.&lt;/p&gt;

&lt;p&gt;The code is a byproduct. The real output of a software engineer is the system of constraints that makes correct code the default and incorrect code structurally difficult.&lt;/p&gt;

&lt;p&gt;This isn't a diminished role. It's a leveled-up role. You're working at a higher level of abstraction. You are defining &lt;em&gt;what good looks like&lt;/em&gt; instead of typing it character by character.&lt;/p&gt;

&lt;h2&gt;
  
  
  On-the-Loop Management
&lt;/h2&gt;

&lt;p&gt;With all the pieces in place, your role becomes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting direction.&lt;/strong&gt; What to build. What to migrate next. Which Jira tickets to pick up. Architecture decisions. Tradeoffs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing specs as tests.&lt;/strong&gt; The TDD red phase is your primary communication channel with the agent. Failing tests are unambiguous specifications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reviewing output.&lt;/strong&gt; Reading diffs, not writing code. Checking "did the agent follow the patterns?" not "is this semicolon in the right place?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Curating the harness.&lt;/strong&gt; When the agent drifts, you don't just fix the instance, you fix the guidance. The correction propagates to all future work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managing infrastructure.&lt;/strong&gt; Docker, CI/CD, deployment pipelines, queue workers, Redis, feature flags. The plumbing that makes everything else work.&lt;/p&gt;

&lt;p&gt;This is management, not micromanagement. You're responsible for the system's output, but you're not manually producing every artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Story in Numbers
&lt;/h2&gt;

&lt;p&gt;This project went from zero to here in about 3 months:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total commits&lt;/td&gt;
&lt;td&gt;258&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull requests&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP tests&lt;/td&gt;
&lt;td&gt;2,700+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conventional commits&lt;/td&gt;
&lt;td&gt;122 (47% of total)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refactoring commits&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test-specific commits&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature commits&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix commits&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React SPA pages&lt;/td&gt;
&lt;td&gt;~15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Features shipped via interim wrappers&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harness files&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Big-bang rewrites&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One engineer. One AI agent. Nine Markdown files. And a codebase that went from "untested legacy monolith" to "well-structured application with dual-frontend migration, automated quality gates, and continuous delivery."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd write the harness earlier.&lt;/strong&gt; I built the harness at commit #109 (out of 258). If I'd built it at commit #30, after the initial test and linting setup, every subsequent commit would have benefited from guided agent output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd invest more in test infrastructure early.&lt;/strong&gt; The &lt;code&gt;UserFactory&lt;/code&gt; facade was a game-changer. Every similar shortcut (factory states, test helpers, assertion macros) pays dividends across hundreds of tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd document scoping rules from day one.&lt;/strong&gt; "New features go here. Bug fixes go here. Don't touch this." The earlier the agent knows the rules, the fewer corrections you make.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Wouldn't Change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Starting with tests.&lt;/strong&gt; Non-negotiable. Everything else depends on the safety net.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keeping it simple.&lt;/strong&gt; Markdown files, Makefile commands, Docker containers, conventional commits. Every piece is boring. Every piece works. The boring stack is the reliable stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental migration.&lt;/strong&gt; Never once did we stop shipping features to "do infrastructure." The migration happened alongside feature work, commit by commit, PR by PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feedback protocol.&lt;/strong&gt; Updating the harness on every review. This is the single highest-leverage habit in the entire workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Point
&lt;/h2&gt;

&lt;p&gt;You can use AI coding agents on real production codebases and get predictable, high-quality results.&lt;/p&gt;

&lt;p&gt;Not by hoping the agent is smart enough. Not by building custom AI infrastructure. Not by trusting vibes.&lt;/p&gt;

&lt;p&gt;By doing the boring work first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write the tests&lt;/li&gt;
&lt;li&gt;Add the linting&lt;/li&gt;
&lt;li&gt;Set up CI&lt;/li&gt;
&lt;li&gt;Establish patterns&lt;/li&gt;
&lt;li&gt;Build the harness&lt;/li&gt;
&lt;li&gt;Update the harness continuously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then let the agent operate within those constraints. Review the output. Update the constraints. Ship the code.&lt;/p&gt;

&lt;p&gt;The agent didn't get smarter over these three months. The guardrails got better. The harness accumulated lessons. The codebase developed a shape that made it harder to do the wrong thing and easier to do the right thing.&lt;/p&gt;

&lt;p&gt;That's not AI magic. That's engineering.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building the Agent Harness: Subdirectory CLAUDE.md Files</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Wed, 08 Apr 2026 15:46:16 +0000</pubDate>
      <link>https://forem.com/tacoda/building-the-agent-harness-subdirectory-claudemd-files-dcl</link>
      <guid>https://forem.com/tacoda/building-the-agent-harness-subdirectory-claudemd-files-dcl</guid>
      <description>&lt;h2&gt;
  
  
  One Big File Doesn't Scale
&lt;/h2&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file at the root of your project. It's the primary way to give the agent project-specific instructions. And for small projects, it works great.&lt;/p&gt;

&lt;p&gt;For this project, it didn't.&lt;/p&gt;

&lt;p&gt;The root &lt;code&gt;CLAUDE.md&lt;/code&gt; grew to cover architecture, testing conventions, API patterns, legacy patterns, SPA patterns, service design, migration strategy, authorization rules, database conventions, and more. The file was huge. Every time Claude started working in &lt;em&gt;any&lt;/em&gt; part of the codebase, it loaded &lt;em&gt;all&lt;/em&gt; the instructions into context.&lt;/p&gt;

&lt;p&gt;Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context window bloat.&lt;/strong&gt; Instructions about database migrations are irrelevant when writing React components. But they're eating context tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signal-to-noise ratio.&lt;/strong&gt; When everything is important, nothing is. The agent has to parse 500 lines of instructions to find the 20 that matter for the current task.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The solution: subdirectory &lt;code&gt;CLAUDE.md&lt;/code&gt; files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Harness Architecture
&lt;/h2&gt;

&lt;p&gt;Claude Code loads &lt;code&gt;CLAUDE.md&lt;/code&gt; files hierarchically. If you're working in &lt;code&gt;app/Actions/&lt;/code&gt;, it loads:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The root &lt;code&gt;CLAUDE.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/Actions/CLAUDE.md&lt;/code&gt; (if it exists)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means you can put &lt;em&gt;scoped&lt;/em&gt; guidance in subdirectories. The agent only loads instructions relevant to where it's working. The root file has the big picture. The subdirectory files have the specifics.&lt;/p&gt;

&lt;p&gt;Here's the harness layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLAUDE.md                              ← Project overview, architecture, shared rules
app/Http/Controllers/Api/CLAUDE.md     ← API controller patterns
app/Http/Controllers/Web/CLAUDE.md     ← Legacy controller rules (bug fixes only)
app/Actions/CLAUDE.md                  ← Action class patterns, Result DTOs
app/Services/CLAUDE.md                 ← Service contracts, strategy pattern
app/Http/Resources/CLAUDE.md           ← API Resource conventions
app/Policies/CLAUDE.md                 ← Authorization patterns
database/migrations/CLAUDE.md          ← Migration naming, column types
tests/CLAUDE.md                        ← Test patterns, UserFactory, TDD workflow
resources/js/spa/CLAUDE.md             ← SPA architecture, Interim wrappers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;When Claude works on an API controller, it sees the root CLAUDE.md (architecture, roles, domain model) plus the API controller harness (QueryBuilder patterns, Resources, thin controllers). It doesn't see the migration naming rules or the SPA component patterns. Clean context. Relevant guidance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Goes in Each Harness File
&lt;/h2&gt;

&lt;p&gt;Each harness file follows the same structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What this area is&lt;/strong&gt; — one sentence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design direction&lt;/strong&gt; — where we're headed (growth area vs. legacy)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patterns&lt;/strong&gt; — code examples the agent should follow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules&lt;/strong&gt; — explicit do/don't constraints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What NOT to do&lt;/strong&gt; — anti-patterns with explanations&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me walk through a few.&lt;/p&gt;

&lt;h3&gt;
  
  
  Actions Harness
&lt;/h3&gt;

&lt;p&gt;The Actions harness defines the single-execute pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Action Class Pattern&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; One public method: &lt;span class="sb"&gt;`execute()`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Accept a Form Request or typed parameters — not raw arrays
&lt;span class="p"&gt;-&lt;/span&gt; Return a Result DTO — not a model or array
&lt;span class="p"&gt;-&lt;/span&gt; Inject services via constructor
&lt;span class="p"&gt;-&lt;/span&gt; Fire domain events for audit trails and side effects
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It includes the Result DTO pattern with named constructors:&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;succeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Thing&lt;/span&gt; &lt;span class="nv"&gt;$thing&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thing&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$thing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And explicit anti-patterns:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## What NOT to Do&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Do not put HTTP concerns in Actions — no response codes, redirects, or JSON
&lt;span class="p"&gt;-&lt;/span&gt; Do not access &lt;span class="sb"&gt;`Request`&lt;/span&gt; objects directly — accept Form Requests passed by the controller
&lt;span class="p"&gt;-&lt;/span&gt; Do not create Actions for simple CRUD that the controller can handle
&lt;span class="p"&gt;-&lt;/span&gt; Do not add multiple public methods — one Action, one execute(), one responsibility
&lt;span class="p"&gt;-&lt;/span&gt; Do not return raw models or arrays — always return a Result DTO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;When Claude creates a new Action, it reads this file and follows the pattern. Every time. It doesn't invent its own approach. It doesn't return raw models. It doesn't add multiple public methods. Because the harness says not to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tests Harness
&lt;/h3&gt;

&lt;p&gt;The Tests harness is one of the most detailed because tests are the quality gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Performance&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**CRITICAL: Never run multiple test processes simultaneously**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Run `make test` exactly once**&lt;/span&gt; as a final check before commit
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**TIMEOUT MUST BE AT LEAST 20 MINUTES**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Frontend-only changes**&lt;/span&gt;: Skip &lt;span class="sb"&gt;`make test`&lt;/span&gt; — run &lt;span class="sb"&gt;`make test-js`&lt;/span&gt; and &lt;span class="sb"&gt;`make lint`&lt;/span&gt; only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It defines the &lt;code&gt;UserFactory&lt;/code&gt; pattern so the agent never uses the wrong factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### UserFactory (Test Facade)&lt;/span&gt;

Always use the test UserFactory facade for creating test users:&lt;span class="sb"&gt;

    use Facades\Tests\Setup\UserFactory;

    $admin = UserFactory::admin()-&amp;gt;create();
    $user = UserFactory::withPermissions('things.manage')-&amp;gt;create();

&lt;/span&gt;Do NOT use &lt;span class="sb"&gt;`User::factory()-&amp;gt;create()`&lt;/span&gt; directly in tests.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it prioritizes test coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Test Coverage Priorities&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Authentication (401 for unauthenticated)
&lt;span class="p"&gt;2.&lt;/span&gt; Authorization (403 for unauthorized)
&lt;span class="p"&gt;3.&lt;/span&gt; Validation (422 for invalid input)
&lt;span class="p"&gt;4.&lt;/span&gt; Happy path (200/201 for valid operations)
&lt;span class="p"&gt;5.&lt;/span&gt; Edge cases and business rules
&lt;span class="p"&gt;6.&lt;/span&gt; Event/notification firing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Web Controllers Harness
&lt;/h3&gt;

&lt;p&gt;This is the shortest harness file, and that's intentional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Web Controllers (Legacy)&lt;/span&gt;

Bug fixes only. No new features. No new routes. No new views.

New features go in API controllers + React SPA pages.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The brevity is the message. When Claude opens a web controller, the harness immediately tells it: you're in legacy territory. Fix bugs. Don't build here.&lt;/p&gt;

&lt;h3&gt;
  
  
  SPA Harness
&lt;/h3&gt;

&lt;p&gt;The SPA harness covers the interim wrapper pattern, component structure, and the relationship between SPA and legacy contexts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Interim Wrappers&lt;/span&gt;

Interim wrappers are a release mechanism, not a separate architecture.
The SPA component is always the source of truth.

The wrapper only passes URL props that differ between contexts.
New features and bug fixes go into the SPA component.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents a common mistake: Claude building features in the interim wrapper instead of the SPA component. The harness makes the hierarchy explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root CLAUDE.md
&lt;/h2&gt;

&lt;p&gt;The root file handles the big picture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Project overview and tech stack&lt;/li&gt;
&lt;li&gt;The two-frontend architecture and migration direction&lt;/li&gt;
&lt;li&gt;Scoping rules (what goes where)&lt;/li&gt;
&lt;li&gt;Domain model overview (User roles, Order lifecycle, etc.)&lt;/li&gt;
&lt;li&gt;Makefile commands&lt;/li&gt;
&lt;li&gt;Pre-commit verification steps&lt;/li&gt;
&lt;li&gt;The TDD workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also contains a &lt;strong&gt;harness table&lt;/strong&gt; that maps areas to their harness files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Area | Harness File | Summary |
|------|-------------|---------|
| API Controllers | &lt;span class="sb"&gt;`app/Http/Controllers/Api/CLAUDE.md`&lt;/span&gt; | Growth direction |
| Web Controllers | &lt;span class="sb"&gt;`app/Http/Controllers/Web/CLAUDE.md`&lt;/span&gt; | Legacy, bug fixes only |
| React SPA | &lt;span class="sb"&gt;`resources/js/spa/CLAUDE.md`&lt;/span&gt; | SPA source of truth |
| Services | &lt;span class="sb"&gt;`app/Services/CLAUDE.md`&lt;/span&gt; | Contract-first design |
| Actions | &lt;span class="sb"&gt;`app/Actions/CLAUDE.md`&lt;/span&gt; | Single execute(), Result DTOs |
| Tests | &lt;span class="sb"&gt;`tests/CLAUDE.md`&lt;/span&gt; | UserFactory, TDD workflow |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This table is surprisingly important. It tells Claude, and human developers, that guidance exists and where to find it. Without the table, harness files might go unread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Feedback Protocol
&lt;/h2&gt;

&lt;p&gt;The harness isn't static. It evolves with every review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Feedback Protocol&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Update the appropriate CLAUDE.md harness file
&lt;span class="p"&gt;2.&lt;/span&gt; Reload that harness file into context
&lt;span class="p"&gt;3.&lt;/span&gt; Re-attempt the change using the updated guidance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Claude does something wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If it's a harness gap → update the harness&lt;/li&gt;
&lt;li&gt;If it's a one-off mistake → correct inline&lt;/li&gt;
&lt;li&gt;If it's a recurring pattern → add it to the "What NOT to Do" section&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reload step is key. After updating a harness file, Claude re-reads it in the same conversation. The correction applies immediately, not just in the next session. And because the harness file is committed to the repo, it applies to &lt;em&gt;all&lt;/em&gt; future sessions.&lt;/p&gt;

&lt;p&gt;This creates a ratchet effect. The harness gets better with every review. Mistakes that happen once get encoded as rules. Over time, the agent's first-attempt accuracy improves because the guidance accumulates lessons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Harness Checks Its Own Work
&lt;/h2&gt;

&lt;p&gt;The harness includes pre-commit verification steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Pre-Commit Verification&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="sb"&gt;`make lint`&lt;/span&gt; — all checks must pass
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="sb"&gt;`make test`&lt;/span&gt; — all PHPUnit tests must pass
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="sb"&gt;`make test-js`&lt;/span&gt; — all Vitest tests must pass
&lt;span class="p"&gt;4.&lt;/span&gt; Browser verification (UI changes only)
&lt;span class="p"&gt;5.&lt;/span&gt; E2E tests (ask before running)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the harness checking its own output. The agent writes code, then runs the same quality gates that a human would. If anything fails, the agent fixes it before presenting the diff for review.&lt;/p&gt;

&lt;p&gt;The agent doesn't need me to tell it "run the tests." The harness says to. It's part of the workflow, not an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why CLAUDE.md files instead of custom tooling?&lt;/strong&gt;&lt;br&gt;
Simplest thing that works. &lt;code&gt;CLAUDE.md&lt;/code&gt; files are plain Markdown, checked into the repo, version-controlled, reviewable in PRs. No custom tool to build, maintain, or explain. Any developer can read them. Any developer can update them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why one file per directory instead of one giant file?&lt;/strong&gt;&lt;br&gt;
Context control. When you're in &lt;code&gt;app/Actions/&lt;/code&gt;, you need Action patterns. When you're in &lt;code&gt;tests/&lt;/code&gt;, you need test patterns. Loading everything everywhere wastes context and drowns the signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why include code examples?&lt;/strong&gt;&lt;br&gt;
Because patterns are easier to follow than rules. "Return a Result DTO" is a rule. Showing the &lt;code&gt;succeeded()&lt;/code&gt; / &lt;code&gt;failed()&lt;/code&gt; static constructors is a pattern the agent can copy. It'll copy the pattern before it reads the rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why include "What NOT to Do"?&lt;/strong&gt;&lt;br&gt;
Because agents learn from examples, including negative examples. If the harness only shows what to do, the agent might still do the wrong thing in novel situations. Anti-patterns draw explicit fences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Building an agent harness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with the root CLAUDE.md.&lt;/strong&gt; Project overview, architecture, shared conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add subdirectory files where patterns matter.&lt;/strong&gt; Actions, Services, Tests, SPA, and any other area with specific conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep each file focused.&lt;/strong&gt; Pattern + rules + anti-patterns. Nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include code examples.&lt;/strong&gt; The agent follows what it sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include a harness table&lt;/strong&gt; in the root file so nobody misses the guidance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the harness on every review.&lt;/strong&gt; Corrections become rules. Rules prevent recurrence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the harness check its own work.&lt;/strong&gt; Pre-commit verification built into the workflow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The harness is not documentation for humans. It's guidance for an autonomous agent. Design it that way: specific, scoped, verifiable, and always evolving.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>programming</category>
      <category>claude</category>
    </item>
    <item>
      <title>The most important post of the series so far!</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Tue, 07 Apr 2026 17:23:37 +0000</pubDate>
      <link>https://forem.com/tacoda/the-most-important-post-of-the-series-so-far-3b2o</link>
      <guid>https://forem.com/tacoda/the-most-important-post-of-the-series-so-far-3b2o</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea" class="crayons-story__hidden-navigation-link"&gt;In-the-Loop to On-the-Loop: How I Stopped Micromanaging My AI Agent&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/tacoda" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F171498%2Fb1207a6e-f740-43c4-bb64-c675e3b3ce1d.jpeg" alt="tacoda profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/tacoda" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ian Johnson
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ian Johnson
                
              
              &lt;div id="story-author-preview-content-3454690" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/tacoda" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F171498%2Fb1207a6e-f740-43c4-bb64-c675e3b3ce1d.jpeg" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ian Johnson&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea" id="article-link-3454690"&gt;
          In-the-Loop to On-the-Loop: How I Stopped Micromanaging My AI Agent
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/softwareengineering"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;softwareengineering&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;2&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            9 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>In-the-Loop to On-the-Loop: How I Stopped Micromanaging My AI Agent</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Tue, 07 Apr 2026 16:17:00 +0000</pubDate>
      <link>https://forem.com/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea</link>
      <guid>https://forem.com/tacoda/in-the-loop-to-on-the-loop-how-i-stopped-micromanaging-my-ai-agent-5gea</guid>
      <description>&lt;h2&gt;
  
  
  I Was the Bottleneck
&lt;/h2&gt;

&lt;p&gt;For the first two months of this project, I used Claude Code with auto-approve turned off. Every file edit, every terminal command, every change... I reviewed it before it executed.&lt;/p&gt;

&lt;p&gt;I read every line. I made inline corrections. I gave real-time direction: "No, use the repository pattern here." "That's the wrong role check." "We use &lt;code&gt;UserFactory::admin()&lt;/code&gt;, not &lt;code&gt;User::factory()&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;I was pair programming with an AI agent. Except I was the worse kind of pair: the one who grabs the keyboard every 30 seconds.&lt;/p&gt;

&lt;p&gt;The results were good. The code was clean. But I was doing most of the thinking and half the typing. The agent was a fancy autocomplete with better suggestions. I wasn't getting the leverage I'd hoped for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Realization
&lt;/h2&gt;

&lt;p&gt;I read an article about "on-the-loop" versus "in-the-loop" human-AI collaboration. The framing clicked immediately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-the-loop:&lt;/strong&gt; You're inside the agent's decision cycle. You approve every action. You're a required step in every operation. The agent can't sneeze without your permission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-the-loop:&lt;/strong&gt; You set the direction, define the constraints, and review the output. The agent operates autonomously within those constraints. You step in when something goes off track, not for every keystroke.&lt;/p&gt;

&lt;p&gt;In-the-loop is micromanagement. On-the-loop is management.&lt;/p&gt;

&lt;p&gt;The problem was obvious: I was micromanaging because I didn't trust the agent to do the right thing. And I didn't trust the agent because there was nothing &lt;em&gt;forcing&lt;/em&gt; it to do the right thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prerequisites
&lt;/h2&gt;

&lt;p&gt;On-the-loop only works if the agent's environment constrains it toward correct output. Without guardrails, autonomy produces slop.&lt;/p&gt;

&lt;p&gt;Look at what we'd built over the previous two months:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guardrail&lt;/th&gt;
&lt;th&gt;What It Constrains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2,700+ tests&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Behavioral correctness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PHP code style&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Psalm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PHP type safety&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prettier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JS/CSS formatting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ESLint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;React/TypeScript patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Frontend type safety&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pre-commit hooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Catches issues before commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI pipeline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Final verification gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actions pattern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where business logic lives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service contracts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How integrations are structured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Policies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How authorization works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Conventional commits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How changes are described&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Trunk-based dev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How changes are delivered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each guardrail narrows the space of "valid output." Together, they create a corridor. The agent can move freely within that corridor, but it can't easily wander off into the weeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is why stages 1–3 came first.&lt;/strong&gt; You can't go on-the-loop with an agent on a codebase that has no tests, no linting, and no architectural patterns. That's not delegation; that's negligence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Switch
&lt;/h2&gt;

&lt;p&gt;I turned on auto-approve for file edits. I started giving Claude higher-level instructions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (in-the-loop):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Create a new file at &lt;code&gt;app/Actions/Orders/CreateOrderAction.php&lt;/code&gt;. Add a constructor that injects &lt;code&gt;NotificationInterface&lt;/code&gt; and &lt;code&gt;AnalyticsService&lt;/code&gt;. Add an &lt;code&gt;execute&lt;/code&gt; method that takes a &lt;code&gt;CreateOrderRequest&lt;/code&gt; and &lt;code&gt;User&lt;/code&gt;..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;After (on-the-loop):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Extract the order creation logic from &lt;code&gt;OrdersController::store()&lt;/code&gt; into a &lt;code&gt;CreateOrderAction&lt;/code&gt; following the existing Action pattern."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent looks at the existing Actions. It sees the pattern. It creates the class, the Result DTO, wires up the controller. It runs &lt;code&gt;make lint&lt;/code&gt; and &lt;code&gt;make test&lt;/code&gt;. Everything passes. I review the diff. It's correct.&lt;/p&gt;

&lt;p&gt;I went from dictating code to reviewing code. My throughput doubled. Maybe more. And the code quality stayed the same because the guardrails enforced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What On-the-Loop Looks Like Day to Day
&lt;/h2&gt;

&lt;p&gt;A typical session now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;I give direction.&lt;/strong&gt; "Implement the Guide SPA page. Here's the Jira ticket."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude reads the harness.&lt;/strong&gt; It checks &lt;code&gt;resources/js/spa/CLAUDE.md&lt;/code&gt; to understand the SPA patterns. It checks &lt;code&gt;app/Http/Controllers/Api/CLAUDE.md&lt;/code&gt; for API conventions. It checks &lt;code&gt;tests/CLAUDE.md&lt;/code&gt; for testing patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude writes failing tests.&lt;/strong&gt; Following TDD, it writes the test descriptions and presents them to me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I review the test specs.&lt;/strong&gt; This takes 2 minutes. I approve or adjust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude implements.&lt;/strong&gt; It builds the API endpoint, the React page, the interim wrapper if needed. It runs lint and tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I review the diff.&lt;/strong&gt; This takes 5–10 minutes. I'm reading code, not writing it. I'm looking for architectural missteps, not formatting issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it's good, we commit and push.&lt;/strong&gt; Claude watches the CI run and alerts me to any failures.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The critical shift: &lt;strong&gt;I'm reviewing output, not directing input.&lt;/strong&gt; I'm checking "did the agent follow the patterns?" not "write this line of code."&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Skills: Codifying the Workflow
&lt;/h2&gt;

&lt;p&gt;As the on-the-loop workflow matured, I noticed I was giving Claude the same high-level instructions repeatedly. "Here's a Jira ticket. Read it. Write tests. Implement. Run lint and tests. Open a PR." So I codified these into reusable Claude Code skills: slash commands that encode the full workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/implement-jira-card&lt;/code&gt;&lt;/strong&gt; takes a Jira issue key, pulls the requirements, writes failing tests using TDD, implements the smallest change to make them pass, runs the quality gates, and prepares a PR, all following the harness patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/implement-change&lt;/code&gt;&lt;/strong&gt; does the same thing for ad-hoc changes that don't have a Jira ticket. You describe the change, and the skill drives the TDD workflow: write failing tests, get approval on the test descriptions, implement, verify.&lt;/p&gt;

&lt;p&gt;These skills are the on-the-loop workflow made executable. Instead of explaining the process each session, I type a slash command and review the output. The skill encodes the sequence I'd otherwise repeat manually: read the context, write tests first, implement in small steps, run the quality gates, ship it.&lt;/p&gt;

&lt;p&gt;The skills don't replace judgment. I still review test descriptions before implementation and review the final diff. But they eliminate the ceremony of setting up each task and ensure the TDD workflow is followed consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Feedback Protocol
&lt;/h2&gt;

&lt;p&gt;Sometimes the agent gets something wrong. When it does, I don't just fix the instance. I fix the &lt;em&gt;guidance&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is the feedback protocol:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The agent produces something incorrect or suboptimal&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I identify the pattern&lt;/strong&gt; — is this a one-off mistake, or a gap in the harness?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it's a gap, I update the relevant CLAUDE.md file&lt;/strong&gt; — add the rule, the example, the anti-pattern&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The harness reloads&lt;/strong&gt; — Claude re-reads the updated guidance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The correction applies to all future work&lt;/strong&gt; — not just this instance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example: Claude was putting notification logic directly in API controllers instead of using the notification service. I added this to the Services harness:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Do not call the chat webhook or external APIs directly from controllers — use the appropriate service."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It never made that mistake again. Across any controller. The harness is a living document that accumulates lessons learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Agentic Flywheel
&lt;/h2&gt;

&lt;p&gt;The feedback protocol above is manual: I notice a gap, I update the harness, the agent reloads. That's how it started. But the current system goes a step further.&lt;/p&gt;

&lt;p&gt;In the project's main &lt;code&gt;CLAUDE.md&lt;/code&gt;, two instructions turn the harness from a static document into a self-improving system:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Change Approval Flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Show diff — present all changes and ask for feedback&lt;/li&gt;
&lt;li&gt;If feedback given — update the relevant &lt;code&gt;CLAUDE.md&lt;/code&gt; harness file to capture the pattern/practice, reload it, then re-apply the changes&lt;/li&gt;
&lt;li&gt;If approved — run all pre-commit checks&lt;/li&gt;
&lt;li&gt;If checks pass — commit and push&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Feedback Protocol:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All feedback about code quality, patterns, or practices follows this loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update the appropriate &lt;code&gt;CLAUDE.md&lt;/code&gt; harness file to capture, define, or refine the pattern&lt;/li&gt;
&lt;li&gt;Reload that harness file into context&lt;/li&gt;
&lt;li&gt;Re-attempt the change using the updated guidance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read that carefully. The agent doesn't just follow the harness. It &lt;em&gt;writes&lt;/em&gt; to the harness. When I give feedback on a diff, the agent's next action isn't to fix the code. It's to update the harness file that governs that area, reload the updated guidance, and then re-apply the change under the new rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the difference between a harness and a flywheel.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A harness is guidance the agent &lt;em&gt;reads&lt;/em&gt;. I write the rules. The agent follows them. When the rules are wrong or incomplete, I update them manually.&lt;/p&gt;

&lt;p&gt;A flywheel is guidance the agent &lt;em&gt;reads and writes&lt;/em&gt;. I give feedback. The agent encodes that feedback into the harness. The next task benefits from the updated harness. That task generates new feedback. The harness evolves again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Feedback → Agent updates harness → Better output → Less feedback → Repeat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every review cycle makes the next cycle faster. The corrections compound. Early in the project, I was giving feedback on almost every diff: "use the service, not a direct call," "that's the wrong factory method," "authorization goes in the policy, not the controller." Each correction became a harness rule. Each rule eliminated a class of future mistakes.&lt;/p&gt;

&lt;p&gt;Three months in, most diffs need zero feedback. The flywheel has accumulated enough guidance that the agent produces correct output on the first pass for the majority of tasks. My reviews shifted from "this is wrong, fix it" to "approved."&lt;/p&gt;

&lt;p&gt;The flywheel has a second-order effect: it forces me to give precise, pattern-level feedback instead of one-off corrections. "Fix this line" doesn't help the harness. "Notification logic belongs in the notification service, not controllers" does. The mechanism shapes the feedback toward reusable rules, which is exactly what you want.&lt;/p&gt;

&lt;p&gt;This is also why the harness is distributed across multiple &lt;code&gt;CLAUDE.md&lt;/code&gt; files: one per architectural boundary (Actions, Services, Controllers, Tests, SPA, etc.). When the agent updates the harness, it updates the specific file governing that area. The feedback lands where future work will read it.&lt;/p&gt;

&lt;p&gt;The flywheel isn't magic. It requires two things: feedback that's worth encoding, and an engineer who reviews diffs carefully enough to generate that feedback. But given those inputs, the system gets better automatically. The agent does the mechanical work of updating documentation, reloading context, and re-applying changes. You just say what's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trust Equation
&lt;/h2&gt;

&lt;p&gt;On-the-loop trust has a formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trust = Tests + Linting + CI + Architectural Patterns + Harness Guidance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any component is missing, trust drops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No tests? You can't verify the agent's output is correct.&lt;/li&gt;
&lt;li&gt;No linting? The output might be inconsistent or buggy in ways tests don't catch.&lt;/li&gt;
&lt;li&gt;No CI? You're trusting local runs that might not match production.&lt;/li&gt;
&lt;li&gt;No patterns? The agent invents its own, and they'll be inconsistent.&lt;/li&gt;
&lt;li&gt;No harness? The agent doesn't know your conventions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All five together? You can hand the agent a Jira ticket and review the PR an hour later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Curator Mindset
&lt;/h2&gt;

&lt;p&gt;My role shifted from &lt;em&gt;writer&lt;/em&gt; to &lt;em&gt;curator&lt;/em&gt;. I don't write most of the code anymore. I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Define the patterns&lt;/strong&gt; — through architecture and harness files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the test specs&lt;/strong&gt; — through TDD red-phase tests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the output&lt;/strong&gt; — diffs, not keystrokes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the harness&lt;/strong&gt; — when the agent drifts or when patterns evolve&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make strategic decisions&lt;/strong&gt; — what to build, in what order, with what tradeoffs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the thing I didn't anticipate: building the harness forced me to articulate my own preferences. Why do I prefer constructor injection? Why Result DTOs instead of returning models? Why one &lt;code&gt;execute()&lt;/code&gt; method per Action? I had reasons for all of these — years of experience, books I'd read, mistakes I'd made — but they lived in my head. The harness made them explicit.&lt;/p&gt;

&lt;p&gt;This is a different kind of engineering. It's more like managing a team than writing code solo. You're responsible for the quality of the output, but you're not doing the mechanical work. You're the curator of design, guardrails, and documentation.&lt;/p&gt;

&lt;p&gt;It's also more fun. I spend my time on the interesting problems (architecture, domain logic, strategic decisions) and let the agent handle the implementation details that follow established patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;I don't have hard before/after metrics (this isn't an A/B test), but the trajectory is clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;258 commits in ~3 months&lt;/strong&gt; — roughly 3 commits per day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;145 PRs merged&lt;/strong&gt; — consistent, steady output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,700+ PHP tests, growing Vitest suite&lt;/strong&gt; — quality gates that hold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 major features migrated to React SPA&lt;/strong&gt; — shipped to production via interim wrappers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17 refactoring commits&lt;/strong&gt; — continuous architectural improvement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero big-bang rewrites&lt;/strong&gt; — incremental progress throughout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The codebase went from "legacy monolith with no tests" to "well-structured application with automated quality gates and a dual-frontend migration in progress." In three months. With one engineer and an AI agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Is a Guardrail Too
&lt;/h2&gt;

&lt;p&gt;It's easy to think of guardrails as code quality tools — tests, linters, static analysis. But the infrastructure decisions are guardrails in their own right.&lt;/p&gt;

&lt;p&gt;Docker ensures every environment is identical. The Makefile provides a single interface for every operation. Redis-backed queues isolate background jobs (CRM sync, notifications, calculations) from the request cycle. Separate queue names mean a third-party API outage doesn't back up critical notifications.&lt;/p&gt;

&lt;p&gt;The agent doesn't need to know how Docker networking works or why the CRM sync runs on its own queue. It just needs &lt;code&gt;make test&lt;/code&gt; to pass and &lt;code&gt;make lint&lt;/code&gt; to be clean. The infrastructure absorbs complexity so the agent (and you) don't have to think about it.&lt;/p&gt;

&lt;p&gt;This is "pull complexity downward" in action. Simple interfaces. Complex implementations hidden behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Could Go Wrong
&lt;/h2&gt;

&lt;p&gt;On-the-loop isn't a silver bullet. There are failure modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The harness is wrong.&lt;/strong&gt; If your &lt;code&gt;CLAUDE.md&lt;/code&gt; files encode bad patterns, the agent will faithfully reproduce bad patterns. The harness is as good as your engineering judgment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tests don't cover the right things.&lt;/strong&gt; If your tests verify implementation details instead of behavior, the agent can pass all tests while doing the wrong thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You stop reviewing.&lt;/strong&gt; On-the-loop doesn't mean no-loop. You still review diffs. You still verify the output makes sense. You just do it at a higher level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You skip the prerequisites.&lt;/strong&gt; If you try to go on-the-loop without tests, linting, and CI, you'll get fast slop instead of slow slop. Still slop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;The path from in-the-loop to on-the-loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the guardrails first.&lt;/strong&gt; Tests, linting, CI, clear architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Establish patterns.&lt;/strong&gt; Actions, Services, Policies — consistent, repeatable, verifiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the harness.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; files that encode your conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start delegating.&lt;/strong&gt; Give higher-level instructions. Review output, not input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the harness continuously.&lt;/strong&gt; Every correction is a lesson the harness absorbs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the system, not the agent.&lt;/strong&gt; You trust the guardrails. The agent just happens to work within them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent didn't get smarter. The environment got smarter. That's the difference.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>Trunk-Based Development with Short-Lived Branches</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Tue, 07 Apr 2026 15:03:24 +0000</pubDate>
      <link>https://forem.com/tacoda/trunk-based-development-with-short-lived-branches-5f74</link>
      <guid>https://forem.com/tacoda/trunk-based-development-with-short-lived-branches-5f74</guid>
      <description>&lt;h2&gt;
  
  
  Why Long-Lived Branches Kill Velocity
&lt;/h2&gt;

&lt;p&gt;You've seen it. A feature branch that started two weeks ago. It's 47 commits behind main. Three people are waiting on it. The merge conflict is 400 lines. Nobody wants to review it because reviewing 2,000 lines of diff is nobody's idea of a good time.&lt;/p&gt;

&lt;p&gt;Long-lived branches are where productivity goes to die. And when you add an AI agent to the mix, they get even worse. The agent writes code against the branch state. Main moves on. By the time you merge, half the agent's assumptions are wrong.&lt;/p&gt;

&lt;p&gt;Trunk-based development fixes this. The rule is simple: &lt;strong&gt;branches live for hours, not days.&lt;/strong&gt; Merge to main early and often. Keep main releasable at all times.&lt;/p&gt;

&lt;p&gt;Trunk-based development doesn't &lt;em&gt;necessarily&lt;/em&gt; mean merging changes straight to main. In my view, it's more about ensuring everything works together to really take advantage of CI. Short-lived branches give us this, as well as the safety net that many developers prefer. Concern about pushing directly to main is a developer preference. Personally, I prefer not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow
&lt;/h2&gt;

&lt;p&gt;Here's what a typical feature looks like in this project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Branch&lt;/strong&gt; — create a branch from main: &lt;code&gt;feat/PROJ-431-dashboard-migration&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; — write tests, implement the feature, run &lt;code&gt;make lint &amp;amp;&amp;amp; make test&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR&lt;/strong&gt; — open a PR. Small diff. Clear description. Conventional commit title.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt; — GitHub Actions runs the full pipeline (lint, test, test-js)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge&lt;/strong&gt; — once CI is green, merge to main&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; — CI triggers a Forge deployment webhook. Staging updates automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire cycle (branch to merged) is usually same-day. Sometimes within an hour for smaller changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  145 PRs in 3 Months
&lt;/h2&gt;

&lt;p&gt;This project has 258 commits across ~3 months. 145 of those went through pull requests. That's roughly 1.6 PRs per day, every day.&lt;/p&gt;

&lt;p&gt;Most PRs are small. A refactoring extraction. A test coverage expansion. A bug fix. A single feature. The biggest PRs were the frontend migration (Tailwind, jQuery removal), and even those were broken into sequential stages.&lt;/p&gt;

&lt;p&gt;Small PRs have compounding benefits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Easier to review&lt;/strong&gt; — you can actually read the diff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easier to revert&lt;/strong&gt; — if something breaks, &lt;code&gt;git revert&lt;/code&gt; one PR, not a 2,000-line changeset&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster CI&lt;/strong&gt; — smaller changes mean fewer test failures to debug&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less merge conflict risk&lt;/strong&gt; — you're never far from main&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conventional Commits
&lt;/h2&gt;

&lt;p&gt;Every commit follows the &lt;a href="https://www.conventionalcommits.org/" rel="noopener noreferrer"&gt;conventional commits&lt;/a&gt; format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat: add GET /api/dashboard endpoint (PROJ-430) (#130)
fix: resolve planner bugs (PROJ-432) (#131)
refactor: extract CreateOrderAction from OrdersController::store() (#80)
test: expand OrdersController test coverage (#59)
docs: document legacy Blade vs React SPA architecture (#119)
ci: add workflow_dispatch trigger for manual CI runs
chore: remove legacy frontend dependencies and dead code (#103)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This isn't just aesthetics. Conventional commits create a machine-readable history. You can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate changelogs automatically&lt;/li&gt;
&lt;li&gt;See at a glance whether a commit is a feature, fix, or refactoring&lt;/li&gt;
&lt;li&gt;Train an agent to follow the same convention (it will, if every existing commit uses it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commit message is a contract. &lt;code&gt;feat:&lt;/code&gt; means new functionality. &lt;code&gt;fix:&lt;/code&gt; means something was broken and now it's not. &lt;code&gt;refactor:&lt;/code&gt; means the behavior didn't change. When the agent writes a commit message, these prefixes help me triage without reading the diff.&lt;/p&gt;
&lt;h2&gt;
  
  
  The CI Pipeline
&lt;/h2&gt;

&lt;p&gt;Every push to main triggers the full pipeline:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="err"&gt;Build&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="err"&gt;Code&lt;/span&gt; &lt;span class="err"&gt;Quality&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="err"&gt;Tests&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="err"&gt;Deploy&lt;/span&gt;
         &lt;span class="err"&gt;(make&lt;/span&gt; &lt;span class="err"&gt;lint)&lt;/span&gt;    &lt;span class="err"&gt;(make&lt;/span&gt; &lt;span class="err"&gt;test&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="err"&gt;make&lt;/span&gt; &lt;span class="err"&gt;test-js)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;The pipeline runs in Docker containers built from the same &lt;code&gt;docker-compose.yml&lt;/code&gt; as local development. Same PHP version. Same Node version. Same MySQL. If it passes locally, it passes in CI.&lt;/p&gt;

&lt;p&gt;The deploy step triggers a webhook with our cloud provider that pulls the latest code, runs migrations, rebuilds assets, and restarts workers:&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;cd &lt;/span&gt;staging.example.com
git pull origin main
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build
php artisan queue:restart
php artisan config:cache
php artisan route:cache
php artisan view:cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Staging updates within minutes of a merge to main. Production deploys are triggered manually (or by the same webhook on the production server) after staging verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: Queue Workers and Redis
&lt;/h2&gt;

&lt;p&gt;The deployment isn't just the web app. We also manage background infrastructure:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue workers&lt;/strong&gt; process async jobs: CRM sync, notification dispatch, and background calculations. The Forge server runs supervised workers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan queue:work redis &lt;span class="nt"&gt;--queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default,crm &lt;span class="nt"&gt;--sleep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nt"&gt;--tries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;queue:restart&lt;/code&gt; in the deploy script gracefully restarts workers so they pick up the new code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis&lt;/strong&gt; backs the queue and can optionally back the cache. Separate Redis databases (&lt;code&gt;DB=0&lt;/code&gt; for cache, &lt;code&gt;DB=1&lt;/code&gt; for queues) prevent queue operations from evicting cached data.&lt;/p&gt;

&lt;p&gt;The Docker Compose stack mirrors this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
  &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;queue-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan queue:work redis --queue=default,crm&lt;/span&gt;
  &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;profiles&lt;/code&gt; key means queue infrastructure only starts when you explicitly ask for it (&lt;code&gt;docker compose --profile queue up&lt;/code&gt;). Local development doesn't need Redis running unless you're testing queue jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The E2E Database
&lt;/h2&gt;

&lt;p&gt;E2E tests (Playwright) run against a separate database: &lt;code&gt;myapp_e2e&lt;/code&gt;. This gets its own migration and seeding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make migrate-e2e    &lt;span class="c"&gt;# Run migrations on E2E database&lt;/span&gt;
make seed-e2e       &lt;span class="c"&gt;# Seed test users with proper roles, permissions, relationships&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The E2E seeder creates users with known credentials and realistic data. It's idempotent — running it twice doesn't create duplicates.&lt;/p&gt;

&lt;p&gt;In CI, the E2E job spins up the full Docker stack (app, nginx, mysql) and runs Playwright against it. Same app, same database engine, same infrastructure as production. The only difference is the data is seeded, not real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continuous Delivery (Not Continuous Deployment)
&lt;/h2&gt;

&lt;p&gt;An important distinction: we practice continuous &lt;em&gt;delivery&lt;/em&gt;, not continuous &lt;em&gt;deployment&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Every merge to main is &lt;em&gt;deployable&lt;/em&gt;. The pipeline proves it: tests pass, linting passes, the build succeeds. But deploying to production is a conscious decision, not an automatic one.&lt;/p&gt;

&lt;p&gt;This matters because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some features are gated behind environment checks or feature flags&lt;/li&gt;
&lt;li&gt;Some changes need manual verification on staging first&lt;/li&gt;
&lt;li&gt;Production deploys happen when &lt;em&gt;we&lt;/em&gt; decide, not when the CI pipeline finishes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The codebase is always releasable. Whether we release is a business decision, not a technical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Enables Agent-Assisted Development
&lt;/h2&gt;

&lt;p&gt;Trunk-based development + CI + conventional commits create something crucial for working with an AI agent: &lt;strong&gt;a fast, reliable feedback loop&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When Claude writes code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The tests tell me if it works (seconds to minutes)&lt;/li&gt;
&lt;li&gt;The linter tells me if it's clean (seconds)&lt;/li&gt;
&lt;li&gt;CI confirms both in an environment I trust (minutes)&lt;/li&gt;
&lt;li&gt;If it passes, I merge. If it doesn't, Claude fixes it.&lt;/li&gt;
&lt;li&gt;The conventional commit tells me what changed without reading the diff.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no "let me review this 2,000-line PR over the weekend." It's: did it pass? Merge. Did it fail? Fix. Ship it. Move on.&lt;/p&gt;

&lt;p&gt;Dave Farley calls this "optimizing for feedback." The faster you know whether a change worked, the faster you can iterate. Trunk-based development with CI gives you feedback in minutes, not days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Branches live for hours.&lt;/strong&gt; If your branch is older than a day, something's wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small PRs, merged often.&lt;/strong&gt; 145 PRs in 3 months. Each one small enough to review in minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventional commits are a communication protocol.&lt;/strong&gt; Both for humans reading the log and agents writing commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI is the source of truth.&lt;/strong&gt; If it passes CI, it's good. If it doesn't, fix it before merging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous delivery means always releasable.&lt;/strong&gt; Deploy when you want, not when you have to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure is code.&lt;/strong&gt; Docker, queue workers, Redis, deploy scripts: all versioned, all reproducible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The combination of tests, linting, CI, and trunk-based development creates a system where changes are small, verified, and frequent. That's exactly the system an AI agent thrives in.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>No Big-Bang Rewrites: Running Two Frontends Without Losing Your Mind</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Tue, 07 Apr 2026 14:36:33 +0000</pubDate>
      <link>https://forem.com/tacoda/no-big-bang-rewrites-running-two-frontends-without-losing-your-mind-4975</link>
      <guid>https://forem.com/tacoda/no-big-bang-rewrites-running-two-frontends-without-losing-your-mind-4975</guid>
      <description>&lt;h2&gt;
  
  
  The Rewrite That Never Was
&lt;/h2&gt;

&lt;p&gt;We needed a modern frontend. The Blade + Bootstrap + jQuery stack was showing its age. The design team had a new UI/UX vision. The natural instinct was: rewrite the frontend in React.&lt;/p&gt;

&lt;p&gt;But big-bang rewrites fail. Joel Spolsky wrote about this in 2000. Fred Brooks explained it before that. The pattern is always the same: you spend months building The New Thing, the old thing keeps getting patches, the two diverge, and you end up with two broken systems instead of one working one.&lt;/p&gt;

&lt;p&gt;So we didn't rewrite. We migrated. Page by page. Feature by feature. And we set up the architecture so both frontends could coexist without developers losing their minds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Paths, One Codebase
&lt;/h2&gt;

&lt;p&gt;The application runs two frontend architectures simultaneously:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Server Layer&lt;/th&gt;
&lt;th&gt;UI Layer&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Legacy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web controllers&lt;/td&gt;
&lt;td&gt;Blade views&lt;/td&gt;
&lt;td&gt;Bug fixes only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SPA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API controllers&lt;/td&gt;
&lt;td&gt;React + TypeScript&lt;/td&gt;
&lt;td&gt;All new features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Legacy path serves ~228 Blade views across ~59 web controllers. It works. Users depend on it. We're not touching it unless something's broken.&lt;/p&gt;

&lt;p&gt;The SPA path is the target architecture. React 19, TypeScript 5, Tailwind CSS 4, mounted at &lt;code&gt;/app&lt;/code&gt; via a catch-all route:&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;Route&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="s1"&gt;'/app/{any?}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;SpaController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'any'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.*'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'onboarding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2fa'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The SPA gets the initial state (user, CSRF token) from Blade via &lt;code&gt;window.__INITIAL_STATE__&lt;/code&gt;, then React Router handles everything client-side.&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment Gating
&lt;/h2&gt;

&lt;p&gt;The SPA is gated to &lt;code&gt;local&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, and &lt;code&gt;testing&lt;/code&gt; environments. Production serves Legacy pages exclusively (with one exception we'll get to).&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;// SpaController&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&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;environment&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'local'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'staging'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'testing'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spa.index'&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;This means we can build, test, and iterate on the SPA without any risk to production. Staging gets the full SPA experience. Production gets the battle-tested Blade views.&lt;/p&gt;

&lt;p&gt;When a feature is stable and tested in the SPA, we have two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wait until the SPA is ungated for production&lt;/li&gt;
&lt;li&gt;Ship it now using the interim wrapper pattern&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The Interim Wrapper Pattern
&lt;/h2&gt;

&lt;p&gt;This is the trick that made the migration practical. When an SPA page is ready for production but the SPA environment gate isn't lifted yet, we mount the React component inside a Blade shell.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The SPA component is the source of truth.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/spa/pages/Dashboard/Dashboard.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Dashboard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;dashboardUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/app/dashboard&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useDashboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dashboardUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppShell&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashboardContent&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AppShell&lt;/span&gt;&lt;span class="p"&gt;&amp;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;&lt;strong&gt;2. The interim wrapper renders it with legacy URL overrides.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/dashboard/InterimDashboard.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Dashboard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../spa/pages/Dashboard/Dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;InterimDashboard&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dashboard&lt;/span&gt; &lt;span class="na"&gt;dashboardUrl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;3. A standalone mount file hydrates it into a Blade shell.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/dashboard/main.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRoot&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-dom/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;InterimDashboard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./InterimDashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard-root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InterimDashboard&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;4. A Blade view provides the mount point.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- dashboard/v2.blade.php --}}
@extends('layouts.app')
@section('content')
    &amp;lt;div id="dashboard-root"&amp;gt;&amp;lt;/div&amp;gt;
    @viteReactRefresh
    @vite('resources/js/dashboard/main.tsx')
@endsection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;the SPA component is always the source of truth.&lt;/strong&gt; The interim wrapper is just a thin shell that renders the SPA component with different URL props. Bug fixes go into the SPA component and automatically apply to both the SPA and the legacy context.&lt;/p&gt;

&lt;p&gt;We shipped six features this way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;SPA Component&lt;/th&gt;
&lt;th&gt;Interim Wrapper&lt;/th&gt;
&lt;th&gt;Blade Shell&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dashboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimDashboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v2.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Onboarding&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OnboardingWizard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimOnboarding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;onboarding.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PlannerWizard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimPlanner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guide&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Guide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimGuide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketplace&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Marketplace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimMarketplace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PlannerHistory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InterimPlannerHistory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;history.blade.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Frontend Overhaul
&lt;/h2&gt;

&lt;p&gt;The migration also involved modernizing the entire frontend stack. This happened in a deliberate sequence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tailwind CSS 4 + Shadcn/ui&lt;/strong&gt;&lt;br&gt;
Replace Bootstrap with Tailwind. Add Shadcn/ui for consistent React components. This was the foundation layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. SPA pages migrated&lt;/strong&gt;&lt;br&gt;
All existing React pages updated to use Tailwind and Shadcn/ui components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. jQuery elimination&lt;/strong&gt;&lt;br&gt;
Every &lt;code&gt;$(document).ready()&lt;/code&gt; and &lt;code&gt;$.ajax()&lt;/code&gt; call replaced with vanilla JS. jQuery removed from the bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Blade template migration&lt;/strong&gt;&lt;br&gt;
All 228 Blade views migrated from Bootstrap classes to Tailwind. This was the biggest single PR, but it was almost entirely CSS class changes; no logic changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Livewire to React&lt;/strong&gt;&lt;br&gt;
The few Livewire components we had were rebuilt in React.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Dead code removal&lt;/strong&gt;&lt;br&gt;
Legacy frontend dependencies, unused JS files, and Bootstrap artifacts cleaned out.&lt;/p&gt;

&lt;p&gt;Each of these was a separate PR, tested independently, merged to main within a day or two. No long-lived branches. No merge conflicts. The test suite verified nothing broke after each change.&lt;/p&gt;
&lt;h2&gt;
  
  
  Feature Flags
&lt;/h2&gt;

&lt;p&gt;For features that needed gradual rollout or A/B testing, we used a feature flag / analytics service:&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;// Server-side feature gate&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;checkGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_dashboard_layout'&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="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard.v2'&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="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard.index'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The analytics service also handles event tracking. Every meaningful user action (order created, ticket submitted, dashboard viewed) gets logged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logDashboardView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'v2'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AnalyticsService&lt;/code&gt; wraps the SDK and no-ops when &lt;code&gt;ANALYTICS_ENABLED=false&lt;/code&gt;, so tests and local development aren't affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping Rules
&lt;/h2&gt;

&lt;p&gt;To keep everyone sane (humans and agents), we defined clear scoping rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug in a legacy-only domain?&lt;/strong&gt; Fix in the web controller and Blade view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug in a migrated domain?&lt;/strong&gt; Fix in the SPA React component. It automatically applies to both contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New feature in any domain?&lt;/strong&gt; Build the API endpoint and SPA page. No new Blade features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrating a domain?&lt;/strong&gt; Follow the sequence: extract to Actions → build API controller → create React page → create interim wrapper if needed.&lt;/p&gt;

&lt;p&gt;These rules are documented in the &lt;code&gt;CLAUDE.md&lt;/code&gt; harness files (we'll get to those in posts 7–8). The agent reads the rules and follows them. No ambiguity about where new code goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Asset Pipeline
&lt;/h2&gt;

&lt;p&gt;Vite 6 handles both the SPA and the interim wrappers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;laravel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;input&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;resources/js/spa/main.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// SPA entry&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/dashboard/main.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// Interim: Dashboard&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/onboarding/main.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Interim: Onboarding&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/planner/main.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Interim: Planner&lt;/span&gt;
                &lt;span class="c1"&gt;// ...&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nf"&gt;react&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;Each interim wrapper gets its own entry point. Vite tree-shakes unused code. The SPA gets its own bundle. Blade pages get the specific entry they need via &lt;code&gt;@vite()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never rewrite. Migrate.&lt;/strong&gt; Page by page, feature by feature, with both systems running in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate the new thing.&lt;/strong&gt; Don't ship the SPA to production until it's proven in staging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use wrappers for early release.&lt;/strong&gt; The interim pattern lets SPA pages ship inside legacy shells.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SPA component is always the source of truth.&lt;/strong&gt; The wrapper is just plumbing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear scoping rules&lt;/strong&gt; prevent confusion about where code goes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flags&lt;/strong&gt; for gradual rollout and experimentation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This architecture means we're never stuck. We can ship features to production today (via interim wrappers) while building toward the full SPA. No pressure. No big bang. Just steady progress.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>react</category>
      <category>laravel</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Actions, Policies, and the Art of Obvious Code</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Mon, 06 Apr 2026 15:51:04 +0000</pubDate>
      <link>https://forem.com/tacoda/actions-policies-and-the-art-of-obvious-code-1c0p</link>
      <guid>https://forem.com/tacoda/actions-policies-and-the-art-of-obvious-code-1c0p</guid>
      <description>&lt;h2&gt;
  
  
  Fat Controllers Die Hard
&lt;/h2&gt;

&lt;p&gt;After extracting traits into services, the controllers were thinner, but still fat. A typical &lt;code&gt;store()&lt;/code&gt; method in &lt;code&gt;OrdersController&lt;/code&gt; did:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate input&lt;/li&gt;
&lt;li&gt;Create the order&lt;/li&gt;
&lt;li&gt;Create related records&lt;/li&gt;
&lt;li&gt;Upload documents&lt;/li&gt;
&lt;li&gt;Send email notifications&lt;/li&gt;
&lt;li&gt;Fire events for audit trails&lt;/li&gt;
&lt;li&gt;Send chat notifications&lt;/li&gt;
&lt;li&gt;Redirect with flash message&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's eight responsibilities in one method. When Claude looked at this controller to understand how orders work, it had to parse all eight concerns interleaved together. When it needed to build an API controller for the same operation, it would copy-paste the web controller and try to adapt it. Badly.&lt;/p&gt;

&lt;p&gt;The fix: Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Action Pattern
&lt;/h2&gt;

&lt;p&gt;An Action is a single-purpose class with one public method: &lt;code&gt;execute()&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Actions\Orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateOrderAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;NotificationInterface&lt;/span&gt; &lt;span class="nv"&gt;$notifications&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;AnalyticsService&lt;/span&gt; &lt;span class="nv"&gt;$analytics&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CreateOrderRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CreateOrderResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Validate preconditions&lt;/span&gt;
        &lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'User has no organization.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Create the order&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&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;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&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="s1"&gt;'organization_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="c1"&gt;// Handle documents&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;uploadDocuments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Side effects&lt;/span&gt;
        &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;notifications&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendOrderNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'New order filed.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logOrderCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&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="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CreateOrderResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;succeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The Result DTO:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateOrderResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;?Order&lt;/span&gt; &lt;span class="nv"&gt;$order&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$error&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="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;succeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$error&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;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;Now the controller is thin:&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;// Web controller&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CreateOrderRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CreateOrderAction&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;auth&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;user&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// API controller — same Action, different response format&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CreateOrderRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CreateOrderAction&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;auth&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;user&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&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 web controller returns a redirect. The API controller returns JSON. The business logic is identical because it lives in the Action, not the controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This accidentally created the perfect migration bridge.&lt;/strong&gt; When I later migrated features from web controllers to API controllers, the Action already existed. I just wired it up to a new controller with a different response format. No duplication. No drift.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Extraction Sequence
&lt;/h2&gt;

&lt;p&gt;Like the services, I extracted Actions one at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreateOrderAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrdersController::store()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UpdateOrderStatusAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrdersController::approve/deny()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreateTicketAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TicketsController::store()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ApproveTicketAction&lt;/code&gt;, &lt;code&gt;DenyTicketAction&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TicketsController&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EntityCalculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EntityController&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreateEntityAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EntityController&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreateOrganizationAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrganizationsController&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CreateCustomOrderAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CustomOrdersController&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each extraction followed the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write tests for the existing behavior (if not already covered)&lt;/li&gt;
&lt;li&gt;Create the Action class&lt;/li&gt;
&lt;li&gt;Create the Result DTO&lt;/li&gt;
&lt;li&gt;Move logic from the controller to the Action&lt;/li&gt;
&lt;li&gt;Wire the controller to use the Action&lt;/li&gt;
&lt;li&gt;Run the full test suite&lt;/li&gt;
&lt;li&gt;Ship it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;TDD drove the whole process. If I was extracting &lt;code&gt;CreateOrderAction&lt;/code&gt;, I first wrote tests against the Action's &lt;code&gt;execute()&lt;/code&gt; method directly. Those tests defined the contract. Then I moved the code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Laravel Policies: Authorization Done Right
&lt;/h2&gt;

&lt;p&gt;Before Policies, authorization was scattered across controllers:&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;// Inline role checks everywhere&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isOrgAdmin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Or worse, duplicated across methods&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'Admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&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;This is a maintenance nightmare. Every controller does its own authorization. An agent building a new controller has to figure out which role checks to copy. Get it wrong, and you have a security hole.&lt;/p&gt;

&lt;p&gt;Laravel Policies centralize authorization into one place per model:&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Policies&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderPolicy&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isOrgAdmin&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;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&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;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$user&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isReviewer&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;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;Now the controller just says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. The Policy handles the role logic. The controller doesn't know or care about roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Role-Scoped Query Builders
&lt;/h2&gt;

&lt;p&gt;Related to Policies, I also extracted role-scoped query builders. Instead of every controller having:&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isOrgAdmin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&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;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&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;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We now have:&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;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query builder encapsulates the scoping logic. The controller doesn't know about roles. The agent doesn't need to figure out scoping. It just calls &lt;code&gt;::forUser($user)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cross-Company Security Fix That Started It All
&lt;/h2&gt;

&lt;p&gt;I should mention what kicked this off. Early in the project, we found cross-company data leakage. While this change did not enter production, it was serious, as users from Company A could see data from Company B because the controllers weren't consistently scoping queries.&lt;/p&gt;

&lt;p&gt;A couple of PRs fixed the immediate issues. But the root cause was architectural: authorization logic was scattered and inconsistent. There was no single source of truth for "who can see what."&lt;/p&gt;

&lt;p&gt;Policies and role-scoped query builders weren't just a nice refactoring. They were the systemic fix for a class of security bugs. Once every query goes through &lt;code&gt;OrderQueryBuilder::forUser()&lt;/code&gt; and every action checks &lt;code&gt;$this-&amp;gt;authorize()&lt;/code&gt;, cross-organization leakage becomes structurally impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is what "design for security" looks like in practice.&lt;/strong&gt; Not penetration testing after the fact, but making the insecure path harder to write than the secure one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Obvious Architecture Beats Documentation
&lt;/h2&gt;

&lt;p&gt;After these refactorings, the codebase has a clear pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request → Controller → authorize() → Action::execute() → Result DTO → Response
                                        ↓
                              Services (notifications, CRM, etc.)
                              Events (audit trails)
                              Query Builders (scoped data access)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Claude needs to build a new feature, it doesn't need a 50-page architecture document. It can look at any existing Action and follow the same pattern. It can look at any existing Policy and understand the authorization model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the right thing easy and the wrong thing hard.&lt;/strong&gt; If the Action pattern is established and every existing feature uses it, the agent will use it too. If authorization goes through Policies, the agent will add policy checks. Not because it read documentation, but because that's the pattern it sees everywhere.&lt;/p&gt;

&lt;p&gt;This is "convention over configuration" taken to its logical conclusion. The codebase &lt;em&gt;is&lt;/em&gt; the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fat controllers are an agent liability.&lt;/strong&gt; If your logic lives in controllers, the agent will copy-paste across controllers and create drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions create a migration bridge.&lt;/strong&gt; Same logic, different response format. Web today, API tomorrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies centralize authorization.&lt;/strong&gt; One source of truth beats scattered inline checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query builders centralize scoping.&lt;/strong&gt; Role-based data access in one place, not everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture is the best documentation.&lt;/strong&gt; Clear patterns are self-reinforcing — the agent follows what it sees.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At this point in the project, we had: tests, linting, CI, services with contracts, Actions with DTOs, Policies, and query builders. The codebase was getting healthy. But we still had a big problem: two frontends.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>laravel</category>
      <category>architecture</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Traits to Services: Refactoring for Testability (and for Agents)</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Mon, 06 Apr 2026 15:35:33 +0000</pubDate>
      <link>https://forem.com/tacoda/traits-to-services-refactoring-for-testability-and-for-agents-4bpn</link>
      <guid>https://forem.com/tacoda/traits-to-services-refactoring-for-testability-and-for-agents-4bpn</guid>
      <description>&lt;h2&gt;
  
  
  The Trait Problem
&lt;/h2&gt;

&lt;p&gt;PHP traits are seductive. You've got some chat notification logic. Four controllers need it. Slap it in a trait, &lt;code&gt;use ChatNotificationTrait&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;Except now you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hidden dependencies&lt;/strong&gt; — the trait calls &lt;code&gt;$this-&amp;gt;&lt;/code&gt; methods that don't exist in the trait itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible coupling&lt;/strong&gt; — change the trait, break four controllers, good luck figuring out which ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untestable logic&lt;/strong&gt; — you can't unit test a trait in isolation because it doesn't &lt;em&gt;exist&lt;/em&gt; in isolation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global state smell&lt;/strong&gt; — traits encourage reaching into the controller's properties&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This codebase had six traits doing serious work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;What It Did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChatNotificationTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send chat webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CrmApiTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRM sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OcrScanApiTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OCR via document scanning API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConvertApiTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExternalApiTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Third-party API integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CalculationTrait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Domain calculations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each one was used in multiple controllers. Each one mixed HTTP client logic, business rules, error handling, and configuration into a single &lt;code&gt;use&lt;/code&gt; statement. Testing any of it meant testing the entire controller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Extraction Plan
&lt;/h2&gt;

&lt;p&gt;I planned all six extractions upfront but executed them one at a time, in separate PRs. Each PR:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Created the contract (interface)&lt;/li&gt;
&lt;li&gt;Created the service implementation&lt;/li&gt;
&lt;li&gt;Bound the interface in the service provider&lt;/li&gt;
&lt;li&gt;Updated all controllers to inject the service instead of using the trait&lt;/li&gt;
&lt;li&gt;Ran the full test suite&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trait stayed in the codebase until every consumer was migrated. Then it got deleted. At no point was the application broken.&lt;/p&gt;

&lt;p&gt;This is the "make change easy, then make the easy change" principle from Kent Beck. Each extraction was a small, safe step. The tests caught any behavioral changes. The linting caught any structural issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract-First Design
&lt;/h2&gt;

&lt;p&gt;Every extraction started with an interface:&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Services\Notifications\Contracts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;NotificationInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendOrderNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendTicketNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Ticket&lt;/span&gt; &lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&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;Then the implementation:&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Services\Notifications&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatNotificationService&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;NotificationInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$webhookUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;HttpClient&lt;/span&gt; &lt;span class="nv"&gt;$http&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendOrderNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;formatOrderMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;Why contracts? Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Testability&lt;/strong&gt; — you can mock &lt;code&gt;NotificationInterface&lt;/code&gt; in tests without caring about chat webhooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappability&lt;/strong&gt; — when we eventually move from chat webhooks to a different notification channel, the interface stays the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boundaries&lt;/strong&gt; — the interface defines what the service &lt;em&gt;does&lt;/em&gt;. The implementation defines &lt;em&gt;how&lt;/em&gt;. Consumers only know about the what.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Extraction Sequence
&lt;/h2&gt;

&lt;p&gt;I did these in a deliberate order, starting with the simplest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. ChatNotificationTrait → ChatNotificationService&lt;/strong&gt;&lt;br&gt;
Simplest extraction. HTTP webhook calls with message formatting. No complex state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. CrmApiTrait → CRM service classes&lt;/strong&gt;&lt;br&gt;
More complex — bulk write API, sync tracking, DTO transformations. But the interface was clean: sync users, sync orders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. OcrScanApiTrait → DocumentScanner service&lt;/strong&gt;&lt;br&gt;
OCR integration. Extracted behind a &lt;code&gt;DocumentScannerInterface&lt;/code&gt; so we could swap OCR providers later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. ConvertApiTrait → Document conversion services&lt;/strong&gt;&lt;br&gt;
Document format conversion. Straightforward HTTP client wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. ExternalApiTrait → ExternalApiClient service&lt;/strong&gt;&lt;br&gt;
Third-party API integration. Authentication, request signing, response parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. CalculationTrait → CalculatorService&lt;/strong&gt;&lt;br&gt;
The most complex extraction. Domain calculation logic with historical configuration tracking. This one needed a &lt;code&gt;ConfigHistory&lt;/code&gt; model to properly separate the calculation from the controller state.&lt;/p&gt;

&lt;p&gt;Each one took about a day. The full sequence took about two weeks. At no point was the app broken. Users never noticed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Behind Enough Abstraction
&lt;/h2&gt;

&lt;p&gt;The key phrase is "behind enough abstraction for things to continue working." When you extract &lt;code&gt;ChatNotificationTrait&lt;/code&gt; into &lt;code&gt;ChatNotificationService&lt;/code&gt;, the controllers that were using &lt;code&gt;$this-&amp;gt;sendChatNotification()&lt;/code&gt; now call &lt;code&gt;$this-&amp;gt;notificationService-&amp;gt;sendOrderNotification()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But you don't change all the controllers at once. You:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the service&lt;/li&gt;
&lt;li&gt;Bind it in the service provider&lt;/li&gt;
&lt;li&gt;Update one controller&lt;/li&gt;
&lt;li&gt;Run the tests&lt;/li&gt;
&lt;li&gt;Update the next controller&lt;/li&gt;
&lt;li&gt;Run the tests again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If something breaks, you know exactly which controller change caused it. Small steps. Fast feedback. Empiricism over dogma.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Boundaries Help Agents
&lt;/h2&gt;

&lt;p&gt;Here's the thing I didn't fully appreciate until later: &lt;strong&gt;clear boundaries help agents more than documentation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I later started using Claude to build features, the agent could look at &lt;code&gt;App\Services\Notifications\Contracts\NotificationInterface&lt;/code&gt; and immediately understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What notification capabilities exist&lt;/li&gt;
&lt;li&gt;What parameters they take&lt;/li&gt;
&lt;li&gt;How to use them (inject the interface, call the method)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare that to the trait world, where the agent would have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Find the trait&lt;/li&gt;
&lt;li&gt;Read the trait to understand what methods it provides&lt;/li&gt;
&lt;li&gt;Figure out which controller properties the trait depends on&lt;/li&gt;
&lt;li&gt;Hope it's using the trait correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service interface is self-documenting. The trait is a mystery box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture is the best documentation for agents.&lt;/strong&gt; If the code structure is clear, the agent doesn't need instructions. It can read the interfaces and follow the patterns.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Infrastructure Angle
&lt;/h2&gt;

&lt;p&gt;These extractions also cleaned up how we handle external integrations at the infrastructure level. Each service got its own configuration:&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/services.php&lt;/span&gt;
&lt;span class="s1"&gt;'crm'&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;'client_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CRM_CLIENT_ID'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'client_secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CRM_CLIENT_SECRET'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'sync_enabled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CRM_SYNC_ENABLED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'realtime_sync'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CRM_REALTIME_SYNC'&lt;/span&gt;&lt;span class="p"&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;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CRM_SYNC_QUEUE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'crm'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And jobs that previously lived inside traits got extracted into proper Laravel jobs running on dedicated queues:&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;// CRM sync runs on its own Redis queue&lt;/span&gt;
&lt;span class="c1"&gt;// so it doesn't block order notifications&lt;/span&gt;
&lt;span class="no"&gt;QUEUE_CONNECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="no"&gt;REDIS_QUEUE_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queue worker in Docker can be spun up with a profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; queue up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means CRM sync can be slow, flaky, or temporarily broken without affecting the rest of the application. The queue retries failed jobs. The dedicated queue means a CRM outage doesn't back up critical notifications.&lt;/p&gt;

&lt;p&gt;Separating concerns in the code naturally led to separating concerns in the infrastructure. That's the kind of compound benefit you get from doing the refactoring properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test Coverage Story
&lt;/h2&gt;

&lt;p&gt;Before the extractions: the controllers were tested, but the trait logic inside them was tested only indirectly. You couldn't test "does the chat message format correctly?" without making an HTTP request to the controller.&lt;/p&gt;

&lt;p&gt;After the extractions: each service has its own test. The controller tests mock the service interface. The service tests verify the actual logic.&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: testing chat notification meant testing the controller&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;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$admin&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders/1/approve'&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;assertOk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ...and hopefully the notification was sent?&lt;/span&gt;

&lt;span class="c1"&gt;// After: test the service directly&lt;/span&gt;
&lt;span class="nv"&gt;$service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChatNotificationService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$webhookUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mockHttp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$service&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendOrderNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Approved'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$mockHttp&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&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 test pyramid got healthier. More unit tests for services, fewer fat integration tests for controllers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Traits are a code smell when they contain business logic. If you're about to use AI agents on a trait-heavy codebase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify your traits&lt;/strong&gt; — especially the ones with external HTTP calls, complex logic, or shared state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract them behind interfaces&lt;/strong&gt; — contract-first, one service at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind the interface in a service provider&lt;/strong&gt; — so consumers inject the contract, not the implementation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the trait until all consumers are migrated&lt;/strong&gt; — then delete it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the full test suite after every change&lt;/strong&gt; — this is non-negotiable&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: clean boundaries, testable services, swappable implementations, and a codebase the agent can actually navigate.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>refactoring</category>
      <category>laravel</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Linting, Static Analysis, and the Pre-Commit Hook That Saved My Sanity</title>
      <dc:creator>Ian Johnson</dc:creator>
      <pubDate>Mon, 06 Apr 2026 15:09:17 +0000</pubDate>
      <link>https://forem.com/tacoda/linting-static-analysis-and-the-pre-commit-hook-that-saved-my-sanity-18nf</link>
      <guid>https://forem.com/tacoda/linting-static-analysis-and-the-pre-commit-hook-that-saved-my-sanity-18nf</guid>
      <description>&lt;h2&gt;
  
  
  The Agent Writes Code. Who Checks It?
&lt;/h2&gt;

&lt;p&gt;In the last post, I talked about why tests come first when working with an AI agent. Tests tell you &lt;em&gt;if the code works&lt;/em&gt;. But they don't tell you if the code is &lt;em&gt;good&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An agent will happily write code that passes all your tests and is also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inconsistently formatted&lt;/li&gt;
&lt;li&gt;Full of type errors Psalm would catch&lt;/li&gt;
&lt;li&gt;Using deprecated patterns&lt;/li&gt;
&lt;li&gt;Missing semicolons in one file and using them in another&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tests catch behavioral bugs. Linting catches structural rot. You need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tooling Stack
&lt;/h2&gt;

&lt;p&gt;I added five tools to this Laravel + React codebase, and each one closed a different gap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;What It Catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;Code style (PSR-12, Laravel conventions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Psalm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;Static analysis (type errors, null safety, dead code)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prettier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JS/CSS/Blade&lt;/td&gt;
&lt;td&gt;Formatting (consistent whitespace, quotes, line length)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ESLint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript/React&lt;/td&gt;
&lt;td&gt;Lint rules (unused vars, hook deps, accessibility)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;Type checking (compile-time type safety)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One &lt;code&gt;make lint&lt;/code&gt; command runs all five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;lint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pint psalm format eslint typecheck&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;If any of them fail, the change doesn't ship. Period.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Agents Need Checkable Standards
&lt;/h2&gt;

&lt;p&gt;Here's the fundamental problem with giving an AI agent a style guide:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Please use consistent formatting and follow our coding conventions."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's a suggestion. The agent might follow it. It might not. You'll spend your review time catching style issues instead of reviewing logic.&lt;/p&gt;

&lt;p&gt;Now compare:&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="nv"&gt;$ &lt;/span&gt;make lint
Pint ........... FAIL &lt;span class="o"&gt;(&lt;/span&gt;3 files&lt;span class="o"&gt;)&lt;/span&gt;
Psalm .......... PASS
Prettier ....... FAIL &lt;span class="o"&gt;(&lt;/span&gt;1 file&lt;span class="o"&gt;)&lt;/span&gt;
ESLint ......... PASS
TypeScript ..... PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's a fact. The agent can run &lt;code&gt;make lint&lt;/code&gt;, see it failed, fix the issues, and run it again. No ambiguity. No judgment call. Pass or fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prose guides are for humans. Machine-checkable standards are for agents.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the same principle behind CI/CD: don't rely on people to remember the rules. Encode the rules into tools that enforce them automatically.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pre-Commit Hooks: The First Gate
&lt;/h2&gt;

&lt;p&gt;I added a pre-commit hook early in the project. It runs a subset of checks before any commit lands:&lt;/p&gt;


&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;



&lt;p&gt;This catches the most common issues before they even hit CI. When Claude generates code that's formatted wrong, the pre-commit hook blocks the commit. Claude sees the failure, fixes the formatting, and tries again. Zero human intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compound Effect
&lt;/h2&gt;

&lt;p&gt;Each tool on its own catches a category of problems. Together, they create something more powerful: &lt;strong&gt;a narrowing of the failure space.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without any tools, the agent can produce code that's wrong in infinite ways — wrong behavior, wrong types, wrong format, wrong style, wrong patterns.&lt;/p&gt;

&lt;p&gt;Add tests: now the behavior is constrained.&lt;br&gt;
Add Pint: now the PHP style is constrained.&lt;br&gt;
Add Psalm: now the types are constrained.&lt;br&gt;
Add Prettier: now the JS formatting is constrained.&lt;br&gt;
Add ESLint: now the React patterns are constrained.&lt;br&gt;
Add TypeScript: now the frontend types are constrained.&lt;/p&gt;

&lt;p&gt;Each layer removes an entire category of "wrong." What's left is a much smaller space of valid code. The agent's job gets easier because there are fewer ways to be wrong.&lt;/p&gt;

&lt;p&gt;Think of it like bowling bumpers. Each tool is a bumper. The ball (the agent's code) can still miss the pins, but it can't end up in the gutter.&lt;/p&gt;
&lt;h2&gt;
  
  
  CI as the Final Gate
&lt;/h2&gt;

&lt;p&gt;Pre-commit hooks are great, but they can be bypassed (accidentally or intentionally). CI is the gate that can't be skipped.&lt;/p&gt;

&lt;p&gt;The GitHub Actions pipeline runs the full check suite on every push:&lt;/p&gt;


&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;


&lt;p&gt;The pipeline has four stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; — Docker images pushed to GitHub Container Registry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Quality&lt;/strong&gt; — &lt;code&gt;make lint&lt;/code&gt; (Pint, Psalm, Prettier, ESLint, TypeScript)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt; — &lt;code&gt;make test&lt;/code&gt; (2,700+ PHP tests), &lt;code&gt;make test-js&lt;/code&gt; (Vitest)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; — Deployment webhook (only on main, only if everything passes)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing merges to main without passing all four stages. This is the same pipeline for human-written code and agent-written code. No exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: Docker All the Way Down
&lt;/h2&gt;

&lt;p&gt;One thing I want to call out — all of this runs in Docker. Every &lt;code&gt;make&lt;/code&gt; target executes inside the Docker app container. The Makefile is the interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;pint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app ./vendor/bin/pint &lt;span class="nt"&gt;--test&lt;/span&gt;

&lt;span class="nl"&gt;psalm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app ./vendor/bin/psalm

&lt;span class="nl"&gt;format&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app npx prettier &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="s2"&gt;"resources/**/*.{js,ts,tsx,css,scss,blade.php}"&lt;/span&gt;

&lt;span class="nl"&gt;eslint&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app npx eslint &lt;span class="s2"&gt;"resources/js/**/*.{ts,tsx}"&lt;/span&gt;

&lt;span class="nl"&gt;typecheck&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility&lt;/strong&gt; — same PHP version, same Node version, same everything, everywhere&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No "works on my machine"&lt;/strong&gt; — if it passes in Docker locally, it passes in CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent doesn't need to know about local setup&lt;/strong&gt; — it just runs &lt;code&gt;make lint&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Docker stack itself is Ubuntu 24.04 LTS with PHP-FPM, Nginx, MySQL 8.0, and optional Redis + queue worker containers. Everything defined in &lt;code&gt;docker-compose.yml&lt;/code&gt;, everything started with &lt;code&gt;make up&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Queue and Background Jobs
&lt;/h2&gt;

&lt;p&gt;The app processes background jobs — background calculations, CRM syncing, notification dispatch. These run through Laravel's queue system backed by Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml (queue profile)&lt;/span&gt;
&lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
  &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;queue-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan queue:work redis --queue=default,crm --sleep=3 --tries=3&lt;/span&gt;
  &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In tests, &lt;code&gt;QUEUE_CONNECTION=sync&lt;/code&gt; runs jobs synchronously so tests don't depend on Redis. In CI, same thing. In production, Redis handles the real work.&lt;/p&gt;

&lt;p&gt;The point: infrastructure decisions like queue drivers, cache drivers, and session drivers all have test-mode equivalents. Getting these right early means the agent never has to think about them.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add linting before you start feature work.&lt;/strong&gt; Every feature you build without linting is a feature you'll have to retroactively lint later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the fix commands obvious.&lt;/strong&gt; If &lt;code&gt;make lint&lt;/code&gt; fails, the error message should tell you to run &lt;code&gt;make pint-fix&lt;/code&gt; or &lt;code&gt;make format-fix&lt;/code&gt;. The agent reads these messages and acts on them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run everything in Docker.&lt;/strong&gt; The consistency is worth the overhead. You never debug environment differences again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI is not optional.&lt;/strong&gt; It's the only gate you can trust completely. Pre-commit hooks help, but CI enforces.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Each tool is a force multiplier.&lt;/strong&gt; Pint alone doesn't transform your workflow. But Pint + Psalm + Prettier + ESLint + TypeScript + tests + CI + pre-commit hooks? That's a system. And systems compound.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Emerging Pattern
&lt;/h2&gt;

&lt;p&gt;Notice what's happening here. We're not building features yet. We're building &lt;strong&gt;the ability to build features safely&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Tests verify behavior. Linting verifies structure. CI verifies both, automatically, on every push. Docker makes it reproducible. The Makefile makes it accessible.&lt;/p&gt;

&lt;p&gt;This is the foundation. In the next post, we'll start refactoring, extracting traits into services, pulling logic into Actions - and every change will be validated by this exact system.&lt;/p&gt;

&lt;p&gt;When the agent writes a refactoring commit, it runs &lt;code&gt;make lint&lt;/code&gt; and &lt;code&gt;make test&lt;/code&gt;. If both pass, the refactoring preserved behavior and maintained code quality. That's not a guess. That's proof.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>linting</category>
      <category>codequality</category>
    </item>
  </channel>
</rss>
