<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Steve McDougall</title>
    <description>The latest articles on DEV Community by Steve McDougall (@juststevemcd).</description>
    <link>https://dev.to/juststevemcd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F95943%2F1189e345-5ada-4adb-ad56-9033d3ef454c.jpeg</url>
      <title>DEV Community: Steve McDougall</title>
      <link>https://dev.to/juststevemcd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/juststevemcd"/>
    <language>en</language>
    <item>
      <title>Give your Laravel AI agent real-time web knowledge</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Thu, 16 Jul 2026 18:50:58 +0000</pubDate>
      <link>https://dev.to/juststevemcd/give-your-laravel-ai-agent-real-time-web-knowledge-478h</link>
      <guid>https://dev.to/juststevemcd/give-your-laravel-ai-agent-real-time-web-knowledge-478h</guid>
      <description>&lt;p&gt;One of the best things about the Laravel AI SDK is that it doesn't lock you into a provider. Swap OpenAI for Anthropic, add Gemini as a failover, and your agent code barely changes. That's the entire pitch, and it mostly holds up.&lt;/p&gt;

&lt;p&gt;Until your agent needs to browse the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider-agnostic promise has a hole in it
&lt;/h2&gt;

&lt;p&gt;The SDK ships two provider tools for giving agents access to the web: &lt;code&gt;WebSearch&lt;/code&gt; and &lt;code&gt;WebFetch&lt;/code&gt;. They're convenient, a couple lines and your agent can search or fetch a page. But they're not really part of the SDK's unified layer, they're implemented natively by whichever AI provider you're using, which means their availability depends entirely on which model you picked.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WebSearch&lt;/code&gt; works on Anthropic, OpenAI, Gemini, and OpenRouter. &lt;code&gt;WebFetch&lt;/code&gt; only works on Anthropic and Gemini. So if you configure a failover chain that includes Groq, DeepSeek, Mistral, or xAI, and one of those becomes the active provider, your agent quietly loses the ability to browse. Not an error, not a warning, just an agent that stops citing sources or stops noticing the page it was supposed to check even changed.&lt;/p&gt;

&lt;p&gt;That's a rough thing to discover in production. You built a failover chain specifically so a rate limit or outage wouldn't take your feature down, and it turns out one of your fallback providers was silently missing a capability the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even when it works, it's pretty shallow
&lt;/h2&gt;

&lt;p&gt;Set the provider problem aside for a second. Even on a provider where &lt;code&gt;WebSearch&lt;/code&gt; and &lt;code&gt;WebFetch&lt;/code&gt; are both available, what you get back is raw: search results or fetched page content that the model has to reason over itself. There's no schema. There's no guaranteed citation tracking. If you want your agent to return a structured, sourced answer, that reasoning happens somewhere in the model's own inference, not in a layer you control or can test.&lt;/p&gt;

&lt;p&gt;For a lot of use cases, that's fine. For anything where the answer needs to be defensible or reproducible, a research assistant, a competitive brief, anything a user might ask "where did this come from," it's not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the tool yourself, on infrastructure that doesn't care which provider is active
&lt;/h2&gt;

&lt;p&gt;The fix is to stop relying on provider-native web tools and give your agent a custom tool instead, one backed by an API rather than whichever model happens to be answering the prompt. That's exactly what a &lt;code&gt;Tool&lt;/code&gt; class in the Laravel AI SDK is for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;juststeveking/tabstack&lt;/code&gt; gives you the client to build it on. Its &lt;code&gt;agent()-&amp;gt;research()&lt;/code&gt; method takes a question, searches multiple sources, synthesizes an answer, and streams back a report with citations, all as one call.&lt;/p&gt;

&lt;p&gt;Since the package doesn't ship Laravel integration out of the box, the first step is binding it in a service provider so it can be injected anywhere:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Providers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JustSteveKing\Tabstack\Tabstack&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;AppServiceProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceProvider&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;register&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="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;singleton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Tabstack&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="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Tabstack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.tabstack.key'&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the key to &lt;code&gt;config/services.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'tabstack'&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;'key'&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;'TABSTACK_API_KEY'&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;Now the tool itself. This wraps &lt;code&gt;agent()-&amp;gt;research()&lt;/code&gt; and hands the agent back a report with its sources listed:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Tools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JustSteveKing\Tabstack\Requests\AgentResearch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JustSteveKing\Tabstack\Requests\ResearchMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JustSteveKing\Tabstack\Tabstack&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Tools\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Stringable&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;WebResearch&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Tool&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;Tabstack&lt;/span&gt; &lt;span class="nv"&gt;$tabstack&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;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Answers a question by researching multiple sources on the web and returns a synthesized, cited report. Use this when you need current information not in your training data.'&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tabstack&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;agent&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;research&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;params&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;AgentResearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ResearchMode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Fast&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&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;metadata&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="s1"&gt;'citedPages'&lt;/span&gt;&lt;span class="p"&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&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;report&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Sources:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$sources&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&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;required&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The question to research'&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-&amp;gt;result()&lt;/code&gt; call blocks until the stream finishes and hands back a typed &lt;code&gt;ResearchResult&lt;/code&gt; rather than making you consume events yourself, which keeps the tool's &lt;code&gt;handle&lt;/code&gt; method simple. If you'd rather stream progress back to the agent as it happens, the client also gives you &lt;code&gt;-&amp;gt;each()&lt;/code&gt; and raw iteration, but for a tool call the blocking result is usually what you want.&lt;/p&gt;

&lt;p&gt;Wire it into any agent through the &lt;code&gt;tools&lt;/code&gt; method, same as any other tool:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Agents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\WebResearch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasTools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&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;MarketAnalyst&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&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;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You help analyze markets and competitors. Use the research tool whenever you need current information. Always mention your sources.'&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;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&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="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebResearch&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="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;Now switch providers all you want:&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&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;MarketAnalyst&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'What are the current pricing trends for cloud browser automation APIs?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Groq&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 agent's ability to research the web doesn't depend on which of those three ends up answering the prompt. The tool is yours, it runs the same regardless of which model called it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats a raw fetch too
&lt;/h2&gt;

&lt;p&gt;You could build something similar with &lt;code&gt;WebFetch&lt;/code&gt; on a supported provider, pointing it at a specific known URL. But that only works when you already know which page has the answer. &lt;code&gt;/research&lt;/code&gt; is built for the opposite case, a question with no known source, where the value is in picking the right sources, reading them, and synthesizing a single answer with the receipts attached. That's a genuinely different job than fetching one page, and it's the piece the SDK's built-in tools don't attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The Laravel AI SDK's provider independence is one of its best features, right up until you reach for a tool that isn't actually part of that independence. Web access shouldn't be something your agent loses depending on which model answered the prompt. Building it as a real &lt;code&gt;Tool&lt;/code&gt; class backed by an API, rather than leaning on whatever the current provider happens to expose, keeps that promise intact.&lt;/p&gt;

&lt;p&gt;If you're building agents with the Laravel AI SDK and want this kind of tool ready to go, &lt;a href="https://packagist.org/packages/juststeveking/tabstack" rel="noopener noreferrer"&gt;juststeveking/tabstack on Packagist&lt;/a&gt; is where I'd start.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>sdk</category>
      <category>agents</category>
    </item>
    <item>
      <title>The Right Agent at the Right Time</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Wed, 15 Jul 2026 18:25:09 +0000</pubDate>
      <link>https://dev.to/juststevemcd/the-right-agent-at-the-right-time-bml</link>
      <guid>https://dev.to/juststevemcd/the-right-agent-at-the-right-time-bml</guid>
      <description>&lt;p&gt;There are two ways I keep watching teams get AI wrong, and they are mirror images of each other.&lt;/p&gt;

&lt;p&gt;The first team fights it. Every line an assistant writes gets rewritten by hand, because trusting it feels reckless. Generation is treated as a threat to be contained rather than a capability to be aimed. These teams perform a lot of ceremony and capture almost none of the speed, and they tell themselves that is the price of quality.&lt;/p&gt;

&lt;p&gt;The second team surrenders to it. Requirements live in a chat log, the assistant produces a pull request nobody can properly review because there is no written intent to review it against, and the definition of done is "it ran once on my machine". This is fast right up until the moment it is catastrophically slow, usually around the third feature, when the context window fills up and the model starts quietly contradicting decisions it made an hour earlier.&lt;/p&gt;

&lt;p&gt;Both teams are asking the wrong question. They are asking how much AI to use, as if the answer sits on a dial between zero and one. The useful question is different. Which work belongs to a machine, at which moment in the life of a change, and who holds the gate between one moment and the next?&lt;/p&gt;

&lt;p&gt;That question has a good answer, and once you have it you can build a way of working around it. I want to walk through a development lifecycle that puts the right agent at the right time, wire it to the standards that keep the architecture honest, and describe the thin runtime you would put underneath to hold the whole thing together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle we are aiming for
&lt;/h2&gt;

&lt;p&gt;Two methods do the heavy lifting, and I am going to lean on the pieces rather than re-argue them.&lt;/p&gt;

&lt;p&gt;Shape Up gives us the outer track. Work is shaped into a bounded pitch before anyone commits to it, then bet on at a betting table, then built by a team that owns the whole thing for a fixed appetite. The word appetite matters. We do not estimate how long a change will take and then miss the estimate. We decide how much the change is worth, fix that budget, and vary the scope to fit. When the work threatens to overrun, we cut scope, not corners, and we never quietly extend the clock.&lt;/p&gt;

&lt;p&gt;OpenSpec gives us the engine inside that track. A shaped pitch is expanded, with a single command, into concrete artefacts: a proposal that says why and what, a set of specs whose acceptance scenarios define done, a design that records the approach, and a task list. The work is carried out against those specs, published, reviewed, and then folded back into a living source of truth. The specs sit in the repository next to the code, so the next change starts from an accurate picture instead of a fading memory.&lt;/p&gt;

&lt;p&gt;There is a gap in that description, and it is the gap this piece is really about. The specs tell you what a change does. They say nothing about how your team has decided to build things. Left to itself, a model filling in a design reaches for whatever architecture is most common in its training data, which is almost never the architecture your team actually agreed on. You end up with a pattern you banned two years ago, or a queue where you standardised on handling things in the request, drizzled through the codebase one well-meaning pull request at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sources of truth
&lt;/h2&gt;

&lt;p&gt;The fix is to recognise that a healthy lifecycle has not two sources of truth but three, and to keep them apart.&lt;/p&gt;

&lt;p&gt;The pitch owns why. It is transient, it belongs to one change, and it evaporates once the work is bet on or dropped.&lt;/p&gt;

&lt;p&gt;The specs own what. They describe the behaviour of a specific change, and they get archived into a living description of what the system does.&lt;/p&gt;

&lt;p&gt;Architectural Decision Records own how. They are durable, they cut across every change, and they record the standing decisions your team has made about the way you build. An ADR is a small, deliberately boring document: a status, the context that forced the decision, the decision itself, and the consequences you are signing up for. You do not edit an accepted one. You supersede it with a new one, so the trail of reasoning survives every change of mind.&lt;/p&gt;

&lt;p&gt;Here is the move that makes ADRs earn their place in an AI-driven lifecycle. The accepted decisions become the constraints every agent works within, and a change that needs a new decision cannot proceed on an agent's say-so. Standards flow into the specs and the conversations, and new standards flow back out under human control. Consumed on the way in, produced on the way out, and a person in the middle of both.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is a state machine with mixed owners
&lt;/h2&gt;

&lt;p&gt;If you list the stages a change moves through, shaped, bet on, proposed, built, published, reviewed, archived, and then ask a simple question at each one, who actually does the work here, you get three answers, not one.&lt;/p&gt;

&lt;p&gt;Some stages are pure human judgement, and an agent has no business touching them. Deciding the appetite is a judgement about worth. Placing the bet is a decision about what we are willing to not do. Accepting an architectural decision is a commitment the whole team lives with for years. No model gets a vote on any of them.&lt;/p&gt;

&lt;p&gt;Some stages are the machine drafting and a human correcting. Expanding a rough pitch into a detailed spec is exactly the kind of structured, high-value work a reasoning model is good at, and exactly the kind of thing a human should read and correct rather than write from a blank page. Drafting a pull request description from a spec is the same shape of work.&lt;/p&gt;

&lt;p&gt;And some stages are effectively automatic. Folding a merged change back into the source of truth is mechanical. It should simply happen.&lt;/p&gt;

&lt;p&gt;That is the whole design, and most agent tooling I see misses it by assuming every stage wants the same treatment. The question is never how clever the model is. It is where the authority sits. Architecture is the sharpest case of human authority in the list. An agent may apply an accepted decision, or draft a proposed one, and that is the ceiling of what it is allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime is the thing that holds the gates
&lt;/h2&gt;

&lt;p&gt;Once you see the lifecycle this way, what you build underneath is small. It is a workflow engine that knows the sequence of stages, knows which of them require a person, knows which agent belongs to each, and moves a change forward one step at a time. Nothing exotic. If you have ever built a workflow engine, you already know the shape.&lt;/p&gt;

&lt;p&gt;What earns its keep is not the cleverness of the runtime but the rules it refuses to break. It will not skip a stage that needs a human. It will not run an agent at a stage where no agent belongs. It will not let a change outrun its appetite. And it will not advance a change on the back of an architectural decision no human has accepted. Four rules that teams normally rely on willpower to follow, moved into something that does not get tired and does not have a deadline.&lt;/p&gt;

&lt;p&gt;That last point is worth saying plainly. A rule that lives only in a wiki page is a suggestion, and suggestions lose to deadlines every time. A rule the runtime enforces is one the team can actually rely on, because it holds at six in the evening when the person driving it would have waved anything through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right agent at the right stage
&lt;/h2&gt;

&lt;p&gt;Walk the stages and the shape of "the right tool at the right time" stops being a slogan.&lt;/p&gt;

&lt;p&gt;At shaping, a fast and cheap critic argues with the draft pitch. It is not there to shape the work, because shaping is human judgement. It pushes back: is this appetite honest, or is a big piece of work wearing a small one's clothes, where are the rabbit holes you have not patched, and, importantly, does this work imply an architectural decision we have not yet made. Surfacing that last one before the betting table means the bet is placed knowing a new decision is coming.&lt;/p&gt;

&lt;p&gt;At betting, there is no agent at all, and that absence is the most important design choice in the whole system. Betting is a decision about what we are willing to sacrifice this cycle, and I do not want a model anywhere near it. Deliberately wiring in nothing, and making that nothing visible rather than accidental, is how you stop the automation creeping into a place it should never reach.&lt;/p&gt;

&lt;p&gt;At proposing, a reasoning model expands the pitch into the full spec, and this is where the standards do their real work. The accepted decisions are handed to the model as constraints, so the design it produces conforms to what your team agreed rather than to what is statistically popular. When the design fits inside those decisions, it cites the ones it stands on, so a reviewer can trace the architecture back to the record that authorised it. When the design escapes them, the model does not improvise. It drafts a new decision record, writes up the context and the tradeoffs, and stops. A design that contradicts an existing decision is handled the same way, as a proposal to supersede that record, which is a heavier call again and just as firmly a human one.&lt;/p&gt;

&lt;p&gt;At building, a coding agent drives the work against the task list and reports where it is on the hill, still figuring things out or executing what it already understands. The agent is fast and trustworthy on the downhill, on the known work. When it reports itself stuck on the uphill for too long, that is your earliest possible warning, long before a missed task count would tell you anything. The standards ride along here too, because they constrain the code as much as the design.&lt;/p&gt;

&lt;p&gt;At publishing, the drafting is mechanical and the action is not. An agent writes the pull request description from the spec and the decisions it cited, and then it stops. A person opens it. Anything that changes the world outside the repository stays behind a human hand.&lt;/p&gt;

&lt;p&gt;At reviewing, a first pass by a model saves the human reviewer from wasting their hour. It checks two mechanical questions before a person looks: does the implementation satisfy every scenario in the specs, and does it conform to the decisions the design cited. A change that quietly wandered off its stated architecture gets caught cheaply, which leaves the human free to judge the only thing a human can, whether this is genuinely the right solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gates are the point
&lt;/h2&gt;

&lt;p&gt;The temptation is always to remove gates for speed, so let me defend them.&lt;/p&gt;

&lt;p&gt;Three kinds matter. The judgement gate, on shaping, betting, and review, where the work waits until a person approves. The architecture gate, where a proposed or superseding decision holds the change until a human rules on it. And the side-effect gate, on publishing, merging, and deleting, actions that reach outside the repository and must never fire on a model's confidence alone.&lt;/p&gt;

&lt;p&gt;The failure mode of agent tooling is rarely a bad line of code. Review catches that. It is an irreversible action, or an unexamined architectural choice, taken confidently at the wrong moment. You put those behind gates precisely so the gate holds when the person driving it is tired and behind and would have clicked yes to anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circuit breaker, and standards that feed both ways
&lt;/h2&gt;

&lt;p&gt;The appetite is not advisory. When a change blows its budget, the work does not roll forward and nobody negotiates an extension. It goes back to shaping, to be re-shaped with what was learned and bet on again from scratch. The default is that unfinished work stops, not that it drags, and that default is the single healthiest habit Shape Up gives you.&lt;/p&gt;

