<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Thiago Alves</title>
        <link>https://thiagoalves.dev/</link>
        <description>I’m Thiago, a Brazilian software engineer based in Austin, Texas. I’ve been building software since 2008, and for many years I’ve split my time between leading teams and keeping my hands on the code.</description>
        <lastBuildDate>Wed, 14 Oct 2026 22:42:20 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Thiago Alves</title>
            <url>https://thiagoalves.dev/favicon.png</url>
            <link>https://thiagoalves.dev/</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <atom:link href="https://thiagoalves.dev/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[How I use AI to review every pull request]]></title>
            <link>https://thiagoalves.dev/journal/how-i-use-ai-to-review-every-pull-request</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/how-i-use-ai-to-review-every-pull-request</guid>
            <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[An agent reviews every pull request before a person does. What it checks, what it refuses to say, and why a false blocking finding costs more than a bug.]]></description>
            <content:encoded><![CDATA[<p>Every pull request on my team gets reviewed by an agent before a person opens it. It reads the diff, checks it
against the repository's own written conventions, argues with itself about what it found, and posts one comment with
a verdict.</p><p>It has been running long enough that I no longer read every backend PR myself, which was the point. What follows is
what I got wrong on the way there, because the interesting parts were never the prompt.</p><h2 id="three-layers-and-only-one-of-them-is-a-person">Three layers, and only one of them is a person</h2><p>Code review used to be one activity. It is now three, stacked, and each one exists to stop the layer above it wasting
someone's attention.</p><p><strong>Formatting and style</strong> is a linter in CI. Not a person, and not the agent either. If an opinion about indentation
reaches either of them, the pipeline is broken. This is the layer everyone has already solved and some teams still
argue in.</p><p><strong>Conventions, architecture and known failure classes</strong> is the agent. These work well precisely because they are
written down somewhere — and if they are not written down, the agent is the forcing function that makes you write
them.</p><p><strong>Intent</strong> is the human. Should this exist? Is this the right trade? Does the product actually want this behavior?
No model has the context to answer that, and pretending otherwise is how you end up with a rubber stamp.</p><p>The ordering matters. If the design is wrong, a comment about a variable name is worse than useless — it implies the
design passed.</p><h2 id="ground-it-in-your-rules-not-the-models">Ground it in your rules, not the model's</h2><p>The first version reviewed pull requests using the model's general knowledge of the framework. The comments were
articulate, plausible, and worthless. It suggested patterns we had deliberately rejected, flagged idioms that are
house style, and missed the things that actually hurt us.</p><p>The fix was to stop asking it what good code looks like and start telling it. The agent's first instruction is to
read the repository's own conventions documentation and everything it links to, then review against that.</p><p>This has a side effect worth more than the reviews. Any rule you cannot write down, the agent cannot enforce — so
every disagreement about style ends in either a documented rule or an admission that it was never a rule, just a
preference. That conversation was overdue on my team by about four years.</p><h2 id="name-the-failure-classes">Name the failure classes</h2><p>"Review this pull request" produces a review of whatever the model finds interesting. What you want is a review of
the things that have bitten you.</p><p>Mine are specific: data crossing a tenant boundary, a new structure built parallel to an entity that already exists,
authorization checked at one of the two levels it needs, a method or table <a href="/journal/avoiding-duplicated-decisions-in-laravel-and-beyond">duplicating one already in the codebase</a>,
and anything on the repository's list of banned patterns.</p><p>That list came from production incidents and from review comments I found myself writing repeatedly. It is not a
theory of good code. It is a list of ways this particular system has actually gone wrong.</p><h2 id="a-false-blocking-finding-costs-more-than-a-missed-bug">A false blocking finding costs more than a missed bug</h2><p>This is the thing I would tell anyone building one of these.</p><p>An automated reviewer that raises a confident, wrong, blocking objection teaches the team a lesson in about two
weeks: this thing cries wolf, skim it. Once that happens, the quality of the reviews stops mattering, because nobody
is reading them carefully.</p><p>So the calibration is deliberately asymmetric. A blocking finding requires concrete evidence with citable lines.
Anything inferred, suspected or reasoned-toward gets downgraded and <strong>phrased as a question</strong> instead of an
assertion. "Is this intentional?" costs the author ten seconds. "This is a security hole" costs them ten seconds, an
argument, and a little of their willingness to read the next one.</p><p>Severity also has teeth. Only blocking findings reject a pull request. Everything else is information.</p><h2 id="make-it-argue-with-itself">Make it argue with itself</h2><p>The review pass is not the last word. Every finding goes to a second, skeptical pass whose only job is to audit the
first one against the actual code and return one of three answers: verified, refuted, or uncertain. It is explicitly
told to say uncertain when it can neither prove nor disprove something — an out that makes the other two answers
mean something.</p><p>The reconciliation is where the thinking is:</p><pre class="language-python shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">for</span><span class="scsBO"> finding </span><span class="sI-6P">in</span><span class="scsBO"> findings</span><span class="s2m6S">:
</span></span><span class="line" line="2"><span class="scsBO">    verdict </span><span class="sFwuq">=</span><span class="scsBO"> refuter_verdicts</span><span class="s2m6S">.</span><span class="s-yd1">get</span><span class="s2m6S">(</span><span class="scsBO">finding</span><span class="s2m6S">[</span><span class="selus">"i"</span><span class="s2m6S">])
</span></span><span class="line" line="3"><span emptyLinePlaceholder>
</span></span><span class="line" line="4"><span class="sI-6P">    if</span><span class="scsBO"> verdict </span><span class="sFwuq">==</span><span class="selus"> "refuted"</span><span class="s2m6S">:
</span></span><span class="line" line="5"><span class="sI-6P">        continue</span><span class="sFwuq">                      # drop it entirely
</span></span><span class="line" line="6"><span class="sI-6P">    if</span><span class="scsBO"> verdict </span><span class="sFwuq">==</span><span class="selus"> "uncertain"</span><span class="s2m6S">:
</span></span><span class="line" line="7"><span class="scsBO">        finding</span><span class="s2m6S">[</span><span class="selus">"uncertain"</span><span class="s2m6S">]</span><span class="sFwuq"> =</span><span class="scsBO"> True   </span><span class="sFwuq"># keep it, flag it
</span></span><span class="line" line="8"><span class="scsBO">    kept</span><span class="s2m6S">.</span><span class="s-yd1">append</span><span class="s2m6S">(</span><span class="scsBO">finding</span><span class="s2m6S">)</span><span class="sFwuq">              # anything else: keep, unchanged
</span></span></code></pre><p>That last line is the important one. If the refuter returns nothing, returns a shape nobody expected, or errors
outright, the finding survives <strong>at its original severity</strong>. A malfunctioning skeptic must never be able to quietly
downgrade a real blocking finding into a question. The whole block is wrapped in a <code>try</code>, and the <code>except</code> keeps
everything.</p><p>Every failure mode in that function has been chosen. It fails toward noise, never toward silence.</p><h2 id="let-code-decide-the-verdict">Let code decide the verdict</h2><p>The model proposes a verdict. It does not get to hold it.</p><p>After reconciliation, a few lines of Python recompute it: rejected if and only if a finding is blocking and not
flagged uncertain. That is the one decision I did not want a language model making on a bad day, and it is four
lines.</p><p>Carried-over findings count the same as new ones. There is no "we already mentioned this" discount — if the problem
is still in the diff, the verdict still reflects it.</p><h2 id="humans-can-overrule-it-the-author-is-not-a-human">Humans can overrule it. The author is not a human.</h2><p>A reviewer nobody can overrule is a tyrant, and one anybody can overrule is decoration.</p><p>So dismissals are honored: if a maintainer says a finding is wrong, the re-review lists it under dismissed and does
not raise it again. That single behavior did more for adoption than any improvement to review quality, because it
turned the bot from something that happens to you into something you can answer.</p><p>The list of people who can dismiss is built in code, not decided by the model, and it excludes two groups: other
bots, and the pull request's own author. Nobody dismisses findings on their own work. That was not a hypothetical —
it is the first thing anyone tries.</p><h2 id="what-it-is-still-bad-at">What it is still bad at</h2><p>It cannot tell you the feature is a bad idea. It cannot tell you this abstraction will be painful in six months
because it has not seen the six months. It cannot tell you the ticket was wrong.</p><p>It also cannot merge anything, which is a design decision rather than a limitation. The layer that has the context
to approve is the layer that has the context to be accountable, and that is still a person.</p><p>What changed is what that person spends their attention on. Before, it went on conventions, naming, missing
authorization checks and the same four comments I had written a hundred times. Now it goes on whether we should be
building this at all — which is the only part of code review I was ever any good at.</p><style>html pre.shiki code .sI-6P, html code.shiki .sI-6P{--shiki-default:#D4D4D8}html pre.shiki code .scsBO, html code.shiki .scsBO{--shiki-default:#F4F4F5}html pre.shiki code .s2m6S, html code.shiki .s2m6S{--shiki-default:#71717B}html pre.shiki code .sFwuq, html code.shiki .sFwuq{--shiki-default:#9F9FA9}html pre.shiki code .s-yd1, html code.shiki .s-yd1{--shiki-default:#FB64B6}html pre.shiki code .selus, html code.shiki .selus{--shiki-default:#46EDD5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}</style>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
        <item>
            <title><![CDATA[Coffee is over]]></title>
            <link>https://thiagoalves.dev/journal/coffee-is-over</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/coffee-is-over</guid>
            <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[One working day, hour by hour, with the internet talking over it. What being a software engineer actually feels like when nothing goes according to plan.]]></description>
            <content:encoded><![CDATA[<p><strong>09:00</strong></p><p>Standup. Fifteen minutes.</p><p><strong>09:40</strong></p><p>Standup ends.</p><p><strong>09:45</strong></p><p><code>feat: add password reset</code></p><p>The ticket says “simple.” I said it was simple too. Out loud. Where people could hear.</p><p><strong>10:40</strong></p><p>Quick call.</p><p>— So for this you’ll want to fire an event and add a Listener that puts a Job on the Redis queue.</p><p>— Wait! What’s a Listener? And a Job? And who is this Redis guy?</p><p><strong>11:05</strong></p><p>The first eighty percent took twenty minutes. I said two days.</p><blockquote><p><em>“If you’re still writing code by hand in 2026, you’re already behind.”</em> — 8,400 likes</p></blockquote><p><strong>11:40</strong></p><p>Four PRs waiting on <a href="/journal/how-i-use-ai-to-review-every-pull-request">my review</a>. Mine can wait.</p><blockquote><p><em>“I haven’t read code in months. Why would I?”</em> — 12,000 likes</p></blockquote><p><strong>12:30</strong></p><p><code>fix: password reset</code><br><code>fix: password reset (actually)</code></p><p><strong>12:55</strong></p><p>I’ll eat after this.</p><p><strong>13:52</strong></p><p>Four hours. The code was right the entire time. I restarted the service.</p><p><code>fix: restart the service</code></p><blockquote><p><em>“AI has made debugging a solved problem.”</em> — 4,000 likes</p></blockquote><p><strong>14:03</strong></p><p>I will not be investigating why.</p><p><strong>14:20</strong></p><p>— Hey, when do you think this will be ready?</p><p>I said two days. It has been two days.</p><p><strong>14:40</strong></p><p>The agent opened a PR. Small, well described, clean diff. It fixes a bug we don’t have.</p><blockquote><p><em>“We shipped our whole Q3 roadmap with three engineers and an agent.”</em> — 11,000 likes</p></blockquote><p><strong>15:20</strong></p><p>The test failed in CI. I ran it again. It passed. I moved on.</p><p><strong>15:45</strong></p><p>— The startup down the road raised eight million. I looked at their website. It’s a form.</p><p><strong>16:10</strong></p><p>This class depends on that one, which depends on another one over there. Today I’d build it completely differently.
If I touch it now I lose the afternoon.</p><p><code>wip</code><br><code>wip 2</code><br><code>revert: wip 2</code></p><p><strong>16:35</strong></p><p>Slack notification. Not now.</p><p>Phone rings.</p><p>— Thiago, can you look at Slack, I’m stuck.</p><p>OK.</p><p>— Never mind, got it.</p><p><strong>16:50</strong></p><p>I did not eat.</p><p><strong>17:50</strong></p><p>— A customer says he can’t update his password.</p><p>What kind of person changes their password on a shopping site at 5:50pm on a Friday?</p><p><strong>17:52</strong></p><p>Works on my machine.</p><p><strong>18:04</strong></p><p>The logs say everything is fine. Either the logs are lying or I am.</p><p><strong>18:10</strong></p><p>— He sent a screenshot in the chat.</p><img alt="Screenshot of the error" height="308" src="/images/articles/being-a-developer-is-frustrating-error.png" width="511"><p><strong>18:11</strong></p><p>Oh.</p><p><strong>19:30</strong></p><p>I should do something else for a living. Something less stressful, away from a screen, talking to people. Although I
don’t like people that much, and here I can work quietly with music on.</p><p>I haven’t left this room since this morning. I’m not complaining.</p><p><strong>20:05</strong></p><p><code>test: add the tests I said I’d written</code></p><blockquote><p><em>“The model writes your tests now. Stop typing.”</em> — 6,200 likes</p></blockquote><p><strong>20:15</strong></p><p>I need a coffee.</p><p>Coffee is over!</p>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
        <item>
            <title><![CDATA[My first experiences as a developer]]></title>
            <link>https://thiagoalves.dev/journal/my-first-experiences-as-a-developer</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/my-first-experiences-as-a-developer</guid>
            <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[I had no computer until I was 18 and picked IT because it looked like it would pay. What the first years actually taught me.]]></description>
            <content:encoded><![CDATA[<p>Most developers start this story the same way. A computer at home, a game they wanted to change, a parent who worked
with technology. Mine doesn’t go like that.</p><p>Until I was eighteen the only piece of technology I owned was a phone without a camera. I wasn’t interested in computers.
I didn’t dislike them either. They were just not part of my life in any direction.</p><p>When I finished high school I had no idea what to do, so I took a technical course in IT. Not because I felt called to
it. Because I understood there would be more job offers.</p><p>That was the whole reason. I picked the thing that looked most likely to pay.</p><h2 id="the-course">The course</h2><p>First semester: maintenance, networking, programming. I don’t think I need to say which one held my attention.</p><p>I was good at mathematics and my logical reasoning was decent, and for the first time that was useful for something
other than a test. At the end of the semester I applied for <a href="/journal/is-a-programming-degree-still-worth-it-in-the-age-of-ai">the degree in Systems Analysis and Development</a>. I passed,
and for a year I did both courses at the same time.</p><h2 id="the-internship">The internship</h2><p>After the technical course I got an internship at the institution where I studied. First contact with programming
outside a classroom. First reality check.</p><p>It was horrible.</p><p>I had no idea what I was doing. Too much new information at once, and my main task was maintaining a system built by
someone far more experienced than me.</p><p>It was the best thing that could have happened.</p><p>Nobody explained the codebase to me. I read it. I broke something small and put it back. I found the function that got
called from everywhere and suddenly understood why the whole thing was shaped the way it was. Within a few months I was
working on my own. The classes started to look useless next to what I was doing outside them. I finished the course
anyway.</p><p>It lasted two and a half years. By the end of it I had built real software in several different technologies, and I had
not yet officially worked a day in the industry. I arrived at my first job already knowing what a real system looks
like.</p><h2 id="day-one">Day one</h2><p>At the beginning of 2011 I entered the job market properly. On my first day I was told I would be working in a
different position from the one I had applied for, with a technology I had never seen.</p><p>I stayed.</p><p>Lost for the second time in two years, and the second time went faster — not because I knew the technology, but
because I had already survived not knowing something. That’s what the internship actually taught me. Not the
language. The tolerance.</p><h2 id="the-first-years">The first years</h2><p>From there it was websites and small apps, one after another, and I was learning new technologies constantly because
the work kept requiring them.</p><p>I was usually the least experienced person in the room, working next to people who had been doing it far longer than
me. Almost nothing I learned in that period came from a classroom. It came from building something that shipped,
watching it break in a way nobody had predicted, and fixing it while someone more senior explained what I should have
seen coming.</p><p>That’s the phase that prepared me for the next step in my career.</p><h2 id="if-i-could-talk-to-that-intern">If I could talk to that intern</h2><p>Things don’t always make sense at first, and that isn’t a signal about you. The first years are for learning,
and for building the resilience to keep going while you’re still confused. The resilience is the part that lasts.</p><p>He wouldn’t have believed me. He’d have gone and found out the hard way, which sometimes is the only way it
works.</p>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
        <item>
            <title><![CDATA[Avoiding duplicated decisions, in Laravel and beyond]]></title>
            <link>https://thiagoalves.dev/journal/avoiding-duplicated-decisions-in-laravel-and-beyond</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/avoiding-duplicated-decisions-in-laravel-and-beyond</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The expensive duplication is not copy-pasted code. It is the same decision made in four places, by four people, none of whom copied anything.]]></description>
            <content:encoded><![CDATA[<p>The duplication that hurts a growing codebase isn't copy-pasted functions. Those are <a href="/journal/how-i-use-ai-to-review-every-pull-request">easy to see</a> and easy to delete.
The expensive kind is the same <em>decision</em> made in four different places — what counts as a visible record, what a
status means to the person looking at it, which fields an endpoint returns. Nobody copied anything. Four people
independently wrote code that agreed on the day it was written and stopped agreeing in March.</p><p>The examples below use a support desk API — tickets, accounts, agents, customers — because the shape is easier to
follow than whatever domain you happen to be in.</p><p>The code is PHP and Laravel because that's what I work in. The ideas aren't. A template method and a strategy are
the same shape in any language with classes, and you'll have your own version of a resource and a validation rule.</p><h2 id="breaking-the-repository-layer-into-view-classes">Breaking the repository layer into view classes</h2><p>Every list endpoint needs the same invisible rules. A ticket belongs to an account. Drafts are only visible to whoever
wrote them. Tickets on a deleted board don't count. Tickets in an archived queue don't count either, unless you asked
for that queue specifically.</p><p>Miss one of those in one query and you don't have a bug, you have a data leak.</p><p>The usual answer is a scope, or a helper the developer remembers to call. "Remembers to" is the problem.</p><p>So the rules live in an abstract base, and the base runs them:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">abstract</span><span class="sI-6P"> class</span><span class="s-yd1"> TicketView</span><span class="sI-6P"> implements</span><span class="scsBO"> View
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> __construct</span><span class="s2m6S">(</span><span class="sI-6P">protected</span><span class="s-yd1"> TicketViewOptions</span><span class="s2m6S"> $</span><span class="scsBO">options</span><span class="s2m6S">)</span><span class="s2m6S"> {}
</span></span><span class="line" line="4"><span emptyLinePlaceholder>
</span></span><span class="line" line="5"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> __invoke</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="s-yd1"> Builder
</span></span><span class="line" line="6"><span class="s2m6S">    {
</span></span><span class="line" line="7"><span class="s2m6S">        $</span><span class="scsBO">query </span><span class="sFwuq">=</span><span class="s-yd1"> Ticket</span><span class="sFwuq">::</span><span class="s-yd1">query</span><span class="s2m6S">();
</span></span><span class="line" line="8"><span emptyLinePlaceholder>
</span></span><span class="line" line="9"><span class="s2m6S">        $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">limitToAccount</span><span class="s2m6S">($</span><span class="scsBO">query</span><span class="s2m6S">,</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">options</span><span class="sFwuq">-&gt;</span><span class="selus">user</span><span class="sFwuq">-&gt;</span><span class="selus">account_id</span><span class="s2m6S">);
</span></span><span class="line" line="10"><span class="s2m6S">        $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">hideOtherPeoplesDrafts</span><span class="s2m6S">($</span><span class="scsBO">query</span><span class="s2m6S">,</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">options</span><span class="sFwuq">-&gt;</span><span class="selus">user</span><span class="s2m6S">);
</span></span><span class="line" line="11"><span class="s2m6S">        $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">excludeDeletedBoards</span><span class="s2m6S">($</span><span class="scsBO">query</span><span class="s2m6S">);
</span></span><span class="line" line="12"><span class="s2m6S">        $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">excludeArchivedQueues</span><span class="s2m6S">($</span><span class="scsBO">query</span><span class="s2m6S">);
</span></span><span class="line" line="13"><span emptyLinePlaceholder>
</span></span><span class="line" line="14"><span class="sI-6P">        return</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">build</span><span class="s2m6S">($</span><span class="scsBO">query</span><span class="s2m6S">);
</span></span><span class="line" line="15"><span class="s2m6S">    }
</span></span><span class="line" line="16"><span emptyLinePlaceholder>
</span></span><span class="line" line="17"><span class="sI-6P">    abstract</span><span class="sI-6P"> protected</span><span class="sI-6P"> function</span><span class="s-yd1"> build</span><span class="s2m6S">(</span><span class="s-yd1">Builder</span><span class="s2m6S"> $</span><span class="scsBO">query</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="s-yd1"> Builder</span><span class="s2m6S">;
</span></span><span class="line" line="18"><span class="s2m6S">}
</span></span></code></pre><p>A subclass never touches <code>__invoke()</code>. It writes <code>build()</code>, which is the only part that makes it different from the
others. It cannot skip the scoping, because it doesn't own the entry point. This is the template method pattern:
the base owns the algorithm and leaves exactly one step to whoever extends it.</p><p>Views and filters both carry a key:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">interface</span><span class="s-yd1"> View
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> __invoke</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="s-yd1"> Builder</span><span class="s2m6S">;
</span></span><span class="line" line="4"><span class="sI-6P">    public</span><span class="sI-6P"> static</span><span class="sI-6P"> function</span><span class="s-yd1"> key</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> string</span><span class="s2m6S">;
</span></span><span class="line" line="5"><span class="s2m6S">}
</span></span><span class="line" line="6"><span emptyLinePlaceholder>
</span></span><span class="line" line="7"><span class="sI-6P">interface</span><span class="s-yd1"> Filter
</span></span><span class="line" line="8"><span class="s2m6S">{
</span></span><span class="line" line="9"><span class="sI-6P">    public</span><span class="sI-6P"> static</span><span class="sI-6P"> function</span><span class="s-yd1"> key</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> string</span><span class="s2m6S">;
</span></span><span class="line" line="10"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> apply</span><span class="s2m6S">(</span><span class="s-yd1">Builder</span><span class="s2m6S"> $</span><span class="scsBO">query</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="s-yd1"> Builder</span><span class="s2m6S">;
</span></span><span class="line" line="11"><span class="s2m6S">}
</span></span></code></pre><p>which means a request can select them by string, and the repository assembles them:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">public</span><span class="sI-6P"> function</span><span class="s-yd1"> startView</span><span class="s2m6S">(</span><span class="sI-6P">string</span><span class="s2m6S"> $</span><span class="scsBO">key</span><span class="s2m6S">,</span><span class="s-yd1"> TicketViewOptions</span><span class="s2m6S"> $</span><span class="scsBO">options</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="s-yd1"> Builder
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    return</span><span class="s2m6S"> (</span><span class="sI-6P">match</span><span class="s2m6S"> ($</span><span class="scsBO">key</span><span class="s2m6S">)</span><span class="s2m6S"> {
</span></span><span class="line" line="4"><span class="s-yd1">        InboxView</span><span class="sFwuq">::</span><span class="s-yd1">key</span><span class="s2m6S">()</span><span class="sI-6P">     =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> InboxView</span><span class="s2m6S">($</span><span class="scsBO">options</span><span class="s2m6S">),
</span></span><span class="line" line="5"><span class="s-yd1">        AssignedView</span><span class="sFwuq">::</span><span class="s-yd1">key</span><span class="s2m6S">()</span><span class="sI-6P">  =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> AssignedView</span><span class="s2m6S">($</span><span class="scsBO">options</span><span class="s2m6S">),
</span></span><span class="line" line="6"><span class="s-yd1">        ReportingView</span><span class="sFwuq">::</span><span class="s-yd1">key</span><span class="s2m6S">()</span><span class="sI-6P"> =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> ReportingView</span><span class="s2m6S">($</span><span class="scsBO">options</span><span class="s2m6S">),
</span></span><span class="line" line="7"><span class="sI-6P">        default</span><span class="sI-6P">              =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> DefaultView</span><span class="s2m6S">($</span><span class="scsBO">options</span><span class="s2m6S">),
</span></span><span class="line" line="8"><span class="s2m6S">    })();
</span></span><span class="line" line="9"><span class="s2m6S">}
</span></span></code></pre><p>A dozen view classes for one model, and the security rules are written once.</p><p>This isn't "put your queries in a repository." A repository with twelve public methods has exactly the problem the
controllers had. The win is that the base class owns the part nobody is allowed to forget.</p><h2 id="declarative-resources-with-lazy-resolvers">Declarative resources with lazy resolvers</h2><p>The second place the same decision gets made is on the way out. One endpoint returns a ticket. Another returns a ticket
with its customer. A third returns a ticket with its customer, its assignee and its attachments. Written as three
resources, they drift — and the one nobody touches for a year is the one still returning a field that was renamed.</p><p>So a resource declares its default shape and its optional relationships separately:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">abstract</span><span class="sI-6P"> class</span><span class="s-yd1"> ApiResource</span><span class="sI-6P"> extends</span><span class="scsBO"> JsonResource
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> toArray</span><span class="s2m6S">(</span><span class="s-yd1">Request</span><span class="s2m6S"> $</span><span class="scsBO">request</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="sI-6P"> array
</span></span><span class="line" line="4"><span class="s2m6S">    {
</span></span><span class="line" line="5"><span class="s2m6S">        $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">readRequest</span><span class="s2m6S">($</span><span class="scsBO">request</span><span class="s2m6S">);
</span></span><span class="line" line="6"><span emptyLinePlaceholder>
</span></span><span class="line" line="7"><span class="sI-6P">        return</span><span class="s2m6S"> [
</span></span><span class="line" line="8"><span class="sFwuq">            ...</span><span class="s2m6S">$</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">resolveFields</span><span class="s2m6S">(),
</span></span><span class="line" line="9"><span class="sFwuq">            ...</span><span class="s2m6S">$</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">resolveOptional</span><span class="s2m6S">(),
</span></span><span class="line" line="10"><span class="s2m6S">        ];
</span></span><span class="line" line="11"><span class="s2m6S">    }
</span></span><span class="line" line="12"><span emptyLinePlaceholder>
</span></span><span class="line" line="13"><span class="sI-6P">    abstract</span><span class="sI-6P"> protected</span><span class="sI-6P"> function</span><span class="s-yd1"> baseFields</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> array</span><span class="s2m6S">;
</span></span><span class="line" line="14"><span emptyLinePlaceholder>
</span></span><span class="line" line="15"><span class="sI-6P">    protected</span><span class="sI-6P"> function</span><span class="s-yd1"> optional</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> array
</span></span><span class="line" line="16"><span class="s2m6S">    {
</span></span><span class="line" line="17"><span class="sI-6P">        return</span><span class="s2m6S"> [];
</span></span><span class="line" line="18"><span class="s2m6S">    }
</span></span><span class="line" line="19"><span class="s2m6S">}
</span></span></code></pre><p>A concrete resource lists each optional relationship with its key and its own condition:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">protected</span><span class="sI-6P"> function</span><span class="s-yd1"> optional</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> array
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    return</span><span class="s2m6S"> [
</span></span><span class="line" line="4"><span class="sI-6P">        new</span><span class="s-yd1"> OptionalRelation</span><span class="s2m6S">(
</span></span><span class="line" line="5"><span class="selus">            'customer'</span><span class="s2m6S">,
</span></span><span class="line" line="6"><span class="sI-6P">            fn</span><span class="s2m6S">()</span><span class="s2m6S"> =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> CustomerResource</span><span class="s2m6S">($</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">ticket</span><span class="sFwuq">-&gt;</span><span class="selus">customer</span><span class="s2m6S">),
</span></span><span class="line" line="7"><span class="sI-6P">            fn</span><span class="s2m6S">()</span><span class="s2m6S"> =&gt;</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">ticket</span><span class="sFwuq">-&gt;</span><span class="selus">customer</span><span class="sFwuq"> !==</span><span class="scsBO"> null</span><span class="s2m6S">,
</span></span><span class="line" line="8"><span class="s2m6S">        ),
</span></span><span class="line" line="9"><span class="sI-6P">        new</span><span class="s-yd1"> OptionalRelation</span><span class="s2m6S">(
</span></span><span class="line" line="10"><span class="selus">            'assignee'</span><span class="s2m6S">,
</span></span><span class="line" line="11"><span class="sI-6P">            fn</span><span class="s2m6S">()</span><span class="s2m6S"> =&gt;</span><span class="sI-6P"> new</span><span class="s-yd1"> AgentResource</span><span class="s2m6S">($</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">ticket</span><span class="sFwuq">-&gt;</span><span class="selus">assignee</span><span class="s2m6S">),
</span></span><span class="line" line="12"><span class="sI-6P">            fn</span><span class="s2m6S">()</span><span class="s2m6S"> =&gt;</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">ticket</span><span class="sFwuq">-&gt;</span><span class="selus">assignee</span><span class="sFwuq"> !==</span><span class="scsBO"> null</span><span class="s2m6S">,
</span></span><span class="line" line="13"><span class="s2m6S">        ),
</span></span><span class="line" line="14"><span class="s2m6S">    ];
</span></span><span class="line" line="15"><span class="s2m6S">}
</span></span></code></pre><p>The closure is lazy, so nothing resolves unless somebody asked for it. And they ask in the query string —
<code>?include_customer=1&amp;include_assignee=1</code>. The resolver reads <code>include_</code> plus its own key and decides.</p><p>Nested resources inherit the parameters, so <code>?include_assignee[fields]=phone</code> reaches the agent resource underneath
without the ticket resource knowing anything about agents.</p><p>A second resolver handles extra columns. <code>baseFields()</code> returns what everyone gets; <code>?fields=reference,priority</code> adds
more, filtered against a blocklist so nobody can ask for <code>password</code> or <code>token</code>.</p><p>One resource per model. Every variation is a query parameter instead of a class.</p><h2 id="enums-with-behavior">Enums with behavior</h2><p>The third place is the smallest and does the most damage. A ticket has a status. What does the status <em>mean</em>?</p><p>It depends who's asking. To the agent, <code>awaiting_customer</code> means there is nothing to do. To the customer, the same
status means they owe you a reply. Write that as a <code>match</code> in the API resource, then again in the notification, then
again in the email, then again in the frontend, and you have four definitions that were identical exactly once.</p><p>So the enum answers the question:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">enum</span><span class="s-yd1"> TicketStatus</span><span class="sFwuq">:</span><span class="sI-6P"> string
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    case</span><span class="scsBO"> AWAITING_CUSTOMER </span><span class="sFwuq">=</span><span class="selus"> 'awaiting_customer'</span><span class="s2m6S">;
</span></span><span class="line" line="4"><span class="sI-6P">    case</span><span class="scsBO"> AWAITING_AGENT    </span><span class="sFwuq">=</span><span class="selus"> 'awaiting_agent'</span><span class="s2m6S">;
</span></span><span class="line" line="5"><span class="sI-6P">    case</span><span class="scsBO"> PENDING_REVIEW    </span><span class="sFwuq">=</span><span class="selus"> 'pending_review'</span><span class="s2m6S">;
</span></span><span class="line" line="6"><span class="sI-6P">    case</span><span class="scsBO"> PENDING_SIGNATURE </span><span class="sFwuq">=</span><span class="selus"> 'pending_signature'</span><span class="s2m6S">;
</span></span><span class="line" line="7"><span class="sI-6P">    case</span><span class="scsBO"> PENDING_UPLOAD    </span><span class="sFwuq">=</span><span class="selus"> 'pending_upload'</span><span class="s2m6S">;
</span></span><span class="line" line="8"><span emptyLinePlaceholder>
</span></span><span class="line" line="9"><span class="sI-6P">    private</span><span class="sI-6P"> const</span><span class="scsBO"> NOTHING_TO_DO    </span><span class="sFwuq">=</span><span class="selus"> 'Nothing to do'</span><span class="s2m6S">;
</span></span><span class="line" line="10"><span class="sI-6P">    private</span><span class="sI-6P"> const</span><span class="scsBO"> WAITING_ON_THEM  </span><span class="sFwuq">=</span><span class="selus"> 'Waiting on the customer'</span><span class="s2m6S">;
</span></span><span class="line" line="11"><span class="sI-6P">    private</span><span class="sI-6P"> const</span><span class="scsBO"> REPLY_NOW        </span><span class="sFwuq">=</span><span class="selus"> 'Reply now'</span><span class="s2m6S">;
</span></span><span class="line" line="12"><span class="sI-6P">    private</span><span class="sI-6P"> const</span><span class="scsBO"> REVIEW_AND_CLOSE </span><span class="sFwuq">=</span><span class="selus"> 'Review and close'</span><span class="s2m6S">;
</span></span><span class="line" line="13"><span emptyLinePlaceholder>
</span></span><span class="line" line="14"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> callToAction</span><span class="s2m6S">(</span><span class="s-yd1">Ticket</span><span class="s2m6S"> $</span><span class="scsBO">ticket</span><span class="s2m6S">,</span><span class="s-yd1"> User</span><span class="s2m6S"> $</span><span class="scsBO">user</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="sI-6P"> string
</span></span><span class="line" line="15"><span class="s2m6S">    {
</span></span><span class="line" line="16"><span class="sI-6P">        if</span><span class="s2m6S"> ($</span><span class="scsBO">ticket</span><span class="sFwuq">-&gt;</span><span class="s-yd1">isOnHold</span><span class="s2m6S">())</span><span class="s2m6S"> {
</span></span><span class="line" line="17"><span class="sI-6P">            return</span><span class="sI-6P"> self</span><span class="sFwuq">::</span><span class="scsBO">NOTHING_TO_DO</span><span class="s2m6S">;
</span></span><span class="line" line="18"><span class="s2m6S">        }
</span></span><span class="line" line="19"><span emptyLinePlaceholder>
</span></span><span class="line" line="20"><span class="sI-6P">        return</span><span class="s2m6S"> $</span><span class="scsBO">user</span><span class="sFwuq">-&gt;</span><span class="s-yd1">isAgentFor</span><span class="s2m6S">($</span><span class="scsBO">ticket</span><span class="s2m6S">)
</span></span><span class="line" line="21"><span class="sFwuq">            ?</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">agentAction</span><span class="s2m6S">()
</span></span><span class="line" line="22"><span class="sFwuq">            :</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">customerAction</span><span class="s2m6S">();
</span></span><span class="line" line="23"><span class="s2m6S">    }
</span></span><span class="line" line="24"><span emptyLinePlaceholder>
</span></span><span class="line" line="25"><span class="sI-6P">    private</span><span class="sI-6P"> function</span><span class="s-yd1"> agentAction</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> string
</span></span><span class="line" line="26"><span class="s2m6S">    {
</span></span><span class="line" line="27"><span class="sI-6P">        return</span><span class="sI-6P"> match</span><span class="s2m6S"> ($</span><span class="sI-6P">this</span><span class="s2m6S">)</span><span class="s2m6S"> {
</span></span><span class="line" line="28"><span class="sI-6P">            self</span><span class="sFwuq">::</span><span class="scsBO">AWAITING_CUSTOMER</span><span class="s2m6S">,
</span></span><span class="line" line="29"><span class="sI-6P">            self</span><span class="sFwuq">::</span><span class="scsBO">PENDING_SIGNATURE</span><span class="s2m6S">,
</span></span><span class="line" line="30"><span class="sI-6P">            self</span><span class="sFwuq">::</span><span class="scsBO">PENDING_UPLOAD    </span><span class="sI-6P">=&gt;</span><span class="sI-6P"> self</span><span class="sFwuq">::</span><span class="scsBO">WAITING_ON_THEM</span><span class="s2m6S">,
</span></span><span class="line" line="31"><span class="sI-6P">            self</span><span class="sFwuq">::</span><span class="scsBO">AWAITING_AGENT    </span><span class="sI-6P">=&gt;</span><span class="sI-6P"> self</span><span class="sFwuq">::</span><span class="scsBO">REPLY_NOW</span><span class="s2m6S">,
</span></span><span class="line" line="32"><span class="sI-6P">            self</span><span class="sFwuq">::</span><span class="scsBO">PENDING_REVIEW    </span><span class="sI-6P">=&gt;</span><span class="sI-6P"> self</span><span class="sFwuq">::</span><span class="scsBO">REVIEW_AND_CLOSE</span><span class="s2m6S">,
</span></span><span class="line" line="33"><span class="s2m6S">        };
</span></span><span class="line" line="34"><span class="s2m6S">    }
</span></span><span class="line" line="35"><span class="s2m6S">}
</span></span></code></pre><p>The enum also groups its own cases:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">public</span><span class="sI-6P"> static</span><span class="sI-6P"> function</span><span class="s-yd1"> awaitingCustomerCases</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> array
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    return</span><span class="s2m6S"> [
</span></span><span class="line" line="4"><span class="sI-6P">        self</span><span class="sFwuq">::</span><span class="scsBO">AWAITING_CUSTOMER</span><span class="s2m6S">,
</span></span><span class="line" line="5"><span class="sI-6P">        self</span><span class="sFwuq">::</span><span class="scsBO">PENDING_SIGNATURE</span><span class="s2m6S">,
</span></span><span class="line" line="6"><span class="sI-6P">        self</span><span class="sFwuq">::</span><span class="scsBO">PENDING_UPLOAD</span><span class="s2m6S">,
</span></span><span class="line" line="7"><span class="s2m6S">    ];
</span></span><span class="line" line="8"><span class="s2m6S">}
</span></span></code></pre><p>and that group is what the filter uses:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="s2m6S">$</span><span class="scsBO">query</span><span class="sFwuq">-&gt;</span><span class="s-yd1">whereIn</span><span class="s2m6S">(</span><span class="selus">'tickets.status'</span><span class="s2m6S">,</span><span class="s-yd1"> TicketStatus</span><span class="sFwuq">::</span><span class="s-yd1">awaitingCustomerCases</span><span class="s2m6S">());
</span></span></code></pre><p>The same definition that renders a label builds a <code>whereIn</code>. Add a status and you add it to one list, rather than to a
filter and a resource and a notification and a mailer.</p><p>A PHP enum isn't a list of constants. It's a class that happens to have a fixed set of instances, and a lot of the
duplication I've removed in recent years came from remembering that.</p><h2 id="four-smaller-ones">Four smaller ones</h2><p><strong>Model traits.</strong> Behavior shared by several models, without a base class that accumulates everything forever. The
models that have children use this one; the rest never see it.</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">trait</span><span class="s-yd1"> WithOpenSubtickets
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> blockingState</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="s-yd1"> Attribute
</span></span><span class="line" line="4"><span class="s2m6S">    {
</span></span><span class="line" line="5"><span class="sI-6P">        return</span><span class="sI-6P"> new</span><span class="s-yd1"> Attribute</span><span class="s2m6S">(</span><span class="scsBO">get</span><span class="s2m6S">:</span><span class="sI-6P"> fn</span><span class="s2m6S">()</span><span class="s2m6S"> =&gt;</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">resolveBlockingState</span><span class="s2m6S">());
</span></span><span class="line" line="6"><span class="s2m6S">    }
</span></span><span class="line" line="7"><span emptyLinePlaceholder>
</span></span><span class="line" line="8"><span class="sI-6P">    private</span><span class="sI-6P"> function</span><span class="s-yd1"> resolveBlockingState</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sFwuq"> ?</span><span class="sI-6P">string
</span></span><span class="line" line="9"><span class="s2m6S">    {
</span></span><span class="line" line="10"><span class="sI-6P">        return</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="s-yd1">children</span><span class="s2m6S">()
</span></span><span class="line" line="11"><span class="sFwuq">            -&gt;</span><span class="s-yd1">whereIn</span><span class="s2m6S">(</span><span class="selus">'status'</span><span class="s2m6S">,</span><span class="s-yd1"> TicketStatus</span><span class="sFwuq">::</span><span class="s-yd1">awaitingAgentCases</span><span class="s2m6S">())
</span></span><span class="line" line="12"><span class="sFwuq">            -&gt;</span><span class="s-yd1">value</span><span class="s2m6S">(</span><span class="selus">'status'</span><span class="s2m6S">);
</span></span><span class="line" line="13"><span class="s2m6S">    }
</span></span><span class="line" line="14"><span class="s2m6S">}
</span></span></code></pre><p><strong>Custom casts.</strong> Four lines, and every write path is covered — the endpoint, the seeder, the import, the invitation
flow, and the one somebody adds next year without reading this.</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">class</span><span class="s-yd1"> NormalisedEmail</span><span class="sI-6P"> implements</span><span class="scsBO"> CastsInboundAttributes
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> set</span><span class="s2m6S">(</span><span class="s-yd1">Model</span><span class="s2m6S"> $</span><span class="scsBO">model</span><span class="s2m6S">,</span><span class="sI-6P"> string</span><span class="s2m6S"> $</span><span class="scsBO">key</span><span class="s2m6S">,</span><span class="sI-6P"> mixed</span><span class="s2m6S"> $</span><span class="scsBO">value</span><span class="s2m6S">,</span><span class="sI-6P"> array</span><span class="s2m6S"> $</span><span class="scsBO">attributes</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="sI-6P"> mixed
</span></span><span class="line" line="4"><span class="s2m6S">    {
</span></span><span class="line" line="5"><span class="sI-6P">        return</span><span class="s-yd1"> mb_strtolower</span><span class="s2m6S">($</span><span class="scsBO">value</span><span class="s2m6S">);
</span></span><span class="line" line="6"><span class="s2m6S">    }
</span></span><span class="line" line="7"><span class="s2m6S">}
</span></span></code></pre><p><strong>Rule objects.</strong> A validation rule that needs a query belongs in a class, not in a closure pasted into four form
requests. One <code>validate()</code> method, testable on its own — a specification, in the older vocabulary.</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">class</span><span class="s-yd1"> UniqueBoardName</span><span class="sI-6P"> implements</span><span class="scsBO"> ValidationRule
</span></span><span class="line" line="2"><span class="s2m6S">{
</span></span><span class="line" line="3"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> __construct</span><span class="s2m6S">(</span><span class="sI-6P">private</span><span class="sI-6P"> readonly</span><span class="sI-6P"> int</span><span class="s2m6S"> $</span><span class="scsBO">accountId</span><span class="s2m6S">)</span><span class="s2m6S"> {}
</span></span><span class="line" line="4"><span emptyLinePlaceholder>
</span></span><span class="line" line="5"><span class="sI-6P">    public</span><span class="sI-6P"> function</span><span class="s-yd1"> validate</span><span class="s2m6S">(</span><span class="sI-6P">string</span><span class="s2m6S"> $</span><span class="scsBO">attribute</span><span class="s2m6S">,</span><span class="sI-6P"> mixed</span><span class="s2m6S"> $</span><span class="scsBO">value</span><span class="s2m6S">,</span><span class="s-yd1"> Closure</span><span class="s2m6S"> $</span><span class="scsBO">fail</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="sI-6P"> void
</span></span><span class="line" line="6"><span class="s2m6S">    {
</span></span><span class="line" line="7"><span class="s2m6S">        $</span><span class="scsBO">taken </span><span class="sFwuq">=</span><span class="s-yd1"> Board</span><span class="sFwuq">::</span><span class="s-yd1">query</span><span class="s2m6S">()
</span></span><span class="line" line="8"><span class="sFwuq">            -&gt;</span><span class="s-yd1">where</span><span class="s2m6S">(</span><span class="selus">'account_id'</span><span class="s2m6S">,</span><span class="s2m6S"> $</span><span class="sI-6P">this</span><span class="sFwuq">-&gt;</span><span class="selus">accountId</span><span class="s2m6S">)
</span></span><span class="line" line="9"><span class="sFwuq">            -&gt;</span><span class="s-yd1">where</span><span class="s2m6S">(</span><span class="selus">'name'</span><span class="s2m6S">,</span><span class="s2m6S"> $</span><span class="scsBO">value</span><span class="s2m6S">)
</span></span><span class="line" line="10"><span class="sFwuq">            -&gt;</span><span class="s-yd1">exists</span><span class="s2m6S">();
</span></span><span class="line" line="11"><span emptyLinePlaceholder>
</span></span><span class="line" line="12"><span class="sI-6P">        if</span><span class="s2m6S"> ($</span><span class="scsBO">taken</span><span class="s2m6S">)</span><span class="s2m6S"> {
</span></span><span class="line" line="13"><span class="s2m6S">            $</span><span class="scsBO">fail</span><span class="s2m6S">(</span><span class="selus">'That board name is already in use.'</span><span class="s2m6S">);
</span></span><span class="line" line="14"><span class="s2m6S">        }
</span></span><span class="line" line="15"><span class="s2m6S">    }
</span></span><span class="line" line="16"><span class="s2m6S">}
</span></span></code></pre><p><strong>Route model binding.</strong> The cheapest one on the list and the one people skip longest. Type-hint the model and the
lookup disappears from every method that needed it.</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">// Before: the same two lines in show, update, destroy and four others
</span></span><span class="line" line="2"><span class="sI-6P">public</span><span class="sI-6P"> function</span><span class="s-yd1"> show</span><span class="s2m6S">(</span><span class="sI-6P">int</span><span class="s2m6S"> $</span><span class="scsBO">id</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="s-yd1"> TicketResource
</span></span><span class="line" line="3"><span class="s2m6S">{
</span></span><span class="line" line="4"><span class="s2m6S">    $</span><span class="scsBO">ticket </span><span class="sFwuq">=</span><span class="s-yd1"> Ticket</span><span class="sFwuq">::</span><span class="s-yd1">findOrFail</span><span class="s2m6S">($</span><span class="scsBO">id</span><span class="s2m6S">);
</span></span><span class="line" line="5"><span emptyLinePlaceholder>
</span></span><span class="line" line="6"><span class="sI-6P">    return</span><span class="sI-6P"> new</span><span class="s-yd1"> TicketResource</span><span class="s2m6S">($</span><span class="scsBO">ticket</span><span class="s2m6S">);
</span></span><span class="line" line="7"><span class="s2m6S">}
</span></span><span class="line" line="8"><span emptyLinePlaceholder>
</span></span><span class="line" line="9"><span class="sFwuq">// After
</span></span><span class="line" line="10"><span class="sI-6P">public</span><span class="sI-6P"> function</span><span class="s-yd1"> show</span><span class="s2m6S">(</span><span class="s-yd1">Ticket</span><span class="s2m6S"> $</span><span class="scsBO">ticket</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="s-yd1"> TicketResource
</span></span><span class="line" line="11"><span class="s2m6S">{
</span></span><span class="line" line="12"><span class="sI-6P">    return</span><span class="sI-6P"> new</span><span class="s-yd1"> TicketResource</span><span class="s2m6S">($</span><span class="scsBO">ticket</span><span class="s2m6S">);
</span></span><span class="line" line="13"><span class="s2m6S">}
</span></span></code></pre><h2 id="the-shape-underneath">The shape underneath</h2><p>Look at the three main ones again and they are the same move.</p><p>The view base runs the scoping, so a new view can't forget it. The resource declares its fields in one place, so a new
endpoint can't quietly invent its own. The enum answers what a status means, so a new consumer can't disagree with the
old ones.</p><p>None of them stop you from writing duplicated code. They make the duplicated version harder to write than the correct one,
which is the only mechanism I've seen survive a team, a deadline and a developer who joined last week.</p><p>Duplication isn't repeated lines. It's repeated decisions. Find the decision that must never be made twice, and move
it somewhere it can't be skipped.</p><style>html pre.shiki code .sI-6P, html code.shiki .sI-6P{--shiki-default:#D4D4D8}html pre.shiki code .s-yd1, html code.shiki .s-yd1{--shiki-default:#FB64B6}html pre.shiki code .scsBO, html code.shiki .scsBO{--shiki-default:#F4F4F5}html pre.shiki code .s2m6S, html code.shiki .s2m6S{--shiki-default:#71717B}html pre.shiki code .sFwuq, html code.shiki .sFwuq{--shiki-default:#9F9FA9}html pre.shiki code .selus, html code.shiki .selus{--shiki-default:#46EDD5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}</style>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
        <item>
            <title><![CDATA[What code comments are actually telling you]]></title>
            <link>https://thiagoalves.dev/journal/what-code-comments-are-actually-telling-you</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/what-code-comments-are-actually-telling-you</guid>
            <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A comment explaining what code does is usually an apology for not writing it clearly. The three comments actually worth keeping, and why the rest lie.]]></description>
            <content:encoded><![CDATA[<p>Every developer has inherited code decorated with comments like "do not touch this" or "magic happens here."
They are funny right up until you have to fix a production incident at 2 AM, and the comment is the only thing
slowing you down.</p><p>Behind most comments is a good intention and a bad habit. In nearly twenty years of reading and reviewing code — and
more recently, setting up <a href="/journal/how-i-use-ai-to-review-every-pull-request">automated review passes</a> — I have almost
never been saved by a comment that explained <em>what</em> a method was doing. Most of the time, the comment was an
apology for writing code that couldn't explain itself — or worse, a quiet lie.</p><h2 id="the-comment-that-tells-you-what-the-code-already-says">The comment that tells you what the code already says</h2><p>The most common comment is the narration:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">// Check if the product is active
</span></span><span class="line" line="2"><span class="sI-6P">function</span><span class="s-yd1"> check</span><span class="s2m6S">($</span><span class="scsBO">object</span><span class="s2m6S">)</span><span class="s2m6S"> {
</span></span><span class="line" line="3"><span class="sI-6P">    return</span><span class="s2m6S"> $</span><span class="scsBO">object</span><span class="sFwuq">-&gt;</span><span class="selus">status</span><span class="sFwuq"> ===</span><span class="selus"> 'active'</span><span class="s2m6S">;
</span></span><span class="line" line="4"><span class="s2m6S">}
</span></span></code></pre><p>The author knew the code was vague. <code>check()</code> doesn't say what it is checking, and <code>$object</code> could be an invoice,
a user, or an associative array. Instead of fixing the names, they added a sentence above it.</p><p>If you fix the method signature, the comment has nothing left to say:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sI-6P">function</span><span class="s-yd1"> isActive</span><span class="s2m6S">(</span><span class="s-yd1">Product</span><span class="s2m6S"> $</span><span class="scsBO">product</span><span class="s2m6S">)</span><span class="sFwuq">:</span><span class="sI-6P"> bool</span><span class="s2m6S"> {
</span></span><span class="line" line="2"><span class="sI-6P">    return</span><span class="s2m6S"> $</span><span class="scsBO">product</span><span class="sFwuq">-&gt;</span><span class="selus">status</span><span class="sFwuq"> ===</span><span class="selus"> 'active'</span><span class="s2m6S">;
</span></span><span class="line" line="3"><span class="s2m6S">}
</span></span></code></pre><p>When code needs a translation layer in English to be understood, the fix is almost never a better comment. It is
narrower methods, typed parameters, and names that describe the domain instead of the data structure.</p><h2 id="comments-rot-faster-than-code">Comments rot faster than code</h2><p>Code has an advantage comments will never have: it has to run. If code breaks, the test suite catches it or production
complains.</p><p>A comment has no compiler, no linter, and no runtime verification. It just sits there.</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">// Returns the current month as a two-digit string
</span></span><span class="line" line="2"><span class="sI-6P">public</span><span class="sI-6P"> function</span><span class="s-yd1"> getCurrentDate</span><span class="s2m6S">()</span><span class="sFwuq">:</span><span class="sI-6P"> string</span><span class="s2m6S"> {
</span></span><span class="line" line="3"><span class="sI-6P">    return</span><span class="s-yd1"> Carbon</span><span class="sFwuq">::</span><span class="s-yd1">now</span><span class="s2m6S">()</span><span class="sFwuq">-&gt;</span><span class="s-yd1">format</span><span class="s2m6S">(</span><span class="selus">'Y-m-d'</span><span class="s2m6S">);
</span></span><span class="line" line="4"><span class="s2m6S">}
</span></span></code></pre><p>This started as a small refactor: someone changed <code>getCurrentMonth()</code> to return a full date, updated the format string,
ran the tests, opened the pull request, and merged it. Nobody updated the comment.</p><p>Six months later, an engineer fixing a billing bug reads the comment, trusts it, and spends two hours tracing why
an invoice grouped by month is creating a record per day. The code was working; the comment was sabotaging the reader.</p><h2 id="the-only-three-comments-worth-writing">The only three comments worth writing</h2><p>I don't ban comments on my teams, but I treat them as a last resort. If a comment is necessary, it almost always
falls into one of three buckets:</p><h3 id="_1-the-bizarre-constraint-the-why-never-the-what">1. The bizarre constraint (the "why", never the "what")</h3><p>Code can tell you <em>what</em> it is doing, but it cannot tell you why an external system forces you to do something unexpected:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">// Stripe webhook events can arrive out of order during account reconnects.
</span></span><span class="line" line="2"><span class="sFwuq">// Do not process subscription.updated before customer.created has settled.
</span></span></code></pre><p>No amount of clean architecture can explain a vendor's bug or an odd regulatory requirement. If the code looks
wrong on purpose, write down why.</p><h3 id="_2-the-temporary-tourniquet-in-production">2. The temporary tourniquet in production</h3><p>Production is on fire, customers are impacted, and you need a dirty five-line fix to stop the bleeding:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">// HOTFIX: bypass cache lookup while Redis cluster rebuilds.
</span></span><span class="line" line="2"><span class="sFwuq">// Ticket: ENG-4122 — remove once failover completes.
</span></span></code></pre><p>Use the comment to mark the hack and tie it to a tracked ticket. Then delete both when the real fix lands.</p><h3 id="_3-framework-configuration-files">3. Framework configuration files</h3><p>Configuration files that ship with frameworks — like Laravel's <code>config/</code> directory — are settings dashboards, not
domain logic. Comments that explain valid options and their trade-offs make sense because nobody reads a config
file to understand an algorithm:</p><pre class="language-php shiki shiki-themes spotlight-prism" style=""><code __ignoreMap=""><span class="line" line="1"><span class="sFwuq">/*
</span></span><span class="line" line="2"><span class="sFwuq">|--------------------------------------------------------------------------
</span></span><span class="line" line="3"><span class="sFwuq">| Application Timezone
</span></span><span class="line" line="4"><span class="sFwuq">|--------------------------------------------------------------------------
</span></span><span class="line" line="5"><span class="sFwuq">| Sensible default for internal timestamps. Stored in UTC, converted at display.
</span></span><span class="line" line="6"><span class="sFwuq">*/
</span></span><span class="line" line="7"><span class="selus">'timezone'</span><span class="sFwuq"> =&gt;</span><span class="selus"> 'UTC'</span><span class="s2m6S">,
</span></span></code></pre><h2 id="if-you-feel-the-urge-to-comment-refactor-first">If you feel the urge to comment, refactor first</h2><p>When you finish a function and feel the urge to explain it with a comment, pause. That urge is your brain telling
you the code is dense, the naming is ambiguous, or the function is doing three things instead of one.</p><p>Extract the private method. Name the boolean variable. Give the parameter a real type.</p><p>If, after doing all of that, someone reading the code would still ask "why would anyone do this?", write the comment.
Otherwise, let the code speak for itself.</p><style>html pre.shiki code .sFwuq, html code.shiki .sFwuq{--shiki-default:#9F9FA9}html pre.shiki code .sI-6P, html code.shiki .sI-6P{--shiki-default:#D4D4D8}html pre.shiki code .s-yd1, html code.shiki .s-yd1{--shiki-default:#FB64B6}html pre.shiki code .s2m6S, html code.shiki .s2m6S{--shiki-default:#71717B}html pre.shiki code .scsBO, html code.shiki .scsBO{--shiki-default:#F4F4F5}html pre.shiki code .selus, html code.shiki .selus{--shiki-default:#46EDD5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}</style>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
        <item>
            <title><![CDATA[Is a programming degree still worth it in the age of AI?]]></title>
            <link>https://thiagoalves.dev/journal/is-a-programming-degree-still-worth-it-in-the-age-of-ai</link>
            <guid isPermaLink="false">https://thiagoalves.dev/journal/is-a-programming-degree-still-worth-it-in-the-age-of-ai</guid>
            <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[When AI can build entire features in seconds, the four-year computer science syllabus is completely obsolete. What learning to build software takes today.]]></description>
            <content:encoded><![CDATA[<p>When I decided to become a programmer eighteen years ago, college felt like the only credible doorway.
I didn't own a computer until I was eighteen, and the industry seemed like an exclusive club where the
rules were only handed out in lecture halls.</p><p>I did the whole academic run: a technical diploma followed by a three-year associate degree in Systems Analysis
and Development. Over 3,000 hours sitting in classrooms.</p><p>Even before AI, my takeaway was that universities moved too slowly, spent too much time on filler curriculum,
and taught almost nothing about how real software gets built and maintained in production.</p><p>Today, with tools that can scaffold entire applications, generate database migrations, and debug errors in seconds,
the traditional four-year degree is completely divorced from reality.</p><p>If you want to become a software engineer today, <strong>going to college for programming is the slowest, most expensive
way to get there.</strong></p><h2 id="what-universities-spend-four-years-teaching">What universities spend four years teaching</h2><p>Look at a typical computer science or software engineering curriculum:</p><p>You spend the first year on general education requirements that have nothing to do with programming. You spend
the next two years learning toy algorithmic puzzles, setting up basic development environments, and completing
isolated homework assignments that never see a real user, a production deployment, or a legacy codebase.</p><p>That entire loop has been collapsed. An entry-level programmer with an AI assistant can scaffold an authentication
flow, spin up a relational database, and deploy a working prototype before an academic committee finishes debating
next semester's syllabus.</p><p>Universities update their course material on a five-year cycle. Software development changes every month.
Paying tens of thousands of dollars to learn how people built software in 2018 is a bad financial investment
and an even worse use of your time.</p><h2 id="what-actually-gets-you-hired-now">What actually gets you hired now</h2><p>The misconception about programming was always that the job was typing code. It never was. The job has always
been understanding a business problem, designing a reliable system, and taking responsibility when it breaks.</p><p>Now that AI can write the initial version of almost any feature in minutes, the bar for human engineers has moved
up, not down. When I hire developers for my team, I don't look for someone who can write an algorithm on a
whiteboard. I look for:</p><ol><li><strong>Architectural judgment:</strong> Can you structure a system that doesn't collapse when data volume scales? Do you
know where the tenant boundaries are, and how authentication actually flows between services?</li><li><strong>Skeptical review:</strong> AI generates confident, plausible code that is frequently insecure, inefficient, or subtly
broken. Can you audit that code, catch the hidden trap, and push back? As I wrote in <a href="/journal/how-i-use-ai-to-review-every-pull-request">how I use AI to review every
pull request</a>, even an automated reviewer has to prove its
findings before anyone should trust them.</li><li><strong>Execution under real constraints:</strong> What happens when an external API times out? How do you migrate a database
table with millions of rows without downtime? How do you trace a production bug when the logs are silent?</li></ol><p>None of this is taught in lecture halls. It is learned by building, shipping, and breaking real systems.</p><h2 id="what-the-learning-path-looks-like-today">What the learning path looks like today</h2><p>If you are starting from zero right now, you have an advantage no previous generation had. You don't have to wait
for a professor's office hours to ask a question. You have an infinitely patient senior engineer sitting in your
terminal:</p><h3 id="_1-build-real-products-from-day-one">1. Build real products from day one</h3><p>Don't spend six months reading theory before writing your first line of code. Pick a concrete problem — a tool for
a local business, an integration you wish existed, a SaaS prototype — and build it.</p><p>Use AI to move fast, but never paste code you cannot explain. Make the model explain the trade-offs: <em>"Why did
you choose this database schema over a normalized structure? What happens to this queue if the worker crashes
mid-job?"</em></p><h3 id="_2-learn-systems-not-just-languages">2. Learn systems, not just languages</h3><p>Languages and frameworks are easier to pick up than ever. What lasts across your career is systems thinking:
how operating systems handle processes, how relational databases execute queries, how HTTP and networking work,
and how automated test suites protect your code from regressions.</p><h3 id="_3-put-software-in-front-of-real-users">3. Put software in front of real users</h3><p>As I shared in <a href="/journal/my-first-experiences-as-a-developer">my first experiences as a developer</a>, my career
only gained momentum when I stopped doing isolated exercises and started dealing with real production code that
clients depended on.</p><p>Build three non-trivial applications. Deploy them to production on a cloud host. Connect a real domain. Set up
error monitoring. Find actual users to try them, and fix whatever breaks.</p><p>An engineer who can point to three live applications, explain the architectural decisions behind them, and show
how they solved real production failures will beat someone holding a four-year diploma every single time.</p><h2 id="final-thoughts">Final thoughts</h2><p>None of this means you shouldn't go to college.</p><p>If walking across that stage, earning a degree, and celebrating that milestone with your family is a personal
dream, that is completely valid. It is an achievement worth being proud of, and nobody can take that away from you.</p><p>Just don't confuse personal fulfillment with professional qualification. If your goal is to become a capable
software engineer and land a great job in today's market, college is no longer what gets you there. Building
real systems, understanding how they fail, and shipping software is.</p>]]></content:encoded>
            <author>me@thiagoalves.dev (Thiago Alves)</author>
        </item>
    </channel>
</rss>