<?xml version="1.0" encoding="UTF-8"?><rss 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" version="2.0"><channel><title><![CDATA[ubeyd.dev]]></title><description><![CDATA[Writing about web dev, tools, and things I learn while building stuff.]]></description><link>https://ubeyd.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 07 Oct 2026 07:14:29 GMT</lastBuildDate><atom:link href="https://ubeyd.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Feedback Google Couldn't See]]></title><description><![CDATA[On Friday, 21 August 2026, Google sent me the same email for the third time. The subject line read "More testing required to access Google Play production", and the body listed the same two possible r]]></description><link>https://ubeyd.dev/the-feedback-google-couldnt-see</link><guid isPermaLink="true">https://ubeyd.dev/the-feedback-google-couldnt-see</guid><category><![CDATA[google play]]></category><category><![CDATA[Android]]></category><category><![CDATA[PWA]]></category><category><![CDATA[Trusted Web Activity]]></category><category><![CDATA[indie-dev]]></category><category><![CDATA[Laravel]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 22 Sep 2026 11:51:15 GMT</pubDate><content:encoded><![CDATA[<p>On Friday, 21 August 2026, Google sent me the same email for the third time. The subject line read "More testing required to access Google Play production", and the body listed the same two possible reasons it had listed in May and again in July: my testers were not engaged with the app, or I had not followed testing best practices. There was nothing in it about my app in particular. There never was.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64493e0be4da7ac60cc931b7/a1d80e4c-e615-42fa-9130-5bcbe0a96edd.png" alt="The rejection email from Google Play Console, titled More testing required to access Google Play production, listing two possible reasons" style="display:block;margin:0 auto" /><p>This time I had been sure. Twelve testers had stayed in my closed test for the whole window, most of them opening the app every day, and I had shipped two releases five days apart, all of it tracked in my own database. None of that moved the answer.</p>
<p>It took four applications and nearly five months to get Akluma onto Google Play, and each rejection corrected a wrong idea I held about what the review measures. The application that finally passed came from a round in which fewer people opened the app each day than in the round Google had just turned down.</p>
<pre><code class="language-text">Application 1   11 May        rejected 13 May
Application 2   17 July       rejected 25 July
Application 3   18 August     rejected 21 August
Application 4   10 September  approved 12 September
</code></pre>
<p><a href="https://akluma.com">Akluma</a> is a free savings tracker I built with Laravel: you set a savings goal, it builds a dated plan and emails you a reminder on each saving date, and it recalculates the plan when life gets in the way. I wrote about how that plan rewrites itself <a href="https://ubeyd.dev/the-savings-plan-that-rewrites-itself">in an earlier post</a>. This one is about getting it into a store.</p>
<h2>A web app in an Android shell</h2>
<p>Akluma is a web app, and why it needed a store listing at all is a story I told <a href="https://dev.to/ubeydullah/i-did-keyword-research-for-my-savings-app-then-i-actually-looked-at-the-search-results-ld6">in a post about my keyword research</a>: most people find apps by searching inside the store, and my research had been blind to that whole channel. The way to put a web app into Google Play is a Trusted Web Activity, a thin Android app whose only job is to open your website full screen in Chrome. There is no second codebase. The app on your phone is akluma.com without the address bar, running against the same server and the same database, and every web deploy reaches it the moment it ships.</p>
<p>Setting that up took about a week in April, and only one detail from it is worth passing on. The file that proves you own the domain, <code>assetlinks.json</code>, has to carry the fingerprint of Google's app signing key and not only the key you upload with, because Google re-signs your app before it reaches anyone's phone. Leave Google's fingerprint out and the address bar appears inside your app.</p>
<p>Then came the requirement I had not planned for. Google asks <a href="https://support.google.com/googleplay/android-developer/answer/14151465">new personal developer accounts</a> to run a closed test with at least 12 testers opted in for 14 continuous days before they can even apply for production. My identity was verified on 20 April, the first release went to closed testing on 24 April, and twelve testers for fourteen days sounded like arithmetic.</p>
<h2>Round one: twelve people and a calendar</h2>
<p>My first theory was exactly that: the gate is arithmetic. Find twelve people, keep them enrolled for fourteen days, apply. So I asked family and friends over WhatsApp to install the app and, above all, not to tap "Leave the program". I did not ask anyone to use it, because by the letter of the rule nobody had to.</p>
<p>I even got the arithmetic wrong at first, holding back my submission because I had read that the 14-day clock restarts with every tester you add. It starts when testers opt in, and the opt-in link only exists once Google has published your release, so you have to submit before you can recruit anyone. The clock has stranger habits than that, which get their own post later in this series.</p>
<p>I applied for production on 11 May, and two days later the first rejection arrived. Its first possible reason was "Testers were not engaged with your app during your closed test."</p>
<p>It was right. When I finally audited that round in July, only 3 of the 13 people on my list had ever registered an account in the app, and one of the three was me.</p>
<p>One good thing did come out of it. On 14 May my wife, who had been using Akluma on the web for most of a year, left this in the closed test's feedback:</p>
<blockquote>
<p>It's a very useful app to set and track saving goals. Easy to use and convenient with the automatic email reminders. What I would suggest adding is the possibility to choose or change the date for the automatic reminder (for example so you can set it around the time when your salary is paid or similar).</p>
</blockquote>
<p>I filed it as a good idea for later. It would matter far more than I expected.</p>
<h2>Round two: paying for engagement</h2>
<p>By June I understood that engagement was the problem, and my second theory was that engagement could be bought. Swapping tests with other developers cost more hours than I had, so I paid a Fiverr seller $30 for a batch of testers. When I checked my database on 9 July, 12 of the 31 people on the list had registered, 4 had an active savings goal, and exactly one had ever moved money. The seller offered me a ready-made answer sheet for Google's application form that described improvements I had never made, and I did not use it. Around 17 July the testers' five-star reviews vanished from my console, most likely removed by Google's checks for coordinated reviews, though that is my inference. Fiverr refunded the order at the end of July, and through all of August, Play's statistics still counted around ten of those testers with the app installed and not one of them opening it.</p>
<p>The real lesson of that round came from a question I should have asked in April: what can Google actually see? Akluma is a Trusted Web Activity, which means that everything a tester does inside it, from registering to creating a goal to adding money, happens in Chrome. Play sees none of it. What Play can see is whether the app gets opened, and on how many different days. I had spent a month asking people to create piggy banks, and piggy banks are invisible to Google.</p>
<p>I applied again on 17 July, with answers I wrote myself and kept honest. On 25 July the same email came back.</p>
<h2>Round three: measuring what Google measures</h2>
<p>My third theory was that if Play counts opens, I should count them too, and drive them on purpose. Before asking anyone for anything, I built the instrument: one row per user per day, the installed app told apart from a browser tab by the referrer Android attaches at launch, a heartbeat counting seconds on screen, and an admin page showing exactly which testers had gone quiet. It went live on 3 and 4 August, and I will come back to how it works.</p>
<p>Then I designed the ask around the one habit Akluma naturally creates. A savings plan can be daily, weekly or monthly, and every saving date sends a reminder email at nine in the morning. So I asked each tester to create one small daily piggy bank, a couple of weeks toward something modest. A daily plan produces about thirteen reasons to open the app in fourteen days, where a weekly one produces two.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64493e0be4da7ac60cc931b7/6545700f-578f-4efb-90e3-ee8617bfbdba.jpg" alt="Akluma's saving reminder email on an Android phone, the daily prompt that brought testers back to the app" style="display:block;margin:0 auto" /><p>From 5 August I messaged testers one by one on WhatsApp almost every day, and 12 of the 13 engaged, nearly all of them daily.</p>
<p>I also shipped. Version 4 went out on 13 August with the feature my wife had asked for in May: on a monthly plan, you can now choose the day of the month your reminders arrive. Version 5 followed on 18 August and replaced a dashboard that had been showing placeholder figures. Those releases mattered twice over, because a web deploy is invisible to Play, and the only update Google can see is a new bundle on the testing track.</p>
<p>I applied on 18 August. On 21 August I got the email at the top of this post.</p>
<h2>What I told Google, and what it could see</h2>
<p>Play Console has a page called Testing feedback, under Ratings and reviews. It is where testers' private feedback lands when they use the "Private feedback to developer" control on the app's Play Store page, and I had looked at it plenty of times over the summer. I never treated it as evidence. The application form asks how you collected feedback from your testers, and I had answered truthfully: over WhatsApp. Once I had told Google where the feedback lived, I assumed it made no difference whether any of it also appeared on that page. It did not help that after the paid reviews vanished, I had stopped asking testers to write anything on Play at all.</p>
<p>The day after the third rejection, I read the page the way a reviewer would. Filtered to all time, it held one item: my wife's, from 14 May, on version code 1. Across the whole span Google had just evaluated there was no new feedback, and the only activity was my own reply to her, posted on 13 August, telling her that the feature she had asked for was live. Meanwhile that round had produced three useful new pieces of feedback, and all three sat in WhatsApp, where Google has no way to look.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64493e0be4da7ac60cc931b7/859b10fc-5c96-4123-a634-5d4f7100cd23.png" alt="The Testing feedback page in Play Console, showing my wife's five-star feedback from May and my reply from August" style="display:block;margin:0 auto" /><p>Google's requirements page for new accounts asks you to "summarize your testing feedback when applying for production access", and I had. What I had not given Google was any way to check the summary. From the reviewer's side of the table, my answer about feedback was a claim with nothing behind it.</p>
<p>I want to be careful here, because Google never told me this was the reason. It gave no reason at all, three times, and a support ticket asking what exactly had been judged insufficient came back with a list of best practices. That near-empty page is one of three explanations I believe fit. The second is that my testers' usage looked instructed, because it was. The third is that numbers which looked healthy over my own fourteen days looked thin over Google's twenty-four, which began at the previous review date on 25 July, eleven days before I began onboarding my testers.</p>
<p>Two other theories died over the following weeks. Google's support told me to fix the issues in my pre-launch report, which had never been generated, and after six uploads support admitted that it cannot generate below an activity threshold they would not name. And for one day I was convinced Play could not see a Trusted Web Activity at all, until Play's own chart of daily users showed that it could.</p>
<h2>Round four: evidence where Google can see it</h2>
<p>The fourth round started without me. Google's 14-day counter restarts on its own at each review date, and my twelve testers had never left the programme, so the app became eligible to apply again around 5 September without a single new recruit. That left me with one simple rule for tester lists: add people, never swap them, because a single dip below twelve restarts the count.</p>
<p>I did try to add people. On 24 and 25 August I emailed 32 existing users of the web app, a personal note to each, inviting them to join the test. None of them did.</p>
<p>So the round came down to the one thing I could still change: putting evidence where Google looks. On 25 August I asked my family and friends to leave private feedback on the app's Play page, and instead of a general request I gave them two questions, in Turkish, that amount to: what would you want to be different in the app, and which part confused you? A general ask produces "great app". A specific question produces something you can act on.</p>
<p>Eight pieces of feedback arrived over the next four days, and versions 7 and 8 shipped within two days of them, answering four. The buttons for adding money moved to the top of a goal's page, and every tappable row on the dashboard gained an arrow and a pressed state, because three testers had separately told me they could not tell what was clickable. Every item got a reply in the console, including the two where the thing a tester wanted already existed and my reply told them where to find it. Version 9 followed on 9 September, the ninth release on the testing track.</p>
<p>I rewrote the application to one rule: cite only what Google can verify on its own side. Dated releases, release notes, feedback items and my replies to them are all events inside Google's system, while numbers from my own database, however careful, verify nothing for a reviewer. Each written answer had to fit in 300 characters, which is a discipline of its own.</p>
<p>I applied on 10 September, and the approval arrived on 12 September. On 14 September I sent the first production release for review, and it was live about two and a half hours later, in 177 countries. The French and Turkish listings followed the next day.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64493e0be4da7ac60cc931b7/fac0aa96-9148-4b12-b537-38b376083a73.png" alt="Akluma: Savings Goal Tracker on its public Google Play page" style="display:block;margin:0 auto" /><h2>What changed, and what I can't claim</h2>
<p>The strangest part of the whole story sits in Play's own chart. The round Google approved had fewer people opening the app than the round it rejected. From 5 to 21 August, Play counted between 4 and 10 daily users, most days between 5 and 9. From 22 August until the day I applied, it counted between 1 and 5, partly because I had told my testers they no longer needed to open it every day.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64493e0be4da7ac60cc931b7/43285b81-6344-443b-949a-453b2526a21c.png" alt="Play Console daily active users from 25 July to 12 September: near zero until 5 August, between 4 and 10 until 21 August, then between 1 and 5" style="display:block;margin:0 auto" /><p>So more engagement is not what changed. What changed is where the evidence lived: feedback inside Google's own channel with a reply under every item, releases that answered specific feedback within two days, and an application that cited nothing Google could not check for itself. I believe that is what made the difference. I cannot prove it, and Google has not said.</p>
<p>If you are stuck behind the same requirement, this is what I would take from my five months:</p>
<ol>
<li><p>The reviewer judges what Play Console shows. Effort that leaves no trace there does not count.</p>
</li>
<li><p>Play cannot see inside a Trusted Web Activity. It counts opens.</p>
</li>
<li><p>Describing feedback in the application is not the same as Google seeing it. Ask testers for private feedback on Play with a specific question, and reply to every item.</p>
</li>
<li><p>A web deploy is invisible to Play. A new bundle on the testing track is the update it sees.</p>
</li>
<li><p>Paying for testers bought me one tester who ever moved money, and a block of installs that sat unopened in my statistics for weeks.</p>
</li>
</ol>
<p>As for how the launch is going, it is too early to say anything honest. Akluma has been on Google Play for a week, the public listing shows 10+ downloads, and Play's own statistics run days behind. What I can say is that it is there, in English, French and Turkish, and that it is the same app the web has been running for over a year.</p>
<p>Akluma is on <a href="https://play.google.com/store/apps/details?id=com.akluma.app">Google Play</a> and at <a href="https://akluma.com">akluma.com</a> if you would like to set a goal and see whether the plan holds.</p>
<hr />
<p><em>This is the first post in a short series on getting Akluma onto Google Play, and the next four each take one finding from this story further. For the background,</em> <a href="https://dev.to/ubeydullah/i-did-keyword-research-for-my-savings-app-then-i-actually-looked-at-the-search-results-ld6"><em>I did keyword research for my savings app. Then I actually looked at the search results.</em></a> <em>explains why a web app needed a store listing at all, and</em> <a href="https://ubeyd.dev/the-savings-plan-that-rewrites-itself"><em>The Savings Plan That Rewrites Itself</em></a> <em>covers what the app does.</em></p>
]]></content:encoded></item><item><title><![CDATA[Ranked Search on SQLite]]></title><description><![CDATA[Deutsche Post does not hire Postboten. Not in its job titles, anyway — the postal-delivery listings are titled "Zusteller" or "Fachkraft Postdienstleistungen", and the word every German speaker would ]]></description><link>https://ubeyd.dev/ranked-search-on-sqlite</link><guid isPermaLink="true">https://ubeyd.dev/ranked-search-on-sqlite</guid><category><![CDATA[SQLite]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[search]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Wed, 05 Aug 2026 15:46:00 GMT</pubDate><content:encoded><![CDATA[<p>Deutsche Post does not hire Postboten. Not in its job titles, anyway — the postal-delivery listings are titled "Zusteller" or "Fachkraft Postdienstleistungen", and the word every German speaker would actually type into a search box, <em>Postbote</em>, appears only somewhere in the body of the description. In the corpus behind my <a href="https://ubeyd.dev/the-job-finder-that-reads-every-listing">job-search engine</a>, 511 listings match "Postbote" through their descriptions, every one of them a real postal job, and not a single one would surface if search only looked at titles. When I wrote about that project before, I stopped at the pipeline that reads the listings; the sentence "before a custom search engine ever ranks it" was a promissory note. This post is the search engine.</p>
<h2>Turning down the sensible option</h2>
<p>The sensible option was Meilisearch. It was on the table, seriously — an open-source faceted search engine that hands you multilingual stemming and typo tolerance for free. It lost the decision for a reason I stand behind: the parts of search I most wanted to build — the ranking, the scoring, the ability to explain a result's position — are exactly the parts Meilisearch would have absorbed into a black box, and it would have added a service to a stack that otherwise fits in a single SQLite file. Also considered and rejected: FTS5 alone (throws away the structured filters the relational schema gives me) and building an inverted index from scratch in PHP (two to three weeks of reinventing what FTS5 does well, with no extra learning in the parts that matter). What shipped is a hybrid: SQL owns the filters, FTS5 owns the free text, and a scorer in PHP owns the ranking.</p>
<h2>Two layers, one query</h2>
<p>Every search resolves through two layers that compose. Layer 1 is plain SQL over indexed columns — country, language, years of experience, role taxonomy, tech stack, canonical-only, not-polluted — and it always runs, cutting the 13,000-listing corpus down to a slice before any text matching happens. Layer 2 is SQLite's FTS5 virtual table, which indexes exactly three columns:</p>
<pre><code class="language-sql">CREATE VIRTUAL TABLE listings_fts USING fts5(
    title,             -- short, high-signal
    description_text,  -- the full HTML-stripped body
    indexed_org_name,  -- the employer
    tokenize = 'trigram'
);
</code></pre>
<p>A worked example from the calibration notes: a candidate filtered to Germany, English-speaking, at most five years of experience, software roles, types <code>postgres backend</code>. Layer 1 narrows 13,158 records to roughly 600. Layer 2 runs <code>MATCH 'postgres backend'</code> over those 600 and returns about 30 with BM25 scores. The scorer ranks the 30. No cluster, no sync job, no second store — the search index lives in the same database file as the rows it indexes.</p>
<h2>Betting on trigrams</h2>
<p>The tokenizer choice is where the corpus pushed back on the defaults. About half the listings are in German, and German welds its words together — <em>Postbote</em> is <code>Post</code> + <code>Bote</code>, and a candidate who types <code>bote</code> is asking a question that word-boundary tokenizers cannot answer. In a head-to-head spike on 2,000 records, FTS5's default <code>unicode61</code> tokenizer found 0 matches for <code>bote</code> and 0 for <code>kube</code>; the <code>trigram</code> tokenizer — which indexes overlapping three-character windows and does not care where words begin — found 477 and 29. That substring property covers compound nouns and partial tech terms in one move, and it is script-agnostic, so Greek and Czech and Polish queries work with zero per-language code.</p>
<p>The bet has a bill attached. The trigram index is 2.35× the size of the default. There is no typo tolerance — <code>developr</code> returns nothing, a limitation documented and accepted rather than papered over. And a three-letter query like "SAP" happily matches inside "ASAP", "WhatsApp", and "Hansaplast", because trigrams have no concept of a word boundary. That last one grew teeth after launch: word-bounded matching eventually came back as a <em>second</em> FTS index (<code>unicode61</code> alongside <code>trigram</code>), blended in one query, with a rank bonus for word-bounded hits — because a user searching "Git" deserves better than every listing containing "digital".</p>
<h2>A title is worth twenty descriptions</h2>
<p>Searching descriptions is what makes Postbote findable, and it is also where the noise lives: for a common word like "Manager", description-only matches run about 80% irrelevant. The answer is not to stop searching descriptions — it is to make the ranking opinionated about where a match landed. FTS5's <code>bm25()</code> accepts per-column weights:</p>
<pre><code class="language-sql">ORDER BY bm25(listings_fts, 20.0, 1.0, 5.0)
-- title ×20, description ×1, org name ×5
</code></pre>
<p>A title match outweighs a description match twenty to one. Jobs actually titled "React Developer" rank far above jobs that mention React once in a nice-to-have list, while the description-only Postbote matches still surface below anything title-relevant. The held-out term analysis that justified this found that tech terms almost never appear in titles at all — a title-only search would lose 77–98% of real matches per term — so the weighting keeps both worlds: recall from descriptions, precision from titles.</p>
<h2>Eight signals, and a scorer that shows its work</h2>
<p>BM25 is one voice in the final ranking, not the verdict. The composite scorer in PHP computes eight normalized components per result: the free-text score, recency (an exponential decay anchored to the corpus snapshot's newest record, not to "now" — the product is honest about being a snapshot), description quality, a canonical-cluster bonus, role match graded by classifier confidence, language match, tech-stack match, and a visa-sponsorship signal where unknown gets a neutral 0.5 rather than a punishment. The weights live in one config file, and PHP is the deliberate seam: scoring pushed into SQL would lock it to the substrate, pushed into FTS5 would lock it to BM25.</p>
<p>Alongside every score, the scorer emits a per-component breakdown — weighted contribution, raw value, and a human-readable note like <em>"posted 12 days ago"</em> or <em>"en+de required, you speak en"</em> — designed to power an explainer panel where any result can answer "why this rank?". Writing this post forced an honest audit: the breakdown is computed on every request, unit-tested, and consumed by a benchmarking command — and no UI component renders it yet. The panel is now a filed issue rather than a quiet assumption, which is its own small lesson about the distance between an architecture document and a screen.</p>
<h2>What launch taught</h2>
<p>The biggest post-launch fix was in how typed queries become FTS5 expressions. The first version joined words with an implicit AND, which turned multi-word queries into word-bag noise — every listing containing both words anywhere. The replacement is two modes selected by syntax: a plain multi-word query becomes a phrase-or-proximity search, <code>"data engineer" OR NEAR("data" "engineer", 100)</code>, and a comma becomes an explicit opt-in to keyword mode where each comma-separated chunk must match as its own phrase. Depending on the query, that dropped 22–98% of the noise.</p>
<p>The companion decision matters as much: when a query finds nothing, the engine does not silently degrade to sloppier matching to avoid an empty screen. It shows the empty state — and makes it smart. <code>laravel developer</code> can match zero listings as text while the corpus is full of Laravel jobs, because the tech extractor understands concepts that exact tokens miss; so the empty state offers clickable recovery tips — "try the Tech: Laravel filter (n jobs)", "try comma-separated keywords" — instead of quietly showing worse results. Honest zero over engagement-driven noise.</p>
<p>Would embeddings make this better? Probably — "postgres" never matching "PostgreSQL DBA" is a real gap, and semantic search sits in the V2 notes next to German morphology and learned per-user weights. But the version that exists ranks thirteen thousand listings in two dozen languages on a database engine most people call a toy, and every interesting decision in it — the tokenizer, the column weights, the eight signals, the refusal to fake results — is mine to explain rather than a vendor's to hide.</p>
<hr />
<p><em>Earlier posts from the same project: <a href="https://ubeyd.dev/the-job-finder-that-reads-every-listing">The Job Finder That Reads Every Listing</a> covers the pipeline that turns raw postings into the structured corpus this engine searches, and <a href="https://ubeyd.dev/fuzzy-search-without-elasticsearch">Fuzzy Search Without Elasticsearch</a> is this post's sibling: the same do-it-with-the-database philosophy applied to a restaurant's client book.</em></p>
]]></content:encoded></item><item><title><![CDATA[Fuzzy Search Without Elasticsearch]]></title><description><![CDATA[There is a moment, in the middle of a dinner service, when a waiter is holding a tablet in one hand and a phone in the other, and the voice on the phone says her name is Ayşe. The tablet's keyboard is]]></description><link>https://ubeyd.dev/fuzzy-search-without-elasticsearch</link><guid isPermaLink="true">https://ubeyd.dev/fuzzy-search-without-elasticsearch</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Nuxt]]></category><category><![CDATA[node js]]></category><category><![CDATA[search]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Wed, 05 Aug 2026 14:32:55 GMT</pubDate><content:encoded><![CDATA[<p>There is a moment, in the middle of a dinner service, when a waiter is holding a tablet in one hand and a phone in the other, and the voice on the phone says her name is Ayşe. The tablet's keyboard is set to English, the waiter is in a hurry, and what actually gets typed into the search box is <code>ayse</code>. In a database of Turkish names, a naive <code>LIKE '%ayse%'</code> returns nothing at all, because as far as PostgreSQL is concerned <code>ş</code> and <code>s</code> are strangers, and the dotless <code>ı</code> in Yılmaz has never once met the <code>i</code> the keyboard produced. The search box in my <a href="https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself">reservation system for an Istanbul restaurant</a> had exactly one job, and this was it: find Ayşe anyway.</p>
<p>This is a write-up of how that search box works. The short version is that there is no Elasticsearch in it, no Meilisearch, no Algolia, nothing that would show up on an architecture diagram as a box labelled "search". There is PostgreSQL, which the application already had, and there is a few hundred lines of TypeScript, and between them they handle typos, missing diacritics, phone fragments, email addresses, and reservation notes, and they rank what they find honestly enough to show the score to the person searching.</p>
<h2>One box, five kinds of query</h2>
<p>The first design decision was that staff get a single search box, not a form with fields. Mid-service nobody wants to decide whether they are searching by name or by phone; they want to type the thing they have and get the person they mean. So the very first thing the backend does with a query is guess what kind of thing it is looking at.</p>
<pre><code class="language-text">"ayse yilmaz"   → multi_word   (contains a space)
"ayse"          → single_word
"a.k@gmail.com" → email        (contains @)
"0533 694 12"   → phone        (digits, spaces, dashes, parens)
"1284"          → id           (digits only)
</code></pre>
<p>Each kind gets its own path through the system. An ID is an exact lookup and short-circuits with full confidence. An email goes through PostgreSQL's <code>similarity()</code> function, because emails are typed from memory and memory is fuzzy. A phone number is stripped down to bare digits — and relieved of the <code>+90</code> country code, since half the staff type it and half do not — and then matched by containment in both directions, so the last five digits of a number are as good a key as the whole thing. Names, the hard case, get the full treatment.</p>
<h2>Casting a wide net in Postgres</h2>
<p>The name path has two layers, and the split between them is the actual architecture of the feature: the database is responsible for <em>recall</em> — finding every row that might plausibly be the one — and the application is responsible for <em>precision</em>, deciding which candidate deserves to be first. PostgreSQL's <code>pg_trgm</code> extension does the recall. It breaks strings into overlapping three-letter fragments and calls two strings similar when their fragments overlap enough, which is exactly the kind of matching that shrugs off a missing letter or a swapped pair.</p>
<p>But trigrams alone do not solve Turkish. <code>ş</code> and <code>s</code> produce different trigrams, so <code>ayse</code> still would not reliably reach <code>Ayşe</code>. The fix is to fold the alphabet down before the comparison ever happens, inside the query itself:</p>
<pre><code class="language-sql">WHERE TRANSLATE(LOWER(name || ' ' || surname), 'şğıöüç', 'sgiouç')
    % TRANSLATE(:query, 'şğıöüç', 'sgiouç')
</code></pre>
<p><code>TRANSLATE</code> maps the Turkish letters onto their plain ASCII neighbours — <code>ş</code> to <code>s</code>, <code>ğ</code> to <code>g</code>, the dotless <code>ı</code> to <code>i</code> — on both sides of the <code>%</code> operator, so the trigram comparison happens in a flattened alphabet where <code>ayse</code> and <code>Ayşe</code> are finally the same word. The same folding exists as a small character map on the TypeScript side, because the application layer is about to re-score everything the database returns and both layers need to agree on what "the same letter" means.</p>
<p>The database query is deliberately generous. It matches against first name, surname, both concatenations in both orders, and the free-text note on the client record, and it returns up to a hundred candidates. Generosity is cheap at this scale — a restaurant's client book is thousands of rows, not millions — and every false positive it lets through is somebody the next layer gets to quietly rank into oblivion.</p>
<h2>Ranking in TypeScript</h2>
<p>The hundred candidates come back into Node, and a small scorer takes over. It computes four signals for each candidate and blends them with fixed weights:</p>
<pre><code class="language-ts">similarity =
    wordMatchScore        * 0.3 +   // query words found in name words
    wordSimilarity        * 0.2 +   // character-level agreement, word by word
    consecutiveMatchScore * 0.3 +   // longest run of matching characters
    noteSimilarity        * 0.2;    // Levenshtein distance against the note
</code></pre>
<p>Each signal earns its place with a different failure mode in mind. The word-match score forgives word order, so <code>yilmaz ayse</code> still finds Ayşe Yılmaz. The consecutive-match score rewards long unbroken runs of agreement, which is what separates a genuine near-miss from a coincidental scatter of shared letters. And the note similarity — a full Levenshtein distance computed against the free-text note on the record — exists because staff remember people by their stories: the note might say <em>window table, birthday in May</em>, and "birthday" is what the waiter remembers at the till. The reservation search runs the same shape of pipeline with its own weights — names carry 60 or 70 percent of the score there, notes twenty, phone ten — joins through to the clients on each booking, and refuses to return anything scoring under ten percent.</p>
<p>The scored list gets sorted, the top ten survive, and that is the entire ranking engine. There is no index to keep warm, no cluster to monitor, and no second data store that can drift out of sync with the truth — the search runs against the same PostgreSQL rows the booking form writes.</p>
<h2>Showing the score to the user</h2>
<p>One decision in the UI still pleases me: the search results show their similarity score. A result card for a client says, in small text under the phone number, <code>similarity: 87%</code>, and cards above seventy percent get visually marked as strong matches. Search results usually present themselves with unearned confidence — the first result simply <em>is</em> the answer, however weak the match was. Telling a waiter "this is 87% likely who you mean" respects both the waiter and the limits of the algorithm: a strong match gets tapped without a second look, and a page of fifty-percenters tells them to ask the caller to spell the name. The search input is debounced, results arrive grouped into a clients section and a reservations section with counts on each, and the whole exchange usually fits inside the pause where the caller says "Yılmaz. Y-ı-l…"</p>
<h2>The extension that had to move</h2>
<p>The production wrinkle, because there is always one: <code>pg_trgm</code> is an extension, and on a managed PostgreSQL instance you do not get to assume where it lands. The migration history tells the story in two files. <code>V72_install_pg_trgm.sql</code> is one hopeful line, <code>CREATE EXTENSION IF NOT EXISTS pg_trgm</code>. <code>V73_prod_db_enable_pg_trgm_becaschema.sql</code> exists because that was not enough on the hosted database — it drops the extension and reinstalls it with <code>SCHEMA becaschema</code>, pinning it into the application's own schema so the <code>%</code> operator and <code>similarity()</code> are actually reachable from the queries that need them. On a laptop, where everything lives on the default search path, the problem is invisible; it only introduces itself in production.</p>
<pre><code class="language-sql">-- V73: the do-over
DROP EXTENSION IF EXISTS pg_trgm;
CREATE EXTENSION pg_trgm SCHEMA becaschema;
</code></pre>
<p>Would this design survive a million clients? Not as written — scoring a hundred candidates in JavaScript per keystroke is a luxury of restaurant-sized data, and at some point the ranking would need to move into SQL with a proper GIN trigram index behind it. But that is precisely the point. The problem was restaurant-sized, so the solution is too: one extension the database already knew how to install, one scorer small enough to read in a sitting, and a search box that finds Ayşe no matter which alphabet the keyboard thinks it is speaking.</p>
<hr />
<p><em>This is the second write-up from the same project: <a href="https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself">The Restaurant Floor That Rearranged Itself</a> covers the live floor plan that regrouped and recoloured its own tables mid-service.</em></p>
<p><em>A related write-up from a different project: <a href="https://ubeyd.dev/ranked-search-on-sqlite">Ranked Search on SQLite</a> applies the same do-it-with-the-database instinct at a larger scale, ranking 13,000 multilingual job listings with SQLite FTS5 and a scorer in PHP rather than a search cluster.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Secret Your Container Has but Your Shell Can't See]]></title><description><![CDATA[Here is a small mystery that cost me an afternoon the first time I met it, and that I have watched trip up good engineers since. You have a containerised application running in production. It holds a ]]></description><link>https://ubeyd.dev/the-secret-your-container-cant-see</link><guid isPermaLink="true">https://ubeyd.dev/the-secret-your-container-cant-see</guid><category><![CDATA[Docker]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[containers]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Thu, 02 Jul 2026 08:29:15 GMT</pubDate><content:encoded><![CDATA[<p>Here is a small mystery that cost me an afternoon the first time I met it, and that I have watched trip up good engineers since. You have a containerised application running in production. It holds a database password, because it fetched one when it started, and it is serving requests without complaint. Then you need to run a single command against it by hand, a database migration, say. You open a shell into the running container and run the migration the ordinary way, and it fails at once with an error that makes no sense.</p>
<pre><code class="language-bash">docker exec &lt;container&gt; php artisan migrate
# SQLSTATE[HY000] [1045] Access denied for user 'app'@'%' (using password: NO)
</code></pre>
<p>The password is not wrong. The application beside you is using it perfectly well. It is simply invisible to you, and the reason it is invisible says something quietly important about how containers actually work.</p>
<h2>A container does not have one environment</h2>
<p>The setup that leads here is a good one, and an increasingly common one. Rather than leave secrets sitting in a file on disk, you have the container fetch them when it boots. An entrypoint script runs first, pulls the database password and everything else from a secrets manager, exports them into its own environment, and then hands control to the application. From that point on the application carries the password in memory and never needs a file for it. It is a clean pattern, and it is exactly what sets the trap.</p>
<p>The instinct most of us carry is that a container has an environment, a single bag of variables that everything inside it shares. That is not how it works. On Linux, every process carries its own copy of the environment, handed down from its parent at the moment it is created. When the entrypoint exported the password, it placed it into one process, the one that went on to become the application. Those variables live in that process and in no other.</p>
<p>Now consider what docker exec actually does. It does not open a window into the application's process. It starts a fresh process inside the container, and that new process is assembled from the container's original configuration, not from whatever the entrypoint exported at runtime. Your shell is a sibling of the application rather than a view into it. It inherited none of the secrets the entrypoint added after boot, so when your migration reaches for the database password, it finds nothing there. The application can see the password. You, standing right next to it, cannot.</p>
<p>You do not have to take my word for it, because you can see the hole directly. The application is running with that password in hand, yet ask your own shell inside the very same container to print it, and you get nothing back.</p>
<pre><code class="language-bash">docker exec &lt;container&gt; printenv DB_PASSWORD
# (prints nothing)
</code></pre>
<p>That empty line is the whole problem, and it is true no matter what the application does with the password afterwards. The value is exactly where it should be, in the process that fetched it. It simply was never in yours.</p>
<h2>Reaching into /proc for what you cannot see</h2>
<p>Once you know where the secret actually lives, the way to reach it becomes obvious. Linux publishes the environment of every running process through the /proc filesystem, and the file /proc/1/environ holds the environment of process one, your application, as a list of variables separated by null bytes. If you can read that file, you can load the application's own environment into your shell and run your command with the exact values the application is using.</p>
<p>The single detail to get right is that the entries are separated by null characters rather than by newlines, so you read them with that in mind.</p>
<pre><code class="language-bash">docker exec &lt;container&gt; bash -c '
  while IFS= read -r -d "" var; do export "$var"; done &lt; /proc/1/environ
  php artisan migrate
'
</code></pre>
<p>The loop walks the null-separated list, exports each variable into the shell, and then runs the migration with the environment the application already has. Nothing is typed, nothing is written to a file, and nothing leaves the machine. The secret is copied from one process straight into another, which is the entire point. You never had to know the password in order to use it.</p>
<h2>Do not paper over it with -e</h2>
<p>There is a tempting shortcut at this point, and it is worth warning against, because it fails in the worst possible way. Since docker exec accepts an -e flag for setting a variable, you might reach for it and simply hand the password across yourself.</p>
<pre><code class="language-bash">docker exec -e DB_PASSWORD='the-password' &lt;container&gt; php artisan migrate
</code></pre>
<p>This has two problems. The smaller one is that it undoes the very thing you set out to do. You went to real trouble to keep the secret off disk and out of sight, and here you are typing it onto a command line, into your shell history, in plain view of anyone glancing at your screen.</p>
<p>The larger problem is that your shell reads the value before docker exec ever receives it. If the password contains a character the shell treats as special, the value that finally arrives inside the container is not the value you typed. An exclamation mark can trigger history expansion. An asterisk can expand into a list of file names. The command usually does not error when this happens. It runs with a quietly wrong value, and that is the real danger. Suppose the command had not been a migration but the one that seeds your administrator accounts. Each account would be created with a password that is subtly not the password anyone was handed. Nothing would complain. The next morning the login page would turn everyone away, and there would be no error anywhere to explain why.</p>
<p>The lesson is not to quote more carefully. It is to keep the secret off the command line entirely. Take it from the process that already holds it, the way the earlier snippet does, and the shell never gets the chance to touch it.</p>
<p>None of this is exotic once you have seen it, which is exactly why it is worth writing down. The whole surprise rests on one assumption that most of us carry without ever examining it, that a container has a single shared environment. It does not. It has one environment for every process, and a secret injected at startup lives only in the process that received it. So when you need that secret from the outside, the move is not to fetch the value and type it back in. It is to reach into /proc and borrow it from the process that already holds it.</p>
<hr />
<p><em>More field notes from production: <a href="https://ubeyd.dev/the-504-that-was-working">The 504 That Was Actually Working</a>, a gateway timeout on deploy day where nothing was actually broken, and <a href="https://ubeyd.dev/the-endpoint-that-moved">The Endpoint That Moved While I Wasn't Looking</a>, an API that had quietly relocated.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Savings Plan That Rewrites Itself]]></title><description><![CDATA[I built Akluma for a simple, selfish reason. I wanted to save money, and I wanted something that would hold me to it. It is a Laravel application — PHP and Blade on the back end, Alpine.js and Tailwin]]></description><link>https://ubeyd.dev/the-savings-plan-that-rewrites-itself</link><guid isPermaLink="true">https://ubeyd.dev/the-savings-plan-that-rewrites-itself</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Alpine.js]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[MySQL]]></category><category><![CDATA[pest ]]></category><category><![CDATA[Fly.io]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 30 Jun 2026 16:26:06 GMT</pubDate><content:encoded><![CDATA[<p>I built Akluma for a simple, selfish reason. I wanted to save money, and I wanted something that would hold me to it. It is a Laravel application — PHP and Blade on the back end, Alpine.js and Tailwind CSS on the front — for setting a savings goal and breaking it into a schedule: save this much, this often, and you will have what you need by this date. It speaks three languages — English, Turkish, and French — and it counts in six currencies, including the West and Central African francs used where I live. None of that is the interesting part.</p>
<p>The interesting part is what happens when the plan changes. A savings tracker, drawn on a whiteboard, looks trivial. Add money, watch the number rise. But a savings <em>plan</em> is a promise to your future self, and promises drift. You miss a week. Your situation improves and you want to save faster. The date you were aiming for slips. The moment any of that happens, the tidy schedule you started with is wrong — and the real work of the app begins, quietly, behind a single button, where the user never sees it.</p>
<p>Akluma is also the project that has taught me the most, precisely because I have kept it running for real users over a long time. The lessons in this post are the kind you only learn by maintaining something, not by building it once and walking away.</p>
<h2>Five numbers that look alike and aren't</h2>
<p>To see how the plan rewrites itself, you first have to meet five numbers Akluma keeps on every goal, all of them columns on a single MySQL table. Three of them never change. Two of them do. I confused them often enough, early on, that I eventually wrote myself a note so I would stop.</p>
<pre><code class="language-text">frozen at creation:
  Price          what the thing you want costs
  Planned total  everything you plan to deposit
  Original Goal  the end total if you follow the plan
alive afterwards:
  Updated Goal   empty until the goal actually moves
  Still to save  recomputed after every change
</code></pre>
<p>Three numbers are frozen the moment you create the goal: the price of the thing you are saving for, the planned total of all your deposits, and your Original Goal — the end total you will reach if you follow the plan to the last payment. Those never move again. They are the promise as you first made it, kept on the record exactly as it was.</p>
<p>The other two are alive. One is simply how much you still have to save, recomputed after every deposit and every change. The subtle one is the Updated Goal. It stays empty — literally <code>null</code> in the database — and only takes a value when a recalculation genuinely moves the goalposts. If you merely re-pace the same goal, paying it off faster without changing the total, the Updated Goal stays empty and the screen keeps showing your original one. That sounds pedantic. It is actually the difference between an interface that tells the truth and one that quietly lies to you about what you set out to do.</p>
<h2>When the goal moves</h2>
<p>So what happens when you change the plan? Say you decide to raise your weekly deposit. You press recalculate, and a Laravel service runs the entire change inside a single MySQL transaction.</p>
<p>First it checks that the change makes sense. The goal is still active. The new amount is positive. There are payments left to reschedule. The new amount is smaller than what you have left to save. Then comes the decision I am most pleased with. The old pending payments are not deleted. They are <em>archived</em> — flagged, set aside, kept on file.</p>
<pre><code class="language-text">you raise your weekly deposit:
  old pending payments   →  archived (v1), not deleted
  new schedule           →  written fresh (v2)
  payments already made  →  left untouched
  ( one transaction: all of it commits, or none does )
</code></pre>
<p>I chose a plain archived flag over Laravel's soft deletes for a reason I wrote down at the time: I wanted to preserve the old schedule as an audit trail. When a user asks "why did my plan change?", the answer should still exist. With the old payments set aside, the service writes a fresh schedule and stamps every new payment with an incremented version number. Version one was your original plan. Version two is this one. The versions let me trace a goal's whole history, generation by generation, which has rescued me more than once when something looked wrong in production.</p>
<p>Only then does the app decide whether your goal truly moved, fill in the Updated Goal if it did, and recompute what you have left. If anything fails along the way, the transaction rolls back and your old plan is still standing, untouched. The schedule rewrites itself, but there is never a half-rewritten moment in between.</p>
<h2>The bug the plan didn't predict</h2>
<p>Here is the kind of thing you only find by living with your own code. When I planned this feature, I wrote down that the new payments should keep numbering from the last payment the user had actually completed. It seemed obvious. Continue from where they left off.</p>
<p>It was wrong. Because the old pending payments are archived rather than deleted, they keep their numbers, sitting quietly in the table. Numbering a new schedule from the last <em>completed</em> payment would have collided with those archived rows. So the shipped code does something slightly different from my plan. It takes the highest payment number across <em>every</em> row, archived ones included, and counts up from there. A one-line change, with a comment to my future self explaining why. The plan did not predict it. The running system did.</p>
<p>A smaller discipline runs underneath all of this too. Money is never a float in Akluma. Every amount passes through <code>Brick\Money</code>, a dedicated PHP money library that keeps currency and precision honest. That matters more than usual when six currencies are in play and two of them — the African francs — have no decimal places at all. A rounding shortcut that looks harmless in dollars quietly breaks in francs.</p>
<h2>What the project demonstrates</h2>
<p>I want to be honest about Akluma's scale. It is a live web application with a few dozen real users, my own savings among them. There is an Android version, but it is a thin wrapper around the website rather than a native app, and it is still in testing — not yet on the Play Store. The reminders that nudge you to save are real, and they go out by email at nine in the morning in each user's own timezone; the push-notification version is designed but not yet built. I would rather tell you exactly what is shipped than dress it up.</p>
<p>None of the stack is exotic, and that is the point. Akluma is a Laravel 12 and PHP application. The front end is Blade and Alpine.js on Tailwind CSS, built with Vite. The data lives in MySQL, the money runs through <code>Brick\Money</code>, and a Pest test suite guards the parts that matter. It deploys to Fly.io on every push through GitHub Actions, across separate staging and production environments. These are boring, proven tools — chosen so I can keep trusting them for years.</p>
<p>What the project demonstrates is not scale. It is care. The engineering worth showing lives in the boring, invisible places. A plan that can change without losing its history. A set of financial numbers that know which of them are allowed to move. A transaction that refuses to leave a half-finished mess. A small bug fixed because I was still around to notice it. Those are the things that decide whether software can be trusted with something as personal as a person's money.</p>
<blockquote>
<p>The hard part of a savings app is not adding up money. It is letting a plan change while keeping faith with the plan it used to be — and being honest, on the screen, about which promise you are still keeping.</p>
</blockquote>
<p>Akluma is live at <a href="https://akluma.com">akluma.com</a> if you would like to set a goal and watch it hold.</p>
<p>Related: building the plan was only half the work. The other half was getting the right people to find it. I wrote that story separately: <a href="https://dev.to/ubeydullah/i-did-keyword-research-for-my-savings-app-then-i-actually-looked-at-the-search-results-ld6">I did keyword research for my savings app. Then I actually looked at the search results.</a></p>
<hr />
<p><em>Part of a short series of write-ups on the things I've built. See also</em> <a href="https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself"><em>The Restaurant Floor That Rearranged Itself</em></a><em>, a reservation system that moved its own tables, and</em> <a href="https://ubeyd.dev/the-job-finder-that-reads-every-listing"><em>The Job Finder That Reads Every Listing</em></a><em>, a job search that reads every listing with local language models.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Restaurant Floor That Rearranged Itself]]></title><description><![CDATA[A reservation system looks, from the outside, like nothing more than a calendar with better manners — a form where a name and a time and a party size go in and a tidy row comes out — and for the first]]></description><link>https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself</link><guid isPermaLink="true">https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself</guid><category><![CDATA[Nuxt]]></category><category><![CDATA[vue]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[node js]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[websockets]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 30 Jun 2026 15:29:16 GMT</pubDate><content:encoded><![CDATA[<p>A reservation system looks, from the outside, like nothing more than a calendar with better manners — a form where a name and a time and a party size go in and a tidy row comes out — and for the first few weeks of building one for a restaurant in Istanbul I believed that too, right up until it became clear that the booking was the easy half and the genuinely hard problem was the floor itself: a living, shifting arrangement of tables that drift together for a party of ten, break apart again when the night thins out, and change colour as guests arrive, settle in, and leave. This is a write-up of that harder half, the quieter machinery behind the booking form, because that is the part of the project I most want a reader to see.</p>
<h2>The part that isn't the booking form</h2>
<p>The restaurant ran the way most do, in sittings: an early evening seating and a later one, a Sunday breakfast, an open buffet, each with its own window of hours and its own rhythm. A host taking bookings over the phone holds the whole room in their head — which tables can be pushed together for the big group at eight, which corner empties out in time for the couple at half past nine, which section is closed tonight — and the quiet ambition of the software was to hold that same picture without ever asking a human to redraw it by hand. The booking form, the customer records, the statistics the owner liked to look at, all of that was real work but it was ordinary work; the thing that turned the project from a calendar into a system was the floor plan, and the floor plan only earned its keep once it could change on its own.</p>
<h2>The floor as a living data model</h2>
<p>The application was a full-stack Nuxt 3 build, a Vue front end talking to a Nitro server in Node, with everything persisted through Prisma into PostgreSQL, and the first real design decision was how to make a physical room addressable. Every table carried two positions rather than one: a home position, where it sat when the floor was at rest, and a live position, where it actually stood at this moment in the middle of service — and that small duplication, a <code>default_location</code> it could always be returned to and a <code>current_location</code> that drifted as the night wore on, turned out to be the hinge the whole feature swung on.</p>
<pre><code class="language-text">one table on the floor:
  table_no          12
  shape             "round"
  default_location  (540, 300)   ← home
  current_location  (580, 285)   ← now, mid-service
  colour            the seated reservation’s status
</code></pre>
<p>The tables belonged to named sections — a garden bar, a main salon, a living room with a fireplace — and the front end drew them onto a floor plan as circles and rectangles sized to their real dimensions, each one filled with a colour it did not choose for itself. The colour came instead from whatever reservation currently owned the table, read off a status whose label could be something as plainly human as <em>Gelmedi</em>, "not arrived", so that a glance at the screen was meant to tell you the same thing a glance across the room would: this table is empty, this one is seated, this one is still waiting on its guests.</p>
<h2>Rearranging the floor every half hour</h2>
<p>This is the heart of it. Every half hour a timer would wake, ask the database which reservations were live at that precise moment, and for each party that had been promised more than one table it would take the lowest-numbered table as an anchor and walk the others out from it in a patient diagonal — forty pixels across and fifteen up for each table in the group — committing the whole rearrangement in a single transaction, so that the floor never flickered through a half-moved state with tables caught mid-stride between where they had been and where they now belonged.</p>
<pre><code class="language-text">a party of ten, three tables pushed together:
  table 1 the anchor — stays where it is
  table 2 +40 across, −15 up
  table 3 +80 across, −30 up
  ( one transaction, then pushed live to every screen )
</code></pre>
<p>What I found quietly satisfying about the design is that there was never a "group" in the database at all. A group was not an entity you created and destroyed; it was simply the fact of several tables sharing a single reservation, made visible by the coordinates the sweep computed for them, and so it could come into being and dissolve again without anything ever needing to be explicitly torn down. A companion sweep, running on its own half-hour offset, did the dissolving: it looked for any table standing away from its home position that no longer had a live reservation to justify the move, and it sent each of those quietly back to where it belonged. Building and testing this against real clock time was the hardest part of the entire project, because the logic only made sense in motion — a table is grouped because a party is seated <em>now</em>, and ungrouped because that <em>now</em> has passed — and getting it to behave correctly meant reasoning constantly about the exact moment the code believed it was living in.</p>
<h2>Keeping every screen in agreement</h2>
<p>A restaurant floor on a busy night is not run by one person but by several at once — a host at the door, waiters threading between the tables, someone watching the whole room from a screen at the back — and the one thing the system could not afford was for two of those screens to disagree, so every move, every reset, and every newly written reservation went out over a WebSocket the moment it happened, and every other screen quietly redrew itself to match, until the floor on the glass and the floor in the room were telling the same story. The server announced each change on its own named channel, a <code>tablesMoved</code> when the half-hour sweep regrouped a party, a <code>tablesReset</code> when the companion sweep sent tables home, and the front end answered each announcement the same way: it went back to the database, fetched the floor afresh, and recomputed every colour, so that no client was ever left quietly reasoning from a stale picture of the room.</p>
<h2>What the project demonstrates</h2>
<p>Stepping back, what the project really shows is a single physical reality — a restaurant floor that hosts already knew how to manage by hand — modelled faithfully enough in software that it could manage itself: a full-stack Nuxt and Node application over PostgreSQL, a floor plan rendered from live data, scheduled jobs that reshaped that floor on a clock, and a real-time layer that kept every screen honest about it. There is an honest coda I owe the work, too: the system was finished and deployed, but it never actually opened to a single diner, because the restaurant's appetite for adopting it ran into business questions that had nothing to do with whether the software worked, and I have made my peace with the fact that a thing can be built well and still never get its night in service.</p>
<blockquote>
<p>The most interesting engineering in a reservation system is not the reservation; it is everything the restaurant already knew how to do by hand — pushing tables together, pulling them apart, reading the room by colour — quietly taught to a database and made to keep itself honest every half hour.</p>
</blockquote>
<p>What I keep from it is less a feature than a habit of mind: that the surest way to make software genuinely useful to people who work with their hands is to model the world they already live in faithfully enough that the screen and the room never have to be reconciled by a person.</p>
<hr />
<p><em>A second write-up from this same project: <a href="https://ubeyd.dev/fuzzy-search-without-elasticsearch">Fuzzy Search Without Elasticsearch</a> covers the search box that finds a client called Ayşe when a hurried waiter types <code>ayse</code>, using PostgreSQL trigrams and a small scoring layer instead of a search cluster.</em></p>
<p><em>Part of a short series of write-ups on the things I've built. See also <a href="https://ubeyd.dev/the-savings-plan-that-rewrites-itself">The Savings Plan That Rewrites Itself</a>, a savings app that rewrites its own plan, and <a href="https://ubeyd.dev/the-job-finder-that-reads-every-listing">The Job Finder That Reads Every Listing</a>, a job search that reads every listing with local language models.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Job Finder That Reads Every Listing]]></title><description><![CDATA[For the last couple of months I have been building a European job search on top of a snapshot of a public board — something like thirteen thousand listings, frozen at a single moment in time — and the]]></description><link>https://ubeyd.dev/the-job-finder-that-reads-every-listing</link><guid isPermaLink="true">https://ubeyd.dev/the-job-finder-that-reads-every-listing</guid><category><![CDATA[AI]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[llm]]></category><category><![CDATA[ollama]]></category><category><![CDATA[Python]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[vue]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Wed, 24 Jun 2026 10:58:20 GMT</pubDate><content:encoded><![CDATA[<p>For the last couple of months I have been building a European job search on top of a snapshot of a public board — something like thirteen thousand listings, frozen at a single moment in time — and the part of the project I most want a reader to see is not the search box or the tidy result list but the quieter machinery sitting behind them, the pipeline that reads every single posting in full before any of it is ever ranked or shown. A job board, left to itself, is a wall of prose written by thousands of different people in a dozen different languages, and the engineering that makes such a wall genuinely searchable turns out to be far less about indexing strings than about understanding what each posting is actually asking for.</p>
<h2>The jobs you can actually apply to</h2>
<p>Picture the person this is built for, because every decision in the project flows from them: someone looking to move to Europe for work, often from a developing country and often with limited time and energy, who opens a board of thirteen thousand listings and needs, with as little ceremony as possible, the small handful of roles they could realistically apply to tomorrow. The trouble is that the obstacles standing between that person and those roles are almost never written into the structured fields a job board lets you filter on; they are buried in the description text, in whatever language the employer happened to write it in. A posting might be written entirely in German for a candidate who does not speak it, or quietly demand five years of experience the candidate has not yet earned, or refuse visa sponsorship in a single sentence three paragraphs down, or be built around a technology stack the candidate has never once touched — four mismatches that decide everything, and not one of which is reliably available as a filter, because not one of them lives anywhere but in the prose.</p>
<h2>Reading every listing before ranking it</h2>
<p>So the heart of the project is a Python pipeline whose entire job is to read each posting and convert that prose into structured signals a search engine can actually use, and the design principle underneath it is one I kept returning to: lean on cheap, deterministic heuristics for the cases that are clear, and reserve a language model for the genuinely ambiguous minority that heuristics quietly get wrong. The clear cases really are clear — a title that says "Backend Engineer", a requirements list that names "Python, PostgreSQL, AWS" under its own header, a German posting that insists on <em>verhandlungssicheres Deutsch</em> (business-fluent German) — and for those a well-built regex pass is faster, more reproducible, and frankly more honest than any model would be. It is the remaining slice that needs judgment, the posting where English is <em>wünschenswert</em> (desirable) rather than required, or where a residency requirement hides halfway down a paragraph about benefits, and that slice is precisely where the models earn their keep.</p>
<p>And the models that handle those hard cases all run locally, on my own machine: every one of them is an open-weights model — Llama 3.1 and Gemma 4 — served through Ollama rather than called through a paid API, which means the pipeline can read the entire corpus as many times over as a calibration pass demands without a budget meter ticking in the background, and it means the same code runs identically whether or not there is a network in front of it. What comes out the other end, for a single posting, is a compact structured row that existed nowhere in the source:</p>
<pre><code class="language-text">one German-language posting  →
{
  languages_required:  ["de", "en"],
  experience_years:    3,
  role:                "Backend Engineering",
  tech_stack:          ["Java", "Spring Boot", "PostgreSQL"],
  sponsorship:         "none_stated",
  relocation:          "provided"
}
</code></pre>
<p>What makes a row like that worth the effort is that nearly every field had to be inferred across a language barrier rather than copied from a field that already existed, and the pipeline does exactly this across the roughly two dozen languages the corpus actually contains — German, French, Spanish, Italian, Dutch, Polish, Czech, Hungarian, and a long multilingual tail behind them — applying the same heuristics-first, model-for-the-hard-cases discipline to language detection, years-of-experience extraction, role classification, tech-stack tagging, and the visa-and-relocation signals that matter most to the very person the whole thing is being built for.</p>
<h2>Calibrated against hand-labelled truth, not vibes</h2>
<p>The temptation with this kind of extraction is to glance at a few outputs, decide they look about right, and ship, and the discipline I held myself to instead was to measure every extractor against a set of postings I had labelled by hand, drawn deliberately from outside the set I used to tune it — because grading a thing on the same examples you tuned it on is the oldest way there is of lying to yourself about how well it works. The numbers that came out of that exercise are the ones I am proudest of, precisely because they are real rather than flattering: language detection lands at a hundred percent precision and ninety-seven percent recall against its held-out labels; the years-of-experience extractor, after a cleanup pass, reaches a hundred percent precision and ninety-three percent recall; the visa, sponsorship, and relocation signals catch every genuinely positive case in the held-out set, a clean hundred percent recall on the one axis where a miss would actually cost the candidate a job they could have had; and the tech-stack tagger sits at roughly three-quarters precision while getting its required-versus-nice-to-have distinction right about eighty-two percent of the time.</p>
<p>The role taxonomy was the most interesting piece of machine learning in the project and also the one I am most careful never to oversell. Rather than impose a set of categories from the top down, I embedded the job titles with a multilingual embedding model called bge-m3 and clustered them, letting the natural job families fall out of the data into a taxonomy of twenty-two top-level functions that I then went through and validated by hand. Classifying a brand-new title into that taxonomy is genuinely hard, and so I treat the model's guess for exactly what it is — a soft signal that helps rank and group the results rather than a hard filter that hides anything — because a classifier that is right about a role two times out of three has no business silently deciding what a job-seeker is never allowed to see.</p>
<h2>The app, the search engine, and one read-only file</h2>
<p>All of that signal would sit inert without something to deliver it, and the front half of the project is a Laravel 13 application with a Vue and Inertia frontend, built on top of a search engine I had to write rather than install. The free-text search runs on SQLite's full-text engine, FTS5, but settling on a single tokenizer turned out to be a trap: the substring-friendly trigram tokenizer is wonderful for German compound words, surfacing <em>Postbote</em> (mail carrier) when you type "bote" and Kubernetes when you type "kube", and it is quietly catastrophic for short names, returning a page full of Digital Marketing roles the moment a candidate searches for "Git" because the letters g-i-t happen to live inside the word "digital". The fix was to run a second, word-level index alongside the first and blend their rankings, so that whole-word matches rise cleanly to the top while the substring bridges remain available underneath them rather than drowning everything above them:</p>
<pre><code class="language-text">q = "Git"
before:  0 of the top 5 results were real  (Digital Marketing, Chief Digital Officer …)
after:   5 of 5 were real Git / version-control roles
</code></pre>
<p>Sitting on top of retrieval is a composite relevance scorer that weighs the free-text match against recency, description quality, role and language and tech-stack fit, and a small bonus for the canonical entry of a duplicate cluster — duplicates being a very real problem on a board where one logistics template can recur, byte for byte, across hundreds of cities, which is why a deduplication pass collapses roughly forty percent of the corpus before the candidate ever lays eyes on it. Calibrating that scorer against a hand-labelled set of queries lifted its ranking quality, measured as NDCG at ten, from 0.69 to 0.83 on a held-out set I had sealed away during the tuning. The whole thing then ships to Fly.io as a single read-only SQLite file, a little over a gigabyte, baked directly into the deploy image, which holds the running cost to roughly six dollars a month and makes a data refresh nothing more exotic than another deploy.</p>
<h2>What the project demonstrates</h2>
<p>Stepping back from the individual parts, what this project is meant to show is a single arc owned end to end: a multilingual data-and-machine-learning pipeline in Python that orchestrates local open-source language models to turn unstructured prose into calibrated, measurable signal, and a full-stack Laravel and Vue application with a custom hybrid search engine that turns that signal into something a real person can actually use, deployed and running in production for the price of a sandwich. The same rule runs through every layer of the project: I never trust a model just because it sounds confident.</p>
<blockquote>
<p>Applied-AI engineering, done honestly, is far less about clever prompting than it is about knowing exactly how often you are wrong — and being willing to hand the hard cases to a model only once you have proven, against labels you wrote by hand, that it has earned the trust.</p>
</blockquote>
<p>The job finder is live at <a href="https://jobs.ubeyd.dev">jobs.ubeyd.dev</a> if you would like to apply your own filters and watch a noisy board collapse down to the roles you could genuinely apply to.</p>
<hr />
<p><em>Related: <a href="https://ubeyd.dev/the-504-that-was-working">The 504 That Was Actually Working</a> — a deeper dive on one challenge from this project, the deploy-day gateway timeout where nothing was actually broken and the fix was a cache that can never be wrong.</em></p>
<p><em>Also from this project: <a href="https://ubeyd.dev/ranked-search-on-sqlite">Ranked Search on SQLite</a> picks up where this post stops, covering the search engine itself: FTS5 with a trigram index, title-boosted BM25, and an eight-signal scorer in PHP.</em></p>
<p><em>Part of a short series of write-ups on the things I've built. See also <a href="https://ubeyd.dev/the-restaurant-floor-that-rearranged-itself">The Restaurant Floor That Rearranged Itself</a>, a reservation system that moved its own tables, and <a href="https://ubeyd.dev/the-savings-plan-that-rewrites-itself">The Savings Plan That Rewrites Itself</a>, a savings app that rewrites its own plan.</em></p>
]]></content:encoded></item><item><title><![CDATA[The 504 That Was Actually Working]]></title><description><![CDATA[I had just put a side project on the public internet for the first time — a European job search, a Laravel and Inertia app with a Vue front end, built on a snapshot of a public board — and I had deplo]]></description><link>https://ubeyd.dev/the-504-that-was-working</link><guid isPermaLink="true">https://ubeyd.dev/the-504-that-was-working</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[developer experience]]></category><category><![CDATA[debugging]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[Fly.io]]></category><category><![CDATA[performance]]></category><category><![CDATA[caching]]></category><category><![CDATA[SQLite]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 23 Jun 2026 14:15:53 GMT</pubDate><content:encoded><![CDATA[<p>I had just put a side project on the public internet for the first time — a European job search, a Laravel and Inertia app with a Vue front end, built on a snapshot of a public board — and I had deployed it to Fly.io the way I like best, with almost no moving parts. The entire database is a single read-only SQLite file, 1.1 gigabytes of it, baked directly into the deploy image and shipped inside the container next to the code. There is no database server to run and no managed instance to point at; refreshing the data is just another deploy. For a read-only app it is a wonderfully boring setup, which is the highest compliment I give infrastructure. The build went green, the machine came up healthy on the dashboard, and I opened the URL with that small, specific pleasure of watching something you made resolve in a browser for the first time.</p>
<p>What resolved was a <code>504 Gateway Time-out</code>, in the flat grey of an error page that has stopped trying.</p>
<h2>A timeout with a healthy machine</h2>
<p>My first move was denial, which is everyone's first move: I reloaded, and got the same <code>504</code> exactly sixty seconds later, to the second. I opened the Fly.io dashboard and the machine was sitting right there — awake, healthy, no crash loop, no out-of-memory kill, nothing in the logs with the shape of a stack trace. Every signal the platform offered me said the application was fine. It simply would not answer the door.</p>
<pre><code class="language-text">HTTP/2 504
server: nginx
</code></pre>
<p>So I SSH'd onto the running machine to interrogate it directly, and the picture got stranger rather than clearer. The database file was exactly where it should be, all 1.1 gigabytes present and accounted for. The health endpoint — the little <code>/up</code> route Laravel ships for precisely this kind of moment — came back instantly. The page I actually wanted did not.</p>
<pre><code class="language-text">GET /up   → 200   in 0.29s
GET /     → 504   at 60.00s
</code></pre>
<p>Those two lines are the whole mystery. The same machine could answer one route in under a third of a second and could not answer the other before nginx gave up at sixty. The lobby light was plainly on while the front door timed out. Whatever was wrong was not the <em>machine</em>; it was something specific to the one page I cared about.</p>
<h2>The database had an alibi</h2>
<p>My first real suspect was the database, and I want to be honest that it was a <em>good</em> suspect. SQLite's query planner leans heavily on a set of statistics that a command called <code>ANALYZE</code> produces, and when those statistics are missing or stale the planner will cheerfully choose the wrong index and turn a sub-hundred-millisecond query into one that takes five, ten, thirty seconds. This very project had been bitten by exactly that before. A cold machine, freshly deployed, serving a query that hangs for half a minute is <em>precisely</em> the silhouette that <code>ANALYZE</code> trouble casts — so reaching for the database was not a wild stab, it was the single most plausible explanation on the board.</p>
<p>I checked it the only way worth checking, by looking at whether the statistics were actually there:</p>
<pre><code class="language-text">sqlite&gt; SELECT count(*) FROM sqlite_stat1;
36
</code></pre>
<p>Thirty-six rows of statistics, which meant <code>ANALYZE</code> had run during the import and the planner had everything it needed. The database had an alibi. While I was in the mood, I ruled out the other familiar ghost — server-side rendering, which runs in its own little Node process and can hang in genuinely interesting ways — by poking its port and watching it refuse the connection <em>instantly</em>, which is the difference between "not running" and "hanging." Two of my three best theories were dead inside ten minutes, the page was still timing out, and I was no closer to why.</p>
<h2>183 seconds</h2>
<p>The obvious thing I had somehow not yet done was time the page — not through nginx, which could only ever tell me "longer than sixty seconds," but underneath it, asking the application itself how long it took to assemble the response. So I dropped into Laravel's <code>tinker</code> shell and rendered the page by hand, twice.</p>
<pre><code class="language-text">1st render:  183.2s   → 200 OK
2nd render:   21.1s   → 200 OK
</code></pre>
<p>There it was, and it rearranged the entire problem. The page was not broken. It was returning a flawless <code>200</code> — it was just taking <strong>a hundred and eighty-three seconds</strong> to produce it the first time, and twenty-one on every visit after. Nothing had crashed. The page was <em>thinking</em>.</p>
<p>What it was thinking about was relevance. Every search runs its matching listings through a composite scorer that pulls each row into PHP and computes a score for it, one at a time, before ranking the set — and on the broadest possible query, the one a visitor sees before they have narrowed anything, that is the entire corpus, something like thirteen thousand listings, scored in a PHP loop. Twenty-one seconds of honest arithmetic, warm. The first render is so much worse because <em>everything</em> is cold at once: the PHP opcode cache is empty, the app's own caches are empty, and that 1.1-gigabyte SQLite file has not yet been pulled into the operating system's page cache, so even the disk reads are paying a one-time warmup tax. SQLite was in the room — just as a contributor to the cold tax, never as the bill itself.</p>
<p>And then the decision that turned a slow page into a broken one. To keep a hobby project cheap, I had told the Fly.io machine to scale to zero whenever it went idle. It is a lovely idea right up until you notice what it means: every visit after a quiet spell now wakes a <em>cold</em> machine and pays the full hundred-and-eighty-three-second tax, and nginx, not unreasonably, stops waiting at sixty. The <code>504</code> was not an error report at all. It was a stopwatch.</p>
<h2>The cache that can't be wrong</h2>
<p>The fix came in three parts, and only one of them is interesting. The two dull ones first: I stopped the Fly.io machine from scaling to zero, so a single instance stays warm with its opcode cache and its page cache intact, and I raised the timeouts — nginx's and PHP's both — to five minutes, so that on a genuinely cold render the page is allowed to <em>finish</em> rather than being cut off at sixty seconds. Together those turn the worst case from "broken" into "slow exactly once."</p>
<p>The interesting part is the cache. I wrapped the whole scored page in Laravel's <code>Cache::remember</code>, keyed on the exact filter state, so the expensive thirteen-thousand-row loop runs once per distinct view and every visit afterward is served from cache in milliseconds:</p>
<pre><code class="language-php">$props = app()-&gt;isProduction()
    ? Cache::remember(\(key, now()-&gt;addDay(), fn () =&gt; \)this-&gt;buildProps(...))
    : $this-&gt;buildProps(...);
