<?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>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>
    <item>
      <title>Relationships, Cardinality, and the Questions Your Schema Is Asking</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 11:59:58 +0000</pubDate>
      <link>https://dev.to/juststevemcd/relationships-cardinality-and-the-questions-your-schema-is-asking-1559</link>
      <guid>https://dev.to/juststevemcd/relationships-cardinality-and-the-questions-your-schema-is-asking-1559</guid>
      <description>&lt;p&gt;In the last article we drew the Clarity ERD and ended up with a clean diagram covering five entities and seven relationships. If you followed along and drew one for your own project, you probably found a few things that surprised you. That is normal, and it is exactly the point.&lt;/p&gt;

&lt;p&gt;Now I want to go deeper. Drawing an ERD is one skill. Reading what it is telling you is another, and the second one is where the real value lives.&lt;/p&gt;

&lt;p&gt;In this article we are going to look at the three relationship types in detail, walk through the most common mistakes I see developers make when modelling data, and show you how to catch them at the diagram stage before they become painful migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Relationships, Properly
&lt;/h2&gt;

&lt;p&gt;We covered these briefly in the last article, but they deserve more attention.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-to-One
&lt;/h3&gt;

&lt;p&gt;A one-to-one relationship means one record in table A maps to exactly one record in table B, and that mapping is unique on both sides.&lt;/p&gt;

&lt;p&gt;These are less common than people expect. When a developer reaches for a one-to-one, I always ask: why is this data in a separate table? Sometimes there is a good reason. You might have a &lt;code&gt;users&lt;/code&gt; table and a &lt;code&gt;user_profiles&lt;/code&gt; table where the profile data is large, infrequently accessed, and cleaner to separate. Or you might have a base &lt;code&gt;users&lt;/code&gt; table that is extended by either a &lt;code&gt;client_profiles&lt;/code&gt; table or a &lt;code&gt;team_member_profiles&lt;/code&gt; table depending on role.&lt;/p&gt;

&lt;p&gt;But a lot of the time, a one-to-one relationship is a sign that the data should just live in the same table. Before you commit to one, ask yourself whether there is a genuine reason for the separation or whether you are adding complexity without adding value.&lt;/p&gt;

&lt;p&gt;In Clarity, we do not have any one-to-one relationships. The model is clean enough that everything fits in its own entity without needing auxiliary tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-to-Many
&lt;/h3&gt;

&lt;p&gt;This is the workhorse of relational data modelling. One record in table A is referenced by many records in table B. The foreign key lives on the "many" side.&lt;/p&gt;

&lt;p&gt;In Clarity: one organisation has many users, so &lt;code&gt;organisation_id&lt;/code&gt; lives on the &lt;code&gt;users&lt;/code&gt; table. One request has many comments, so &lt;code&gt;request_id&lt;/code&gt; lives on the &lt;code&gt;comments&lt;/code&gt; table. The pattern is consistent.&lt;/p&gt;

&lt;p&gt;The mistake I see most often with one-to-many is putting the foreign key on the wrong side. If you put &lt;code&gt;user_ids&lt;/code&gt; (as some kind of serialised array) on the &lt;code&gt;organisations&lt;/code&gt; table instead of &lt;code&gt;organisation_id&lt;/code&gt; on the &lt;code&gt;users&lt;/code&gt; table, you have inverted the relationship and created a mess. You cannot join efficiently, you cannot enforce referential integrity, and you will hit a wall the moment you need to query it properly.&lt;/p&gt;

&lt;p&gt;The rule is simple: the foreign key always goes on the child. The "many" side. If you are unsure which side is the child, ask yourself which record is owned by the other. A user is owned by an organisation. A comment is owned by a request. The owned record holds the foreign key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Many-to-Many
&lt;/h3&gt;

&lt;p&gt;A many-to-many relationship means records on both sides can be connected to multiple records on the other side. You cannot model this directly with a foreign key on either table. You need a pivot table that sits between them and holds the connection.&lt;/p&gt;