&lt;p&gt;The standards behave the same way, enforced rather than hoped for. Every spec and every agent conversation is bounded by decisions a human accepted, so the architecture cannot drift one convenient pull request at a time. And every new decision surfaces as its own gate rather than hiding inside a diff, so the team decides architecture on purpose, with the reasoning written down, at the moment the choice is actually made.&lt;/p&gt;

&lt;p&gt;It is worth being clear about how these records relate to a plain conventions file, because most teams already keep one of those. A conventions file is the distilled current rule an agent follows day to day. It tells the model what to do. A decision record is the reasoning behind a rule, captured at the moment it was made, so that when someone later proposes to overturn it they are arguing with the original context rather than guessing at it. Keep both, and generate the conventions from the accepted decisions if you can, so the two never drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this, and not just a set of commands
&lt;/h2&gt;

&lt;p&gt;You might reasonably ask why a lifecycle needs a runtime at all, when the tools already give you the commands and the decision records are just files in a folder.&lt;/p&gt;

&lt;p&gt;Because the process is exactly the thing that gets skipped under pressure, and the person under pressure is the worst possible guardian of it. The value of the runtime is that it holds the gate the human would not. It refuses to advance past review late in the day. It trips the breaker on the change everyone is emotionally committed to finishing. It leaves betting to people by having nothing to delegate it to. And it will not let a design invent architecture, because a proposed decision parks the change until a person signs the record. The discipline stops depending on everyone remembering to be disciplined and becomes a property of the system.&lt;/p&gt;

&lt;p&gt;The heart of all this, the sequence of stages, the gates, the appetite, the decision log, has nothing to do with any particular framework or even any particular language. It is a workflow engine with mixed authority and a standards layer, and I would build it to stay independent of any framework and let the AI tooling plug in at the edges. Which, you will notice, is itself an architectural decision, the kind of thing that belongs in a record with its context and its consequences written down. The runtime ends up built the way its own decision log says to build things, and that is the whole idea quietly working on itself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next time I want to open up the two agents I skated past here, the critic that argues with your pitch and the reviewer that reads AI-generated work for both correctness and conformance, because those are the two places where getting the model's role slightly wrong quietly poisons everything downstream.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiassisteddevelopment</category>
      <category>specdrivendevelopment</category>
      <category>shapeup</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Fibers Plus the Polling API: What Async PHP Actually Looks Like Now</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Wed, 15 Jul 2026 18:25:05 +0000</pubDate>
      <link>https://dev.to/juststevemcd/fibers-plus-the-polling-api-what-async-php-actually-looks-like-now-1coi</link>
      <guid>https://dev.to/juststevemcd/fibers-plus-the-polling-api-what-async-php-actually-looks-like-now-1coi</guid>
      <description>&lt;p&gt;I wrote about the Polling API RFC a few weeks back and framed it as underrated, mostly because the coverage kept missing that the real motivation was the internal &lt;code&gt;php_poll.h&lt;/code&gt; API rather than the userspace &lt;code&gt;Io\Poll&lt;/code&gt; classes. I stand by that. But I got a fair bit of feedback along the lines of "fine, but what do I actually build with it?" That is a reasonable question, and it is the one I want to answer here.&lt;/p&gt;

&lt;p&gt;The timing is good for it too. The RFC passed 33 to 1 with four abstentions and closed on 3 June, and the implementation is already merged to master. Alpha 1 is scheduled for 2 July, feature freeze and Beta 1 land in mid-August, and GA is pencilled in for 19 November. Which means the Polling API has gone from RFC text to something you can compile off master and poke at right now, ahead of the formal alpha tag. So that is what I did.&lt;/p&gt;

&lt;p&gt;At the same time, there is a genuinely live argument running through the PHP community right now about which async approach to actually use in production: Fibers with a userland event loop, ReactPHP, Swoole, or Amp v3 on Revolt. Nobody has tied that debate cleanly to what the Polling API changes. I want to do that properly, with real code, not just vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fibers are the concurrency model, the Polling API is the missing primitive
&lt;/h2&gt;

&lt;p&gt;These two things get conflated constantly, so let me separate them cleanly before we build anything.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Fiber&lt;/code&gt; is PHP's cooperative concurrency primitive, in core since 8.1. It gives you a function that can pause itself with &lt;code&gt;Fiber::suspend()&lt;/code&gt; and be resumed later with &lt;code&gt;$fiber-&amp;gt;resume()&lt;/code&gt;, keeping its own stack and local state intact across the pause. That is the entire feature. It does not know anything about sockets, timers, or I/O. It is a control-flow primitive, nothing more.&lt;/p&gt;

&lt;p&gt;An event loop is the thing that decides when to resume a suspended fiber. Historically in PHP, that meant &lt;code&gt;stream_select()&lt;/code&gt; under the hood, or reaching for a PECL extension like ext-uv or ext-event to get native epoll or kqueue. ReactPHP ships four separate loop implementations, &lt;code&gt;StreamSelectLoop&lt;/code&gt;, &lt;code&gt;ExtUvLoop&lt;/code&gt;, &lt;code&gt;ExtEventLoop&lt;/code&gt;, &lt;code&gt;ExtEvLoop&lt;/code&gt;, precisely because there was no single reliable native primitive to build on. AMPHP built its own equivalent story through Revolt.&lt;/p&gt;

&lt;p&gt;The Polling API is that missing primitive. It does not replace Fibers, and it is not an event loop itself. It is the one thing underneath an event loop that PHP never had natively: a fast way to ask the operating system "which of these file descriptors are ready" without maintaining four separate backend implementations to get there.&lt;/p&gt;

&lt;p&gt;Put together, Fibers give you pausable functions and the Polling API gives you an efficient way to know when to resume them. That is the whole story. Everything else, timers, cancellation, backpressure, is stuff you or a library builds on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the smallest possible scheduler
&lt;/h2&gt;

&lt;p&gt;The best way to understand what Amp v3 and ReactPHP are actually doing internally is to build a toy version yourself. So let us do that: a scheduler that runs several fibers concurrently, each one fetching a URL over a raw non-blocking socket, resumed by a single &lt;code&gt;Io\Poll\Context&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;Io\Poll\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MiniScheduler&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;Context&lt;/span&gt; &lt;span class="nv"&gt;$poll&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** @var array&amp;lt;int, Fiber&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$fibers&lt;/span&gt; &lt;span class="o"&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;__construct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;poll&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;Context&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;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$task&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;$fiber&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;Fiber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$fiber&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;start&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;$fiber&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isTerminated&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;;&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;fibers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;spl_object_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fiber&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fiber&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;run&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;while&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;fibers&lt;/span&gt; &lt;span class="o"&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$fiber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;'fiber'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

                &lt;span class="nv"&gt;$fiber&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$watcher&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;$fiber&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isTerminated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;unset&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;fibers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;spl_object_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fiber&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="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;awaitReadable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$fiber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fiber&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$handle&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;StreamPollHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fiber'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fiber&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Fiber&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;suspend&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&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;awaitWritable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$fiber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fiber&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$handle&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;StreamPollHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fiber'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fiber&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Fiber&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;suspend&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&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;That is genuinely most of it. &lt;code&gt;spawn()&lt;/code&gt; starts a fiber, which runs until it hits &lt;code&gt;Fiber::suspend()&lt;/code&gt;. &lt;code&gt;run()&lt;/code&gt; calls &lt;code&gt;wait()&lt;/code&gt; on the poll context, which blocks until a watched stream is actually readable, then resumes exactly the fiber waiting on it. No polling in a tight loop, no scanning every stream on every tick. The operating system tells us the moment something is ready, and we hand control straight back to the fiber that cares.&lt;/p&gt;

&lt;p&gt;Now the actual task, a non-blocking fetch over a raw socket. This needed one correction from my first draft of this: writing to a non-blocking stream can short-write, so the request has to be written in a loop that awaits writability whenever the kernel send buffer pushes back, rather than trusting a single &lt;code&gt;fwrite()&lt;/code&gt; call to send the whole thing:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;writeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;MiniScheduler&lt;/span&gt; &lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$data&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;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&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;$written&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Write failed'&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;$written&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;awaitWritable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$written&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;function&lt;/span&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;MiniScheduler&lt;/span&gt; &lt;span class="nv"&gt;$scheduler&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;$host&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;$path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_socket_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tcp://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:80"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&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;$stream&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Connect to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;stream_set_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;writeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"GET &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; HTTP/1.1&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;Host: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;Connection: close&lt;/span&gt;&lt;span class="se"&gt;\r\n\r\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;feof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;awaitReadable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&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;On termination: the loop relies on &lt;code&gt;feof()&lt;/code&gt; flipping true after a read that hits end of stream, which is the same pattern the RFC's own TCP client example uses. It holds here for a reason worth being explicit about: &lt;code&gt;awaitReadable()&lt;/code&gt; only requests &lt;code&gt;Event::Read&lt;/code&gt;, but &lt;code&gt;Event::Error&lt;/code&gt; and &lt;code&gt;Event::HangUp&lt;/code&gt; are automatically monitored by every backend regardless of what you asked for, so &lt;code&gt;wait()&lt;/code&gt; still returns the watcher when the server closes the connection. The code does not branch on &lt;code&gt;hasTriggered()&lt;/code&gt; before calling &lt;code&gt;fread()&lt;/code&gt;, it just calls it unconditionally once woken, and &lt;code&gt;fread()&lt;/code&gt; on a closed non-blocking stream returns an empty string and sets the EOF flag rather than blocking or erroring. That is what makes the final iteration exit cleanly instead of hanging on the last descriptor. If you want to be stricter about it, checking &lt;code&gt;hasTriggered(Event::Read)&lt;/code&gt; before reading and handling &lt;code&gt;Event::HangUp&lt;/code&gt; separately is the more defensive version, I just did not want to bury the core loop under branches that do not change the outcome for a plain HTTP GET.&lt;/p&gt;

&lt;p&gt;Wire five of these into a scheduler and run them concurrently:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$scheduler&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;MiniScheduler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$hosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'httpbin.org'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'jsonplaceholder.typicode.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hosts&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&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="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;" bytes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fibers, each blocked on its own socket, all resumed independently by one &lt;code&gt;Io\Poll\Context&lt;/code&gt;. This is, structurally, what Revolt's driver does and what ReactPHP's loop does. We just wrote the smallest version that still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not showing you, on purpose
&lt;/h2&gt;

&lt;p&gt;This scheduler has no timers, no cancellation tokens, no error propagation from a fiber back to the caller, no protection against a fiber that never suspends and blocks everyone else, and no DNS handling beyond whatever &lt;code&gt;stream_socket_client()&lt;/code&gt; gives you for free. That is not an oversight. It is the point.&lt;/p&gt;

&lt;p&gt;Amp v3 and ReactPHP exist because building a correct, safe version of this is genuinely hard, and getting cancellation and backpressure right across hundreds of concurrent fibers is not a weekend project. Do not take this scheduler anywhere near production. Take the understanding it gives you into whichever library you actually reach for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarking it, honestly
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about the limits here before I give you numbers. PHP 8.6 is still pre-alpha as I write this, self-compiled off master, not a packaged build you can &lt;code&gt;apt install&lt;/code&gt;. There is no stable release, no distro packages, and the &lt;code&gt;Io\Poll&lt;/code&gt; implementation can still change before GA. Treat what follows as directional, not a production SLA.&lt;/p&gt;

&lt;p&gt;I ran the three-host fetch above four ways: sequential blocking &lt;code&gt;file_get_contents()&lt;/code&gt; calls, the mini scheduler above on the 8.6 alpha build, ReactPHP's &lt;code&gt;StreamSelectLoop&lt;/code&gt; on 8.4, and Amp v3 on Revolt on 8.4. Same three hosts, same simple GET request, ten runs each, median taken.&lt;/p&gt;

&lt;p&gt;Sequential blocking came in around the sum of all three round trips, no surprise there, since each request waits for the last to finish before starting. The mini scheduler and ReactPHP's &lt;code&gt;StreamSelectLoop&lt;/code&gt; landed close to each other, both roughly bounded by the slowest single request rather than the sum, which is exactly what you want from concurrent I/O. Amp v3 on Revolt was marginally ahead of both, which tracks, since Revolt has years of tuning behind it that my forty-line scheduler does not.&lt;/p&gt;

&lt;p&gt;The interesting result was not the timing, it was the CPU behaviour. &lt;code&gt;stream_select()&lt;/code&gt; based loops do measurably more userspace work rescanning descriptor sets as the number of watched streams grows. The &lt;code&gt;Io\Poll\Context&lt;/code&gt; version stayed flat. Three streams versus thirty streams cost roughly the same per &lt;code&gt;wait()&lt;/code&gt; call, because epoll does not rescan the whole set, it just tells you what is ready. That is the actual payoff, and it will not show up clearly until someone benchmarks this at real concurrency, hundreds or thousands of streams, not three.&lt;/p&gt;

&lt;p&gt;One honest gap in this comparison: ReactPHP and Amp v3 ran on 8.4, the mini scheduler ran on 8.6 pre-alpha, so what I measured is loop implementation and PHP version changing together, not the loop in isolation. A same-version comparison, ReactPHP and Amp v3 both running against a Polling API backend on the same 8.6 build, would isolate the loop's contribution properly. That is not possible yet because neither project has shipped an &lt;code&gt;Io\Poll&lt;/code&gt; driver, which is rather the point of this whole piece. Take the CPU behaviour as the more reliable signal here, since that is architectural and does not depend on which PHP build ran it, and take the wall-clock numbers as a rough sanity check rather than a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves Fibers versus ReactPHP versus Swoole versus Amp v3
&lt;/h2&gt;

&lt;p&gt;This is the argument I said I would tie together, so here is where I land on it.&lt;/p&gt;

&lt;p&gt;Swoole is not really part of this comparison, and treating it as one option among four is where a lot of the online debate goes wrong. Swoole is a different PHP runtime. It replaces how your application boots, patches core functions to be non-blocking transparently, and gives you a production HTTP server with worker processes built in. You do not add Swoole to an app, you build the app for Swoole from the start. It is genuinely excellent for the right workload, and the C-level coroutine switching gives it a real edge over userland Fibers when you need every millisecond and have the ops discipline to manage a compiled extension in your Docker images. The Polling API changes nothing about that trade-off, since Swoole was never limited by the thing the Polling API fixes.&lt;/p&gt;

&lt;p&gt;ReactPHP and Amp v3 are the ones actually affected here, and in the same direction. Both exist to give you an event loop on top of Fibers without asking you to rewrite your application around a different runtime. Both have spent years maintaining multiple backend implementations, &lt;code&gt;StreamSelectLoop&lt;/code&gt;, &lt;code&gt;ExtUvLoop&lt;/code&gt;, &lt;code&gt;ExtEventLoop&lt;/code&gt;, &lt;code&gt;ExtEvLoop&lt;/code&gt; for ReactPHP, an equivalent spread for Amp, purely to paper over the fact that PHP core never gave them a single reliable native polling primitive. The Polling API is that primitive, finally, and it collapses the reason those parallel implementations exist. I would expect Revolt in particular to pick this up quickly as a native backend, since Revolt already sits underneath Amp v3 as the shared low-level driver, and this is exactly the kind of internal plumbing it was built to abstract over.&lt;/p&gt;

&lt;p&gt;None of this makes the choice between ReactPHP and Amp v3 disappear. That is still mostly a style question. Amp v3's fiber-first API reads closer to synchronous code, ReactPHP's promise-chaining style gives you more explicit control over the loop if you want it, and they are interoperable through &lt;code&gt;revolt/event-loop-adapter-react&lt;/code&gt; if you genuinely need both in one process. What the Polling API does is remove the excuse for either of them to keep maintaining bespoke native backends, and it means a five dollar droplet running vanilla PHP 8.6 gets the same epoll performance that used to require a compiled PECL extension most shared hosts never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick PestPHP check on the scheduler
&lt;/h2&gt;

&lt;p&gt;If you want to verify the concurrency claim rather than take my word for the timings, this is roughly how I tested it, using two servers that each sleep for a fixed period before responding, to confirm the total time tracks the slowest request rather than the sum:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'resumes concurrent fibers without serialising the wait time'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$scheduler&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;MiniScheduler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'sleepy-one.test'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sleepy-two.test'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/sleep?ms=200'&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="nv"&gt;$scheduler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&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="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$started&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$elapsed&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;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.35&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;Two requests that each take roughly two hundred milliseconds finishing in well under the combined four hundred is the whole test. It is not a sophisticated assertion, but it is the one that actually matters here, and it is the one that fails immediately if you accidentally write blocking code inside a fiber and forget it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;If you are building application code today, reach for Amp v3 or ReactPHP, not a hand-rolled scheduler, and pick based on whether you want fiber-native ergonomics or explicit promise control. If you are running a workload where every millisecond and every byte of memory per connection matters and you have the operational maturity for a compiled extension, Swoole earns its complexity. If you maintain one of those libraries yourself, the Polling API is the thing to go build against right now, while it is still ahead of alpha and your feedback can actually shape what ships in November.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;PHP spent a decade being told it could not do this properly. Turns out it just needed the operating system to stop being a stranger.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>php86</category>
      <category>fibers</category>
      <category>asyncphp</category>
    </item>
    <item>
      <title>The Polling API Is the Most Underrated RFC PHP Has Shipped in Years</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 23 Jun 2026 21:09:47 +0000</pubDate>
      <link>https://dev.to/juststevemcd/the-polling-api-is-the-most-underrated-rfc-php-has-shipped-in-years-2d32</link>
      <guid>https://dev.to/juststevemcd/the-polling-api-is-the-most-underrated-rfc-php-has-shipped-in-years-2d32</guid>
      <description>&lt;p&gt;While most of the community spent the spring arguing about generics, a different RFC slipped into PHP 8.6 with almost none of the attention it deserved. No long Twitter threads. No blog posts dissecting the implications. Just a quiet vote that closed on the third of June with 33 in favour, one against, and four abstaining, from a list of names that includes the Composer author, the FrankenPHP creator, and a healthy chunk of the people who actually maintain the async libraries you depend on.&lt;/p&gt;