</code></pre>
<p>And here the deployment model I picked at the very beginning quietly pays for itself. Caching is usually the easy half of a hard problem; the hard half is <em>invalidation</em> — knowing the precise moment a cached answer has gone stale and tearing it down. I wrote none of that, and I never will, because the data this cache is built from cannot change underneath it. The corpus is a read-only SQLite file baked into the image, and the only way the data ever changes is a new deploy — which ships a new image, a fresh machine, a fresh and empty cache. The file and the cache are replaced together, atomically, by the single act of deploying.</p>
<blockquote>
<p>A cached page here isn't "probably still fresh." It is <em>structurally incapable</em> of being stale — because the only thing that could spoil it also throws it away.</p>
</blockquote>
<p>Had a managed Postgres sat behind this app, with the application writing to it, that paragraph would be a lie, and I would owe the codebase a careful, bug-prone invalidation layer for the rest of its life. The read-only-baked-SQLite model is exactly what lets me delete that entire category of work. The database that looked so guilty in the second act turns out to be the reason the fix is clean.</p>
<h2>What the error was telling me</h2>
<p>I think this one cost me an extra beat because <code>504</code> <em>reads</em> as a crash. It lives in the same mental drawer as <code>500</code> and segfaults and the stack traces you go hunting through — the drawer labelled <em>something is broken</em>. But nothing here was broken. The machine was healthy, the file was intact, the query was correct, and the page returned a perfect <code>200</code> the moment you gave it enough room. The error was never a failure; it was a <em>mismatch</em> — between how long the page took to think and how long the proxy in front of it had agreed to wait. There was no bug in the ordinary sense to find, because the bug was a decision: I had parked a cold-expensive page behind a scale-to-zero switch, and the timeout was simply the seam where those two choices met.</p>
<p>The lesson I am keeping is a small and slightly embarrassing one about reading errors literally. A <code>504</code> does not mean <em>broken</em>. It means <em>this took too long</em> — and "too long" is a question with two halves, how long the work takes and how long you have agreed to wait for it, and the fix can live in either half. I spent my first hour certain the work was wrong. The work was fine. I had simply never told anyone how long it was allowed to take.</p>
<hr />
<p><em>Related: <a href="https://ubeyd.dev/the-job-finder-that-reads-every-listing">The Job Finder That Reads Every Listing</a> — the project this gateway timeout came from, an end-to-end tour of the AI pipeline that reads every listing and the app it ships in. And <a href="https://ubeyd.dev/the-endpoint-that-moved">The Endpoint That Moved While I Wasn't Looking</a> — another deploy-day investigation where the obvious, loudly-announced explanation turned out to be the wrong one. And <a href="https://ubeyd.dev/the-secret-your-container-cant-see">The Secret Your Container Has but Your Shell Can't See</a>, on a database password that quietly vanished inside <code>docker exec</code>.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Endpoint That Moved While I Wasn't Looking]]></title><description><![CDATA[Everything on this blog gets here through two Claude Code slash commands. The first, /draft-post, turns a working session into a markdown file. The second, /publish-post, takes that file and pushes it]]></description><link>https://ubeyd.dev/the-endpoint-that-moved</link><guid isPermaLink="true">https://ubeyd.dev/the-endpoint-that-moved</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[developer experience]]></category><category><![CDATA[debugging]]></category><category><![CDATA[GraphQL]]></category><category><![CDATA[Hashnode]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Mon, 22 Jun 2026 16:15:26 GMT</pubDate><content:encoded><![CDATA[<p>Everything on this blog gets here through two Claude Code slash commands. The first, <code>/draft-post</code>, turns a working session into a markdown file. The second, <code>/publish-post</code>, takes that file and pushes it to Hashnode through their GraphQL API. They are unremarkable in the way most good tooling is unremarkable — I type a command, a post appears — and I had stopped thinking about them entirely, which is usually the sign that a tool has become genuinely good.</p>
<p>Then I started a new side project, wanted to be able to blog from it too, and went to wire the same two commands into the new repository. The wiring itself was the boring kind of work: a gitignored file to hold the credentials, a directory for the drafts to live in, a couple of lines so the publish command knew where to read its API key from. None of it was interesting, and all of it worked. <code>/draft-post</code> wrote files. A read query against my own blog came back instantly with the right data. And then I tried to actually publish something, and the whole thing walked straight into a wall I would spend the rest of the afternoon trying to climb.</p>
<h2>A 301 with no body</h2>
<p>The publish step does one thing: it POSTs a GraphQL mutation to Hashnode's API endpoint. Instead of the JSON I expected, I got this:</p>
<pre><code class="language-text">HTTP/2 301
location: https://hashnode.com/announcements/graphql-api
server: cloudflare
</code></pre>
<p>The body was the small, anonymous Cloudflare stub that says <code>301 Moved Permanently</code> and nothing else. It is worth sitting with how strange that response is, because the strangeness was a clue I did not read carefully enough at the time. A working API does not redirect you. It returns data, or it returns a <code>401</code>, or it hands you a structured error object explaining what you did wrong. A <code>301</code> to a marketing announcement is the API declining to behave like an API at all — it is the front door of the building telling you the building has been replaced by a billboard. And the billboard had an address: <code>/announcements/graphql-api</code>. The redirect was, in effect, pointing at the reason it existed. I just read the wrong reason off it.</p>
<h2>I paid for the wrong fix</h2>
<p>The announcement, when I followed it, explained that Hashnode had retired free access to its GraphQL API a few weeks earlier. Every request — reads and writes alike — now required a Pro plan on the publication. Reads, the page noted almost in passing, used to be free; they were not anymore.</p>
<p>This fit the symptom so cleanly that it did not feel like a theory at all, it felt like a diagnosis. My request was being turned away, and here was an official page, which my request had been redirected <em>to</em>, explaining that requests now got turned away unless you paid. The redirect was practically holding up a sign with the answer on it. So I upgraded. I paid, I waited a few minutes for whatever needed to propagate to propagate, and I re-ran the exact same command — and the redirect came back identical to the byte.</p>
<p>That is the most dangerous kind of red herring there is: a symptom that lines up perfectly with a recent, loudly-announced change. It is plausible enough to spend money on. I want to be honest about the money here, because the honest version is more interesting than the self-deprecating one. The fifty dollars was not wasted — the API genuinely does require Pro now, so I would have needed it regardless of anything else. But it was also not the fix. I had been missing two different things at the same time, and buying Pro had quietly removed exactly one of them, which is the worst possible outcome, because it leaves the symptom completely unchanged and you with no way to tell that you made progress. My next move was to generate a brand-new access token from the now-paid account, on the reasonable theory that my old token predated the whole change. Same redirect. Not one byte different.</p>
<h2>Three AIs and one blind spot</h2>
<p>When I am properly stuck, I do a particular thing: I write down everything I have already ruled out, in as much detail as I can stand, and I hand that brief to a couple of other models. So I took the evidence to ChatGPT and to Gemini, alongside the Claude session I was already working in, and I laid it all out. The token did not matter — old and new both failed identically. The headers did not matter — I had tried it with and without an explicit <code>Accept</code>, with a real browser <code>User-Agent</code> glued on, no difference. The path did not matter — a trailing slash still redirected, a <code>/graphql</code> suffix timed out at the edge, a hostname I guessed at returned a flat <code>404</code>. It was not my machine and not my country, because I had reproduced the same redirect from my laptop and from a second machine on another continent, landing on two different Cloudflare data centers, every single time.</p>
<p>The two models agreed with each other, and with me, on the shape of the thing. The endpoint was the documented one. Pro was supposed to switch the API on automatically. It was not a propagation delay. The redirect was happening at the edge, before authentication ever entered the picture. Where they split was on the cause, and the split is the part I keep thinking about. Gemini was confident and admirably specific: the breaking change, it told me, was the format of the <code>Authorization</code> header — Hashnode now expected a <code>Bearer</code> prefix, and the edge was bouncing anything that did not match the pattern. It cited integration write-ups to back the claim. ChatGPT was more cautious, noted that Hashnode's own documentation contradicted itself on whether the token wanted a <code>Bearer</code> prefix at all, and then made the observation that should have stopped me sooner than it did: a redirect that fires <em>before</em> authentication cannot possibly care what your token looks like.</p>
<p>I tested Gemini's fix anyway, because it was the only concrete lever anyone had handed me. Raw token, <code>Bearer</code> token, lowercase <code>bearer</code> token — three requests, three identical redirects. It was a beautifully constructed theory, with sources, and it was wrong the instant it met a <code>curl</code>.</p>
<h2>The dashboard had it the whole time</h2>
<p>I had a support email half-drafted and I was making my peace with publishing the next few posts by hand when I opened the Hashnode dashboard to copy the support address out of it. And there, on the first screen, in a panel I had apparently never once read, was a heading that said <em>Your GraphQL API endpoint</em>, and underneath it:</p>
<pre><code class="language-text">https://gql-beta.hashnode.com
</code></pre>
<p>Not <code>gql.hashnode.com</code>. <code>gql-beta</code>. The endpoint had moved. Every request I had made all afternoon — every request that the Claude session and ChatGPT and Gemini had so carefully reasoned about — had been aimed at a hostname Hashnode had quietly retired, and that retired hostname now does precisely one thing: it <code>301</code>s everyone, paid or free, valid token or not, sensible headers or not, to the announcement explaining that the API had changed. The redirect had never been a paywall and never been an auth problem. It was a forwarding address on an old door.</p>
<p>One request to the new host:</p>
<pre><code class="language-bash">curl -s -X POST https://gql-beta.hashnode.com \
  -H "Content-Type: application/json" \
  -d '{"query":"{ publication(host: \"ubeyd.dev\") { title } }"}'