&lt;p&gt;Clarity does not have a many-to-many in the current design, so let me add one to illustrate the point. Suppose we wanted to add tagging to requests, where a request can have many tags and a tag can be applied to many requests. That is a classic many-to-many.&lt;/p&gt;

&lt;p&gt;The entities involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request {
    uuid id
    string title
    ...
}

Tag {
    uuid id
    string name
    string colour
}

RequestTag {
    uuid request_id
    uuid tag_id
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;RequestTag&lt;/code&gt; pivot table is the relationship. It holds nothing but the two foreign keys (and sometimes additional data about the relationship itself, like a &lt;code&gt;created_at&lt;/code&gt; timestamp or a user who applied the tag).&lt;/p&gt;

&lt;p&gt;In Laravel, this maps to a &lt;code&gt;belongsToMany&lt;/code&gt; relationship on both models:&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;// Request model&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;tags&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Tag&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'request_tags'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Tag model&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;requests&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToMany&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'request_tags'&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 common mistake here is not recognising when you have a many-to-many. Developers often start by modelling it as a one-to-many and then discover mid-build that the constraint they assumed does not hold. When you spot a noun in your application that sounds like it could belong to many different records of another type, treat it as a signal to check whether you are looking at a pivot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common ERD Mistakes
&lt;/h2&gt;

&lt;p&gt;Let me walk through the mistakes I see most often and how to spot them before they cause problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storing arrays in a column
&lt;/h3&gt;

&lt;p&gt;If you ever find yourself writing &lt;code&gt;tags: string&lt;/code&gt; or &lt;code&gt;assigned_to: json&lt;/code&gt; on an entity, stop. That is almost always a sign that the data belongs in a related table. Arrays in columns cannot be indexed properly, cannot be joined against, and are a maintenance headache the moment the data inside them grows or needs to be queried individually.&lt;/p&gt;

&lt;p&gt;In the Clarity ERD, &lt;code&gt;assigned_to&lt;/code&gt; is a single foreign key pointing at one user. That is correct because our business rule says a request has one assignee. But if the requirement had been "a request can be assigned to multiple developers", the right answer would be an &lt;code&gt;assignments&lt;/code&gt; pivot table, not a JSON array of user IDs in a column.&lt;/p&gt;

&lt;h3&gt;
  
  
  The missing pivot
&lt;/h3&gt;

&lt;p&gt;Related to the above: when you draw a many-to-many relationship between two entities without drawing the pivot table, you have an incomplete diagram. Always draw the pivot explicitly. It forces you to think about what data the relationship itself carries, and it makes the migration obvious when you get to that stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nullable foreign keys as a shortcut
&lt;/h3&gt;

&lt;p&gt;It is tempting to make a foreign key nullable when you are not sure whether a relationship will always exist. Sometimes that is correct. In Clarity, &lt;code&gt;assigned_to&lt;/code&gt; on &lt;code&gt;Request&lt;/code&gt; is nullable because a request might not have an assignee yet. That is a real business rule.&lt;/p&gt;

&lt;p&gt;But sometimes a nullable foreign key is a sign that the relationship is modelled incorrectly. If you find yourself with a record that should always belong to a parent but the foreign key keeps being null in practice, the relationship direction might be wrong, or the data might belong in a different table entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conflating user roles with user types
&lt;/h3&gt;

&lt;p&gt;This one is specific to applications like Clarity, but it comes up constantly. When your system has multiple types of users (clients and team members, customers and staff, students and teachers), there is a temptation to use a single &lt;code&gt;role&lt;/code&gt; column and call it done.&lt;/p&gt;

&lt;p&gt;Sometimes that is fine. But if the two user types have genuinely different data requirements, different relationships to other entities, or different constraints on what they can do, a single role column will not hold the weight. You end up with columns that are only relevant to one type of user, nullable columns everywhere, and logic scattered across your codebase to handle the differences.&lt;/p&gt;

&lt;p&gt;In the Clarity design I chose a single &lt;code&gt;users&lt;/code&gt; table with a &lt;code&gt;role&lt;/code&gt; column and a nullable &lt;code&gt;sub_role&lt;/code&gt;. That works for our use case because the data requirements for clients and team members are similar enough. But it is a decision worth examining on your own projects. If the profiles for your two user types are genuinely different shapes, a polymorphic users table or a base users table with separate profile tables might serve you better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revisiting the Clarity ERD
&lt;/h2&gt;

&lt;p&gt;Now that we have covered these in detail, let me take one more look at the Clarity diagram and point out the decisions that are worth noting.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Request&lt;/code&gt; entity has two foreign keys pointing at &lt;code&gt;User&lt;/code&gt;: &lt;code&gt;submitted_by&lt;/code&gt; and &lt;code&gt;assigned_to&lt;/code&gt;. This is intentional, but it means the Eloquent relationships on &lt;code&gt;Request&lt;/code&gt; need to be named explicitly to avoid ambiguity:&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;submitter&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'submitted_by'&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;assignee&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'assigned_to'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you wrote &lt;code&gt;$this-&amp;gt;belongsTo(User::class)&lt;/code&gt; without the second argument, Laravel would look for a &lt;code&gt;user_id&lt;/code&gt; column that does not exist. Naming the foreign key explicitly in the relationship definition is the correct approach here, and the ERD is what made this obvious before we wrote a single line of code.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Comment&lt;/code&gt; entity has &lt;code&gt;is_internal&lt;/code&gt; as a boolean. This is a simple field, but it carries real access control logic: when &lt;code&gt;is_internal&lt;/code&gt; is true, the comment must be excluded from any query that is serving data to a client user. That is an application-layer concern rather than a schema concern, but spotting it on the diagram is a reminder to handle it consistently across every query that touches comments.&lt;/p&gt;

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

&lt;p&gt;Go back to the ERD you drew in the last exercise. Now look at every relationship line and ask these questions for each one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the foreign key on the correct side?&lt;/li&gt;
&lt;li&gt;Is this truly a one-to-many, or could it become a many-to-many as the application grows?&lt;/li&gt;
&lt;li&gt;Are there any arrays or JSON fields that should be a related table?&lt;/li&gt;
&lt;li&gt;Are there any nullable foreign keys that feel like shortcuts rather than genuine business rules?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix what you find. A diagram is cheap to change. A migration is not.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the next article, we are going to step back from the data model and look at the bigger picture: what application architecture actually means for backend developers, and how to draw a simple system diagram that shows how the pieces of Clarity fit together.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>erd</category>
      <category>cardinality</category>
      <category>databasedesign</category>
      <category>datamodeling</category>
    </item>
    <item>
      <title>Your First ERD</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 11:59:56 +0000</pubDate>
      <link>https://dev.to/juststevemcd/your-first-erd-52h6</link>
      <guid>https://dev.to/juststevemcd/your-first-erd-52h6</guid>
      <description>&lt;p&gt;Up until now, everything we have done has been about understanding the problem. We interrogated the brief, surfaced the assumptions, wrote user stories, and shaped the features into bounded pieces of work. That is a lot of thinking before touching anything technical, and it is exactly the right order to do things in.&lt;/p&gt;

&lt;p&gt;Now we get to use that thinking. Because once you know what your application needs to do, the next question is: what does it need to remember?&lt;/p&gt;

&lt;p&gt;That question is what an Entity Relationship Diagram is designed to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an ERD Actually Is
&lt;/h2&gt;

&lt;p&gt;An ERD is a visual map of the data your application works with. It shows you the things your system needs to store (entities), the properties those things have (attributes), and the connections between them (relationships).&lt;/p&gt;

&lt;p&gt;Before you write a single migration, before you think about table names or column types, drawing an ERD gives you a bird's-eye view of your entire data model. It lets you spot problems, ask questions, and make decisions on paper, where changing your mind costs nothing.&lt;/p&gt;

&lt;p&gt;I have seen developers skip this step and pay for it later. A misunderstood relationship between two entities can mean a painful migration weeks into a build, or worse, a data model that quietly produces wrong results and nobody notices until a client reports it. Fifteen minutes with a pencil and a blank page is a much cheaper way to find those problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Building Blocks
&lt;/h2&gt;

&lt;p&gt;Every ERD is made of three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entities&lt;/strong&gt; are the nouns in your system. The things your application tracks and stores. In Clarity, the entities we can already see from our user stories are: organisations, users, requests, comments, and attachments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attributes&lt;/strong&gt; are the properties of each entity. A user has a name, an email address, and a role. A request has a title, a description, and a status. Attributes map directly to the columns you will eventually write in your migrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relationships&lt;/strong&gt; are the connections between entities. An organisation has many users. A user can submit many requests. A request can have many comments. These connections are what give your data model its shape, and getting them right is the most important part of the exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cardinality
&lt;/h2&gt;

&lt;p&gt;When you draw a relationship between two entities, you need to describe how many of one thing can be connected to how many of another. This is called cardinality, and there are three basic types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-to-one.&lt;/strong&gt; One record in table A corresponds to exactly one record in table B. These are relatively rare. An example might be a user having one profile, where the profile data lives in a separate table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-to-many.&lt;/strong&gt; One record in table A corresponds to many records in table B. This is the most common relationship in most applications. One organisation has many users. One user has many requests. One request has many comments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Many-to-many.&lt;/strong&gt; Many records in table A can be connected to many records in table B. This always requires a pivot table. An example in Clarity might be if we allowed a request to be tagged, where a request can have many tags and a tag can belong to many requests.&lt;/p&gt;

&lt;p&gt;Getting cardinality wrong is the most expensive ERD mistake. If you model a one-to-many relationship as a one-to-one, you will hit a wall the moment a second record tries to attach itself somewhere it cannot go. Drawing it out first makes these mistakes obvious before they are baked into your schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing the Clarity ERD
&lt;/h2&gt;

&lt;p&gt;Let me walk through how I would build the Clarity ERD from our user stories and shaped features.&lt;/p&gt;

&lt;p&gt;I start by listing every entity I can see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organisation&lt;/li&gt;
&lt;li&gt;User&lt;/li&gt;
&lt;li&gt;Request&lt;/li&gt;
&lt;li&gt;Comment&lt;/li&gt;
&lt;li&gt;Attachment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then for each entity, I list its attributes. I am not worrying about data types yet, just the fields that need to exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organisation:&lt;/strong&gt; id, name, created_at&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User:&lt;/strong&gt; id, organisation_id, name, email, password, role (client or team_member), sub_role (developer or admin, nullable), invited_at, created_at&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt; id, organisation_id, submitted_by (user_id), assigned_to (user_id, nullable), title, description, status, created_at, updated_at&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comment:&lt;/strong&gt; id, request_id, user_id, body, is_internal, created_at&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attachment:&lt;/strong&gt; id, request_id, user_id, filename, path, mime_type, created_at&lt;/p&gt;

&lt;p&gt;Now the relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Organisation has many Users&lt;/li&gt;
&lt;li&gt;An Organisation has many Requests&lt;/li&gt;
&lt;li&gt;A User (client) submits many Requests&lt;/li&gt;
&lt;li&gt;A User (developer) is assigned to many Requests&lt;/li&gt;
&lt;li&gt;A Request has many Comments&lt;/li&gt;
&lt;li&gt;A Request has many Attachments&lt;/li&gt;
&lt;li&gt;A User posts many Comments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Mermaid syntax, which I would recommend learning because it lets you write diagrams as code and store them in version control alongside your project, this looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;erDiagram
    Organisation {
        uuid id
        string name
        timestamp created_at
    }

    User {
        uuid id
        uuid organisation_id
        string name
        string email
        string role
        string sub_role
        timestamp invited_at
        timestamp created_at
    }

    Request {
        uuid id
        uuid organisation_id
        uuid submitted_by
        uuid assigned_to
        string title
        text description
        string status
        timestamp created_at
        timestamp updated_at
    }

    Comment {
        uuid id
        uuid request_id
        uuid user_id
        text body
        boolean is_internal
        timestamp created_at
    }

    Attachment {
        uuid id
        uuid request_id
        uuid user_id
        string filename
        string path
        string mime_type
        timestamp created_at
    }

    Organisation ||--o{ User : "has many"
    Organisation ||--o{ Request : "has many"
    User ||--o{ Request : "submits"
    User ||--o{ Comment : "posts"
    Request ||--o{ Comment : "has many"
    Request ||--o{ Attachment : "has many"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Render that in any Mermaid viewer and you get a complete picture of the Clarity data model. Every entity, every attribute, every relationship, on a single page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Diagram
&lt;/h2&gt;

&lt;p&gt;The notation on the relationship lines is worth understanding. In Mermaid ERDs, each side of a relationship is described with two symbols: one for the minimum cardinality and one for the maximum.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;||&lt;/code&gt; means exactly one. &lt;code&gt;o{&lt;/code&gt; means zero or more. So &lt;code&gt;||--o{&lt;/code&gt; reads as: exactly one on the left, zero or more on the right.&lt;/p&gt;

&lt;p&gt;Put together, &lt;code&gt;Organisation ||--o{ User&lt;/code&gt; reads as: one organisation has zero or more users. An organisation can exist with no users yet (useful when you first create it), but every user belongs to exactly one organisation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ERD Is Telling You
&lt;/h2&gt;

&lt;p&gt;The real value of drawing this out is what you notice when you step back and look at it.&lt;/p&gt;

&lt;p&gt;I can already see a potential question in the Clarity diagram. The &lt;code&gt;Request&lt;/code&gt; entity has two foreign keys pointing at &lt;code&gt;User&lt;/code&gt;: &lt;code&gt;submitted_by&lt;/code&gt; and &lt;code&gt;assigned_to&lt;/code&gt;. That is fine and intentional, but it means when you build the Eloquent relationships on the &lt;code&gt;Request&lt;/code&gt; model, you will need named relationships rather than a single &lt;code&gt;belongsTo&lt;/code&gt;. Something like &lt;code&gt;submitter()&lt;/code&gt; and &lt;code&gt;assignee()&lt;/code&gt; rather than just &lt;code&gt;user()&lt;/code&gt;. That is a small thing, but it is much better to notice it here than halfway through writing a controller.&lt;/p&gt;

&lt;p&gt;I can also see that &lt;code&gt;Comment&lt;/code&gt; and &lt;code&gt;Attachment&lt;/code&gt; both have a &lt;code&gt;user_id&lt;/code&gt;. That means both clients and team members can post comments and add attachments, which matches our user stories. If the stories said only team members could add attachments, that &lt;code&gt;user_id&lt;/code&gt; column would need a constraint we would need to enforce at the application layer. The diagram surfaces that decision.&lt;/p&gt;

&lt;p&gt;This is what ERDs are actually for. Not documentation. Not formality. They are a thinking tool that forces you to look at your data model as a whole before you start building pieces of it in isolation.&lt;/p&gt;

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

&lt;p&gt;Take the project from the previous exercises and draw an ERD for it. Start with just the entities and relationships, then add attributes once the structure feels right.&lt;/p&gt;

&lt;p&gt;Use Mermaid if you want to keep it in code, or draw.io if you prefer a visual tool. The format matters less than the act of drawing it out and looking at what it tells you.&lt;/p&gt;

&lt;p&gt;Pay particular attention to your foreign keys. For every relationship line you draw, ask yourself: does this direction make sense? What happens if the parent record is deleted? Is there anything here that I assumed was a one-to-many that might actually be a many-to-many?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the next article, we are going to go deeper on relationships and cardinality, look at some of the most common ERD mistakes, and talk about how to catch the wrong relationship on paper before it becomes the wrong migration in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>erd</category>
      <category>databasedesign</category>
      <category>datamodeling</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shaping Before You Build</title>
      <dc:creator>Steve McDougall</dc:creator>
      <pubDate>Tue, 19 May 2026 11:59:24 +0000</pubDate>
      <link>https://dev.to/juststevemcd/shaping-before-you-build-5c5l</link>
      <guid>https://dev.to/juststevemcd/shaping-before-you-build-5c5l</guid>
      <description>&lt;p&gt;We now have a solid set of user stories for Clarity. We know who the actors are, what they need to do, and what done looks like for each feature. That is real progress. But there is a gap between a well-written user story and something a developer can confidently pick up and build within a reasonable timeframe.&lt;/p&gt;

&lt;p&gt;That gap is what shaping is designed to close.&lt;/p&gt;

&lt;p&gt;Shaping is a concept from the Shape Up methodology, written by Ryan Singer at Basecamp. If you have not read it, I would strongly recommend it. It is free at basecamp.com/shapeup, and it is one of the most practical pieces of writing on software product development I have come across. This article is not a replacement for it, but it will introduce you to the core ideas and show you how to apply them to Clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Shaping Solves
&lt;/h2&gt;

&lt;p&gt;User stories are great for describing what a user needs. They are not great at describing how much work that need represents, or how to build it in a way that is actually feasible within a given timeframe.&lt;/p&gt;

&lt;p&gt;Without shaping, a story like "as a client, I want to add a comment to my request" can balloon into a real-time threaded messaging system with emoji reactions and read receipts, or it can be a simple text box that posts to a list. Both satisfy the story. One takes two days to build, the other takes two months.&lt;/p&gt;

&lt;p&gt;Shaping forces you to make that decision deliberately, before anyone writes a line of code. It gives features a specific form: a defined scope, a considered approach, and an explicit boundary around what is included and what is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Elements of a Shaped Feature
&lt;/h2&gt;

&lt;p&gt;Every shaped feature has three components.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;appetite&lt;/strong&gt;. This is the amount of time you are willing to spend on the feature, decided upfront. Not an estimate of how long it will take, but a deliberate decision about how much it is worth. This is one of the most powerful ideas in Shape Up, and it inverts the normal way teams think about time. Instead of estimating a feature and then planning around that estimate, you decide how much time a feature deserves and then shape the work to fit inside that boundary.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;the solution&lt;/strong&gt;. This is a rough sketch of how the feature will work, at a level of detail that is enough to build from without being so prescriptive that it removes all creative problem-solving from the developer. Shape Up calls these "fat marker sketches" because the point is to communicate structure and flow, not pixel-perfect detail.&lt;/p&gt;

&lt;p&gt;The third is &lt;strong&gt;the boundaries&lt;/strong&gt;. This is an explicit list of what is not included. Boundaries are as important as the solution itself, because they are what prevent scope from quietly expanding during the build. If you do not write down what is out of scope, someone will assume it is in scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a Pitch
&lt;/h2&gt;

&lt;p&gt;The output of shaping is called a pitch. A pitch is a short document that captures all three elements and makes the case for why the feature is worth building in this cycle.&lt;/p&gt;

&lt;p&gt;A pitch is not a specification. It does not tell developers exactly how to implement something. It tells them what problem they are solving, roughly how it should work, how much time they have, and what the edges of the work are.&lt;/p&gt;

&lt;p&gt;Let me write a pitch for one of the Clarity features.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Pitch: Request Comments&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Appetite:&lt;/strong&gt; Two days&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Clients and team members currently have no way to communicate directly on a specific request within Clarity. Context gets lost in email threads, and there is no record of decisions or questions attached to the work itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; A simple comment thread on each request detail page. Any user with access to the request can post a comment. Team members can mark a comment as internal, which hides it from clients. Comments appear in chronological order. No threading, no reactions, no editing after posting in the first version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rough sketch:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request detail page
---------------------
[ Request title ]
[ Status badge ] [ Assigned to: Sarah ]
[ Description ]

Comments
---------
[ Client User ] 14 May
Can you confirm the deadline for this?

[ Developer ] 15 May  [internal]
Need to check with the PM before responding.

[ Text area: Add a comment... ]
[ Internal? checkbox ] [ Post comment ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Out of scope:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Threaded replies&lt;/li&gt;
&lt;li&gt;Editing or deleting comments&lt;/li&gt;
&lt;li&gt;Emoji reactions or mentions&lt;/li&gt;
&lt;li&gt;Email notifications on new comments (deferred to a later cycle)&lt;/li&gt;
&lt;li&gt;File attachments on comments&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Look at what that pitch gives you. A developer picking this up knows exactly what they are building, roughly how it should look and behave, how long they have to build it, and where the edges of the work are. They are not going to spend a day building a notification system because they assumed it was included. They are not going to build a threaded reply UI because no one said not to.&lt;/p&gt;

&lt;p&gt;That clarity (again, no pun intended) is what makes teams fast. Not velocity metrics, not sprint ceremonies. Clear, bounded work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appetite as a Design Tool
&lt;/h2&gt;

&lt;p&gt;I want to spend a moment on appetite specifically, because it is the idea most developers initially resist.&lt;/p&gt;

&lt;p&gt;The instinct when you hear "two days for a comment system" is to think about all the things a comment system could be and conclude that two days is not enough. But that framing is backwards. The appetite is not a constraint on what you could build. It is a statement about what the problem is worth solving right now.&lt;/p&gt;

&lt;p&gt;A comment thread that works and ships in two days is more valuable than a fully-featured messaging system that takes six weeks and delays everything else. You can always come back and add threading, notifications, and mentions in a later cycle if the basic version proves its value. What you cannot do is un-spend six weeks.&lt;/p&gt;

&lt;p&gt;This is one of the most important mental shifts in moving from junior to mid-level. Juniors often think about features in terms of what they could be. Mid-level developers think about features in terms of what they need to be, right now, to solve the problem in front of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shaping the Clarity Features
&lt;/h2&gt;

&lt;p&gt;Let me apply appetites to the full set of Clarity user stories, so we have a shaped backlog to build from as the series continues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client authentication and onboarding&lt;/strong&gt;&lt;br&gt;
Appetite: three days. Clients are invited by an admin, set a password via an invitation link, and log in. No social auth, no self-registration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submitting a request&lt;/strong&gt;&lt;br&gt;
Appetite: two days. Title, description, and optional file attachments. Status set to "submitted" on creation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request status and assignment&lt;/strong&gt;&lt;br&gt;
Appetite: two days. Team members can update status and assign a developer. Status and assignee are visible to clients. No real-time updates in the first version; a page refresh is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comment thread&lt;/strong&gt;&lt;br&gt;
Appetite: two days. As pitched above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team member management&lt;/strong&gt;&lt;br&gt;
Appetite: one day. Admins can invite team members by email, set their role, and deactivate their account. Invitations expire after 48 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request list views&lt;/strong&gt;&lt;br&gt;
Appetite: one day. Clients see a list of their own requests. Team members see all requests with basic filtering by status.&lt;/p&gt;

&lt;p&gt;That is the full Clarity application shaped into six discrete pieces of work, with a total appetite of twelve days. That is not an estimate of how long it will take. It is a decision about how much time the initial version of Clarity is worth. If any piece of work is running over its appetite, the right question is not "how do we go faster?" but "what can we remove from this piece to fit inside the time we agreed it was worth?"&lt;/p&gt;

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

&lt;p&gt;Take one of the user stories you wrote in the last exercise and write a pitch for it. Define an appetite, sketch a rough solution, and write out the out of scope list.&lt;/p&gt;

&lt;p&gt;The out of scope list is the most important part. Be honest about what you are tempted to include, and then deliberately leave it out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In the next article, we are going to move from features into data, and look at how to draw your first Entity Relationship Diagram before you write a single migration.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shaping</category>
      <category>scopemanagement</category>
      <category>productthinking</category>
      <category>planning</category>
    </item>
  </channel>
</rss>