&lt;p&gt;That RFC is the Polling API, authored by Jakub Zelenka as part of his ongoing stream evolution work. I want to make the case that it is the most consequential thing to land in PHP in years, and that the reason nobody is talking about it is exactly the reason it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem you have been quietly working around
&lt;/h2&gt;

&lt;p&gt;If you have ever written anything that needs to watch more than one socket at a time, you have met &lt;code&gt;stream_select()&lt;/code&gt;. It is the only I/O multiplexing primitive PHP has shipped for most of its life, and it is built on the &lt;code&gt;select()&lt;/code&gt; system call from 1983. It works. It also carries a set of limitations that every async library author has had to engineer around.&lt;/p&gt;

&lt;p&gt;The first is the file descriptor ceiling. The current implementation caps out at around 1024 descriptors on most systems, which is fine until the moment it very much is not. The second is the complexity. &lt;code&gt;select()&lt;/code&gt; is O(n): it scans every descriptor you hand it on every single call, so performance degrades as you add connections. The third is that it gives you no access to the mechanisms the rest of the world has been using for two decades. There is no epoll on Linux, no kqueue on BSD or macOS, no event ports on Solaris. There is no edge-triggered mode and no one-shot mode.&lt;/p&gt;

&lt;p&gt;This is why every serious async runtime reaches past &lt;code&gt;select()&lt;/code&gt;. Node, Nginx, Go's net package, Rust's Tokio: they all sit on epoll or kqueue, because that is the foundation high-concurrency networking is built on. PHP was the last major runtime without native access to it, which is why libraries like AMPHP and ReactPHP have shipped multiple driver backends for years. You write your StreamSelect driver for the common case, then a separate libuv driver for the people who installed ext-uv, because a single native high-performance option simply did not exist. That "install ext-uv for performance" line in the README is a workaround for a hole in the language itself.&lt;/p&gt;

&lt;p&gt;The Polling API fills the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually is, and what it is not
&lt;/h2&gt;

&lt;p&gt;The proposal adds a small set of classes under a new &lt;code&gt;Io\Poll&lt;/code&gt; namespace. It automatically selects the best polling backend for the platform you are running on, and exposes a consistent interface on top of it. On Linux you get epoll, on macOS and BSD you get kqueue, on Solaris and illumos you get event ports, on Windows you get WSAPoll, and everything else falls back to &lt;code&gt;poll()&lt;/code&gt;. You do not have to think about any of that. You create a context and it picks the right backend.&lt;/p&gt;

&lt;p&gt;Here is the whole thing in one breath. You create a &lt;code&gt;Context&lt;/code&gt;, you wrap a stream in a &lt;code&gt;StreamPollHandle&lt;/code&gt;, you add it to the context with the events you care about, and you call &lt;code&gt;wait()&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;Io\Poll\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$poll&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;Context&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_socket_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tcp://0.0.0.0:8080'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;stream_set_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$server&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="nv"&gt;$poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&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;StreamPollHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'server'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;while&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// a watcher only comes back if it has events ready&lt;/span&gt;
        &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHandle&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;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasTriggered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// accept, read, write, whatever this descriptor needs&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;That is the shape of it. &lt;code&gt;wait()&lt;/code&gt; blocks until something is ready or the timeout expires, then hands you back only the watchers that actually triggered. No scanning the full set yourself, no guessing which descriptor woke you up.&lt;/p&gt;

&lt;p&gt;The important thing to understand is what the RFC deliberately leaves out. This is not an event loop. There are no timers in the first cut, no signal handling, no child process management. It is one primitive: a way to watch file descriptors efficiently using whatever the operating system does best. If you want a full event loop with all the trimmings, you still reach for AMPHP or ReactPHP. The difference is that those libraries can now sit on a single native backend instead of maintaining their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody is talking about
&lt;/h2&gt;

&lt;p&gt;Here is where I think the coverage has missed the real story. Every article I have seen frames this as a userspace feature, a faster &lt;code&gt;stream_select()&lt;/code&gt; for people building WebSocket servers. That is a genuine benefit, and it is also explicitly the secondary one.&lt;/p&gt;

&lt;p&gt;Read the RFC carefully and the primary motivation is the internal API. Jakub is building a unified polling interface that PHP core and extensions can share, and the userspace classes are a gift that falls out of having done that work properly. The internal &lt;code&gt;php_poll.h&lt;/code&gt; header is the actual point.&lt;/p&gt;

&lt;p&gt;Why does that matter to you, someone writing application code who will probably never type &lt;code&gt;new Context()&lt;/code&gt;? Because of what it unlocks underneath. Safe, efficient signal handling in ZTS, which is exactly what FrankenPHP needs for its goroutine-based threading mode. More flexible event handling in PHP-FPM workers, replacing the ad-hoc implementations that exist today. A cross-platform timer foundation, which has been a genuine pain on macOS. A standard interface that the sockets and curl extensions can build on rather than each rolling their own.&lt;/p&gt;

&lt;p&gt;The future scope section reads like a roadmap for the next few years of PHP's networking internals: &lt;code&gt;SocketPollHandle&lt;/code&gt;, &lt;code&gt;CurlPollHandle&lt;/code&gt;, &lt;code&gt;TimerHandle&lt;/code&gt;, &lt;code&gt;SignalHandle&lt;/code&gt;, FPM migrating onto the internal loop, and the consolidation of all the scattered polling code across the codebase into one place. None of that is glamorous. All of it is the kind of foundational plumbing that quietly raises the ceiling for everything built on top.&lt;/p&gt;

&lt;p&gt;The best infrastructure changes are invisible. You do not notice epoll. You notice that Nginx handles ten thousand connections without sweating, and you never think about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design choices worth admiring
&lt;/h2&gt;

&lt;p&gt;A couple of decisions in the API are worth slowing down on, because they tell you something about how carefully this was put together.&lt;/p&gt;

&lt;p&gt;The first is the &lt;code&gt;Handle&lt;/code&gt; interface. It is a marker interface with no methods, and you cannot implement it from userland. Try, and you get a fatal error at class declaration time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fatal error: Io\Poll\Handle cannot be implemented by user classes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks restrictive until you understand why. Every concrete handle type has to register a C-level operations table (&lt;code&gt;php_poll_handle_ops&lt;/code&gt;) that tells the backend how to extract the underlying file descriptor and check whether the handle is still valid. A userland class cannot provide that, so allowing it to implement the interface would only produce handles that do not work. By locking the interface to internal classes, the contract stays enforceable: any &lt;code&gt;Handle&lt;/code&gt; that reaches the polling backend is guaranteed to have a working ops table. If you need to poll a custom resource, you wrap a stream in &lt;code&gt;StreamPollHandle&lt;/code&gt; and let the existing machinery do its job. Clean userspace surface, all the messy file descriptor logic kept on the C side where it belongs.&lt;/p&gt;

&lt;p&gt;The second is the &lt;code&gt;Watcher&lt;/code&gt;. You never construct one directly; &lt;code&gt;Context::add()&lt;/code&gt; returns it to you, and it carries everything about that registration. You can ask it which events it is watching, which ones just triggered, and the arbitrary user data you attached. You can modify it in place or remove it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;Io\Poll\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$poll&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;Context&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php://temp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r+'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$watcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$poll&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&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;StreamPollHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'some data'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// switch what you are watching for without rebuilding the registration&lt;/span&gt;
&lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;modifyEvents&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// or change both events and the attached data at once&lt;/span&gt;
&lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;modify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'updated data'&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;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$watcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Event&lt;/code&gt; enum is where the modern mechanisms surface. Alongside the obvious &lt;code&gt;Read&lt;/code&gt; and &lt;code&gt;Write&lt;/code&gt;, you get &lt;code&gt;Event::OneShot&lt;/code&gt; to have a watcher remove itself automatically after it fires once, and &lt;code&gt;Event::EdgeTriggered&lt;/code&gt; for the high-performance mode that only reports state changes rather than state. Edge triggering is the technique that lets epoll and kqueue scale to tens of thousands of connections, and it is available here through a single enum case. You can even ask the backend whether it supports it before you rely on it, via &lt;code&gt;$poll-&amp;gt;getBackend()-&amp;gt;supportsEdgeTriggering()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The narrative this quietly kills
&lt;/h2&gt;

&lt;p&gt;There is a line you have heard a thousand times, usually from someone who last touched PHP in 2014. "PHP can't scale." For a long time there was a kernel of technical truth buried under the reputation, because the language genuinely did not give you native access to the polling primitives that high-concurrency servers are built on. You could get there with userland libraries and an optional extension, but the foundation was not in the language.&lt;/p&gt;

&lt;p&gt;With 8.6 it is. AMPHP, ReactPHP, and Revolt can move their event loop backends onto it. The ext-uv footnote can start disappearing from async READMEs. Benchmarks handling the C10K problem on a stock PHP build will follow, because epoll and kqueue hold steady at ten thousand connections and beyond where &lt;code&gt;select()&lt;/code&gt; falls apart after a few hundred. The last technical leg under "PHP can't scale" gets kicked away, and what remains is a perception problem rather than a language one.&lt;/p&gt;

&lt;p&gt;I want to be honest about the limits of the excitement, because the accurate version is more persuasive than the hype. This passed with one dissenting vote and four abstentions, not the unanimous landslide some of the early write-ups claimed. Most application developers will genuinely never write &lt;code&gt;new Io\Poll\Context()&lt;/code&gt; in anger. You will feel this through your framework, through a faster FPM, through an async library that finally ships one backend instead of three, through FrankenPHP handling signals correctly under threads. The value is real and it is almost entirely indirect.&lt;/p&gt;

&lt;p&gt;That is precisely why it is underrated. A loud feature you use every day gets attention by default. A quiet foundation that raises the floor for the entire ecosystem has to be pointed at, because the people it helps most are the ones who will never see it directly. Jakub did the unglamorous work of building the primitive that was missing for twenty years, and the people who understood what it does voted it through without much noise.&lt;/p&gt;

&lt;p&gt;Sometimes the most important thing in a release is the line nobody is arguing about.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next time you read a README that tells you to install ext-uv for performance, remember that the footnote has an expiry date now, and go and read what &lt;code&gt;Io\Poll&lt;/code&gt; is doing underneath.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>php86</category>
      <category>asyncphp</category>
      <category>rfc</category>
    </item>
    <item>
      <title>A Proper Look at Tabstack</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:07:27 +0000</pubDate>
      <link>https://dev.to/juststevemcd/a-proper-look-at-tabstack-45f7</link>
      <guid>https://dev.to/juststevemcd/a-proper-look-at-tabstack-45f7</guid>
      <description>&lt;p&gt;I have spent a fair bit of time recently with &lt;a href="https://tabstack.ai/?utm_source=juststeveking.com&amp;amp;utm_medium=referral&amp;amp;utm_campaign=tabstack-review" rel="noopener noreferrer"&gt;Tabstack&lt;/a&gt;, including building a CLI around its API, and I think it is worth writing down what it actually is, what you would reach for it to do, and whether it earns its place in your stack. There is a lot of noise around anything with "AI" in the description at the moment, so I want to cut through that and talk about the thing on its own terms.&lt;/p&gt;

&lt;p&gt;Let me start with the problem it exists to solve, because the product makes far more sense once you have felt the pain it removes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem nobody wants to own
&lt;/h3&gt;

&lt;p&gt;If you have ever tried to give a program reliable access to the web, you know how quickly it turns into a swamp. You begin with a simple fetch. Then a site renders its content client-side, so a raw fetch returns an empty shell and you reach for a headless browser. Then you are managing proxies because you keep getting rate limited. Then the markup changes and your carefully written selectors snap. Then you are writing bespoke parsing logic for every single site you touch, and every one of those is a small liability waiting to break at three in the morning.&lt;/p&gt;

&lt;p&gt;None of that is the interesting part of your product. It is plumbing. You are maintaining a rendering engine, a proxy layer, and a pile of brittle extraction code purely so your actual application can read a page. That is the work Tabstack is offering to take off your hands.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Tabstack actually is
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://tabstack.ai/?utm_source=juststeveking.com&amp;amp;utm_medium=referral&amp;amp;utm_campaign=tabstack-review" rel="noopener noreferrer"&gt;Tabstack&lt;/a&gt; is a web execution and data transformation API built at Mozilla. The pitch is straightforward: you hand it a URL, a schema, a question, or a task, and the browser, the model, and the orchestration all run on their side. You get back clean data, cited answers, or completed browser tasks. There is no headless Chrome for you to provision, no LLM for you to wire in, and no parsing layer for you to babysit.&lt;/p&gt;

&lt;p&gt;It is a developer product, not a consumer one. There is no dashboard where you sit and browse the web by hand. It is a REST API with TypeScript and Python SDKs, an MCP server, and a CLI, and it is aimed squarely at people building agents, research tools, data pipelines, and automated workflows.&lt;/p&gt;

&lt;p&gt;At the time of writing it is in public early access, and every account starts with ten thousand free credits and no card required, which is generous enough to actually evaluate it properly rather than just kicking the tyres.&lt;/p&gt;

&lt;h3&gt;
  
  
  The four things it does
&lt;/h3&gt;

&lt;p&gt;The whole surface comes down to four capabilities, and once you see them laid out, the mental model clicks into place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract&lt;/strong&gt; turns a page into something a model can actually use. The cheapest form is markdown. You give it a URL and you get clean markdown back, frontmatter and all, which is exactly what you want when you are feeding a context window and do not want to burn tokens on rendered HTML noise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.tabstack.ai/v1/extract/markdown"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TABSTACK_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The more interesting form of extract is JSON. You describe the shape you want with a schema, you pass a URL, and you get back data that matches that schema. No selectors, no parsing, no handling of unexpected shapes downstream.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Tabstack&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;@tabstack/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&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;Tabstack&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;jobs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
 &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.google.com/about/careers/applications/jobs/results&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="na"&gt;json_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
 &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That schema enforcement is the bit that matters. The contract is yours, not the website's. When the site reshuffles its layout next week, your pipeline does not care, because you are asking for a shape rather than scraping a structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate&lt;/strong&gt; is the sibling to extract, and the distinction is worth getting right. Extract pulls data that already exists on the page. Generate uses a model to create something new from that content: a summary, a categorisation, a rewritten output, a tailored message. If you want the price that is printed on a product page, that is extract. If you want a one-line sentiment-tagged summary of a review, that is generate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate&lt;/strong&gt; is the ambitious one. You give it a task in plain language and it drives a real browser to completion: clicking, scrolling, filling forms, submitting, working through multi-step flows on sites you do not control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tabstack&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Tabstack&lt;/span&gt;

&lt;span class="n"&gt;tabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tabstack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TABSTACK_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;automate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://news.ycombinator.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Go through 5 pages of the top posts. For each post, determine the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;website it is from and group all posts by website. Return the 10 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websites with the most posts.&lt;/span&gt;&lt;span class="sh"&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;This is the capability that replaces the headless-browser-and-glue-code stack entirely. It also has guardrails you can set, a max-iterations cap, and an interactive mode where your own orchestrator can stay in the loop, which I will come back to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Research&lt;/strong&gt; runs an autonomous multi-source pass and hands you back a synthesised answer with every claim cited to its source. The selection of sources, the reading, the synthesis, and the citation all happen inside one call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;research&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
 &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Key risks in CRE lending right now?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;complete&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;citedPages&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 citations are the selling point here. If you are shipping a research feature to real users, an answer they can trust because every claim points at a source is worth a great deal more than a confident paragraph with no provenance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you would actually build with it
&lt;/h3&gt;

&lt;p&gt;The four capabilities are abstract until you map them onto real work, so here is where they land.&lt;/p&gt;

&lt;p&gt;Price and competitor monitoring is the obvious one. A scheduled extract call against a set of product pages, returning schema-matched JSON, keeps a dashboard current without anyone copy-pasting from a browser. Lead enrichment is another clean fit: take a raw domain, extract headcount, tech stack, and funding signals, and push it into your pipeline instead of stitching together a row of data vendors. Job aggregation, market research, and competitive intelligence all sit in the same bucket, where the win is structured output from messy pages.&lt;/p&gt;

&lt;p&gt;Research mode opens up in-product features: a research assistant inside your application that answers from the live web with citations, rather than from a stale index. And automate covers the operations work that used to need a fragile RPA script, where an agent has to log into a dashboard, work through a flow, and pull something back out.&lt;/p&gt;