</code></pre>
<pre><code class="language-text">{"data":{"publication":{"title":"ubeyd.dev"}}}
</code></pre>
<p>A flat <code>200</code>, and the data sitting right there. A minute later a real publish mutation put a throwaway test post on the live site, and I opened it in the browser just to watch it exist. The thing that had eaten my afternoon, survived a paid subscription, and defeated three language models reasoning in concert was a four-character difference in a URL, displayed in a panel on the first screen of a dashboard I open every single time I publish.</p>
<h2>What I'd been assuming</h2>
<p>The uncomfortable part is not that I missed it. The uncomfortable part is that all four of us missed it in exactly the same way. The Claude session, ChatGPT, Gemini, and me — we reasoned carefully, and within the frame we had silently accepted we reasoned <em>correctly</em>. We ruled out tokens and headers and networks with real, methodical rigor. What not one of us did was interrogate the single thing the entire investigation was standing on: that the URL was still the right URL. The bug was not hiding in any of the variables we kept testing. It was sitting in the one constant we never thought to make a variable.</p>
<p>There is a sharper edge here for anyone who, like me, has started reaching for a second and third model when the first one gets stuck. Adding ChatGPT and Gemini did not help, and — this is the part worth internalizing — it could not have helped, because all three models shared the identical blind spot. They could multiply my reasoning, but they could not multiply my access. The single surface that held the answer was the product's own dashboard, behind my own login, and that was mine to go and look at and nobody else's. I had been treating "I am stuck" as a reasoning problem, to be solved by throwing more reasoning at it, when it was an <em>access</em> problem the whole time.</p>
<blockquote>
<p>Three models can multiply your reasoning. They cannot multiply your access. The thing none of them can see is still yours to go and look at.</p>
</blockquote>
<p>It rhymes, a little uncomfortably, with something I wrote here not long ago about an AI quietly building seed data that disagreed with production in ways no test could catch — that an AI collaborator can only ever be as correct as the ground truth you show it. This was that same lesson wearing a different hat. The ground truth was not in the documentation, which was stale. It was not in the changelog, which described the wrong half of the problem with great confidence. It was not in the agreement of three capable models, who were unanimous and unanimously working the wrong frame. It was on a panel I had never read, on a dashboard I look at every time I ship a post. I just had to be the one to actually look.</p>
<hr />
<p><em>Related: <a href="https://ubeyd.dev/six-claude-code-slash-commands">Six Slash Commands I Built on Top of Claude Code</a> — the two commands in this story, <code>/draft-post</code> and <code>/publish-post</code>, are two of the eight I didn't cover there. And <a href="https://ubeyd.dev/the-504-that-was-working">The 504 That Was Actually Working</a> — another deploy-day investigation where the obvious, loudly-announced explanation turned out to be wrong. And <a href="https://ubeyd.dev/the-secret-your-container-cant-see">The Secret Your Container Has but Your Shell Can't See</a>, on a database password that quietly vanished inside <code>docker exec</code>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Six Slash Commands I Built on Top of Claude Code]]></title><description><![CDATA[I have fourteen markdown files in ~/.claude/commands/. They look like this:
$ ls -1 ~/.claude/commands/
close-gl-issue.md
draft-post.md
gh-issue.md
gl-issue.md
handoff.md
improve-issue.md
prod-db-aklu]]></description><link>https://ubeyd.dev/six-claude-code-slash-commands</link><guid isPermaLink="true">https://ubeyd.dev/six-claude-code-slash-commands</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[developer experience]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[workflow]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 14 Apr 2026 16:07:25 GMT</pubDate><content:encoded><![CDATA[<p>I have fourteen markdown files in <code>~/.claude/commands/</code>. They look like this:</p>
<pre><code class="language-bash">$ ls -1 ~/.claude/commands/
close-gl-issue.md
draft-post.md
gh-issue.md
gl-issue.md
handoff.md
improve-issue.md
prod-db-akluma.md
publish-post.md
ship-akluma.md
ship-aclient.md
staging-db-akluma.md
staging-db.md
update-docs.md
worktree.md
</code></pre>
<p>Each file is a Claude Code slash command — a markdown document whose contents are prepended to the agent's prompt when you type <code>/&lt;filename&gt;</code> into a session. They are, quite literally, just text. They do not feel like they should matter very much. They do, though, and they are the difference, for me, between a raw AI assistant that is vaguely useful on a good day and a set of sharp, purpose-built tools that absorb specific pieces of toil from a Laravel legacy codebase I did not originally write. The CLI is not really my tool. These files are.</p>
<p>This post is a tour of six of them, ordered loosely by the chronology of a working day. Some of the fourteen are variants for a different project, and a few are database tunnels that are useful without being interesting; I have left those out. The six I have picked are the ones whose job description surprised me the most — either when I wrote them, or later, when I caught myself realising how much of my day they had quietly eaten.</p>
<h2><code>/improve-issue &lt;url&gt;</code></h2>
<p>The first command in the catalog is also the most embarrassing. I create GitLab issues during meetings, and those meetings move fast enough that my issue descriptions are often one-third of a sentence and a typo. Sometimes the title is literally the last thing someone said out loud. I know that when I get back to my desk, I will not remember what it meant, and more importantly, the Claude Code session I eventually point at the issue will not either. So I built a command that rewrites my own bad issues into real ones before I start work on them.</p>
<p>It takes the full URL of an issue, sniffs the platform, fetches the issue and every comment through the appropriate CLI, and then picks between three triage paths depending on how clear the thing is. Before any of that, though, if the issue smells UI-shaped — a mention of a page, a form, a button, a layout — the command is hard-coded to stop and demand a screenshot before going any further. No exceptions. I have wasted too much time guessing which page was broken from a title like "fix asset register" to ever want the command to be polite about this again.</p>
<p>The output is not a comment on the issue. It is a full replacement of the title and description, posted via the API:</p>
<pre><code class="language-bash">glab api "projects/ateam%2Faclient_docker_src/issues/&lt;number&gt;" \
  --method PUT -f title="\(TITLE" -f description="\)BODY"