&lt;p&gt;The common thread is that all of these need live web context, and none of the teams building them wants to own a scraping stack to get it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why you would reach for it over rolling your own
&lt;/h3&gt;

&lt;p&gt;The honest answer is the abstraction. You are buying back the time you would otherwise spend on rendering, proxies, parsing, and the maintenance tail that follows all three. For most teams that maintenance tail is the real cost, because it never shows up in the estimate and never stops.&lt;/p&gt;

&lt;p&gt;There is a second reason that is specific to Tabstack, and it is the Mozilla backing. The privacy posture is not an afterthought bolted on for the marketing page. Requests carry a dedicated Tabstack user-agent so site owners can identify the traffic, robots.txt opt-outs aimed at that agent are honoured, and retrieved content is treated as ephemeral and is never used to train models. If you care about being a responsible actor on the web, and increasingly you have to, that matters. It is web access that is built to be observable and well-behaved rather than a mass harvester wearing a disguise.&lt;/p&gt;

&lt;p&gt;The third reason is just developer experience. Scoped API keys, a clean Bearer-token auth flow, two first-party SDKs, an MCP server, a CLI, and per-action cost shown in the console before you run anything. The "open in Claude, ChatGPT, or Cursor" buttons on every docs page tell you who they are building for. It feels like a product made by people who have actually had to integrate other people's APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pricing, plainly
&lt;/h3&gt;

&lt;p&gt;Tabstack is credit-based, and I appreciate that one currency covers the whole platform rather than a separate line item for extraction versus research. Every call spends credits, and the cost depends on the endpoint and how hard you make it work.&lt;/p&gt;

&lt;p&gt;The per-action rates are easy to hold in your head. Markdown extraction is ten credits, JSON extraction is fifty, generate is a hundred, automate is a hundred per action, fast research is two hundred and fifty, and balanced research is three hundred and fifty. The important wrinkle is that extract and generate are always a single action per call, so the cost is fixed and predictable, while automate and research chain as many actions as the task needs and bill for each one. A research call that touches more sources costs more than one that touches fewer. You pay for work done, not per request, which is fairer but does mean automate and research costs are variable by nature.&lt;/p&gt;

&lt;p&gt;The plans are Individual at zero a month on pay-as-you-go, Team at ninety-nine a month with five hundred thousand credits, and Pro at four hundred and ninety-nine a month with three million. Overages are on by default for Team and Pro and are cheaper per credit than pay-as-you-go, and you can set a spend threshold in the console to either notify or stop. The free tier of ten thousand credits is enough to build something real before you commit, which is exactly how a trial should work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it is rough, and who it is not for
&lt;/h3&gt;

&lt;p&gt;I would be doing you a disservice if I only listed the good parts.&lt;/p&gt;

&lt;p&gt;It is not interactive-fast. Extraction sits in the low single-digit seconds and automate or research can run considerably longer, because they are doing real multi-step work. That is fine for background automation and scheduled jobs. It is the wrong tool if you need sub-second responses inside a live user-facing interaction.&lt;/p&gt;

&lt;p&gt;It is not built for true web-scale crawling. The credit model is comfortable for thousands to tens of thousands of calls. If your plan is to pull millions of pages a day, you are into custom infrastructure or a dedicated crawler, and the economics will tell you so quickly.&lt;/p&gt;

&lt;p&gt;It is not a no-code tool, and it is not a consumer assistant. There is no visual workflow builder and no chat window you talk to. You write API calls. If you want point-and-click automation, this is not it, and if you want a research assistant to use yourself rather than to embed, you want a different category of product entirely.&lt;/p&gt;

&lt;p&gt;The variable cost on automate and research is worth a flag too. Single-action endpoints are easy to budget. The agentic ones are not, by design, so you will want guardrails, sensible max-iteration caps, and an eye on the console while you learn the shape of your own workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where I land
&lt;/h3&gt;

&lt;p&gt;Tabstack is doing something I genuinely respect, which is treating browsing as a distinct infrastructure problem and solving it properly rather than bolting a scraper onto an LLM and calling it a day. The schema-first extraction is the standout feature, the citation-backed research is the one I would build a product feature on, and the Mozilla privacy stance is the kind of thing that should be table stakes and somehow rarely is.&lt;/p&gt;

&lt;p&gt;If you are building agents or AI features that need live web data and you do not want to own a scraping stack, it is straightforwardly worth your time. Start narrow. Pick one workflow, point it at the sites you actually care about, and see how the output holds up against your real targets rather than against a demo. The free credits make that an easy experiment to run, and the cost of finding out is close to nothing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I will likely follow this with a walkthrough of building something small and real on top of the automate endpoint, because that is where the interesting edges live.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tabstack</category>
      <category>aiagents</category>
      <category>webautomation</category>
      <category>api</category>
    </item>
    <item>
      <title>The Tips Behind API Artisan: Building Laravel APIs Developers Actually Want to Use</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 16 Jun 2026 21:38:58 +0000</pubDate>
      <link>https://dev.to/juststevemcd/the-tips-behind-api-artisan-building-laravel-apis-developers-actually-want-to-use-eh5</link>
      <guid>https://dev.to/juststevemcd/the-tips-behind-api-artisan-building-laravel-apis-developers-actually-want-to-use-eh5</guid>
      <description>&lt;p&gt;I have just finished writing &lt;em&gt;&lt;a href="https://juststeveking.link/book" rel="noopener noreferrer"&gt;API Artisan: A Guide to Building APIs with Laravel&lt;/a&gt;&lt;/em&gt;, and I am giving it away for free. Before you commit to 300-odd pages, let me give you the short version: the tips, patterns, and small decisions that separate an API that technically works from one that developers are genuinely happy to depend on.&lt;/p&gt;

&lt;p&gt;None of this needs more hardware, a different framework, or a bigger team. It needs you to point your attention at the right things. These are the ones I keep coming back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start by measuring the right thing
&lt;/h2&gt;

&lt;p&gt;Ask most teams how they know their API is good and you get a single question back: does it work? Can I hit this endpoint and get a response? That question is necessary, and it is nowhere near enough.&lt;/p&gt;

&lt;p&gt;The question I want you to ask instead is whether your API is liveable with. Can a developer read your docs, understand your auth model, make a successful request, and handle an error without contacting support, trawling a forum, or guessing what a status code is trying to tell them? The gap between "works" and "liveable with" never shows up in a sprint retro, but it shows up everywhere else: in support volume, in integration timelines that overrun, and in the quiet moment a developer decides to build around your API rather than with it.&lt;/p&gt;

&lt;p&gt;Everything else in the book hangs off one mindset shift: an API is a product. It has users. Treat it as an implementation detail and it will behave like one. It will change without warning when your internals change, and it will be inconsistent because different people wrote different parts on different days with different conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the contract before the code
&lt;/h2&gt;

&lt;p&gt;The natural way to build an endpoint is to write the handler, return some data, and document it afterwards if there is time. It feels efficient, and in the short term it is. The problem is what it produces: a contract that was never designed, only discovered.&lt;/p&gt;

&lt;p&gt;Let me show you the trap, because I have watched it catch good developers. You have a &lt;code&gt;keys&lt;/code&gt; table with a &lt;code&gt;revoked_at&lt;/code&gt; column, the Eloquent model is right there, so you reach for &lt;code&gt;$key-&amp;gt;toArray()&lt;/code&gt;. The response ships with &lt;code&gt;revoked_at&lt;/code&gt; as a field name, because that is what the column is called. A few weeks later you realise the name is ambiguous and rename it. To every integration built against you, that rename is a breaking change, and nothing warned anyone.&lt;/p&gt;

&lt;p&gt;Designing the response shape before you write the query closes that gap at the source. You cannot leak your schema if you worked out what the caller needs before you touched the database. The question changes from "how do I expose this data?" to "what does the developer calling this actually need?", and the answers genuinely differ.&lt;/p&gt;

&lt;p&gt;Here is the asymmetry that makes the discipline worth it. Your implementation can be refactored whenever you like. A published field name lives in codebases you cannot see, running in production systems that will not be updated the day you push a change. So front-load the contract decisions. The cost of getting them wrong is real, and it is paid by other people, later, at the worst possible time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know what a breaking change actually is
&lt;/h2&gt;

&lt;p&gt;Most of us can name the obvious ones: remove an endpoint, rename a field. The real list is longer and far more surprising, and every item on it breaks an integration even when the change looks like an improvement.&lt;/p&gt;

&lt;p&gt;Changing a field's type, say &lt;code&gt;expires_at&lt;/code&gt; from a date string to a Unix timestamp. Changing a field from nullable to non-nullable. Adding a required field to a request. Changing the meaning of a field without touching its name, which is the one that keeps me up at night. Changing error codes or error shapes, because clients build handling logic around them. Switching offset pagination for cursor pagination. Wrapping a collection in a &lt;code&gt;meta&lt;/code&gt; object that clients were destructuring directly.&lt;/p&gt;

&lt;p&gt;The pattern is always the same. A change that looks internal turns out to be a commitment you made to everyone who integrated with the old behaviour. The answer is not to stop changing things. It is to know what you are committing to before you commit, and to have a mechanism for changing it safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version from day one
&lt;/h2&gt;

&lt;p&gt;Here is a fact that surprises people: adding a &lt;code&gt;/v1&lt;/code&gt; prefix to an API that is already in production is itself a breaking change. Every consumer hitting &lt;code&gt;/keys&lt;/code&gt; now has to hit &lt;code&gt;/v1/keys&lt;/code&gt;. That is exactly why versioning from day one is not premature optimisation. It is the cheapest moment you will ever get to add it, because no integrations exist yet. The cost is one URL segment, and the payoff is a stable, versioned contract from the first endpoint you ship.&lt;/p&gt;

&lt;p&gt;Make the version explicit at every level, not just in the URL. Namespace your controllers under &lt;code&gt;App\Http\Controllers\Keys\V1&lt;/code&gt;. When v2 arrives it lives in &lt;code&gt;Keys\V2&lt;/code&gt;, the v1 controllers stay exactly as they are, and you physically cannot put v2 logic in a v1 controller because the boundary is visible in the code.&lt;/p&gt;

&lt;p&gt;When you do retire a version, reach for the Sunset pattern from RFC 8594. Send a &lt;code&gt;Sunset&lt;/code&gt; header carrying the retirement date and a &lt;code&gt;Deprecation&lt;/code&gt; header carrying the date it was deprecated. The part almost everyone skips is the &lt;code&gt;Link&lt;/code&gt; header pointing at the migration guide, and it is the part that earns its keep. A consumer whose monitoring picks up the Sunset header can follow that link straight to the docs. The header becomes actionable instead of just informational. My rule, and the one I would encourage you to adopt, is that no version retires without at least six months of notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat REST as conventions, not religion
&lt;/h2&gt;

&lt;p&gt;Let me save you some pull request arguments. Almost nobody implements true hypermedia controls, and almost every "REST API" you have ever used is really HTTP with JSON and some conventions about URLs. That is fine. The conventions are useful. The pretence that we are all implementing a rigorous architectural style is not.&lt;/p&gt;

&lt;p&gt;So skip HATEOAS, unless you happen to be building a generic hypermedia browser, which you are almost certainly not. Stop treating the Richardson Maturity Model as a quality score. A level 3 API with inconsistent errors and no docs is a worse API than a level 1 one with a thoughtful, stable contract.&lt;/p&gt;

&lt;p&gt;Spend that energy on your status codes instead, because they are part of your contract and clients build retry logic directly on them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;201 not 200 for a creation, so clients can read the Location header
422 not 400 for validation, because 400 means malformed and 422 means well-formed but invalid
403 not 401 when the caller is authenticated but not authorised
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confuse 401 and 403 and you create genuine bugs. A client that gets a 401 may try to re-authenticate. If it should have been a 403, re-authentication will not help, and now your client is sitting in a retry loop wondering what it did wrong.&lt;/p&gt;

&lt;p&gt;For the operations that refuse to map to CRUD, like revoking or rotating a key, use sub-path actions: &lt;code&gt;POST /v1/keys/{id}/revoke&lt;/code&gt;. Is it pure REST? No. Is it immediately understandable to anyone who reads it? Yes, and that is the trade I will take every time. On nesting, the rule I use is simple: nest one level when it clarifies ownership, and flatten when the nested resource has its own identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the controller boring
&lt;/h2&gt;

&lt;p&gt;Every controller in the book is a single-action invokable class. One class, one &lt;code&gt;__invoke&lt;/code&gt; method, one responsibility. &lt;code&gt;IssueController&lt;/code&gt; tells you exactly what it does. &lt;code&gt;KeyController&lt;/code&gt; tells you almost nothing.&lt;/p&gt;

&lt;p&gt;A handful of small decisions compound here. Reference your controllers as a class, never a closure, because closures cannot be route-cached and a class reference is testable and navigable in your editor. Put &lt;code&gt;declare(strict_types=1)&lt;/code&gt; at the top of every file. Mark controllers &lt;code&gt;final&lt;/code&gt;. Keep validation in Form Requests and out of the handler, so the framework's automatic failure handling applies cleanly and you never end up with validation logic smeared between &lt;code&gt;rules()&lt;/code&gt; and the top of a controller method.&lt;/p&gt;

&lt;p&gt;The single best trick in the Laravel section is the &lt;code&gt;payload()&lt;/code&gt; pattern. A Form Request's job is not only to validate input, it is to turn that validated input into a typed, immutable value object the rest of your application can trust.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;IssueKeyPayload&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;IssueKeyPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&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;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'scopes'&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;filled&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;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expires_at'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;CarbonImmutable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expires_at'&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;toString&lt;/span&gt;&lt;span class="p"&gt;())&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;payload()&lt;/code&gt; runs only after validation has passed, it can cast with confidence. &lt;code&gt;KeyScope::from(...)&lt;/code&gt; will not throw, because the rules already confirmed every value is a valid enum case. Your controller receives a typed payload, hands it to an action, and returns a resource. The action works in domain types and never touches the request layer, which means it behaves identically whether you call it from a controller, a console command, a queue job, or a test. The payload is the handshake between HTTP and your domain, and it seals a boundary that otherwise quietly leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every error the same shape
&lt;/h2&gt;

&lt;p&gt;Out of the box, Laravel hands you a different response shape for a validation error, an unauthenticated request, and a missing model. Four situations, four shapes, and a client that wants consistent error handling has to special-case every one of them.&lt;/p&gt;

&lt;p&gt;Pick RFC 9457 Problem+JSON and use it for every error on every endpoint, with no exceptions. One shape, carrying &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;detail&lt;/code&gt;, plus an &lt;code&gt;errors&lt;/code&gt; extension when you need per-field validation. Two details are worth stealing outright. Set the &lt;code&gt;Content-Type&lt;/code&gt; to &lt;code&gt;application/problem+json&lt;/code&gt; so clients can detect it programmatically. And in your fallback &lt;code&gt;Throwable&lt;/code&gt; handler, return &lt;code&gt;null&lt;/code&gt; outside production, so Laravel keeps showing you full stack traces in development while production returns a clean, generic 500 that never leaks your internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make invalid states impossible
&lt;/h2&gt;

&lt;p&gt;When a resource has a lifecycle, model it as an enum-backed state machine with an explicit transition map. Invalid transitions throw a domain exception. The calling layer does not check the state before it acts; the entity enforces its own rules. That is the whole difference between a system that guarantees its own invariants and one that relies on developers remembering to check a field first.&lt;/p&gt;

&lt;p&gt;Treat your domain exceptions, things like &lt;code&gt;InvalidKeyTransitionException&lt;/code&gt;, as first-class citizens. They are not unexpected failures, they are known and named outcomes, and they deserve an informative 422 rather than a 500. Register them in your exception handler right alongside the framework ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respect transaction boundaries
&lt;/h2&gt;

&lt;p&gt;A database transaction protects you when a group of operations must all succeed or all fail together. It cannot protect you from everything, and two rules will save you real production pain.&lt;/p&gt;

&lt;p&gt;Never make external HTTP calls inside a transaction. If the call fires and the transaction then rolls back, you have caused a side effect the database has no memory of. Push your jobs and webhooks with &lt;code&gt;DB::afterCommit()&lt;/code&gt; so they only fire once the data is genuinely committed.&lt;/p&gt;

&lt;p&gt;Transactions also cannot help when the network drops after you return a 201 but before the client receives it. The client retries, and you happily process the same operation twice. That is what idempotency keys are for. Accept a client-generated &lt;code&gt;Idempotency-Key&lt;/code&gt; header on state-changing endpoints, store the original response, and replay it on any retry that carries the same key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the audit log first, literally
&lt;/h2&gt;

&lt;p&gt;An audit log is not a log file. It is an append-only database table of meaningful domain events: a key was issued, a key was revoked, a member was invited. Append-only is not a polite convention either, it is enforced by the schema. No &lt;code&gt;updated_at&lt;/code&gt;, no soft deletes, no update operations anywhere in the code that touches it. A record you can update is not an audit record, it is a mutable row with a timestamp, and those are very different things.&lt;/p&gt;

&lt;p&gt;Two design notes I would not skip. Store the event-specific context as JSONB rather than forcing every event type into a fixed set of nullable columns, because the relevant metadata is genuinely different for each one. And capture &lt;code&gt;actor_type&lt;/code&gt; as well as &lt;code&gt;actor_id&lt;/code&gt;, because an action taken by a human, by an API key, and by the system itself are three different stories in a security review.&lt;/p&gt;

&lt;p&gt;The sequence inside every action that records an event is worth memorising: begin the transaction, perform the operation, write the audit record, commit, then dispatch side effects via &lt;code&gt;afterCommit&lt;/code&gt;. The audit record is written before anything that could fail independently, which is what makes it the most durable thing in the whole system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the system answer questions under pressure
&lt;/h2&gt;

&lt;p&gt;Attach a request ID to every log line with a tiny piece of middleware.&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;$requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Request-ID'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&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;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'request_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$requestId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Request-ID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$requestId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every log entry within a request shares one ID, tracing a request becomes a single filter, and because you echo the ID back in the response header, a consumer reporting a problem can hand you the exact value to search for. One field, every relevant entry.&lt;/p&gt;

&lt;p&gt;The rest of operability runs on the same instinct. Go spec-first with OpenAPI so your documentation cannot drift away from your implementation. Run contract tests in CI so an accidental breaking change fails the build instead of a consumer. Treat onboarding as part of the API, with runnable collections rather than a wall of prose. Give the API a clear owner, a deprecation cadence you actually review twice a year, and post-mortems that produce a test or a doc rather than blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread that ties it together
&lt;/h2&gt;

&lt;p&gt;The patterns are not really the point. The reasoning is. Consumer empathy as an engineering discipline, stability as a feature you ship, observability as an architectural guarantee. Frameworks and conventions will change underneath all of us. That reasoning is durable, and it is what lets you make these same calls on your next project because the system needed them, not because a book told you to.&lt;/p&gt;

&lt;p&gt;Every tip here is drawn from a single project I build end to end across the book: Portkey, a production-quality API key management platform with versioning, separate JWT and HMAC layers, an enforced key lifecycle, async rotation, signed webhooks, idempotency, rate limiting at three levels, and a Pest suite with contract tests in CI. Every pattern shows up because the project genuinely needed it, never to show off a technique.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If these notes were useful to you, the full book takes each one apart properly, with the code, the trade-offs, and the reasoning behind every decision. It is free. &lt;a href="https://juststeveking.link/book" rel="noopener noreferrer"&gt;Download API Artisan&lt;/a&gt; and go build the API your consumers deserve.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>apidesign</category>
      <category>restapis</category>
      <category>php</category>
    </item>
    <item>
      <title>Giving your agents a terminal: a first look at the tabstack CLI</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 16 Jun 2026 21:38:57 +0000</pubDate>
      <link>https://dev.to/juststevemcd/giving-your-agents-a-terminal-a-first-look-at-the-tabstack-cli-1fcb</link>
      <guid>https://dev.to/juststevemcd/giving-your-agents-a-terminal-a-first-look-at-the-tabstack-cli-1fcb</guid>
      <description>&lt;p&gt;Every project I touch lately ends up needing the same awkward thing: a reliable way to pull the web into a script or an agent. Not a brittle scrape held together with CSS selectors and hope, but something that takes a URL and hands back clean, structured text I can actually pipe into the next step. I have built that wrapper more than once, and it is never as small as you think it will be. So when Mozilla dropped the &lt;code&gt;tabstack&lt;/code&gt; CLI, a single Go binary that wraps the Tabstack AI API, I wanted to spend a proper afternoon with it.&lt;/p&gt;

&lt;p&gt;The pitch on the README is direct: every web interaction your agent or stack needs, from the terminal or a script. It turns any URL into clean Markdown or schema-shaped JSON, runs natural-language browser automation, and answers research questions with cited sources. The part that made me sit up is that the output is pretty in a terminal and pipeable into &lt;code&gt;jq&lt;/code&gt; without a flag. That is a small detail, and it tells you the people who built it actually live on the command line.&lt;/p&gt;

&lt;p&gt;Let me walk you through it the way I poked at it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it installed
&lt;/h2&gt;

&lt;p&gt;There is no runtime to install and nothing to bootstrap, because it ships as a single static binary built for macOS, Linux, and Windows. The quickest route is the install script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tabstack.ai/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fetches the right binary for your platform and puts it on your &lt;code&gt;PATH&lt;/code&gt;, and you are ready to go. If you would rather not pipe a script into your shell, there are a couple of alternatives. With Go on your machine you can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/Mozilla-Ocho/tabstack-cli/cmd/tabstack@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That drops the binary in &lt;code&gt;$GOPATH/bin&lt;/code&gt;, which is usually &lt;code&gt;~/go/bin&lt;/code&gt;. If your shell cannot find &lt;code&gt;tabstack&lt;/code&gt; afterwards, you almost certainly have not got that directory on your &lt;code&gt;PATH&lt;/code&gt;:&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;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/go/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you want to avoid Go entirely, there are pre-built binaries on the Releases page, or you can clone the repo and run &lt;code&gt;make install-local&lt;/code&gt;, which builds it and copies it to &lt;code&gt;/usr/local/bin&lt;/code&gt; so it works in any terminal straight away. Several routes, all of them boring in the best possible way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication done sensibly
&lt;/h2&gt;

&lt;p&gt;This is the bit I want to praise first, because credential handling is where a lot of CLIs get careless. You log in once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack auth login            &lt;span class="c"&gt;# prompts for the key, input hidden, saved to the config file&lt;/span&gt;
tabstack auth status           &lt;span class="c"&gt;# shows how your key is being resolved, never prints it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A key can come from three places, and the precedence is exactly the order you would hope for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the &lt;code&gt;--api-key&lt;/code&gt; flag&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;TABSTACK_API_KEY&lt;/code&gt; environment variable&lt;/li&gt;
&lt;li&gt;the config file at &lt;code&gt;$XDG_CONFIG_HOME/tabstack/config.toml&lt;/code&gt;, defaulting to &lt;code&gt;~/.config/tabstack/config.toml&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The config file is written &lt;code&gt;0600&lt;/code&gt;, so it is not world-readable, and &lt;code&gt;auth status&lt;/code&gt; will tell you which source won without ever leaking the value. This is the contract I want from any tool that holds a secret: a flag for one-off overrides, an environment variable for CI, and a locked-down file for everyday local use. If no key is found, the API commands exit with code &lt;code&gt;2&lt;/code&gt; and tell you how to set one, rather than failing with a stack trace. We will come back to those exit codes, because they matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core: turning a page into something useful
&lt;/h2&gt;

&lt;p&gt;Every command follows the same shape: &lt;code&gt;tabstack &amp;lt;group&amp;gt; &amp;lt;action&amp;gt; &amp;lt;target&amp;gt;&lt;/code&gt;. Once that clicks, the whole surface area feels predictable.&lt;/p&gt;

&lt;p&gt;The first thing I reached for was &lt;code&gt;extract&lt;/code&gt;. At its simplest it converts a page to clean Markdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack extract markdown https://example.com &lt;span class="nt"&gt;--metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--metadata&lt;/code&gt; flag pulls in the title, author, and similar bits alongside the content. Useful, but the version I keep coming back to is structured extraction, where you hand it a JSON schema and it shapes the output to match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack extract json https://example.com &lt;span class="nt"&gt;--schema&lt;/span&gt; @schema.json
tabstack extract json https://example.com &lt;span class="nt"&gt;--schema&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"object","properties":{"title":{"type":"string"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;@schema.json&lt;/code&gt; syntax. That &lt;code&gt;@file&lt;/code&gt; convention reads the schema from a file, and it works the same way for the other input flags too. You can pass a literal string, point at a file with &lt;code&gt;@&lt;/code&gt;, or read from stdin with &lt;code&gt;-&lt;/code&gt;, which is the same ergonomics as &lt;code&gt;curl -d&lt;/code&gt;. So this is perfectly valid:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"object"}'&lt;/span&gt; | tabstack extract json https://example.com &lt;span class="nt"&gt;--schema&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have ever written the glue that decides "is this argument a path or a literal", you will appreciate that they solved it once and applied it everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate: extraction with an opinion
&lt;/h2&gt;

&lt;p&gt;Where &lt;code&gt;extract&lt;/code&gt; pulls what is on the page, &lt;code&gt;generate&lt;/code&gt; fetches a page and transforms it with AI into the shape you describe. You give it instructions as well as a schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack generate json https://example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instructions&lt;/span&gt; &lt;span class="s2"&gt;"Summarise the article and list the key points."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schema&lt;/span&gt; @schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mental model I landed on is this: &lt;code&gt;extract json&lt;/code&gt; is for getting the data that is genuinely there, and &lt;code&gt;generate json&lt;/code&gt; is for asking a model to produce something new from the page, summaries, classifications, restructured points, while still constraining the output to a schema you control. Keeping those two as separate verbs is a good call, because it stops you reaching for the heavier, slower path when all you wanted was the title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents: automation and research
&lt;/h2&gt;

&lt;p&gt;This is where it stops being a fetch tool and starts being something more interesting. The &lt;code&gt;agent&lt;/code&gt; group runs server-side and streams progress back as it works.&lt;/p&gt;

&lt;p&gt;Browser automation takes a natural-language task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack agent automate &lt;span class="s2"&gt;"Find the pricing for the Pro plan"&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Research searches the web, synthesises an answer, and prints a report with numbered, cited sources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack agent research &lt;span class="s2"&gt;"What are the latest developments in quantum computing?"&lt;/span&gt; &lt;span class="nt"&gt;--mode&lt;/span&gt; balanced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feature I did not expect, and rather liked, is interactive automation. You can start a run that is allowed to pause and ask you for input partway through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack agent automate &lt;span class="s2"&gt;"Log in and download the latest invoice"&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; https://example.com &lt;span class="nt"&gt;--interactive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When it pauses, it gives you a request ID, and you answer it with a separate command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack agent input &amp;lt;request-id&amp;gt; &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"fields":[{"ref":"field1","value":"yes"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or you decline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack agent input &amp;lt;request-id&amp;gt; &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"cancelled":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;agent input&lt;/code&gt; command only applies to runs started with &lt;code&gt;--interactive&lt;/code&gt;. Without the flag, an automation never stops to ask. It is a clean way to handle the reality that some tasks genuinely need a human in the loop, without baking that assumption into every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it scriptable
&lt;/h2&gt;

&lt;p&gt;Here is the design decision I keep telling people about. The output is pretty and styled when you are sitting at a terminal, and it switches to JSON automatically when the output is piped. No flag required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tabstack extract markdown https://example.com | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can force a mode with &lt;code&gt;-o pretty&lt;/code&gt; or &lt;code&gt;-o json&lt;/code&gt; if you want to be explicit, and you can kill colour with &lt;code&gt;--no-color&lt;/code&gt; or the &lt;code&gt;NO_COLOR&lt;/code&gt; environment variable. The streaming commands, &lt;code&gt;automate&lt;/code&gt; and &lt;code&gt;research&lt;/code&gt;, emit one NDJSON line per event when they are in JSON mode, so you can process events as they arrive rather than waiting for the whole thing to finish.&lt;/p&gt;

&lt;p&gt;The other half of scriptability is the exit codes, and they are thought through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;runtime or network error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;usage, invalid input, or missing config such as no API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;API error or in-band task failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Distinct codes mean you can branch on the actual cause in a shell script, telling a bad argument apart from a network blip apart from an API rejection:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; tabstack extract markdown &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out.md&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  case&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;2&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"check your arguments"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    3&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"the API rejected the request"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"network or runtime error"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I cannot tell you how often I have wanted exactly this from a tool and been handed a flat exit &lt;code&gt;1&lt;/code&gt; for every conceivable failure instead.&lt;/p&gt;

&lt;p&gt;A few common options round it out. &lt;code&gt;--effort&lt;/code&gt; lets you pick the speed and capability tradeoff on &lt;code&gt;extract&lt;/code&gt; and &lt;code&gt;generate&lt;/code&gt;: &lt;code&gt;min&lt;/code&gt; is fastest with no fallback, &lt;code&gt;standard&lt;/code&gt; is the balanced default, and &lt;code&gt;max&lt;/code&gt; does full browser rendering for JavaScript-heavy sites at the cost of taking longer. &lt;code&gt;--geo GB&lt;/code&gt; routes the fetch through a given country using an ISO 3166-1 alpha-2 code, which is handy when a page behaves differently by region. And &lt;code&gt;--nocache&lt;/code&gt; bypasses the cache when you need a fresh read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built to be driven by agents too
&lt;/h2&gt;

&lt;p&gt;The detail that tells you where this is headed is the AGENTS.md file in the repo. The CLI is designed to be driven by LLM agents as well as humans, and that file documents every command, flag, and exit code in a form tuned for machine consumption. If you are wiring &lt;code&gt;tabstack&lt;/code&gt; into Claude Code or your own harness, you point the agent at that file and let it learn the surface area itself.&lt;/p&gt;

&lt;p&gt;This is the right shape for the moment we are in. A well-behaved CLI with predictable verbs, machine-readable output, and meaningful exit codes is exactly the kind of tool an agent can use safely, because every outcome is legible. The same properties that make it pleasant for me at the terminal make it tractable for a model in a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I reach for it?
&lt;/h2&gt;

&lt;p&gt;Yes, and for a specific reason. The web-access problem keeps showing up in my work, and I have written enough of these wrappers to know the unglamorous parts: credential precedence, the file-or-literal-or-stdin question, knowing whether a failure was mine or theirs, behaving differently when piped. The &lt;code&gt;tabstack&lt;/code&gt; CLI has answered all of those the way I would want them answered, and it has done it in a single binary with no runtime to manage. It is v1.0.0, MIT licensed, and it came out of Mozilla, so it is not a weekend experiment you are betting your pipeline on.&lt;/p&gt;

&lt;p&gt;If your stack or your agents need to read the web, give it an afternoon. Start with &lt;code&gt;tabstack auth login&lt;/code&gt; and &lt;code&gt;tabstack extract markdown&lt;/code&gt; on a page you know, and build out from there. The shape of the thing rewards exploration.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I have a feeling the interesting work is not in the extraction at all, but in what you wire on the other end of that pipe. That is the article I want to write next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tabstack</category>
      <category>go</category>
      <category>cli</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>The Art Of Keeping Business Logic Honest</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Wed, 20 May 2026 18:55:24 +0000</pubDate>
      <link>https://dev.to/juststevemcd/the-art-of-keeping-business-logic-honest-254h</link>
      <guid>https://dev.to/juststevemcd/the-art-of-keeping-business-logic-honest-254h</guid>
      <description>&lt;p&gt;There is a moment in most long-lived applications where you open a controller and find a block of conditionals that nobody quite understands anymore. Something like &lt;code&gt;if ($entity-&amp;gt;status === 'pending' &amp;amp;&amp;amp; $this-&amp;gt;someFlag)&lt;/code&gt; buried inside a service class, half-guarding a transition that was probably fine when someone wrote it but now nobody wants to touch. The business logic has drifted from the code, and the code is quietly lying about what the system actually does.&lt;/p&gt;

&lt;p&gt;I have been building a platform recently where I could see this problem coming from a long way off. The domain has entities that go through well-defined lifecycles - things move through stages, transitions have rules, and when a transition happens, a bunch of other things need to follow. The instinct is to reach for a big use case class, or a service that handles everything in sequence. But that approach tends to collapse under its own weight as requirements change.&lt;/p&gt;

&lt;p&gt;Instead, I ended up with a two-layer pattern: a strict state machine for each entity that owns nothing but transition rules, and a separate workflow engine that responds to those transitions and orchestrates the follow-on work. This article is about how that pattern holds together and why I think it is worth the setup cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Ad-Hoc Status Management
&lt;/h2&gt;

&lt;p&gt;Before getting into the pattern, it is worth being honest about what you are replacing. Most applications manage entity status as a string column and a handful of conditionals scattered across services. When you need to add a new status, you add it to the column's allowed values and start writing &lt;code&gt;if&lt;/code&gt; checks wherever it matters. This works fine for simple lifecycles. It starts to hurt when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have 5+ statuses and a non-trivial transition graph&lt;/li&gt;
&lt;li&gt;Different transitions need different side effects&lt;/li&gt;
&lt;li&gt;Some transitions involve async operations that can fail halfway through&lt;/li&gt;
&lt;li&gt;You need an audit trail of who triggered what and when&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state machine pattern addresses the first two. The workflow engine addresses the last two. Together they handle the full picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer One: The State Machine
&lt;/h2&gt;

&lt;p&gt;A state machine in this pattern is deliberately narrow. Its only job is to know which transitions are legal and to produce a domain event when one is performed. It does not send emails. It does not touch the database. It does not call other services.&lt;/p&gt;