</code></pre>
<p>Comments annotate; a rewritten description replaces. I want the original carelessness gone, not appended to.</p>
<h2><code>/gl-issue &lt;number&gt;</code></h2>
<p>This is the load-bearing command of the whole directory. It is the one I run most days, and the one that most obviously rewards the effort of having written the command file in the first place. The friction it absorbs is a subtle one: starting work on an issue on a legacy codebase used to mean the agent would either drown in the project's several hundred pages of documentation, reading everything and running out of context before it even touched the code, or skip the documentation entirely and implement the issue blind. Neither was a good outcome. What I wanted was selective reading — load the specific documentation categories this particular issue actually needs, and nothing else.</p>
<p>The way the command does this is, I think, the single nerdiest thing in the directory. When the command fires, it spawns a separate Claude subagent running on Sonnet — cheaper and faster than the main Opus session — whose only job is to read <code>docs/doc-router.yaml</code>, read the issue, and return a JSON array of category IDs for the main agent to load. The instruction inside the subagent's prompt is explicit about the format:</p>
<pre><code class="language-text">Return ONLY a JSON array of category IDs, e.g.: ["auth", "database", "tenant-isolation"]
</code></pre>
<p>The main agent then reads every file under each matched category plus an "always" set that loads on every issue, and only then starts investigating the codebase. The triage adds about thirty seconds to the start of a session and saves easily twenty minutes of the main agent chasing the wrong files. The command also has a hard-coded screenshot gate that fires before any code is read — if the issue involves anything visual, the agent is required to stop and ask for a screenshot before going any further — and a TDD Red-Green-Pause loop inside the implementation step, because I like my agents to write the failing test before they touch the production code. The doc triage is the part that surprised me; the rest is just the command enforcing habits I already had and kept forgetting to apply.</p>
<h2><code>/worktree &lt;number&gt;</code></h2>
<p>Parallel worktrees have their own two-post story, which I will link at the end of this one, so I will skip over the infrastructure and talk only about the command I eventually wrapped around it. Before this command existed, spinning up a new parallel session looked like this: fetch the issue, read the title, derive a short kebab-case slug by hand, pick a prefix (<code>feature/</code> or <code>bugfix/</code> based on the labels), assemble the branch name, run <code>./scripts/parallel-session.sh create feature/export-report-pdf-345 345</code>, wait for the script to print the worktree path, and then run <code>code ../aclient-wt-export-report-pdf/</code> to open the new window. Six steps, most of them mechanical and therefore forgettable.</p>
<p>Now it is:</p>
<pre><code class="language-bash">/worktree 345
</code></pre>
<p>One integer. The command fetches the issue with <code>glab</code>, slugifies the title, picks the prefix from the labels, runs the script, parses the worktree path from the script's output, and opens a new VS Code window on the worktree directory. The output it hands back looks like this:</p>
<pre><code class="language-text">Session ready for issue #345!

  Directory:  ../aclient-wt-export-report-pdf
  App:        http://localhost:902