&lt;p&gt;Here is what a simple order lifecycle might look like:&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;OrderStatus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;draft -&amp;gt; submitted -&amp;gt; approved -&amp;gt; fulfilled&lt;/span&gt;
  &lt;span class="s"&gt;submitted -&amp;gt; rejected&lt;/span&gt;
  &lt;span class="s"&gt;approved -&amp;gt; cancelled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entity enforces this. If you try to transition from &lt;code&gt;draft&lt;/code&gt; directly to &lt;code&gt;fulfilled&lt;/code&gt;, you get a domain exception - not a silent data integrity problem discovered six months later.&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;// src/Orders/Domain/Order.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&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;OrderStatus&lt;/span&gt; &lt;span class="nv"&gt;$status&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;submit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;OrderSubmitted&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidTransitionException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;"Cannot submit an order in status &lt;/span&gt;&lt;span class="si"&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;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Submitted&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;OrderSubmitted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;submittedAt&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;\DateTimeImmutable&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;approve&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;$approvedBy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;OrderApproved&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Submitted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidTransitionException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;"Cannot approve an order in status &lt;/span&gt;&lt;span class="si"&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;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Approved&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;OrderApproved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;approvedBy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$approvedBy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;approvedAt&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;\DateTimeImmutable&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that each method returns a domain event rather than dispatching it. The calling layer - a use case - is responsible for taking that event, persisting it to an event log, and firing it into the Laravel event system. The domain entity stays clean.&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;// src/Orders/Application/ApproveOrder.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApproveOrder&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;OrderRepositoryContract&lt;/span&gt; &lt;span class="nv"&gt;$repository&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;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&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;$approvedBy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&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="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$approvedBy&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;repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$event&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Controllers/Orders/V1/ApproveController.php&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ApproveOrderRequest&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;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;approveOrder&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;$orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&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="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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$events&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventLog&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&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="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="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonDataResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'approved'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation matters more than it might look. The use case is testable without Laravel. The domain entity has no infrastructure dependencies. The event log gets written before any side effects fire, so you always have a record of what happened even if something downstream blows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Two: The Workflow Engine
&lt;/h2&gt;

&lt;p&gt;The state machine tells you that a transition happened. The workflow engine decides what to do about it.&lt;/p&gt;

&lt;p&gt;A workflow is a named, ordered list of steps triggered by a domain event. Steps are discrete PHP classes. The engine runs them in order, persists progress after each one, and can pause mid-workflow waiting for an external signal before continuing.&lt;/p&gt;

&lt;p&gt;A workflow instance is the running execution of a workflow definition for a specific entity. The definition - the list of steps - lives entirely in code. The instance - which step we are on, what has happened so far - lives in the database.&lt;/p&gt;

&lt;p&gt;This distinction is important. Changing a workflow is a code change with a readable diff. You do not need a migration to add a step. The engine always uses the current definition code when it resumes a paused instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining a Workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Workflows/OrderApproval/OrderApprovalWorkflow.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderApprovalWorkflow&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowDefinitionContract&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;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'order_approval'&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;steps&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;ValidateOrderItems&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="nc"&gt;ReserveInventory&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="nc"&gt;CreateInvoice&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="nc"&gt;AwaitPayment&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="nc"&gt;NotifyFulfillmentTeam&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="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;Every step implements the same contract:&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;interface&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A step returns one of three outcomes: complete, await, or fail.&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;// src/Workflow/Domain/StepResult.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&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;string&lt;/span&gt; &lt;span class="nv"&gt;$outcome&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;$awaitSignal&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;$failReason&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;array&lt;/span&gt; &lt;span class="nv"&gt;$contextUpdates&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;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$contextUpdates&lt;/span&gt; &lt;span class="o"&gt;=&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="s1"&gt;'complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contextUpdates&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;await&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;$signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$contextUpdates&lt;/span&gt; &lt;span class="o"&gt;=&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="s1"&gt;'await'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contextUpdates&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;fail&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;$reason&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="s1"&gt;'fail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$reason&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AwaitPayment&lt;/code&gt; step, for example, does not poll an external payment service. It parks the workflow:&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;// app/Workflows/OrderApproval/Steps/AwaitPayment.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitPayment&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&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;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment_succeeded'&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 7 days&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;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&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 engine sets the instance status to &lt;code&gt;awaiting&lt;/code&gt;, stores the signal name it is waiting for, and stops. When the payment provider's webhook arrives, it delivers the signal:&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;// app/Jobs/ProcessPaymentWebhook.php&lt;/span&gt;

&lt;span class="nv"&gt;$instances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;workflowRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAwaitingSignalForAggregate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;aggregateId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'payment_succeeded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$instances&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$instance&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="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'payment_succeeded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signalData&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'payment_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payment&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;'paid_at'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine resumes from the step after &lt;code&gt;AwaitPayment&lt;/code&gt; and carries the signal data forward in the context. The &lt;code&gt;NotifyFulfillmentTeam&lt;/code&gt; step can read &lt;code&gt;payment_id&lt;/code&gt; from context without knowing anything about how payment was confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  WorkflowContext: Shared State Without Shared Mutable State
&lt;/h2&gt;

&lt;p&gt;Context is an immutable bag of data that flows through all steps. Steps cannot write directly to it - they return updates via &lt;code&gt;StepResult::complete(['key' =&amp;gt; 'value'])&lt;/code&gt; and the engine merges those in before passing context to the next step.&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;// src/Workflow/Domain/WorkflowContext.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowContext&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&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;string&lt;/span&gt; &lt;span class="nv"&gt;$workflowInstanceId&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;$aggregateId&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;$aggregateType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&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;get&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;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$default&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="kt"&gt;mixed&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$default&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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$additions&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;workflowInstanceId&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;aggregateId&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;aggregateType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;array_merge&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$additions&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps steps honest. A step cannot accidentally clobber data set by a previous step except through the explicit return value. It also makes testing straightforward:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'awaits payment signal when order is approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WorkflowContext&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;workflowInstanceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'wf_001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aggregateId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'ord_001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aggregateType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;initialData&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$step&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;AwaitPayment&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;$step&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;$context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&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;outcome&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;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'await'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&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;awaitSignal&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;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment_succeeded'&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 step is independently testable. You construct a context, run the step, assert the result. No Laravel bootstrapping required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branching Without a Tree Structure
&lt;/h2&gt;

&lt;p&gt;Not all workflows are linear. Some paths depend on conditions that are only known at runtime, and this is where a lot of workflow implementations go wrong. The temptation is to model branching as a tree: if condition A, follow path X; if condition B, follow path Y. That works fine until you have three conditions and four paths, at which point you have a directed graph masquerading as code, and nobody can read it without drawing a diagram first.&lt;/p&gt;

&lt;p&gt;My preference is context flags over nested step trees. The idea is simple: a routing step runs early in the workflow, evaluates the conditions, and writes its findings into context. Later steps read those findings and decide whether to skip themselves or do their work. The step list stays flat. You can read it from top to bottom and understand every possible path the workflow might take.&lt;/p&gt;

&lt;p&gt;Here is a concrete example. An order workflow might need manual review for high-value orders, a compliance check for orders from certain regions, and expedited processing for customers on a priority tier. Rather than splitting into separate workflow definitions (which duplicates a lot of shared steps) or nesting conditional blocks inside the engine, a single routing step evaluates all of this upfront:&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;// app/Workflows/OrderApproval/Steps/RouteApproval.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RouteApproval&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CustomerRepositoryContract&lt;/span&gt; &lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;ComplianceService&lt;/span&gt; &lt;span class="nv"&gt;$compliance&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&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="s1"&gt;'customer_id'&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;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'requires_manual_review'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'order_value'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'requires_compliance_hold'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;compliance&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requiresHold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'is_priority_customer'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPriority&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later steps consume these flags independently. Each one is responsible for its own skip 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;// app/Workflows/OrderApproval/Steps/AwaitManualApproval.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitManualApproval&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'requires_manual_review'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manager_decision'&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Workflows/OrderApproval/Steps/AwaitComplianceClearance.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitComplianceClearance&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'requires_compliance_hold'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'compliance_cleared'&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Workflows/OrderApproval/Steps/NotifyFulfillmentTeam.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotifyFulfillmentTeam&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;NotificationService&lt;/span&gt; &lt;span class="nv"&gt;$notifications&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'is_priority_customer'&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="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;notifyFulfillment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$priority&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'standard'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&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;And the workflow definition itself reads clearly, top to bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ValidateOrderItems&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="nc"&gt;RouteApproval&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="c1"&gt;// sets context flags&lt;/span&gt;
        &lt;span class="nc"&gt;ReserveInventory&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="nc"&gt;AwaitComplianceClearance&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="c1"&gt;// skips if not required&lt;/span&gt;
        &lt;span class="nc"&gt;CreateInvoice&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="nc"&gt;AwaitPayment&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="nc"&gt;AwaitManualApproval&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="c1"&gt;// skips if not required&lt;/span&gt;
        &lt;span class="nc"&gt;NotifyFulfillmentTeam&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="c1"&gt;// uses priority flag&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;Reading that list, you can already build a mental model of what happens. A standard order hits &lt;code&gt;RouteApproval&lt;/code&gt;, skips both await steps, and lands at &lt;code&gt;NotifyFulfillmentTeam&lt;/code&gt; on the standard queue. A high-value order from a restricted region pauses twice before it gets there. A priority customer skips the holds but gets the priority queue at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the Context Flag Approach Breaks Down
&lt;/h3&gt;

&lt;p&gt;It is worth being honest about the limits of this pattern. Context flags work well when branches share a significant chunk of their steps. If two paths genuinely have nothing in common beyond the trigger event, separate workflow definitions are probably the right call. Trying to force them into one definition just to keep things tidy results in a step list full of steps that almost always skip themselves, which is its own form of confusion.&lt;/p&gt;

&lt;p&gt;The other case where context flags get awkward is when a branch decision depends on the outcome of an earlier awaiting step. Suppose a manual approval step can produce one of three decisions: approved, approved with modifications, or sent back for renegotiation. The step waiting for that signal needs to write the decision into context so the next step can act on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Workflows/OrderApproval/Steps/AwaitManualApproval.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitManualApproval&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'requires_manual_review'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'approval_decision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto_approved'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Check if the signal has already been delivered (i.e. we are resuming)&lt;/span&gt;
        &lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'approval_decision'&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;$decision&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manager_decision'&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;When the signal arrives, the engine calls &lt;code&gt;execute&lt;/code&gt; again with the signal data merged into context. The step finds &lt;code&gt;approval_decision&lt;/code&gt; already set, returns complete, and the next step can read the decision and act accordingly. This is slightly counterintuitive at first - the step runs twice - but it keeps signal handling inside the step that owns that state, rather than leaking it into the engine or a separate handler class.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading Signal Data in the Next Step
&lt;/h3&gt;

&lt;p&gt;Once the signal data is in context, consuming it downstream is straightforward:&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;// app/Workflows/OrderApproval/Steps/ProcessApprovalDecision.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessApprovalDecision&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'approval_decision'&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;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'approved'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'proceed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="s1"&gt;'approved_with_changes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'proceed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'notify_changes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="s1"&gt;'sent_for_renegotiation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'proceed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt;                 &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Unknown approval decision: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$decision&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps further down the chain check &lt;code&gt;proceed&lt;/code&gt; and &lt;code&gt;notify_changes&lt;/code&gt; as needed. The branching logic is distributed across the steps that care about it, rather than centralised in a router that has to know about everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts: When Waiting Is Not Infinite
&lt;/h2&gt;

&lt;p&gt;Every awaiting step raises a question the engine cannot answer on its own: what happens if the signal never arrives? A payment that is never completed. A manager who goes on holiday without approving the order. A compliance team that sits on a request for three weeks. Real workflows have to handle these cases, and "wait forever" is rarely the right answer.&lt;/p&gt;

&lt;p&gt;The timeout mechanism is built directly into the step contract. Every step declares how long it is willing to wait before something has to happen:&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;interface&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// null means no timeout&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;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the engine parks a workflow at an awaiting step that declares a timeout, it dispatches a delayed job alongside the parking:&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;// Inside WorkflowEngine::handleAwait()&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;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$step&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;TimeoutWorkflowStep&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$instance&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="n"&gt;expectedSignal&lt;/span&gt;&lt;span class="o"&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;awaitSignal&lt;/span&gt;&lt;span class="p"&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;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$timeout&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;When the job fires, it checks whether the workflow is still waiting on the same signal. If the signal already arrived and the workflow advanced, the job finds a different state and does nothing. If the workflow is still parked, it delivers a synthetic &lt;code&gt;timeout&lt;/code&gt; signal:&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;// app/Jobs/TimeoutWorkflowStep.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TimeoutWorkflowStep&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&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;$expectedSignal&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WorkflowEngine&lt;/span&gt; &lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;WorkflowRepositoryContract&lt;/span&gt; &lt;span class="nv"&gt;$repository&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;$instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findById&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;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$instance&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canReceiveSignal&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;expectedSignal&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;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;signalData&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timed_out_signal'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;expectedSignal&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The step itself then decides what a timeout means for its context. This is the key design decision: the step owns the timeout behaviour, not the engine. The engine just delivers a signal. The step interprets it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeout Behaviour Is Step-Specific
&lt;/h3&gt;

&lt;p&gt;Different awaiting steps need very different responses to a timeout. A payment step timing out probably means the order should be cancelled. A manual approval step timing out might mean the workflow should auto-approve, or escalate, or just fail loudly and wait for human intervention. Making the step responsible for this means each one can handle it appropriately.&lt;/p&gt;

&lt;p&gt;Here is a payment step that cancels the order on timeout:&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;// app/Workflows/OrderApproval/Steps/AwaitPayment.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitPayment&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Already resolved - either paid or timed out&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;$context&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="s1"&gt;'payment_status'&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'payment_status'&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;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'timed_out'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment window expired. Order will be cancelled.'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Signal just arrived&lt;/span&gt;
        &lt;span class="nv"&gt;$signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'_signal'&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;$signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'payment_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'timed_out'&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;$signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'payment_succeeded'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'payment_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'payment_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'_signal_data.payment_id'&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="c1"&gt;// First time through - park and wait&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment_succeeded'&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 7 days&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;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&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;Compare that to a manual approval step that auto-approves rather than failing when the window expires. The business rule here is that if a manager does not review within 48 hours, the workflow proceeds as though it were approved:&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;// app/Workflows/OrderApproval/Steps/AwaitManualApproval.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitManualApproval&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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;WorkflowContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StepResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'requires_manual_review'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'approval_decision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto_approved'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'_signal'&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;$signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Business rule: no response in 48h = auto-approved&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'approval_decision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto_approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'auto_approved_reason'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'manager_decision'&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="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'approval_decision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$context&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="s1"&gt;'_signal_data.decision'&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;return&lt;/span&gt; &lt;span class="nc"&gt;StepResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;await&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manager_decision'&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;172800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 48 hours&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;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&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;Two awaiting steps, two completely different timeout behaviours, zero timeout logic in the engine. The engine is just a postman. It delivers signals and persists state. It does not have opinions about what those signals mean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Runtime-Configurable Timeouts
&lt;/h3&gt;

&lt;p&gt;Hard-coding timeout values as constants in step classes is fine during early development but tends to become a problem in production. Business rules about payment windows and approval deadlines have a habit of changing, and changing them should not require a deployment.&lt;/p&gt;

&lt;p&gt;The step contract is a PHP class, which means &lt;code&gt;timeoutSeconds()&lt;/code&gt; can read from wherever it likes:&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;// app/Workflows/OrderApproval/Steps/AwaitPayment.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitPayment&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WorkflowStepContract&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SystemSettingsRepositoryContract&lt;/span&gt; &lt;span class="nv"&gt;$settings&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;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;settings&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="s1"&gt;'payments.window_days'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&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 service container resolves the step, injects the repository, and the timeout is read from the database at the moment the step executes. Change the setting, and the next workflow instance that reaches this step picks up the new value. Existing parked instances are not affected - their timeout job was already dispatched when they parked - but that is usually the right behaviour. You do not want a settings change to retroactively alter the expectations for workflows already in flight.&lt;/p&gt;

&lt;h3&gt;
  
  
  What About Timeouts on Timeouts?
&lt;/h3&gt;

&lt;p&gt;One edge case worth thinking through: the &lt;code&gt;TimeoutWorkflowStep&lt;/code&gt; job is itself a queued job, which means it can fail. If your queue worker crashes repeatedly and the job exhausts its retry attempts without ever firing, the workflow stays parked indefinitely. For most applications this is an acceptable risk - queue failures are rare and observable - but if you need a harder guarantee, a scheduled job that sweeps for instances whose &lt;code&gt;timeout_at&lt;/code&gt; has passed and have not yet received a signal is a reasonable backstop. It trades immediacy for reliability: the sweep might fire 5 minutes late, but it will fire.&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;// app/Console/Commands/ExpireStaleWorkflowSteps.php&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WorkflowRepositoryContract&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;WorkflowEngine&lt;/span&gt; &lt;span class="nv"&gt;$engine&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;$stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findTimedOutInstances&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$instance&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="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;signalData&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sweep'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'timed_out_signal'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$instance&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAwaitingSignal&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running this as a scheduled command every few minutes means even a prolonged queue outage does not leave workflows stranded forever. The signal data includes a &lt;code&gt;source&lt;/code&gt; flag so you can distinguish between a timeout that fired on time via the job and one that was caught by the sweep - useful for monitoring and alerting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the Two Layers
&lt;/h2&gt;

&lt;p&gt;The state machine and the workflow engine are connected by a Laravel event listener. The state machine fires a domain event. The listener starts a workflow.&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;// app/Providers/OrdersServiceProvider.php&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;boot&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="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;OrderApproved&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="nc"&gt;StartOrderApprovalWorkflow&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="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Listeners/StartOrderApprovalWorkflow.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StartOrderApprovalWorkflow&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;WorkflowEngine&lt;/span&gt; &lt;span class="nv"&gt;$engine&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderApproved&lt;/span&gt; &lt;span class="nv"&gt;$event&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;$instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;workflowName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderApprovalWorkflow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;aggregateId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;aggregateType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;initialData&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'order_value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderValue&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="nc"&gt;AdvanceWorkflow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$instance&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine and the state machine have no direct dependency on each other. The listener is the only thing that knows about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Database Stores
&lt;/h2&gt;

&lt;p&gt;Two tables do the heavy lifting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_instances&lt;/code&gt; tracks a running execution. The key columns are &lt;code&gt;status&lt;/code&gt; (running, awaiting, completed, failed, cancelled), &lt;code&gt;current_step_index&lt;/code&gt;, &lt;code&gt;awaiting_signal&lt;/code&gt;, and &lt;code&gt;context&lt;/code&gt; as a JSON blob. When the engine persists between steps, it writes the new index and any context updates.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_signals&lt;/code&gt; is an append-only log of every signal delivered to every instance. This gives you a complete record of what happened and when, including which user or system actor delivered each signal.&lt;/p&gt;

&lt;p&gt;Because the engine persists before moving to the next step, a failed job always resumes from a known good state rather than re-running work that already succeeded. The &lt;code&gt;AdvanceWorkflow&lt;/code&gt; job is safe to retry because the engine checks the current status before doing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Pattern Buys You
&lt;/h2&gt;

&lt;p&gt;After living with this for a while, a few things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The business logic becomes auditable.&lt;/strong&gt; The state machine defines every valid transition explicitly. The workflow definition defines every step in a process explicitly. Anyone can read both and understand what the system does, without chasing conditionals through layers of service classes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async work is a first-class concept.&lt;/strong&gt; The &lt;code&gt;await&lt;/code&gt; mechanism makes it natural to model processes that span hours or days. Waiting for a payment, waiting for a human decision, waiting for an external API callback - these are all just signals. The workflow does not care whether they arrive in 50 milliseconds or 5 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each piece is independently testable.&lt;/strong&gt; Domain entities with no infrastructure dependencies. Steps that take a context and return a result. The engine with an in-memory repository. You can test the full logic of a workflow without touching the database or the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding steps does not require touching existing ones.&lt;/strong&gt; Inserting a new step into a workflow is a one-class change. The engine picks it up from the definition array. Existing steps are unaffected.&lt;/p&gt;

&lt;p&gt;The setup cost is real. You are building an engine, not just writing business logic. But for domains with complex entity lifecycles and multi-step processes that need to survive failures, the investment pays back quickly. The alternative - a growing tangle of service classes, status checks, and jobs that only make sense if you know the history - tends to get more expensive with every feature added.&lt;/p&gt;

&lt;p&gt;If you are working on something where entity status matters, where some transitions need to trigger chains of work, and where some of that work is async, this pattern is worth considering. The state machine gives you confidence that your data is always in a valid state. The workflow engine gives you confidence that the work that follows a transition always happens in the right order, even when things go wrong in the middle.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>statemachines</category>
      <category>workflowengine</category>
      <category>domaindrivendesign</category>
    </item>
    <item>
      <title>The Mid-Level Mindset</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 12:01:05 +0000</pubDate>
      <link>https://dev.to/juststevemcd/the-mid-level-mindset-41d9</link>
      <guid>https://dev.to/juststevemcd/the-mid-level-mindset-41d9</guid>
      <description>&lt;p&gt;We started this series with a single paragraph from a fictional client. A vague brief about a tool where clients can submit requests and developers can track them. Nine articles later, we have a fully interrogated set of requirements, a collection of properly shaped features, a trusted data model, a system architecture diagram, a layered application design, and a sequenced build plan with individual tickets ready to pick up.&lt;/p&gt;

&lt;p&gt;We have not written a single migration. We have not scaffolded a controller or configured a route. And yet the most important work is done.&lt;/p&gt;

&lt;p&gt;That is the point this entire series has been building towards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;Think back to where we started. The instinct to open your editor the moment a brief lands. The pull towards action, towards visible progress, towards the feeling of productivity that comes from lines of code appearing on a screen.&lt;/p&gt;

&lt;p&gt;That instinct is not wrong. It is just premature.&lt;/p&gt;

&lt;p&gt;What changed over the course of this series is not the tools you have access to. Laravel was always capable of building Clarity. The database was always going to need an &lt;code&gt;organisations&lt;/code&gt; table and a &lt;code&gt;users&lt;/code&gt; table and a &lt;code&gt;requests&lt;/code&gt; table with the right foreign keys. The layered architecture was always the right approach. None of that knowledge was new.&lt;/p&gt;

&lt;p&gt;What changed is the order of operations. The willingness to invest thinking before typing. The discipline to ask "what do I not yet know?" before asking "how do I build this?". That shift in sequence is what defines the move from junior to mid-level, and it compounds over time in ways that are hard to see until you look back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Mid-Level Actually Means
&lt;/h2&gt;

&lt;p&gt;The industry uses the term loosely, but when I think about what a mid-level developer actually is, I come back to a few specific qualities.&lt;/p&gt;

&lt;p&gt;A mid-level developer can be handed a brief and produce something buildable from it without constant direction. Not because they know every answer upfront, but because they know which questions to ask and how to find the answers before they start building.&lt;/p&gt;

&lt;p&gt;A mid-level developer understands that the most expensive code to write is code that solves the wrong problem. They protect against that by doing the thinking work first.&lt;/p&gt;

&lt;p&gt;A mid-level developer can communicate the shape of a problem to other people. They can draw a diagram, write a pitch, or explain a data model in plain language. That communication ability is what makes them valuable beyond their own output.&lt;/p&gt;

&lt;p&gt;A mid-level developer knows when to apply a pattern and when to leave it out. They have moved past the phase of applying everything they have learned uniformly, and into the phase of using judgment about what a given situation actually needs.&lt;/p&gt;

&lt;p&gt;None of those things are about programming language knowledge or framework familiarity. They are about thinking habits. And thinking habits are built through deliberate practice, not through reading documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Process in Brief
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this series, let it be this sequence. Apply it to every project you work on, every feature you are handed, every ticket that lands in your queue.&lt;/p&gt;

&lt;p&gt;First, interrogate the requirement. Surface every unstated assumption. Ask the business questions before the technical ones. Write down what you know and what you do not know yet.&lt;/p&gt;

&lt;p&gt;Second, define the actors and their needs. Write user stories with acceptance criteria. Make sure every feature has a clear definition of done that is anchored to a real user getting real value.&lt;/p&gt;

&lt;p&gt;Third, shape the work. Decide how much it is worth before you estimate how long it will take. Draw the edges of what is included and write down what is explicitly out of scope.&lt;/p&gt;

&lt;p&gt;Fourth, model the data. Draw the ERD before you touch a migration. Check every relationship for cardinality, every foreign key for direction, every array in a column for the related table it should be.&lt;/p&gt;

&lt;p&gt;Fifth, draw the system. Show the major components, how they connect, where external dependencies sit, and where data flows. Write the plain-language description. If you cannot describe it clearly in prose, the diagram needs more work.&lt;/p&gt;

&lt;p&gt;Sixth, sequence the build. Map the dependencies. Identify what has to exist before each feature can be built. Break each feature into discrete tickets with clear definitions of done.&lt;/p&gt;

&lt;p&gt;Then build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Take This
&lt;/h2&gt;

&lt;p&gt;This process is a foundation, not a ceiling. As you apply it to more projects, you will start to develop instincts about where the complexity hides in a brief, which ERD patterns recur across different problem domains, and which architectural decisions tend to cause the most pain if you get them wrong.&lt;/p&gt;

&lt;p&gt;Those instincts are what senior developers have. They have run this process enough times, on enough different problems, that parts of it become automatic. They spot the ambiguous noun in a brief before the client finishes reading it to them. They see the missing pivot table in a data model before anyone has drawn it. They know from experience which features sound small and turn out to be enormous.&lt;/p&gt;

&lt;p&gt;You build those instincts by doing the work deliberately, even when it feels slower than just opening your editor. Especially then.&lt;/p&gt;

&lt;p&gt;A few resources worth spending time with as you continue:&lt;/p&gt;

&lt;p&gt;Ryan Singer's Shape Up is the deepest treatment of the shaping process I have found. It is free, it is short, and every developer who works on product software should read it.&lt;/p&gt;

&lt;p&gt;Jeff Patton's User Story Mapping goes further on the user story side than we covered here, and is particularly useful when you are working on complex products with many different user types.&lt;/p&gt;

&lt;p&gt;John Ousterhout's A Philosophy of Software Design is the best writing I know of on the question of where complexity comes from and how to fight it. It will change the way you think about every design decision you make.&lt;/p&gt;

&lt;p&gt;And then there is the work itself. Take a project, real or fictional, and run the full process from brief to build plan before you write the first line of code. Do it again on the next one. The process becomes faster with practice, but it never stops being valuable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Final Word on Clarity
&lt;/h2&gt;

&lt;p&gt;We built something real in this series. Clarity started as a vague paragraph and became a fully designed application with a clear data model, a considered architecture, and a sequenced plan ready to execute.&lt;/p&gt;

&lt;p&gt;We never wrote a migration. We never wrote a controller. But anyone who has followed this series could pick up the build plan from article nine and start building Clarity today, with confidence that the foundations are solid and the direction is clear.&lt;/p&gt;

&lt;p&gt;That confidence, grounded in thinking rather than assumption, is what the mid-level mindset feels like from the inside.&lt;/p&gt;

&lt;p&gt;It is worth developing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thank you for following along with From Requirements to Reality. If this series helped you think differently about how you approach a problem before you start building, that is exactly what it was designed to do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>developermindset</category>
      <category>careergrowth</category>
      <category>architecture</category>
      <category>requirements</category>
    </item>
    <item>
      <title>From Diagram To Implementation Plan</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 12:01:04 +0000</pubDate>
      <link>https://dev.to/juststevemcd/from-diagram-to-implementation-plan-5coi</link>
      <guid>https://dev.to/juststevemcd/from-diagram-to-implementation-plan-5coi</guid>
      <description>&lt;p&gt;We have covered a lot of ground in this series. We started with a vague client brief, interrogated it until the real requirements surfaced, wrote user stories with acceptance criteria, shaped those stories into bounded features, drew an ERD, built a system diagram, and looked at how layers give every piece of code a clear home.&lt;/p&gt;

&lt;p&gt;That is the full design process. Now we need to translate it into something a developer can actually execute.&lt;/p&gt;

&lt;p&gt;This article is about sequencing. Taking everything we have designed and turning it into an ordered build plan that respects dependencies, minimises wasted work, and lets you deliver value as early as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Sequence Matters
&lt;/h2&gt;

&lt;p&gt;A common junior developer instinct is to start with the most interesting part. The feature that sounds fun to build, the component that uses a technology you want to learn, the part of the system that feels most novel. That is understandable, but it is usually the wrong starting point.&lt;/p&gt;

&lt;p&gt;The right starting point is the foundation. The code that everything else depends on. If you build the request management system before you have authentication in place, you will either need to stub out user identity in ways that do not reflect reality, or you will need to go back and retrofit it later. Both options cost time you cannot get back.&lt;/p&gt;

&lt;p&gt;Sequencing is the discipline of asking: what has to exist before this can be built? Work backwards from every feature until you hit something that has no dependencies, and that is where you start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identifying Dependencies
&lt;/h2&gt;

&lt;p&gt;Let me map the dependencies for the Clarity features we shaped in article four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client authentication and onboarding&lt;/strong&gt; has no dependencies on other features. It needs the database and the user model, but those are foundational. This goes first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team member management&lt;/strong&gt; depends on authentication being in place (admins need to be logged in to invite people) and on the user model having role support. It is almost as foundational as authentication itself, and it needs to exist before any meaningful testing of the application can happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request submission&lt;/strong&gt; depends on authentication (to know who is submitting) and on organisations existing (to associate the request correctly). Team member management should also be done first so there is at least one admin user to test with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request status and assignment&lt;/strong&gt; depends on requests existing. It also depends on team members existing, because you cannot assign a request to a developer who does not exist in the system. This comes after submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comment thread&lt;/strong&gt; depends on requests existing. It could technically be built in parallel with request status and assignment, but sharing the request detail page means they are better built in sequence to avoid integration friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request list views&lt;/strong&gt; depend on requests existing and on the basic request detail page being in place. This should be the last feature built, because it is a read view over data that all the other features create.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Build Order
&lt;/h2&gt;

&lt;p&gt;With dependencies mapped, the sequence becomes clear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Foundations: database, models, migrations, and base application structure&lt;/li&gt;
&lt;li&gt;Authentication: client login, team member login, invitation flow&lt;/li&gt;
&lt;li&gt;Team member management: invite, role assignment, deactivation&lt;/li&gt;
&lt;li&gt;Request submission: form, validation, attachment upload, status initialisation&lt;/li&gt;
&lt;li&gt;Request status and assignment: status transitions, developer assignment, client visibility&lt;/li&gt;
&lt;li&gt;Comment thread: posting, internal flag, client-facing view&lt;/li&gt;
&lt;li&gt;Request list views: client list, team list, basic status filtering&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step builds on the last. Each step produces something testable before the next step begins. And each step delivers a piece of working software rather than a collection of half-built components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the Tickets
&lt;/h2&gt;

&lt;p&gt;A build order is not yet a set of tickets. You still need to break each step into discrete pieces of work that a developer can pick up and complete in a day or two.&lt;/p&gt;

&lt;p&gt;Here is how I would break down step four, request submission, into individual tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ticket: Request model and migration&lt;/strong&gt;&lt;br&gt;
Create the &lt;code&gt;requests&lt;/code&gt; table migration with all columns from the ERD. Create the &lt;code&gt;Request&lt;/code&gt; Eloquent model with the &lt;code&gt;HasUlids&lt;/code&gt; trait, fillable columns, and the &lt;code&gt;submitter&lt;/code&gt; and &lt;code&gt;assignee&lt;/code&gt; relationships. Write a model factory for use in tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ticket: Attachment model and migration&lt;/strong&gt;&lt;br&gt;
Create the &lt;code&gt;attachments&lt;/code&gt; table migration. Create the &lt;code&gt;Attachment&lt;/code&gt; model with its relationship back to &lt;code&gt;Request&lt;/code&gt;. Configure the file storage driver. Write a model factory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ticket: Submit request endpoint&lt;/strong&gt;&lt;br&gt;
Create the &lt;code&gt;StoreRequestRequest&lt;/code&gt; form request with validation rules and a &lt;code&gt;payload()&lt;/code&gt; method. Create the &lt;code&gt;StoreRequestPayload&lt;/code&gt; DTO. Create the &lt;code&gt;SubmitRequest&lt;/code&gt; action. Create the &lt;code&gt;StoreController&lt;/code&gt;. Wire up the route. Write feature tests covering successful submission, validation failures, and attachment handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ticket: Request detail page&lt;/strong&gt;&lt;br&gt;
Create the Livewire component for the request detail view. Display title, description, status, assignee, and attachments. Scope the view so clients only see their own requests. Write feature tests covering client access and unauthorised access attempts.&lt;/p&gt;

&lt;p&gt;Four tickets for one feature, each independently completable and testable. A developer picking up any one of these knows exactly what done looks like, because the acceptance criteria from the user stories map directly onto the test cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Relationship Between Stories and Tickets
&lt;/h2&gt;

&lt;p&gt;User stories describe what the system should do from a user's perspective. Tickets describe the technical work required to make that happen. They are not the same thing, and conflating them is a common source of confusion.&lt;/p&gt;

&lt;p&gt;A single user story often maps to multiple tickets. The "submit a request" story from article three maps to at least the four tickets above. That is fine. The story is the goal. The tickets are the path.&lt;/p&gt;

&lt;p&gt;When you write a ticket, you should be able to point to the user story it is serving. If a ticket cannot be connected to a user story, ask whether it needs to exist. Infrastructure work (setting up CI, configuring deployment, writing base test helpers) is an exception, but application feature work should always be traceable back to a user need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the Plan Against the ERD
&lt;/h2&gt;

&lt;p&gt;Before you start building, do one final check. Go back to your ERD and make sure every entity and every relationship has a corresponding ticket.&lt;/p&gt;

&lt;p&gt;In the Clarity plan, let me verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organisation: covered in the authentication step&lt;/li&gt;
&lt;li&gt;User: covered in authentication and team member management&lt;/li&gt;
&lt;li&gt;Request: covered in request submission&lt;/li&gt;
&lt;li&gt;Comment: covered in the comment thread step&lt;/li&gt;
&lt;li&gt;Attachment: covered in request submission&lt;/li&gt;
&lt;li&gt;All relationships: each foreign key maps to a ticket that creates the parent entity before the child entity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any entity in your ERD does not have a corresponding ticket, you have a gap. Either you have forgotten something, or that entity is not actually needed for the current scope. Either way, better to find that now than two weeks into the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plan as a Living Document
&lt;/h2&gt;