A new VS Code window is open. Start Claude there and run:
  /gl-issue 345
</code></pre>
<p>The last line is a deliberate pass-off to the next command in the pipeline. The <code>destroy</code> mode is the symmetrical opposite — <code>/worktree destroy 345</code> finds the session by issue number, tears down the Docker stack, syncs <code>develop</code>, and deletes the branch — which means the whole lifecycle of a parallel session is now bookended by two commands that each take a single integer as their argument.</p>
<h2><code>/handoff</code></h2>
<p>This is the weird one. It is also the only command in this post that you do not use by typing a number into a fresh session — you use it by <em>pausing</em> the one you are already in. The friction it absorbs is a specific one: sometimes, mid-investigation, a small side-task turns up that does not really warrant a branch of its own. A one-line config change, a dependency bump, a typo I noticed in a file I was reading for a completely unrelated reason. Creating a new feature branch for a ten-line change feels heavy, and so does ignoring it and hoping I remember later. What I wanted was a way to hand the task to <em>another</em> Claude session — one that already has a live feature branch — and have that session fold the work into its own next commit.</p>
<p>The command produces a single fenced code block, nothing else, which I copy and paste into the other session's chat window. The block contains a self-contained implementation brief: investigation summary, file paths with line numbers, the exact edits, the verification steps. But the part that makes the whole thing work is the preamble of git rules that the brief opens with:</p>
<pre><code class="language-text">Git rules — READ CAREFULLY:
- Do NOT create a branch, switch branches, or run any git commands
- Do NOT commit this work separately — fold it into your next commit alongside your own work
- Do NOT stash, discard, or touch your existing uncommitted changes
- Do NOT question why this task is on your branch — it's intentional
</code></pre>
<p>That last rule is the one I had to add after the first time I tried this, because the receiving session, very reasonably, looked at the task it had just been handed, noticed that it had nothing to do with the branch it was on, and started asking me whether I was sure about this. I was. I am still. Every branch has a minimum cognitive cost — a new MR to review, a new CI run to wait for, a new context to hold open in my head — and some ten-line tasks are not worth paying that cost for. The command encodes that judgment so I do not have to re-argue it every time.</p>
<h2><code>/ship-aclient push</code></h2>
<p>This is the command that absorbs the git shipping workflow: commit, push, create the merge request, wait for the pipeline, merge, sync, clean up. It has four modes — <code>push</code>, <code>develop</code>, <code>staging</code>, and <code>quick</code> — and the most interesting of the four is <code>quick</code>, because of the guardrail built into it.</p>
<p>The problem the guardrail solves is that I trust myself to run <code>quick</code> for small, low-risk changes, and I do not trust myself to know the difference. A typo fix is obviously quick. A routing refactor is obviously not. Somewhere between the two there is a long tail of changes where my judgment is wrong often enough to matter. So the command does not trust me either. Before it proceeds with a <code>quick</code> flow, it checks the diff against two criteria. The first is a hard-coded list of high-risk file patterns:</p>
<pre><code class="language-text">Dockerfile, docker-compose.yml, docker/**, deploy/**
.gitlab-ci.yml, composer.lock, package-lock.json
routes/web.php, config/**, app/Http/Middleware/**
app/Providers/**, app/Services/**, database/migrations/**
</code></pre>
<p>If any file in the diff matches any of those patterns, the command refuses to proceed quietly and asks me to confirm that I really do want to take the quick route. The second criterion is a numeric threshold — four files and a hundred changed lines, calculated from the project's eightieth-percentile commit size — with an exclusion list of always-low-risk paths like <code>resources/views</code>, <code>tests</code>, and <code>docs</code> that do not count toward the total. Between the two criteria, the command catches essentially every change that my past self would have regretted shipping on the quick route.</p>
<h2><code>/update-docs</code></h2>
<p>If <code>/gl-issue</code> is the load-bearing command of the catalog, <code>/update-docs</code> is the most quietly valuable one. I run it after every issue I close, and roughly half the time it does something I did not expect. The obvious half of its job is the expected one: read the documentation index, compare it against the diff, and update any docs that have drifted out of sync with the code. That is the <code>UPDATE</code> action in the command's plan output. The less obvious half is an action called <code>CAPTURE</code>, which is governed by a step inside the command file called <strong>Knowledge Audit</strong>.</p>
<p>The step asks a single question: <em>if a fresh LLM session had to work on the same area of code tomorrow, what would it waste significant time re-discovering?</em> Then it applies a filter — the knowledge has to have taken more than a few minutes to figure out, it has to not be obvious from reading the code casually, and it has to not already exist in any documentation the command has just read. Anything that survives the filter is written out as a short markdown note and saved into a <code>codebase-notes/</code> subfolder, named after the area of code rather than after the issue — <code>data-inventory-normalization.md</code>, not <code>issue-309-notes.md</code>. The template for these notes is deliberately sparse:</p>
<pre><code class="language-markdown"># &lt;Area of Code&gt;

## What This Covers
&lt;one-sentence summary&gt;

## Key Findings
### &lt;finding title&gt;
&lt;what it is, why it matters, where it lives&gt;

## Files Involved
- `path/to/file.php` — role in this flow
</code></pre>
<p>Those notes are, in effect, a knowledge base I am building for <em>my own future agents</em>. Every time a fresh session starts work on an area I have been in before, it can read the note that the previous session left behind and skip the part where it re-discovers, for the third or fourth time, that the double-query pattern in <code>FormsController</code> is there because of a legacy French translation fallback. The command does not replace my human memory of the project — the project is too large for that to be a realistic goal — but it gives my agents a working memory that survives between sessions, which is a thing the raw tool does not offer.</p>
<h2>Six commands, one pattern</h2>
<p>None of these commands is individually clever. Each one started as a single frustration I had hit in a single session, and each one got written the way a sysadmin writes an alias — to stop having to type the same thing twice. What they have in common is not the specific instructions inside each file, which are mostly unremarkable, but the shape of the thing they are doing underneath. Each command absorbs one specific piece of cognitive toil that I used to carry in my head and am quietly relieved not to carry anymore. The CLI is not my tool. These files are. There are eight more in the directory I did not write about, and I would miss every one of them if they vanished tomorrow.</p>
<hr />
<p><em>Related: <a href="https://ubeyd.dev/parallel-ai-sessions-docker-worktrees">How I Run Parallel AI Coding Sessions on the Same Laravel Project</a> and <a href="https://ubeyd.dev/laravel-parallel-worktrees-demo-seeder">The half I left out of my parallel worktrees post</a> — the two earlier posts in this loose series on building tooling around Claude Code for a Laravel legacy project.</em></p>
]]></content:encoded></item><item><title><![CDATA[The half I left out of my parallel worktrees post]]></title><description><![CDATA[A few weeks ago I wrote about how I run parallel AI coding sessions on the same Laravel project, each one isolated in its own git worktree with its own Docker stack, its own database, and its own localhost port. That post was about the infrastructure...]]></description><link>https://ubeyd.dev/laravel-parallel-worktrees-demo-seeder</link><guid isPermaLink="true">https://ubeyd.dev/laravel-parallel-worktrees-demo-seeder</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[developer experience]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[Seeders]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Tue, 14 Apr 2026 14:24:11 GMT</pubDate><content:encoded><![CDATA[<p>A few weeks ago I wrote about <a target="_blank" href="https://ubeyd.dev/parallel-ai-sessions-docker-worktrees">how I run parallel AI coding sessions on the same Laravel project</a>, each one isolated in its own git worktree with its own Docker stack, its own database, and its own localhost port. That post was about the infrastructure — how to spin up three independent development environments from a single repository in about ninety seconds. What I didn't admit in that post, and what I think I owe the follow-up to, is that the infrastructure on its own was not quite enough. It solved a real problem, but it left a quieter one untouched.</p>
<h2 id="heading-the-empty-worktree">The empty worktree</h2>
<p>The quieter problem was that each freshly spawned worktree was a ghost town. Running <code>migrate:fresh --seed</code> would populate the database with countries, question templates, rating scales, and a handful of starter users, and that was all. Structurally, it was a complete application. Functionally, every page beyond the admin panel was an empty table. There were no filled-out forms, no assets, no incidents, no remediation plans, and no way to tell whether a visual change to the audit register was actually working without first clicking through ten minutes of form-filling to generate something for the register to display. That rather defeats the point of being able to spin up three sessions in ninety seconds.</p>
<p>What I really needed was a seeder that produced a <em>populated</em> application. Not just the reference data that every install needs — the lookup tables and the question templates — but the kind of transactional data that a real tenant would accumulate after a week of actual use. Finished assessments. Audits with ratings and the remediation plans those ratings imply. A handful of incidents. Assets with real map coordinates so the location map would actually render. The obvious move was to build Laravel model factories for every domain model I cared about and then compose them inside a single <code>DemoDataSeeder</code> that stitched them together into a believable scenario.</p>
<h2 id="heading-factories-and-a-seeder">Factories and a seeder</h2>
<p>That is what I set out to do, and it went more or less the way you would expect. Working with Claude, I built the factories in dependency order — starting with the simple lookup tables, then users and organizations, then the structure of the forms, then the form instances, then the assets, then the responses, and finally the remediation plans and incidents that hang off everything else. Each factory got a small smoke test that asserted it could create a single instance and persist it without anything blowing up. The repository's test count went from eighty-nine to one hundred and nine, and every page I cared about in the application started showing data again. The dashboards had numbers. The audit register had two completed audits. The asset register had twelve assets, four of them with map pins. The application looked like a functioning product for the first time in my worktrees.</p>
<p>The shape of the result I was aiming for was a single method chain that would do most of the setup work for me — something like this:</p>
<pre><code class="lang-php">User::factory()-&gt;organization()-&gt;create();
</code></pre>
<p>One line, and behind the scenes it would create a new organization account, attach the fifteen rows of reference data that every real organization in this application needs in order to function, and leave me with an object I could then hang assets, audits, and responses off of inside the demo seeder. Getting a single line to do that much useful work is really what all twenty-or-so factories were for.</p>
<p>One small design decision is worth mentioning, because it turned out to make a disproportionate difference to how pleasant the environment was to use. My original plan had been to create separate demo organizations — Acme Corp, Beta Industries — each with their own fictitious admin users, so that you could log in as <code>demo-orgadmin@example.com</code> and see their data. I prototyped this, and then I threw it away. Remembering a new set of credentials every time you spin up a worktree is friction, and friction compounds when you are spinning up worktrees all day long. The better approach was to hang the demo data off the Test Organization that already existed in the bootstrap seeders, so that the familiar <code>orgadmin@example.com</code> account — the one I use everywhere else — would quietly find itself in front of a fully populated application with no new accounts, no new passwords, and no mental overhead at all.</p>
<p>The final piece was the environment guard. I wanted the seeder to run on every environment that wasn't production — local development for me, but also our staging server, where the team needed a populated application for the same reasons I did. One line inside <code>DatabaseSeeder</code> was enough:</p>
<pre><code class="lang-php"><span class="hljs-keyword">if</span> (! app()-&gt;environment(<span class="hljs-string">'production'</span>)) {
    <span class="hljs-keyword">$this</span>-&gt;call(DemoDataSeeder::class);
}
</code></pre>
<p>A normal deployment runs <code>php artisan migrate</code>, which doesn't touch seeders at all, so shipping this code to staging was entirely safe. The demo data only appears when someone explicitly runs <code>migrate:fresh --seed</code>, which is exactly when — and only when — you want it to.</p>
<h2 id="heading-one-more-check-before-i-merged">One more check, before I merged</h2>
<p>At this point I thought I was done. The factories existed, the seeder ran, the application was populated, and the test suite was green. I was ready to open the merge request and move on. Before I did, though, I wanted to do one more check — the kind of check that doesn't feel necessary until you have been bitten by skipping it. I wanted to compare what my seeder was actually putting into the database against what a real user had been writing into our staging environment over the previous few weeks. Not the shape of the data, not the row counts, but the exact column values, row by row and field by field.</p>
<p>I am very glad I did, because my seeder was lying to me, and the lies were subtle enough that no automated test would ever have caught them.</p>
<h2 id="heading-a-column-called-useremail">A column called <code>user_email</code></h2>
<p>The first mismatch I found is still, several days later, my favourite. My seeder was populating a column called <code>user_email</code> on internal audit responses with, reasonably enough, the email address of the user who had submitted the response. The real application, it turned out, writes the literal string <code>'0'</code> into that column for internal users, and nothing in the schema, nothing in the column name, and nothing in any reasonable person's mental model would ever have hinted at this.</p>
<pre><code class="lang-text">What my seeder wrote:    user_email = 'orgadmin@example.com'
What production writes:  user_email = '0'
</code></pre>
<p>It was one of those small pieces of behaviour that only exists because a controller, at some point years ago, needed a sentinel value and nobody ever revisited the decision. My seeder was not wrong in a way that would break anything — the application was perfectly happy with <code>'orgadmin@example.com'</code> sitting in that column. It was wrong in a way that made the seeded data look slightly nicer than the real data, and that, I am now convinced, is the worst kind of wrong, because it makes local development quietly drift away from production reality without anybody noticing.</p>
<p>There were ten of these mismatches in total, and every single one of them traced back to the same underlying pattern. The controllers in this application write their data using raw SQL INSERT statements that only touch a specific subset of columns — nine, in some cases, out of a table with thirty — and every other column in the row is left sitting at whatever default the migration originally set, which was almost always NULL. A client ID my seeder was cheerfully populating. A progress percentage my seeder was calculating. A type field my seeder was deriving from the form. None of these fields were actually written by the application in production. All of them had, quietly and without any particular announcement, become columns that the schema promised but the code simply never touched.</p>
<p>Correcting the seeder, once I understood the pattern, was the easy part. I went back through the <code>DemoDataSeeder</code> and stopped setting any column that the application itself did not set, and the staging diff came back clean. The harder question — and I think the reason this whole detour is worth writing about at all — is how I would ever have caught any of this without the staging comparison. I do not think I would have. Every automated test I could have written would have been reading the same schema I was writing to, which means the tests would have agreed with the seeder that everything was in order. The only authoritative source of truth was the production code path itself — the actual controller writing the actual INSERT statement, and the actual staging database that had been touched by that code path for weeks on end. Anything less than that would have missed it.</p>
<h2 id="heading-what-i-actually-learned">What I actually learned</h2>
<p>This, I think, is the lesson that matters most, and it is specifically a lesson about working with an AI collaborator on a legacy codebase. Claude was a very capable partner throughout this work. It built the factories in the right order. It wrote smoke tests without being asked. It raised sensible questions about ownership columns and prerequisite chains and the difference between assessment responses and audit responses, and it caught several mistakes I would otherwise have made on my own. What it could not do — and I do not think any AI collaborator could reasonably have done — was know that <code>user_email = '0'</code> was the right value. That knowledge does not live in the schema, it does not live in the column names, it does not live in the tests, and it does not live in any piece of documentation I had put into the conversation. It lives only in the line of controller code that writes the INSERT, and in the staging database that reflects the consequences of that line running against real users over time. If I had not gone looking for it, neither of us would have found it.</p>
<p>So the moral of this follow-up, for me, is a slightly adjusted version of the one I ended the first post with. That first post was about teaching the AI about the environment — making sure it understood that a worktree was not an ordinary branch, and making sure it would not run cleanup commands that would kill the session. This one is about something adjacent, and a little more uncomfortable. It is about accepting that on a legacy codebase, an AI collaborator can only ever be as correct as the ground truth you show it.</p>
<blockquote>
<p>The schema is not ground truth. The code that actually runs in production is.</p>
</blockquote>
<p>If you are going to let the AI write your seed data, you have to be willing to compare what it wrote against a database that was shaped by that production code — and you have to be willing to do the comparison yourself, because nothing else will do it for you.</p>
<h2 id="heading-where-things-stand-now">Where things stand now</h2>
<p>The parallel worktrees, for what it is worth, are now as genuinely useful as I had hoped they would be when I wrote the first post. I can spin up three sessions at once, and every single one of them boots into an application that looks and behaves like a real product. The infrastructure, it turns out, was the easy half of the problem. The data — and the small, uncomfortable process of making sure the data wasn't quietly lying to me — was the other half, the half I didn't write about the first time round. This is me writing about it now.</p>
<hr />
<p><em>Related: <a target="_blank" href="https://ubeyd.dev/parallel-ai-sessions-docker-worktrees">How I Run Parallel AI Coding Sessions on the Same Laravel Project</a> — the first post in this pair, where the whole parallel-worktree setup got built in the first place.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Run Parallel AI Coding Sessions on the Same Laravel Project]]></title><description><![CDATA[I work solo on a Laravel monolith. My AI pair programmer (Claude Code) is fast, thorough, and relentless — but it can only work on one branch at a time. While it's deep in a feature implementation, I']]></description><link>https://ubeyd.dev/parallel-ai-sessions-docker-worktrees</link><guid isPermaLink="true">https://ubeyd.dev/parallel-ai-sessions-docker-worktrees</guid><category><![CDATA[AI]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[developer experience]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Ubeydullah Keleş]]></dc:creator><pubDate>Fri, 03 Apr 2026 11:39:45 GMT</pubDate><content:encoded><![CDATA[<p>I work solo on a Laravel monolith. My AI pair programmer (Claude Code) is fast, thorough, and relentless — but it can only work on one branch at a time. While it's deep in a feature implementation, I'm just... sitting there. Waiting. Reading the diff. Running the verification steps.</p>
<p>I wanted to launch a second session on a different issue. And a third. Each one visible in the browser. Each one isolated. Each one disposable.</p>
<p>The problem? Docker Compose is bound to one directory. One project. One set of ports. You can't just <code>docker-compose up</code> twice and hope for the best.</p>
<p>Here's how I solved it.</p>
<p><strong>My setup:</strong> macOS, VS Code with the Claude Code extension, Docker Desktop, and a Laravel 12 app running in Docker Compose. Everything in this post assumes a similar stack, but the core ideas — parameterized ports, isolated databases, worktree-aware tooling — apply to any Dockerized web application.</p>
<h2>The problem in one sentence</h2>
<p>Docker Compose ties your <code>localhost:8000</code> (or whatever your app port is) to a single project directory. If you want to work on two branches simultaneously and <strong>see both in the browser</strong>, you need two separate Docker stacks on different ports — and they can't collide.</p>
<h2>Why not just use git stash?</h2>
<p>Because stashing is sequential. I don't want to:</p>
<ol>
<li>Stash my work</li>
<li>Switch branches</li>
<li>Spin up the other feature</li>
<li>Verify it</li>
<li>Switch back</li>
<li>Pop the stash</li>
<li>Remember where I was</li>
</ol>
<p>I want to open two VS Code windows, each on a different branch, each with its own <code>localhost</code> URL. Work on both. Alt-tab between them. No context switching tax.</p>
<h2>The architecture: worktrees + parameterized Docker</h2>
<p>The solution has two parts:</p>
<p><strong>1. Git worktrees</strong> give you multiple checked-out copies of the same repo. They share the git object store (so they're lightweight — not full clones), but each has its own branch, working directory, and files.</p>
<pre><code>~/dev/
├── my-project/                    # Main repo (localhost:8000)
├── my-project-wt-export-button/   # Worktree 1 (localhost:8001)
└── my-project-wt-fix-sidebar/     # Worktree 2 (localhost:8002)
</code></pre>
<p><strong>2. Parameterized Docker Compose ports</strong> let each directory run its own stack without port collisions.</p>
<h3>Step 1: Make your ports configurable</h3>
<p>Replace hardcoded ports in <code>docker-compose.yml</code> with environment variables:</p>
<pre><code class="language-yaml">services:
  app:
    ports:
      - "${APP_PORT:-8000}:80"
  mysql:
    ports:
      - "${DB_PORT:-3306}:3306"
  mailpit:
    ports:
      - "${MAIL_HTTP_PORT:-8025}:8025"
      - "${MAIL_SMTP_PORT:-1025}:1025"
</code></pre>
<p>The <code>:-8000</code> syntax means "use 8000 if the variable isn't set." Your main repo works exactly as before — zero behavior change. But now each worktree can set its own ports in a local <code>.env</code>.</p>
<h3>Step 2: Isolate session cookies</h3>
<p>This one bit me during testing. I logged into <code>localhost:8000</code>, then <code>localhost:8001</code>, then went back to <code>:8000</code> — and I was logged out.</p>
<p>Why? Browsers share cookies across ports on the same domain. Both apps set a cookie called <code>laravel_session</code> for <code>localhost</code>. Logging into one overwrites the other.</p>
<p>The fix: each worktree gets a unique session cookie name.</p>
<pre><code class="language-env"># In the worktree's .env:
SESSION_COOKIE=my_app_session_8001
</code></pre>
<p>Laravel reads <code>SESSION_COOKIE</code> from the environment. One line, problem solved.</p>
<h3>Step 3: Automate everything with a script</h3>
<p>I wrote a ~200 line bash script that handles the entire lifecycle:</p>
<pre><code class="language-bash"># Create a parallel session
./scripts/parallel-session.sh create feature/export-button
# Output:
#   Session ready!
#   App: http://localhost:8001
#   MySQL: localhost:3307
#   Mailpit: http://localhost:8026

# List active sessions
./scripts/parallel-session.sh list
# Output:
#   my-project-wt-export-button  http://localhost:8001  [feature/export-button]  (running)

# Destroy when done
./scripts/parallel-session.sh destroy feature/export-button
</code></pre>
<p>The <code>create</code> command:</p>
<ol>
<li>Runs <code>git worktree add</code> to create the worktree (from the <code>develop</code> branch)</li>
<li>Copies <code>.env</code> from the main repo</li>
<li>Overwrites port variables with auto-calculated unique values</li>
<li>Sets a unique <code>SESSION_COOKIE</code></li>
<li>Runs <code>docker-compose up -d</code></li>
</ol>
<p>Port assignment is simple: session N gets base port + N. If you destroy session 1 and create a new one, it reuses the gap.</p>
<pre><code>| Session     | App    | MySQL  | Mailpit |
|-------------|--------|--------|---------|
| Main repo   | :8000  | :3306  | :8025   |
| Worktree 1  | :8001  | :3307  | :8026   |
| Worktree 2  | :8002  | :3308  | :8027   |
</code></pre>
<h3>Step 4: Seed independently</h3>
<p>Each worktree gets its own MySQL volume. The database starts empty. After creating a session:</p>
<pre><code class="language-bash">cd ~/dev/my-project-wt-export-button
docker-compose exec app php artisan migrate:fresh --seed
</code></pre>
<p><strong>Timing matters here.</strong> If your Dockerfile uses a named volume for <code>vendor/</code>, it starts empty on first boot. Your entrypoint likely runs <code>composer install</code> to populate it. You need to wait for that to finish before running artisan commands — otherwise you'll get "class not found" errors because the packages aren't installed yet. My script waits for Apache to respond (which means the entrypoint completed) before attempting to seed.</p>
<p>This is actually a <strong>feature, not a bug</strong>. Each session has a clean database, seeded from scratch. No stale data from another branch's migrations. No "works on my machine because I manually ran that one migration."</p>
<h2>The daily workflow</h2>
<p><strong>Starting a parallel session:</strong></p>
<ol>
<li>Run the create script (30 seconds — Docker images are cached)</li>
<li>Run <code>migrate:fresh --seed</code> (10 seconds)</li>
<li>Open the worktree directory in VS Code</li>
<li>Start working</li>
</ol>
<p><strong>While working:</strong></p>
<ul>
<li>Each VS Code window is a completely independent project</li>
<li>Each has its own <code>localhost</code> URL for browser testing</li>
<li>Git works normally — <code>commit</code>, <code>push</code>, create PRs/MRs</li>
<li>Tests target the correct containers automatically (Docker Compose resolves from the current directory)</li>
</ul>
<p><strong>Cleanup after merging:</strong></p>
<p>Once you've merged the MR in GitLab, the cleanup flow is straightforward:</p>
<ol>
<li>Close the worktree's VS Code window — there's nothing left to do there.</li>
<li>Switch to the main repo's VS Code window and run the destroy command:</li>
</ol>
<pre><code class="language-bash">./scripts/parallel-session.sh destroy feature/export-button
</code></pre>
<p>This stops the Docker containers, removes the database volume, deletes the worktree directory, and leaves you back on <code>develop</code>. From there you can sync your local branch with a quick <code>git pull origin develop</code> and delete the feature branch with <code>git branch -d feature/export-button</code>. The entire teardown takes about five seconds.</p>
<h2>"But what about database conflicts?"</h2>
<p>This was my biggest concern too. If session 1 adds column <code>pdf_path</code> and session 2 adds column <code>export_format</code> to the same table, what happens?</p>
<p>Nothing dramatic. Each session creates a migration file with a unique timestamp. When both branches merge:</p>
<ul>
<li>Both migration files coexist in <code>database/migrations/</code></li>
<li><code>php artisan migrate</code> runs them in timestamp order</li>
<li>Both columns get added</li>
</ul>
<p>The worktree databases are <strong>disposable scratch pads</strong>. You never merge database <em>data</em> — you merge migration <em>files</em>. The reconciliation happens in git, the same way it always does.</p>
<p>The only real conflict scenario: two branches modify the <em>exact same column</em>. That shows up as a git merge conflict in the PR, same as if you'd built the features sequentially.</p>
<h2>Resource cost</h2>
<p>Each parallel session runs its own MySQL, Redis, and app container. On my M-series Mac:</p>
<ul>
<li>~250MB RAM per session</li>
<li>~500MB disk per MySQL volume</li>
<li>Docker image layers are shared (cached), so no extra disk for the app image</li>
</ul>
<p>I comfortably run 2-3 sessions alongside my main repo. Beyond that, my laptop would probably start complaining.</p>
<h2>What I'd do differently</h2>
<p><strong>Cookie isolation should be the default.</strong> I wasted 20 minutes debugging mysterious logouts before realizing cookies were the issue. If you're building this, add <code>SESSION_COOKIE</code> to your <code>.env.example</code> from day one.</p>
<p><strong>Named volumes are your friend.</strong> Docker Compose auto-prefixes volume names with the project name (derived from the directory name). Each worktree directory has a unique name, so volumes are automatically isolated. I didn't have to configure anything — it just worked.</p>
<p><strong>Don't share the database.</strong> I briefly considered having all worktrees connect to the same MySQL instance (different databases). Bad idea. Different branches have different migrations. Isolation is the whole point.</p>
<p><strong>Don't race your own entrypoint.</strong> My first attempt added a <code>composer install</code> step to the setup script — but the container's entrypoint was already running <code>composer install</code> to populate the empty vendor volume. Two concurrent composer installs on the same directory corrupted zip downloads and crashed the container. The fix was to let the entrypoint do its job and have the script wait for Apache to respond before proceeding.</p>
<p><strong>Check your API key restrictions.</strong> If you use third-party APIs with domain or URL restrictions (Google Maps, Stripe, OAuth callbacks), they're probably locked to <code>localhost:8000</code>. Your worktree on <code>localhost:8001</code> will get silent failures — a blank map, a rejected OAuth redirect, a 403 from a payment form. I got a console error on Google Maps because my API key's "Website restrictions" only listed <code>:8000</code>. The fix was trivial (add <code>:8001/*</code> and <code>:8002/*</code> to the allowed list), but the debugging wasn't obvious. Add your worktree ports to every restricted API key upfront.</p>
<p><strong>Audit every <code>git add -A</code> in your tooling.</strong> If you symlink shared directories into worktrees (documentation, assets, anything gitignored), a blanket <code>git add -A</code> can pick them up in unexpected ways. I now require explicit file-by-file staging in worktree contexts. It's a minor inconvenience that eliminates a whole category of "why did that get committed?" moments.</p>
<h2>Teach your AI agent about worktrees</h2>
<p>This is the part nobody warns you about. Your Docker setup can be flawless, your ports perfectly isolated, your databases independently seeded — and your AI coding assistant will still trip over itself if it doesn't know it's running inside a worktree.</p>
<p>The first time I shipped code from a worktree session, the agent finished by cheerfully suggesting: "When you've merged, run <code>/ship-aclient develop</code> to sync up." That command switches to the <code>develop</code> branch — which is already checked out in the main repo. Git refuses to check out the same branch in two worktrees. The command fails immediately.</p>
<p>The fix was to teach the agent to detect its environment. If the working directory name contains <code>aclient-wt-</code>, the agent knows it's in a worktree session and adjusts its behavior accordingly. Instead of suggesting branch switches and local cleanup, it tells you to close the VS Code window and run the destroy command from the main repo. Same outcome, correct path to get there.</p>
<p>The second issue was subtler. I symlink the project's <code>docs/</code> directory into each worktree so the agent can reference documentation during implementation. The directory is gitignored, so it should be invisible to version control. But <code>git add -A</code> — the lazy, catch-all staging command that every AI agent defaults to — doesn't always play nicely with symlinks in worktree contexts. The solution was to forbid <code>git add -A</code> entirely when working in a worktree and require the agent to stage files by name. It's a small constraint that prevents a class of confusing errors.</p>
<p>If you're building a similar setup with an AI coding tool, the lesson is this: the infrastructure is the easy part. The hard part is making sure your agent's playbook — its prompt templates, command files, or system instructions — includes conditional logic for the worktree context. Anywhere your prompts say "switch to develop," "sync the branch," or "stage all changes," you need a worktree-aware variant. Otherwise your carefully isolated sessions will work perfectly right up until the moment the AI tries to clean up after itself.</p>
<h2>The result</h2>
<p>I went from working on one issue at a time to running 2-3 parallel AI sessions. While Claude is writing tests for feature A, I'm reviewing the implementation plan for feature B. While waiting for feature B's CI pipeline, I'm verifying feature C in the browser.</p>
<p>My throughput roughly doubled. Not because the AI got faster, but because <em>I</em> stopped being the bottleneck.</p>
<p>The entire setup is:</p>
<ul>
<li>One <code>docker-compose.yml</code> change (4 hardcoded ports to 4 env variables)</li>
<li>One <code>.env.example</code> update (document the new variables)</li>
<li>One bash script (~200 lines)</li>
<li>One line in <code>.gitignore</code> (<code>docker-compose.override.yml</code>)</li>
</ul>
<p>No external tools. No paid services. No complex orchestration. Just git, Docker, and a shell script.</p>
<hr />
<p><em>Follow-up: <a href="https://ubeyd.dev/laravel-parallel-worktrees-demo-seeder">The half I left out of my parallel worktrees post</a> — how I populated each worktree with realistic data, and the staging comparison that caught my demo seeder quietly lying to me.</em></p>
<p><em>The code snippets in this post are simplified from a real production setup. Your ports, service names, and seeder commands will differ. The principles are universal for any Dockerized web app.</em></p>
]]></content:encoded></item></channel></rss>