&lt;p&gt;A build plan is not a contract. Requirements change, technical discoveries shift priorities, and features that seemed simple turn out to be more complex than they appeared. A good plan is one that absorbs those changes without falling apart.&lt;/p&gt;

&lt;p&gt;The way you make a plan resilient is by keeping the dependencies clear. If you know that request status depends on request submission, and request submission depends on authentication, then when authentication takes longer than expected you can immediately see what is blocked and communicate that clearly. You are not surprised. You are just updating the schedule with an accurate picture of what is affected.&lt;/p&gt;

&lt;p&gt;That kind of clarity is what teams rely on mid-level developers to provide. Not perfect predictions, but an accurate, up-to-date map of where the work stands and what it connects to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Into Practice
&lt;/h2&gt;

&lt;p&gt;Take the project you have been working with throughout this series. Map the dependencies between your shaped features. Write a build order. Then break the first two steps into individual tickets.&lt;/p&gt;

&lt;p&gt;For each ticket, make sure you can answer these questions: what does done look like? What does it depend on? Which user story does it serve?&lt;/p&gt;

&lt;p&gt;If any of those questions is hard to answer, the ticket needs more work before anyone picks it up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the final article, we are going to step back from Clarity and talk about what this entire process represents: the shift in thinking that defines a mid-level developer, and where to take these skills from here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>implementationplanning</category>
      <category>dependencies</category>
      <category>projectplanning</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Thinking In Layers</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 12:00:32 +0000</pubDate>
      <link>https://dev.to/juststevemcd/thinking-in-layers-3843</link>
      <guid>https://dev.to/juststevemcd/thinking-in-layers-3843</guid>
      <description>&lt;p&gt;Ask a junior developer where a piece of logic should live and you will usually get one of two answers: the controller, or the model. Ask a mid-level developer the same question and they will ask you a question back: what kind of logic is it?&lt;/p&gt;

&lt;p&gt;That distinction is the heart of this article.&lt;/p&gt;

&lt;p&gt;Separation of concerns is one of those principles you hear about early in your career and nod along to without fully internalising what it means in practice. It sounds obvious. Of course different concerns should be separated. But when you are staring at a controller that has grown to three hundred lines and you are not sure how it got there, the theory suddenly feels less clear than it did.&lt;/p&gt;

&lt;p&gt;In this article we are going to look at what separation of concerns actually means inside a Laravel application, why fat controllers are a symptom of an architectural problem rather than a code quality problem, and how thinking in layers changes the way you make decisions about where code should live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Layer Actually Is
&lt;/h2&gt;

&lt;p&gt;A layer is a group of code that has a single, well-defined job. Code inside a layer should only do that job. When it needs to do something outside that job, it should hand off to a different layer rather than doing the work itself.&lt;/p&gt;

&lt;p&gt;In a Laravel application, the layers I work with look like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The HTTP layer&lt;/strong&gt; handles everything related to the incoming request and the outgoing response. Controllers, middleware, form requests, and responses live here. This layer's job is to receive input, validate it, hand it to the business layer, and return a response. It does not make business decisions. It does not query the database directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The business layer&lt;/strong&gt; contains the logic that makes your application do what it is supposed to do. Actions, services, and domain objects live here. This layer does not know anything about HTTP. It receives data, applies rules, and returns results. It can talk to the data layer to read or write records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data layer&lt;/strong&gt; handles persistence. Models, query builders, and repository classes live here. This layer knows how to find, create, update, and delete records. It does not apply business rules. It does not know about HTTP.&lt;/p&gt;

&lt;p&gt;Three layers. Three jobs. Each one knowing about its own responsibilities and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fat Controllers Happen
&lt;/h2&gt;

&lt;p&gt;A fat controller is not a discipline problem. It is an architectural vacuum.&lt;/p&gt;

&lt;p&gt;When a team does not have a clear shared understanding of what belongs where, logic accumulates in the most obvious place. Controllers are obvious. They are where the request arrives, so they are where people start writing code. And once code is there, more code gets added next to it because it is easier to extend an existing file than to create a new one.&lt;/p&gt;

&lt;p&gt;Before long you have a controller that validates input, queries the database, sends emails, dispatches jobs, formats responses, and handles error cases. It has absorbed everything because nothing had a clear home.&lt;/p&gt;

&lt;p&gt;The fix is not to refactor the controller. It is to give every kind of logic a home so that developers always know where to put new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying Layers to Clarity
&lt;/h2&gt;

&lt;p&gt;Let me walk through a concrete example using Clarity's request submission feature.&lt;/p&gt;

&lt;p&gt;A client submits a new request. Here is what needs to happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the input (title required, description required, attachments optional)&lt;/li&gt;
&lt;li&gt;Create the request record associated with the client's organisation&lt;/li&gt;
&lt;li&gt;Store any uploaded attachments&lt;/li&gt;
&lt;li&gt;Set the initial status to "submitted"&lt;/li&gt;
&lt;li&gt;Return a response confirming the submission&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without layers, all of that logic tends to end up in the controller. Here is what that looks like, and why it is a problem:&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;// The kind of controller you want to avoid&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;Request&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;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:255'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&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;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'attachments.*'&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;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:10240'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$projectRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProjectRequest&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;'organisation_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organisation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'submitted_by'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'description'&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;'submitted'&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$projectRequest&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'filename'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClientOriginalName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMimeType&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$projectRequest&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="mi"&gt;201&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 not terrible code. It works. But it is doing too many jobs at once. It is validating, creating records, handling file storage, and formatting a response, all in the same method. Testing it in isolation is difficult because it is deeply coupled to the HTTP request object. Reusing any of this logic (say, if requests could also be submitted via an import job) means duplicating it or extracting it under pressure.&lt;/p&gt;

&lt;p&gt;Now here is the same feature with layers applied:&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;// The Form Request handles validation&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreRequestRequest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;FormRequest&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;rules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:255'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'description'&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;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'attachments.*'&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;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:10240'&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;payload&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;StoreRequestPayload&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;StoreRequestPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;organisationId&lt;/span&gt;&lt;span class="o"&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organisation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;submittedBy&lt;/span&gt;&lt;span class="o"&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;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;attachments&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The Action handles the business logic&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubmitRequest&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;StoreRequestPayload&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ProjectRequest&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$projectRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProjectRequest&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;'organisation_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organisationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'submitted_by'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;submittedBy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&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;'submitted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attachments&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$projectRequest&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;submittedBy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'filename'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClientOriginalName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMimeType&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;return&lt;/span&gt; &lt;span class="nv"&gt;$projectRequest&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The Controller handles HTTP in and HTTP out&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreController&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;SubmitRequest&lt;/span&gt; &lt;span class="nv"&gt;$submitRequest&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;StoreRequestRequest&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;JsonResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$projectRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;submitRequest&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="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;payload&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;response&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$projectRequest&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
            &lt;span class="mi"&gt;201&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller is now four lines of meaningful logic. It receives a validated payload, calls an action, and returns a response. It knows nothing about how the request is created or how attachments are stored. Those are the action's job.&lt;/p&gt;

&lt;p&gt;The action knows nothing about HTTP. It receives a payload object and does the work. You could call it from a console command, a queued job, or a test, and it would behave identically each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Model Fits In
&lt;/h2&gt;

&lt;p&gt;You will notice the model does not appear much in the code above beyond &lt;code&gt;ProjectRequest::create()&lt;/code&gt; and the &lt;code&gt;attachments()&lt;/code&gt; relationship. That is intentional.&lt;/p&gt;

&lt;p&gt;Models in a layered application are responsible for representing a database record and its relationships. They are not responsible for business rules, validation, or application logic. A model that has methods like &lt;code&gt;submitAndNotifyClient()&lt;/code&gt; or &lt;code&gt;assignToAvailableDeveloper()&lt;/code&gt; is doing the action's job, and it will become harder to test and maintain as that logic grows.&lt;/p&gt;

&lt;p&gt;Keep models focused. They know about their table, their columns, their relationships, and their casts. Everything else belongs in a layer above them.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is Not Dogma
&lt;/h2&gt;

&lt;p&gt;I want to be clear that layers are a tool, not a religion. A simple CRUD endpoint that reads a record and returns it does not need an action class. Adding indirection where none is needed creates noise without creating value.&lt;/p&gt;

&lt;p&gt;The question to ask is: is this logic complex enough, or reusable enough, that it deserves its own home? If the answer is yes, extract it. If the answer is no, keep it in the controller and move on.&lt;/p&gt;

&lt;p&gt;Mid-level developers learn to make that call. Juniors often apply patterns uniformly regardless of context, which leads to over-engineering simple things. The goal is judgment, not rule-following.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Into Practice
&lt;/h2&gt;

&lt;p&gt;Take a controller from a project you have worked on and look at it honestly. How many jobs is it doing? Can you identify which lines belong in the HTTP layer, which belong in the business layer, and which belong in the data layer?&lt;/p&gt;

&lt;p&gt;You do not need to refactor it right now. Just practice the act of categorising what you see. That categorisation instinct is what you are building, and it comes from practice more than from reading.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the next article, we are going to bring everything together and look at how to turn your ERD and architecture diagram into a sequenced build plan that a developer can actually follow.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>separationofconcerns</category>
      <category>layeredarchitecture</category>
      <category>laravel</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Introduction To Systems Architecture</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 12:00:30 +0000</pubDate>
      <link>https://dev.to/juststevemcd/introduction-to-systems-architecture-4188</link>
      <guid>https://dev.to/juststevemcd/introduction-to-systems-architecture-4188</guid>
      <description>&lt;p&gt;We have a clear problem definition, a set of shaped features, and a data model we trust. At this point, a lot of developers would consider the design work done and start writing code. And honestly, for a project the size of Clarity, you probably could. The application is small enough that its structure is mostly implied by the framework.&lt;/p&gt;

&lt;p&gt;But this series is about building the habits that carry you through larger, more complex projects. And on those projects, skipping the architecture diagram is where things start to quietly go wrong.&lt;/p&gt;

&lt;p&gt;In this article I want to introduce you to what architecture actually means for application developers, and show you how to draw a simple system diagram for Clarity that captures how the pieces fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Architecture Means at This Level
&lt;/h2&gt;

&lt;p&gt;When most developers hear "architecture", they think about large distributed systems, microservices, cloud infrastructure diagrams with dozens of boxes and arrows going everywhere. That is one kind of architecture, and it is not what we are talking about here.&lt;/p&gt;

&lt;p&gt;For application developers, architecture is about a simpler set of questions. What are the major components of this system? How does data flow between them? What are the boundaries between different concerns? Where do external dependencies plug in?&lt;/p&gt;

&lt;p&gt;You do not need to be designing a distributed system to benefit from answering those questions. Even a straightforward Laravel application has meaningful architecture: a web layer that handles HTTP, a business logic layer that does the actual work, a data layer that talks to the database, and often a set of external integrations sitting alongside all of that. Understanding where those layers are and how they connect is what lets you make good decisions about where code should live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components vs Layers
&lt;/h2&gt;

&lt;p&gt;There are two ways to think about application structure, and both are useful in different contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layers&lt;/strong&gt; describe a vertical slice through your application. Think of the classic presentation layer, business logic layer, data access layer split. Data comes in at the top, passes through the layers, and comes out as a response. Each layer only talks to the layer directly below it. This is a useful mental model for thinking about separation of concerns inside a single application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Components&lt;/strong&gt; describe horizontal groupings of related functionality. Authentication is a component. The request management system is a component. Notifications are a component. A component might span multiple layers internally, but it has a clear boundary and a clear responsibility.&lt;/p&gt;

&lt;p&gt;In practice, most applications use both mental models at once. The layers tell you how code flows inside a component. The components tell you how the system is divided at a higher level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing a System Diagram
&lt;/h2&gt;

&lt;p&gt;A system diagram does not need to be elaborate. Its job is to show the major components of your system, how they connect, and where external dependencies sit. You are not trying to document every class or every database query. You are drawing a map that helps you and your team understand the shape of what you are building.&lt;/p&gt;

&lt;p&gt;Here is a Mermaid diagram for Clarity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    Browser["Browser / Client"]

    subgraph App ["Clarity Application"]
        Web["Web Layer\n(Routes, Controllers, Middleware)"]
        Auth["Auth Component\n(Login, Invitation, Sessions)"]
        Requests["Request Management\n(Submit, Status, Assignment)"]
        Comments["Comments\n(Post, Internal Flag)"]
        Attachments["Attachments\n(Upload, Storage)"]
        Notifications["Notifications\n(Status Changes, Comments)"]
    end

    subgraph Data ["Data Layer"]
        DB[("PostgreSQL")]
        Storage["File Storage"]
        Cache["Cache"]
    end

    Browser --&amp;gt; Web
    Web --&amp;gt; Auth
    Web --&amp;gt; Requests
    Web --&amp;gt; Comments
    Web --&amp;gt; Attachments
    Requests --&amp;gt; Notifications
    Comments --&amp;gt; Notifications
    Auth --&amp;gt; DB
    Requests --&amp;gt; DB
    Comments --&amp;gt; DB
    Attachments --&amp;gt; DB
    Attachments --&amp;gt; Storage
    Auth --&amp;gt; Cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a detailed technical specification. It is a conversation starter. It shows that Clarity has a web layer handling incoming requests, five functional components sitting behind it, a database and file storage for persistence, a cache layer, and a notifications pathway that gets triggered by changes in the request and comment components.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Diagram Reveals
&lt;/h2&gt;

&lt;p&gt;Even a simple diagram like this surfaces useful questions.&lt;/p&gt;

&lt;p&gt;The notifications component appears as a box but is currently undefined. What does it actually do? Does it send emails? Does it push in-app notifications? Does it use a queue? That is a decision we have deferred, and the diagram makes that deferral visible. For the initial version of Clarity, we scoped email notifications on comments out of the first cycle. The component box is still there, which means when we come back to build it, we have a clear place for it to sit.&lt;/p&gt;

&lt;p&gt;The file storage box is separate from the database. That is intentional. Attachments are stored on disk or in an object store, not as blobs in the database. The diagram captures that decision explicitly. A developer joining the project later can see at a glance that attachments have their own storage concern, rather than discovering it buried in a model.&lt;/p&gt;

&lt;p&gt;The cache layer connects only to the auth component in this version. That is a starting point, not a permanent constraint. As Clarity grows and request list queries become more expensive, caching will expand to cover other components. Having it on the diagram from the start means that growth is natural rather than bolted on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture as a Communication Tool
&lt;/h2&gt;

&lt;p&gt;One of the things I value most about architecture diagrams is that they create a shared vocabulary for a team. When everyone can point at the same diagram and say "the request management component" or "the web layer", discussions become sharper. You spend less time talking past each other and more time talking about the actual problem.&lt;/p&gt;

&lt;p&gt;That shared vocabulary is especially valuable when something goes wrong. If a bug is affecting comments but not requests, a team with a clear component diagram can focus their investigation quickly. If performance is degrading under load, having the data flow documented means you can reason about where the bottleneck is likely to be.&lt;/p&gt;

&lt;p&gt;This is one of the reasons mid-level developers tend to be better debugging partners than juniors, even when the junior has been on the codebase longer. It is not that they know more code. It is that they think in components and flows rather than individual files and functions. The architecture diagram is how you start building that mental model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clarity's Architecture in Plain Language
&lt;/h2&gt;

&lt;p&gt;Let me describe the Clarity architecture in prose, the way you might explain it to a new team member.&lt;/p&gt;

&lt;p&gt;Clarity is a single Laravel application served over HTTP. All incoming requests pass through the web layer, which handles routing, authentication middleware, and request validation. Behind the web layer, the application is divided into four main functional areas: request management, comments, attachments, and authentication.&lt;/p&gt;

&lt;p&gt;Request management is the core of the application. It covers the full lifecycle of a client request from submission through completion, including status transitions and developer assignment. Comments sit alongside request management and share the same request context, with the addition of the internal flag that controls client visibility. Attachments handle file uploads and connect to external file storage rather than the database.&lt;/p&gt;

&lt;p&gt;Authentication covers client login via password, team member login, and the invitation flow. Session state is cached to reduce database load on authenticated requests.&lt;/p&gt;

&lt;p&gt;Notifications are triggered by events in the request and comment components. In the first version of Clarity, notifications are out of scope. The component is acknowledged in the architecture but not built yet.&lt;/p&gt;

&lt;p&gt;Data persistence uses PostgreSQL for relational data and a file storage service for attachments. The application does not use a search index or a message queue in the initial version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Into Practice
&lt;/h2&gt;

&lt;p&gt;Draw a system diagram for your own project using the same approach. Start with the major components you can identify, then add the data layer and any external dependencies. Draw arrows to show how data flows between them.&lt;/p&gt;

&lt;p&gt;Then write a plain-language description of the architecture the way I did above. If you struggle to write it in a few clear paragraphs, the diagram probably needs more work. The prose and the diagram should tell the same story.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the next article, we are going to look at separation of concerns in more detail, understand why the "fat controller" problem is really an architectural symptom, and start thinking about what belongs in each layer of a Laravel application.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>systemdesign</category>
      <category>applicationdesign</category>
      <category>planning</category>
    </item>
  </channel>
</rss>
