<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Syed Ahmer Shah</title>
    <description>The latest articles on DEV Community by Syed Ahmer Shah (@syedahmershah).</description>
    <link>https://dev.to/syedahmershah</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3021645%2F318ac063-caf0-4573-89af-095ce4da0f17.png</url>
      <title>DEV Community: Syed Ahmer Shah</title>
      <link>https://dev.to/syedahmershah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syedahmershah"/>
    <language>en</language>
    <item>
      <title>Inside the Database Stack</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:37:44 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/inside-the-database-stack-283g</link>
      <guid>https://dev.to/thesiliconarchitect/inside-the-database-stack-283g</guid>
      <description>&lt;p&gt;Think of a restaurant. Your Django code is the &lt;strong&gt;waiter&lt;/strong&gt;, PostgreSQL is the &lt;strong&gt;kitchen&lt;/strong&gt;, and the disk is the &lt;strong&gt;pantry&lt;/strong&gt;. When dinner is slow, people blame the waiter's handwriting (the ORM). Far more often the waiter is making 200 separate trips to the kitchen, or the cook is checking every shelf in the pantry because nobody labeled them.&lt;/p&gt;

&lt;p&gt;This article follows one order from the waiter's pad to the pantry shelf and back, and ends with what actually makes databases slow.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part I: The Waiter (Python &amp;amp; Django ORM)
&lt;/h3&gt;

&lt;p&gt;Django open-sourced its ORM in 2005. Its core idea is the &lt;strong&gt;QuerySet&lt;/strong&gt;, a &lt;em&gt;description&lt;/em&gt; of the data you want rather than the data itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# no SQL has run
&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total__gt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# still nothing
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="c1"&gt;# shows the SQL it *would* run
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;strong&gt;lazy evaluation&lt;/strong&gt;. The waiter writes the order on the pad but doesn't walk to the kitchen until you actually need food: when you loop over the results, call &lt;code&gt;list()&lt;/code&gt;, slice with a step, or ask for &lt;code&gt;len()&lt;/code&gt;. Each &lt;code&gt;.filter()&lt;/code&gt; returns a &lt;em&gt;new&lt;/em&gt; QuerySet, and Django compiles the final chain into a single SQL statement only at the last moment.&lt;/p&gt;

&lt;p&gt;That laziness is a benefit. You can build queries across functions without touching the database. It is also a trap, because a harmless-looking attribute access inside a loop can silently send the waiter running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ORM vs raw SQL.&lt;/strong&gt; The ORM buys you safety (parameterized queries that block SQL injection), portability, and readable code. It costs you some CPU: building the query objects, compiling SQL, and turning every row into a full Python model instance. For a few hundred rows, this overhead is invisible. For a hundred thousand rows, &lt;code&gt;.values()&lt;/code&gt;, &lt;code&gt;.only()&lt;/code&gt;, or &lt;code&gt;.iterator()&lt;/code&gt; can shrink it considerably.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part II: Too Many Trips (Query Performance)
&lt;/h3&gt;

&lt;p&gt;The classic failure is the &lt;strong&gt;N+1 query problem&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;          &lt;span class="c1"&gt;# 1 query
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# +1 query per order
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Show 500 orders and you've sent 501 queries. It is like a waiter walking to the kitchen 500 times for 500 glasses of water. Each trip is fast, but the &lt;em&gt;trips&lt;/em&gt; add up, especially when the database sits across a network.&lt;/p&gt;

&lt;p&gt;Django has two fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;select_related("customer")&lt;/code&gt;&lt;/strong&gt; performs a SQL &lt;code&gt;JOIN&lt;/code&gt; and fetches the related row in the same query. Use it for single-valued relations (foreign keys, one-to-one).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;prefetch_related("items")&lt;/code&gt;&lt;/strong&gt; runs a &lt;em&gt;second&lt;/em&gt; query and stitches the results together in Python. Use it for many-valued relations, where a join would duplicate parent rows.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two metrics matter here, and people often confuse them. &lt;strong&gt;Query count&lt;/strong&gt; is how many trips you made. &lt;strong&gt;Query latency&lt;/strong&gt; is how long each one took. A page with 400 fast queries and a page with 3 slow ones need &lt;em&gt;different&lt;/em&gt; cures. Tools like &lt;code&gt;django-debug-toolbar&lt;/code&gt; or &lt;code&gt;CaptureQueriesContext&lt;/code&gt; show both.&lt;/p&gt;

&lt;p&gt;Beyond N+1, the usual bottlenecks are fetching columns you never use, calling &lt;code&gt;len(qs)&lt;/code&gt; when &lt;code&gt;qs.count()&lt;/code&gt; or &lt;code&gt;qs.exists()&lt;/code&gt; would do, saving rows one at a time instead of &lt;code&gt;bulk_create()&lt;/code&gt;, and filtering in Python what the database could filter itself.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part III: The Pantry (Database Fundamentals)
&lt;/h3&gt;

&lt;p&gt;PostgreSQL is a &lt;strong&gt;process-per-connection&lt;/strong&gt; system. Each client gets its own backend process, and all of them share memory buffers and a write-ahead log (&lt;strong&gt;WAL&lt;/strong&gt;), which records changes before they hit the main files so a crash can't lose them.&lt;/p&gt;

&lt;p&gt;Rows live in a &lt;strong&gt;table&lt;/strong&gt; stored as fixed-size &lt;strong&gt;pages&lt;/strong&gt;, 8 KB each by default. Imagine a warehouse of unlabeled boxes. Finding "all orders from customer 42" means opening every box. That is a &lt;strong&gt;sequential scan&lt;/strong&gt;, and it is the default when nothing smarter is available.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;index&lt;/strong&gt; is the label system. The standard type is the &lt;strong&gt;B-tree&lt;/strong&gt;, a balanced, sorted tree dating back to 1970. Like a phone book, it lets Postgres jump to the right place in a handful of steps instead of reading everything.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;composite index&lt;/strong&gt; covers several columns, and &lt;em&gt;order matters&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_cust_date&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps queries filtering on &lt;code&gt;customer_id&lt;/code&gt;, or on &lt;code&gt;customer_id&lt;/code&gt; plus &lt;code&gt;created_at&lt;/code&gt;. It barely helps a query filtering on &lt;code&gt;created_at&lt;/code&gt; alone, just as a phone book sorted by surname is useless for finding everyone named "Ahmed."&lt;/p&gt;

&lt;p&gt;The key idea is &lt;strong&gt;selectivity&lt;/strong&gt;: how well a value narrows the search. An index on &lt;code&gt;customer_id&lt;/code&gt; (many distinct values) is sharp. An index on a boolean &lt;code&gt;is_active&lt;/code&gt; where 98% of rows are &lt;code&gt;true&lt;/code&gt; is nearly worthless, because the database would still read most of the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexes hurt, too.&lt;/strong&gt; Every insert, update, or delete must also update every index. They consume disk and memory, and they slow writes. An index is a trade, not a gift.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part IV: The Kitchen's Brain (Query Planning)
&lt;/h3&gt;

&lt;p&gt;When SQL arrives, PostgreSQL's &lt;strong&gt;optimizer&lt;/strong&gt; doesn't just run it. It considers many possible strategies, estimates the &lt;strong&gt;cost&lt;/strong&gt; of each using table statistics, and picks the cheapest. Think of a GPS comparing routes.&lt;/p&gt;

&lt;p&gt;You can ask to see the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows the &lt;em&gt;plan&lt;/em&gt; and the &lt;em&gt;estimated&lt;/em&gt; costs without running anything. Add &lt;code&gt;ANALYZE&lt;/code&gt; and Postgres actually executes the query and reports real times and row counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Careful:&lt;/strong&gt; &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; really runs the statement. For an &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt;, wrap it in a transaction and &lt;code&gt;ROLLBACK&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The plan names the access method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Sequential scan:&lt;/strong&gt; read the whole table. Fine for small tables, or when you need most rows anyway.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Index scan:&lt;/strong&gt; walk the index, then fetch each matching row. Great for a few rows.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Bitmap scan:&lt;/strong&gt; collect matching locations from the index, sort them, then read the pages in order. This is the middle path for "more than a few, fewer than most."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For joins, three algorithms compete: &lt;strong&gt;nested loop&lt;/strong&gt; (for each row on the left, look up the right, good for small inputs), &lt;strong&gt;hash join&lt;/strong&gt; (build a lookup table in memory, good for large unsorted inputs), and &lt;strong&gt;merge join&lt;/strong&gt; (zip two sorted lists together).&lt;/p&gt;

&lt;p&gt;The most useful habit is comparing the planner's &lt;em&gt;estimated&lt;/em&gt; rows with the &lt;em&gt;actual&lt;/em&gt; rows. A big gap usually means stale statistics, and &lt;code&gt;ANALYZE table_name;&lt;/code&gt; often fixes it. The optimizer is only as good as its guesses.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part V: Many Diners, One Kitchen (Transactions &amp;amp; Concurrency)
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;transaction&lt;/strong&gt; bundles steps into one all-or-nothing unit. Databases promise &lt;strong&gt;ACID&lt;/strong&gt;, a term formalized in 1983:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Atomicity:&lt;/strong&gt; all steps happen, or none do.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Consistency:&lt;/strong&gt; rules (constraints) always hold.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Isolation:&lt;/strong&gt; concurrent transactions don't trample each other.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Durability:&lt;/strong&gt; once committed, it survives a crash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL delivers isolation through &lt;strong&gt;MVCC&lt;/strong&gt; (multi-version concurrency control). Instead of making readers wait for writers, it keeps multiple versions of a row. Readers see a consistent snapshot while writers create new versions. Readers don't block writers, and writers don't block readers. The price is cleanup: old versions pile up until &lt;code&gt;VACUUM&lt;/code&gt; removes them.&lt;/p&gt;

&lt;p&gt;Postgres defaults to &lt;strong&gt;Read Committed&lt;/strong&gt; isolation, where each statement sees data committed before it began. &lt;strong&gt;Repeatable Read&lt;/strong&gt; and &lt;strong&gt;Serializable&lt;/strong&gt; are stricter, and Serializable can abort transactions with errors you must retry.&lt;/p&gt;

&lt;p&gt;Snapshots don't stop every conflict. Take a classic &lt;strong&gt;race condition&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;      &lt;span class="c1"&gt;# two requests both read 100, both write 50
&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;             &lt;span class="c1"&gt;# one withdrawal vanishes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to lock the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_for_update&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
    &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select_for_update()&lt;/code&gt; takes a &lt;strong&gt;row lock&lt;/strong&gt;, so the second request waits its turn. For simple arithmetic, an &lt;code&gt;F("balance") - 50&lt;/code&gt; expression pushes the math into the database itself and is even safer.&lt;/p&gt;

&lt;p&gt;Locks bring the risk of &lt;strong&gt;deadlocks&lt;/strong&gt;: transaction A holds row 1 and wants row 2, while B holds row 2 and wants row 1. It is two people in a narrow hallway, each waiting for the other to step aside. Postgres detects this (after &lt;code&gt;deadlock_timeout&lt;/code&gt;, 1 second by default) and kills one transaction. The cure is to &lt;strong&gt;lock rows in a consistent order&lt;/strong&gt; everywhere.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part VI: How to Measure Honestly
&lt;/h3&gt;

&lt;p&gt;I'm deliberately not printing benchmark numbers here. Figures from my machine would be meaningless on yours, and invented ones would be worse. What matters is the method:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Fix the environment.&lt;/strong&gt; Same hardware, same Postgres version, same config, warm cache &lt;em&gt;and&lt;/em&gt; cold cache runs.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Scale the dataset.&lt;/strong&gt; Test at 1K, 100K, and 10M rows. Many problems only appear at scale, and a missing index looks harmless on a small table.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Measure several things:&lt;/strong&gt; latency (report p50 and p95, not just the average), throughput (queries per second under concurrency), memory (&lt;code&gt;tracemalloc&lt;/code&gt;), and &lt;strong&gt;query count&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Compare like with like:&lt;/strong&gt; ORM vs equivalent hand-written SQL, and indexed vs unindexed, changing &lt;em&gt;one variable at a time&lt;/em&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.test.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CaptureQueriesContext&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;CaptureQueriesContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queries,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it many times, discard the first (cold) run, and keep the distribution.&lt;/p&gt;




&lt;h3&gt;
  
  
  Part VII: Findings
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What actually causes slowness.&lt;/strong&gt; In most web applications, the usual suspects are, in rough order of how often they appear: too many queries (N+1), missing or unusable indexes on filtered columns, fetching far more rows or columns than needed, and long-held locks. Rarely is the culprit that Python is "too slow" at building SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the ORM is expensive.&lt;/strong&gt; Materializing huge result sets into model instances, hidden per-row queries, and ORM-generated queries nobody has ever inspected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where ORM optimizations matter.&lt;/strong&gt; &lt;code&gt;select_related&lt;/code&gt;, &lt;code&gt;prefetch_related&lt;/code&gt;, &lt;code&gt;only()&lt;/code&gt;/&lt;code&gt;defer()&lt;/code&gt;, &lt;code&gt;values()&lt;/code&gt;, &lt;code&gt;bulk_create()&lt;/code&gt;, &lt;code&gt;exists()&lt;/code&gt; and &lt;code&gt;iterator()&lt;/code&gt;. These change the &lt;em&gt;shape&lt;/em&gt; of the work, and that is where the big wins live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where indexes matter.&lt;/strong&gt; On columns used in &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt;, and &lt;code&gt;ORDER BY&lt;/code&gt;, in large tables, with high selectivity. Always confirm with &lt;code&gt;EXPLAIN&lt;/code&gt; that the index is actually used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When raw SQL is justified.&lt;/strong&gt; Complex reporting, window functions or CTEs the ORM expresses awkwardly, or a measured hot path where the ORM's generated query is demonstrably poor. Use parameterized queries, never string formatting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-offs.&lt;/strong&gt; Readability and safety against fine control. Fast reads against slower writes (indexes). Strict isolation against throughput.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myths worth retiring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;em&gt;"The ORM is slow."&lt;/em&gt; Usually the &lt;em&gt;queries&lt;/em&gt; are slow, not the ORM.&lt;/li&gt;
&lt;li&gt;  &lt;em&gt;"Raw SQL is always faster."&lt;/em&gt; Not if it has the same plan and the same number of round trips.&lt;/li&gt;
&lt;li&gt;  &lt;em&gt;"More indexes, more speed."&lt;/em&gt; Each one taxes every write.&lt;/li&gt;
&lt;li&gt;  &lt;em&gt;"An index will always be used."&lt;/em&gt; The planner may rightly ignore it.&lt;/li&gt;
&lt;li&gt;  &lt;em&gt;"&lt;code&gt;select_related&lt;/code&gt; everywhere."&lt;/em&gt; Joining tables you never read just makes the kitchen work harder.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one-sentence takeaway:&lt;/strong&gt; measure first, count your queries, read your plans, and only then optimize.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt; &lt;a href="https://docs.djangoproject.com/en/stable/topics/db/optimization/" rel="noopener noreferrer"&gt;Django Docs: Database access optimization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://docs.djangoproject.com/en/stable/ref/models/querysets/#select-for-update" rel="noopener noreferrer"&gt;Django Docs: &lt;code&gt;select_for_update()&lt;/code&gt; and QuerySet API&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.postgresql.org/docs/current/using-explain.html" rel="noopener noreferrer"&gt;PostgreSQL Docs: Using EXPLAIN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.postgresql.org/docs/current/indexes-types.html" rel="noopener noreferrer"&gt;PostgreSQL Docs: Index Types (B-tree)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://www.postgresql.org/docs/current/mvcc-intro.html" rel="noopener noreferrer"&gt;PostgreSQL Docs: Concurrency Control and MVCC&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>programming</category>
      <category>python</category>
      <category>sql</category>
      <category>coding</category>
    </item>
    <item>
      <title>RAG Retrieval Strategies on a 50k-Document Dataset: BM25 vs Dense vs Hybrid</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:09:12 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/rag-retrieval-strategies-on-a-50k-document-dataset-bm25-vs-dense-vs-hybrid-ejj</link>
      <guid>https://dev.to/thesiliconarchitect/rag-retrieval-strategies-on-a-50k-document-dataset-bm25-vs-dense-vs-hybrid-ejj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On 50k documents, retrieval quality matters more than your choice of vector database. At this size you can run exact search and skip the infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BM25&lt;/strong&gt; is great at exact terms (error codes, names, IDs) and weak at paraphrases. &lt;strong&gt;Dense&lt;/strong&gt; retrieval is the reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid&lt;/strong&gt; (BM25 + dense, merged with Reciprocal Rank Fusion) is the best default. It's cheap to build and covers both failure modes.&lt;/li&gt;
&lt;li&gt;Add a reranker only after you've measured that hybrid isn't enough. Build a small eval set first, because without one you're guessing.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup nobody tells you about
&lt;/h2&gt;

&lt;p&gt;Say you've built a RAG app over 50,000 documents: support tickets, internal docs, maybe PDFs. The demo worked. Then a real user asked a real question, and the model confidently answered from the wrong paragraph.&lt;/p&gt;

&lt;p&gt;Nine times out of ten, the LLM isn't the problem. &lt;strong&gt;Retrieval is.&lt;/strong&gt; If the right chunk never reaches the prompt, no model can save you.&lt;/p&gt;

&lt;p&gt;One clarification before we start. "50k documents" usually means far more &lt;em&gt;chunks&lt;/em&gt;. If each document splits into about 10 chunks, you're searching roughly 500k pieces of text. That number matters for memory and latency, so keep it in mind below.&lt;/p&gt;

&lt;p&gt;I haven't run a fresh benchmark on your data, and I won't pretend to. This article uses published research and documented numbers to show what each approach does, where it breaks, and how to decide. If you want the background on what embeddings actually are, my post on &lt;a href="https://ahmershah.dev/blogs/syedahmershah-the-anatomy-of-ai-deconstructing-the-brain-into-vectors-and-math" rel="noopener noreferrer"&gt;the anatomy of AI&lt;/a&gt; covers vectors from the ground up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three contenders at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Dense&lt;/th&gt;
&lt;th&gt;Hybrid (RRF)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Matches on&lt;/td&gt;
&lt;td&gt;Exact words&lt;/td&gt;
&lt;td&gt;Meaning&lt;/td&gt;
&lt;td&gt;Both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong at&lt;/td&gt;
&lt;td&gt;IDs, codes, names, jargon&lt;/td&gt;
&lt;td&gt;Paraphrases, natural questions&lt;/td&gt;
&lt;td&gt;Mixed real-world queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak at&lt;/td&gt;
&lt;td&gt;Synonyms, vocabulary mismatch&lt;/td&gt;
&lt;td&gt;Rare tokens, unseen domains&lt;/td&gt;
&lt;td&gt;Slightly more moving parts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs a model&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (embeddings)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build effort&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;td&gt;Low, once the other two exist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  BM25: the librarian with perfect index cards
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BM25&lt;/strong&gt; is a keyword-ranking function from the 1990s. It scores a chunk higher when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;it contains your query words (term frequency),&lt;/li&gt;
&lt;li&gt;those words are rare across the corpus (inverse document frequency), and&lt;/li&gt;
&lt;li&gt;the chunk isn't just long and rambling (length normalization).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; imagine a librarian who never reads the books. She only keeps meticulous index cards of which words appear where. Ask for "ERR-4012" and she hands you the exact page in seconds. Ask "why does my app crash on startup" and she's stuck unless the page literally says "crash" and "startup."&lt;/p&gt;

&lt;p&gt;Here's a minimal version using &lt;code&gt;rank_bm25&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rank_bm25&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BM25Okapi&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="c1"&gt;# keep dots, dashes and underscores *inside* a token (ERR-4012, user_id,
&lt;/span&gt;    &lt;span class="c1"&gt;# v2.4.1) but not at its edges, so "startup." still matches "startup"
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[a-z0-9]+(?:[._\-][a-z0-9]+)*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;tokenized_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bm25&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BM25Okapi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenized_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_scores&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;()[::&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest notes. First, &lt;code&gt;rank_bm25&lt;/code&gt; is fine for prototypes, but it scores every document per query. For production, use something with an inverted index like Elasticsearch, OpenSearch, Tantivy, or Postgres full-text search. Second, your &lt;em&gt;tokenizer&lt;/em&gt; is half the battle. The regex above keeps &lt;code&gt;ERR-4012&lt;/code&gt; as one token on purpose. A default tokenizer that splits on punctuation can quietly wreck exact-match queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where BM25 wins:&lt;/strong&gt; product codes, function names, legal clause numbers, rare jargon, anything where the user types the exact words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it loses:&lt;/strong&gt; vocabulary mismatch. The user says "refund," the doc says "reimbursement." BM25 sees two unrelated strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dense retrieval: the friend who gets what you mean
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dense retrieval&lt;/strong&gt; turns text into vectors (embeddings) using a neural model. Chunks with similar &lt;em&gt;meaning&lt;/em&gt; land close together in vector space, and you search by finding the nearest neighbors to your query vector.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; instead of index cards, you have a well-read friend. You describe a vague idea ("that thing where the server keeps retrying and makes it worse") and they say "oh, you want the doc on retry storms." They understand meaning, but they might blank on the exact serial number you asked for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-small-en-v1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 50k chunks x 384 float32 is about 77 MB. 500k chunks is about 770 MB.
&lt;/span&gt;&lt;span class="n"&gt;doc_vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dense_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc_vecs&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;          &lt;span class="c1"&gt;# cosine similarity (vectors are normalized)
&lt;/span&gt;    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's missing: no vector database, no ANN index. That's deliberate.&lt;/p&gt;

&lt;h3&gt;
  
  
  You probably don't need an ANN index yet
&lt;/h3&gt;

&lt;p&gt;Approximate nearest neighbor indexes like HNSW trade a bit of recall for speed. At small scale, you may be paying that trade for nothing. pgvector, for example, does exact nearest neighbor search by default, which gives perfect recall. Plenty of teams run tens of thousands of vectors on a plain sequential scan, and skipping the index avoids build time, maintenance cost, and the recall loss of approximate search.&lt;/p&gt;

&lt;p&gt;If your 50k documents become 500k chunks, revisit that, but measure first. A brute-force matrix multiply over a few hundred thousand vectors is still quick on a decent machine; it's the memory (see the comment in the code above) you'll feel first. When you do need HNSW, here's the standard pgvector shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- higher ef_search = better recall, slower queries&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always compare ANN results against exact results on your own queries before trusting the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where dense wins:&lt;/strong&gt; paraphrases, natural-language questions, cross-vocabulary matches, multilingual queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it loses:&lt;/strong&gt; exact identifiers, rare tokens, and anything where the embedding model has never seen your domain's vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the research says (and what it doesn't)
&lt;/h2&gt;

&lt;p&gt;The most cited evidence here is &lt;strong&gt;BEIR&lt;/strong&gt;, a benchmark covering zero-shot retrieval across a wide range of settings. Its headline finding surprised people at the time. Dense models beat BM25 by a clear margin on MS MARCO, the data they were trained on, yet BM25 held up as a robust baseline almost everywhere else. The authors found dense models do well when training and target data overlap heavily, but fall short on datasets with large domain shift.&lt;/p&gt;

&lt;p&gt;That's the key insight for your 50k-document corpus. Your data is probably &lt;em&gt;not&lt;/em&gt; MS MARCO. It has your company's acronyms, your product names, your weird internal vocabulary. A dense model trained on generic web text is, in effect, out-of-domain. That's exactly where BM25 refuses to embarrass itself.&lt;/p&gt;

&lt;p&gt;Two honest caveats:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BEIR is from 2021.&lt;/strong&gt; Embedding models have improved a lot since. Don't read it as "BM25 beats dense." Read it as "dense models can fail unpredictably on unfamiliar domains, and lexical search is a safety net."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark averages hide your queries.&lt;/strong&gt; A model can win on average and still lose on the 20% of queries your users care about most.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;BEIR also showed the cost side. BM25 answers in milliseconds on a CPU, while a cross-encoder reranker on top of it was orders of magnitude slower per query in the paper's setup. Reranking helps, but it isn't free, and we'll come back to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid: two witnesses are better than one
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; picture two witnesses to a crime. One remembers exact details (the license plate), and the other remembers the overall impression (a dark sedan, driving erratically). Either alone can mislead you. Together, they converge on the right car.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;hybrid retrieval&lt;/strong&gt;: run BM25 and dense search in parallel, then merge the two ranked lists.&lt;/p&gt;

&lt;p&gt;The tricky part is the merge. BM25 scores and cosine similarities live on completely different scales, so you can't just add them. You &lt;em&gt;can&lt;/em&gt; normalize and weight them, but the weights drift as your data changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reciprocal Rank Fusion (RRF)
&lt;/h3&gt;

&lt;p&gt;RRF sidesteps the scale problem by ignoring scores entirely and using only &lt;strong&gt;rank positions&lt;/strong&gt;. It comes from a 2009 SIGIR paper by Cormack, Clarke, and Büttcher. The formula is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF(d) = Σ over rankers  1 / (k + rank(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with k = 60 as the usual default. The paper found that value near-optimal in a pilot study, and it has stuck around ever since. Because RRF is score-independent, using only ranks, one retriever with weird score ranges can't dominate the result.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ranking&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;bm25_ids&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;dense_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;dense_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;bm25_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_ids&lt;/span&gt;&lt;span class="p"&gt;])[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; RRF is like a talent show with two judges, where each judge only gives rankings, never scores. A contestant who is 2nd on both lists beats one who is 1st on one list and 40th on the other. &lt;strong&gt;Consensus beats a single loud opinion.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two practical tips: pull a generous candidate pool (50 or so) from each retriever before fusing, and treat &lt;code&gt;k = 60&lt;/code&gt; as a starting point rather than a law.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real-world data point on hybrid
&lt;/h2&gt;

&lt;p&gt;Anthropic published numbers on this in their &lt;em&gt;Contextual Retrieval&lt;/em&gt; write-up. The headline result: combining contextual embeddings with contextual BM25 cut the top-20-chunk retrieval failure rate by 49%, from 5.7% to 2.9%. They also explain &lt;em&gt;why&lt;/em&gt; BM25 earns its place: embedding models can miss exact-match queries like unique identifiers, which BM25 handles well.&lt;/p&gt;

&lt;p&gt;A fair caution comes from critics of that post. The best number, a 67% reduction, comes from stacking &lt;em&gt;several&lt;/em&gt; techniques, including reranking, not from any single trick. One analysis pointed out that the headline figures measure only top-k retrieval failure rate, which may not match what &lt;em&gt;you&lt;/em&gt; care about. So don't copy their pipeline wholesale. Take the lesson: &lt;strong&gt;hybrid beats either alone, and each added layer needs its own measurement.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually decide: build a tiny eval
&lt;/h2&gt;

&lt;p&gt;This is the part most tutorials skip, and it's the part that matters. Before you pick a strategy, write down 50 to 100 real questions with the chunk IDs that &lt;em&gt;should&lt;/em&gt; answer them. Then measure &lt;strong&gt;recall@k&lt;/strong&gt;: what fraction of the time the right chunk shows up in the top k results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eval_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relevant_ids&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;eval_set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;retrieved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;search_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
        &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retrieved&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relevant_ids&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eval_set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bm25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_search&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hybrid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eval_set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull your eval questions from real user logs if you have them, and include the ugly ones: typos, error codes, vague questions. Hand-label them yourself. It's boring, and it's the highest-leverage hour you'll spend on this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at the failures, not just the number.&lt;/strong&gt; If BM25 misses are paraphrase problems and dense misses are ID problems, you've just proven hybrid is justified for &lt;em&gt;your&lt;/em&gt; data, not someone else's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do on a 50k-document corpus
&lt;/h2&gt;

&lt;p&gt;If I were handed this project tomorrow, here's the order I'd work in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with BM25 alone.&lt;/strong&gt; It's fast to build and a strong baseline. If it already hits your recall target, you may be done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add dense retrieval with a decent open embedding model&lt;/strong&gt;, brute-force, no vector DB. Compare the two on your eval set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuse with RRF.&lt;/strong&gt; In most real corpora with mixed vocabulary, this is where recall jumps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix chunking before adding fancier models.&lt;/strong&gt; Chunks that lose their context (like a paragraph saying "it increased 12%" with no idea what "it" is) hurt &lt;em&gt;both&lt;/em&gt; retrievers. This is the problem Anthropic's contextual approach targets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a reranker last&lt;/strong&gt;, and only if hybrid recall@50 is good but precision@5 is poor. That pattern means the right chunk is in the pool but ranked too low, which is exactly the reranker's job. Budget for the latency, since reranking can cost far more per query than the retrieval itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for HNSW only when measurements say so&lt;/strong&gt;, such as high query volume or a corpus that grew far past your current size.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common mistakes I see
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skipping evaluation&lt;/strong&gt; and trusting vibes from five hand-picked demo queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizing BM25 carelessly&lt;/strong&gt;, so identifiers and code get shredded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalizing and summing raw scores&lt;/strong&gt; from different retrievers instead of fusing ranks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-engineering infrastructure&lt;/strong&gt; (a managed vector DB, three services) for a dataset whose vectors fit in under a gigabyte of RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blaming the LLM&lt;/strong&gt; for answers that were doomed by what retrieval handed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring metadata filters.&lt;/strong&gt; If users usually ask about one product or one date range, filtering before search often beats any clever ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;There's no universally best retriever. BM25 is the reliable, literal-minded librarian. Dense retrieval is the intuitive friend who sometimes gets overconfident. Hybrid puts them in the same room and lets them check each other's work.&lt;/p&gt;

&lt;p&gt;At 50k documents, you have the luxury of simplicity: brute-force vectors, a plain keyword index, and fusion code you can read in 15 seconds. Spend your effort on a good eval set and good chunking. Those two things will move your answer quality more than any model swap.&lt;/p&gt;

&lt;p&gt;The snippets above are illustrative, so test them on your own data before relying on them.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Thakur et al. (2021). &lt;a href="https://arxiv.org/pdf/2104.08663" rel="noopener noreferrer"&gt;BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models&lt;/a&gt;. NeurIPS Datasets and Benchmarks.&lt;/li&gt;
&lt;li&gt;Cormack, Clarke, Büttcher (2009). &lt;a href="https://dl.acm.org/doi/10.1145/1571941.1572114" rel="noopener noreferrer"&gt;Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods&lt;/a&gt;. SIGIR '09.&lt;/li&gt;
&lt;li&gt;Anthropic. &lt;a href="https://www.anthropic.com/engineering/contextual-retrieval" rel="noopener noreferrer"&gt;Introducing Contextual Retrieval&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Almond AI. &lt;a href="https://medium.com/almond-ai/responding-to-anthropics-contextual-retrieval-for-rag-apps-why-context-is-not-all-you-need-af503985aa55" rel="noopener noreferrer"&gt;Responding to Anthropic's Contextual Retrieval: Why Context is NOT All You Need&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;pgvector. &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector on GitHub&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>One Seat, Two Buyers: Race Conditions, IDOR and Keys in a Cinema Booking System</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:08:35 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/one-seat-two-buyers-race-conditions-idor-and-keys-in-a-cinema-booking-system-9db</link>
      <guid>https://dev.to/thesiliconarchitect/one-seat-two-buyers-race-conditions-idor-and-keys-in-a-cinema-booking-system-9db</guid>
      <description>&lt;p&gt;Seat G7 at the 9 PM show. Two people have it open on their phones. Both tap it, both see it turn yellow, both press Confirm within the same half-second.&lt;/p&gt;

&lt;p&gt;Exactly one of them should get a ticket. The other should see "that seat was just taken, here are the two next to it". Not an error page, not a refund e-mail tomorrow, not two people in the lobby holding the same seat.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://ahmershah.dev/blogs/bookmymovie-case-study-cinema-ticketing-platform" rel="noopener noreferrer"&gt;BookMyMovie&lt;/a&gt;, a cinema booking platform, on my own with Laravel, React and MySQL. This is how it handles the moments where people collide, what it does about the quieter security problems (guessable IDs, secret keys, bots), and the bugs that still got through.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "check, then insert" is wrong
&lt;/h2&gt;

&lt;p&gt;The obvious code looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks fine. Isn't.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Seat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$showId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$seatId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BookingSeat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'show_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$showId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'seat_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$seatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requests run the &lt;code&gt;if&lt;/code&gt; at the same moment. Both see "available", because neither has inserted anything yet. Both insert. You've sold the seat twice, and no test that runs one request at a time will ever notice.&lt;/p&gt;

&lt;p&gt;Every fix below is a different way of making "check" and "act" one indivisible step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: row locks, always in the same order
&lt;/h2&gt;

&lt;p&gt;Holding seats and checking out both run inside a transaction that starts by locking rows with &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$seatIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. The customer: serialises this person's double clicks and second tab.&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. The show: everyone buying seats for this show now queues here.&lt;/span&gt;
    &lt;span class="nv"&gt;$show&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Show&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'show_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. The seats themselves, then 4. the cart.&lt;/span&gt;
    &lt;span class="nc"&gt;Seat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$seatIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Only now: is every seat still available?&lt;/span&gt;
    &lt;span class="c1"&gt;// ...insert the holds...&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details carry most of the weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The order never changes.&lt;/strong&gt; Adding seats and checking out, the two paths that race for the same seats, take user, then show, then seats, then cart. The shorter paths take fewer locks but never reverse that order: removing a seat locks only the cart, and cancelling locks the booking and then the show, which nothing locks the other way round. If one request locked the show and then the cart while another locked the cart and then the show, each could end up holding the lock the other needs, and MySQL would have to kill one. With one order everywhere, the second request just waits its turn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;3&lt;/code&gt; at the end&lt;/strong&gt; retries the whole transaction up to three times if MySQL still reports a deadlock (it can, under odd index-gap locking). The customer never sees it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The snapshot trap I checked for
&lt;/h3&gt;

&lt;p&gt;MySQL's default isolation level, REPEATABLE READ, has a trap I wrote about in &lt;a href="https://ahmershah.dev/blogs/the-last-mango-problem-race-conditions-in-a-real-marketplace" rel="noopener noreferrer"&gt;The Last Mango Problem&lt;/a&gt;: a plain &lt;code&gt;SELECT&lt;/code&gt; inside a transaction reads from a snapshot taken at the transaction's first plain read, not from the latest data. If that snapshot is taken &lt;em&gt;before&lt;/em&gt; you wait for a lock, you can wait patiently for the other buyer to finish and then read a picture of the world from before they bought the seat.&lt;/p&gt;

&lt;p&gt;In BookMyMovie the availability check reads a view, &lt;code&gt;v_seat_availability&lt;/code&gt;, with a plain &lt;code&gt;SELECT&lt;/code&gt;. It's safe here only because of when the snapshot is taken. The locking reads at the top (&lt;code&gt;FOR UPDATE&lt;/code&gt;) don't create a snapshot. The first plain read happens &lt;em&gt;after&lt;/em&gt; the show lock is held. And every transaction that sells a seat for this show must hold that same show lock, so by the time we read, any competing sale has either committed (and is in our snapshot) or hasn't started. Move one innocent-looking &lt;code&gt;exists()&lt;/code&gt; query above the show lock and that guarantee is gone. That is exactly why the next layer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: a unique key that tolerates NULL
&lt;/h2&gt;

&lt;p&gt;Locks are code, and code has bugs. So the database has the final word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;booking_seats_live_seat_unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;show_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seat_lock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;seat_lock&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; while a ticket is live and &lt;code&gt;NULL&lt;/code&gt; once the booking is cancelled. In MySQL a unique index allows any number of rows that contain &lt;code&gt;NULL&lt;/code&gt;. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a cancelled ticket (&lt;code&gt;seat_lock = NULL&lt;/code&gt;) doesn't block the seat being sold again;&lt;/li&gt;
&lt;li&gt;a second &lt;strong&gt;live&lt;/strong&gt; ticket for the same seat at the same show is impossible, whatever the PHP does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have a test that skips every check in the application and inserts the duplicate row directly. MySQL throws, and the test passes only when it does. The cart has the same kind of guard, &lt;code&gt;UNIQUE (seat_id, show_id)&lt;/code&gt; on &lt;code&gt;cart_items&lt;/code&gt;, so two carts can't hold one seat either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbi8ysqzqogg5nhv7zpm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbi8ysqzqogg5nhv7zpm.webp" alt="The booking core of the data model: shows, seats, carts and cart items, bookings and booking seats. The unique keys on cart items, booking seats and bookings are the database's last word on who owns a seat." width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: idempotency for the double tap
&lt;/h2&gt;

&lt;p&gt;Networks retry. People double-tap. A slow checkout gets a second press of Confirm. Each checkout form carries a UUID generated when the page loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idempotency_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'idempotency_key'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.booking.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;booking_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bookings.idempotency_key&lt;/code&gt; is unique, so even two submits that both pass this check at the same instant can't both insert: the second fails on the key and the customer lands on the booking the first one made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: status locks, so nothing is refunded twice
&lt;/h2&gt;

&lt;p&gt;The cancellation path had a hole I found while writing tests for it. A customer cancels from the app while an admin refunds the same booking from the back office. Both callers locked the booking and checked "is it still confirmed?", but that check lived in each caller, not in the code that does the unwinding. Any future caller that forgot it (a scheduled job, a new admin button) could run the unwind a second time: seats released twice, the coupon returned twice, the gift card refilled twice.&lt;/p&gt;

&lt;p&gt;So the booking's status now moves through a compare-and-set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STATUS_TRANSITIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'confirmed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no_show'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'completed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="s1"&gt;'no_show'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;claimStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;booking_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;canMoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StaleStatusException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Cannot move from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getKey&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'booking_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// only if nobody changed it&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'booking_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$claimed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StaleStatusException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Changed while it was being updated.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm20xjmmdmy2qgoe6kl7q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm20xjmmdmy2qgoe6kl7q.webp" alt="The back office's bookings and refunds list. The Cancel button on each confirmed booking runs through the same status lock as a customer's cancellation." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BookingLifecycle::unwind()&lt;/code&gt; calls &lt;code&gt;claimStatus('cancelled', …)&lt;/code&gt; &lt;em&gt;first&lt;/em&gt;, before it touches a single seat or balance. If the row isn't &lt;code&gt;confirmed&lt;/code&gt; any more, the &lt;code&gt;UPDATE&lt;/code&gt; matches nothing, the exception rolls the whole transaction back, and nothing is refunded. The test for it cancels a booking, then tries again with a stale copy of the model that still says "confirmed". It must be refused, and the show's sold count must not move.&lt;/p&gt;

&lt;p&gt;The same guarded-update idea appears in smaller places: "tell the watchlist this film is open for booking" is claimed with &lt;code&gt;UPDATE … WHERE booking_opened_notified_at IS NULL&lt;/code&gt;, so it happens exactly once even if two admins save the film at the same moment, and the show's sold count is only lowered &lt;code&gt;WHERE booked_seats &amp;gt;= n&lt;/code&gt;, so it can never go negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: locks that live outside the database
&lt;/h2&gt;

&lt;p&gt;When seats come back (a cancellation, or a 10-minute hold that ran out), a job tells that show's waitlist, first come, first served. The scheduler sweeps every minute and a cancellation dispatches the job straight away, so the same show's job was often queued twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessShowWaitlist&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldBeUnique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$uniqueFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;uniqueId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;showId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WithoutOverlapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'show-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;showId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;releaseAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expireAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ShouldBeUnique&lt;/code&gt; drops a second dispatch while one is waiting; &lt;code&gt;WithoutOverlapping&lt;/code&gt; stops two workers running the same show at once. Both use atomic cache locks, not database rows. Inside the job, the waitlist rows are still locked with &lt;code&gt;FOR UPDATE&lt;/code&gt; and each person is marked notified in the same transaction, so nobody gets two "seats just opened" pushes.&lt;/p&gt;

&lt;p&gt;The home page's cached rails use the same idea against a different problem. With &lt;code&gt;Cache::remember&lt;/code&gt;, the moment the cache expires every visitor rebuilds it at once (a cache stampede). &lt;code&gt;Cache::flexible&lt;/code&gt; keeps serving the stale copy while exactly one request, holding a lock, rebuilds it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81bkb5bknntz1ny2bj2f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81bkb5bknntz1ny2bj2f.webp" alt="The booking lifecycle: available, held for 10 minutes, confirmed, then completed, no-show or cancelled. Only cancelling puts the seat back on sale." width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  IDOR: the booking number is not a password
&lt;/h2&gt;

&lt;p&gt;Insecure Direct Object Reference is the bug where changing an ID in the URL shows you someone else's data. Booking numbers look like &lt;code&gt;BM-2026-7KQ2X9TD&lt;/code&gt;. They're random, but "hard to guess" is not access control, because booking numbers get screenshotted, forwarded and printed.&lt;/p&gt;

&lt;p&gt;So every customer-facing lookup is scoped to the person asking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;userBooking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Booking&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;// the whole point&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'booking_number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                 &lt;span class="c1"&gt;// someone else's booking: 404, not 403&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns 404 rather than 403 on purpose: a 403 would confirm the booking exists. A test signs in as a second customer and tries to view and cancel the first customer's booking; both must 404.&lt;/p&gt;

&lt;p&gt;The places that &lt;em&gt;have&lt;/em&gt; to work without an account get a different treatment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;How it's protected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Split-the-bill share links&lt;/td&gt;
&lt;td&gt;A 40-character random token per share, unique in the database. Knowing the booking number gets you nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticket QR codes&lt;/td&gt;
&lt;td&gt;The booking number plus an HMAC-SHA256 signature over it, keyed with the app key. The box office recomputes it and compares with &lt;code&gt;hash_equals()&lt;/code&gt;, so an edited booking number fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gift card balance check&lt;/td&gt;
&lt;td&gt;The code is random (&lt;code&gt;BMM-XXXX-XXXX&lt;/code&gt;), and the endpoint is limited to 10 checks a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin member pages&lt;/td&gt;
&lt;td&gt;Numeric IDs, but only reachable by signed-in staff, and every admin route maps to a role; routes missing from the map require the highest one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjrev20oo7ja0xj5m2oc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjrev20oo7ja0xj5m2oc.webp" alt="Staff and roles: owner, manager and box office, each explained in one line, with every person's role and two-step sign-in status." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That last point is the rule I'd pass on: &lt;strong&gt;authorisation should fail closed.&lt;/strong&gt; In BookMyMovie's admin, a new route that nobody remembered to classify needs the highest role, instead of being open to every staff account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys: what breaks when a secret moves
&lt;/h2&gt;

&lt;p&gt;Most security incidents in small apps aren't clever. They're a key in the wrong place or a key that changed. Things I learned the hard way in this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;APP_KEY&lt;/code&gt; does more than encrypt sessions.&lt;/strong&gt; It signs every ticket QR code and encrypts staff two-step secrets. Rotating it invalidates every ticket already issued and locks every staff member out of 2FA. That's written down in the security policy now, not just in my head.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Truncating a signature is a trade-off.&lt;/strong&gt; The ticket signature is the first 16 hex characters (64 bits) of the HMAC, to keep the QR code small enough to scan quickly in a dark lobby. That's plenty against guessing, because checking a signature needs a staff session, but I'd rather say it out loud than pretend 64 bits is 256.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys belong in &lt;code&gt;.env&lt;/code&gt; only.&lt;/strong&gt; reCAPTCHA, JazzCash, Easypaisa, wallet certificates and the web-push VAPID pair are read from configuration and nowhere else. On XAMPP for Windows, generating VAPID keys failed until OpenSSL was pointed at its config file; the command that generates them now sets that itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing is a leak too.&lt;/strong&gt; When an admin e-mail doesn't exist, the login still hashes a throwaway password, so "unknown e-mail" and "wrong password" take the same time and don't reveal which staff accounts exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When adding keys added a bug
&lt;/h3&gt;

&lt;p&gt;The most embarrassing key problem was the opposite of a leak. With no reCAPTCHA keys, local development used Google's public test keys and everything looked fine. When the real v2 and v3 keys went in, every form showed the v2 checkbox &lt;em&gt;and&lt;/em&gt; v3's floating badge, and the server demanded both tokens. Two captchas on a login form is a great way to lose customers.&lt;/p&gt;

&lt;p&gt;The redesign: when a v3 key exists, v3 runs invisibly on every submit and the checkbox is hidden. If v3's score is too low, or its token didn't load, the server remembers that for this form in the session and fails with "one more check". The next render shows the checkbox, and that submit is judged by the checkbox alone. One Google check at a time, and the stricter one only for traffic that earned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still got through
&lt;/h2&gt;

&lt;p&gt;Everything above is about data. The worst bug I shipped in this project had nothing to do with data.&lt;/p&gt;

&lt;p&gt;The seat map page and the lazy-loaded 3D model of the hall both imported one small geometry file. In a production build, Rollup merged that file into the seat map's own chunk, which made the page a "shared chunk" with no entry of its own in Vite's manifest. Laravel looks pages up in that manifest by name, so every visit to the seat map (the page every booking goes through) returned a 500. The development server builds nothing ahead of time, so it never showed up there. All the locks in this article were protecting a page nobody could open.&lt;/p&gt;

&lt;p&gt;I found it while taking screenshots for these articles, fixed it by giving the shared file its own chunk, and added a test that fails if any page is missing from the manifest. The general lesson: &lt;strong&gt;your concurrency tests are only as good as your deployment tests.&lt;/strong&gt; A race-proof checkout behind a broken build is still a broken checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I'd use again
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put every "check, then act" on money, seats or stock inside one transaction with &lt;code&gt;FOR UPDATE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Take locks in one documented order everywhere, and retry deadlocks.&lt;/li&gt;
&lt;li&gt;Make the database refuse the impossible state (&lt;code&gt;UNIQUE&lt;/code&gt; with a nullable column is a neat trick for "at most one live row").&lt;/li&gt;
&lt;li&gt;Give every submit an idempotency key with a unique index.&lt;/li&gt;
&lt;li&gt;Move statuses with compare-and-set, and put the allowed transitions in one place.&lt;/li&gt;
&lt;li&gt;Use atomic cache locks for jobs and caches that more than one process can run.&lt;/li&gt;
&lt;li&gt;Scope every lookup to its owner and return 404 for someone else's object.&lt;/li&gt;
&lt;li&gt;Sign anything you hand out that can't be scoped (tickets, share links), and compare signatures in constant time.&lt;/li&gt;
&lt;li&gt;Write down what each secret signs or encrypts before you ever rotate it.&lt;/li&gt;
&lt;li&gt;Test the build you deploy, not just the code you wrote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code, including the tests that try to sell one seat to two customers and to cancel one booking twice, is on &lt;a href="https://github.com/ahmershahdev/bookmymovie" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The &lt;a href="https://ahmershah.dev/blogs/bookmymovie-case-study-cinema-ticketing-platform" rel="noopener noreferrer"&gt;case study&lt;/a&gt; covers the rest of the project: the stack, design, SEO and what I'd change.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>mysql</category>
      <category>laravel</category>
      <category>backend</category>
    </item>
    <item>
      <title>BookMyMovie: Building a Cinema Ticketing Platform</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:07:51 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/bookmymovie-building-a-cinema-ticketing-platform-2aeb</link>
      <guid>https://dev.to/thesiliconarchitect/bookmymovie-building-a-cinema-ticketing-platform-2aeb</guid>
      <description>&lt;p&gt;Buying a cinema ticket in Pakistan still often means calling the cinema, queueing at the counter, or using a site that shows you a seat map, lets you pick a seat, and then tells you at the last step that somebody else already has it. Prices are rarely clear before you commit, premium screens (IMAX, Dolby, recliners) are hard to compare across cinemas, and the ticket is usually a screenshot you hope the usher will accept.&lt;/p&gt;

&lt;p&gt;BookMyMovie is my answer to that. It started as my Aptech DISM project in July 2026 and ended up as a full platform that I designed and built alone: the research, the UI, the database, the backend, payments, security, SEO, the admin back office, tests and documentation. You browse films, watch trailers, pick seats on a live map (or from inside a 3D model of the hall), add snacks, book, and get a signed QR e-ticket that still opens with no signal.&lt;/p&gt;

&lt;p&gt;This is how it's put together, what went wrong, and what I'd change.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one rule
&lt;/h2&gt;

&lt;p&gt;"Never sell the same seat twice, and never show a price you won't charge."&lt;/p&gt;

&lt;p&gt;A shop can apologise for a late parcel. Two people holding tickets for G7 at the 9 PM show is a scene in the lobby. Almost every design decision below comes back to that sentence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7j07ykfcpqdrv4m8rfsn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7j07ykfcpqdrv4m8rfsn.webp" alt="BookMyMovie's home page: a trailer plays silently behind the featured film, with Book tickets, Watch trailer and Film details, and the next films in the rotation along the bottom." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who uses it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Moviegoers&lt;/strong&gt; browse films and cinemas, watch trailers, compare up to four films, pick seats, mix adult and child tickets, add snacks, apply a coupon, a gift card or loyalty points, and pay online or at the counter. They get a QR e-ticket (with Apple and Google Wallet passes when configured), a watchlist, a waitlist for sold-out shows, a way to split the bill with friends, and "verified booking" reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Box-office staff&lt;/strong&gt; scan tickets at the door, refund bookings, issue gift cards and look up members.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managers and the owner&lt;/strong&gt; run films, showtimes, row prices, coupons, snack stock, reviews, bans, site settings and SEO, and read revenue and occupancy reports. Three roles (owner, manager, box office) are enforced on every admin route, with authenticator-app two-step sign-in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq1ckghqvwhoxz6uvhiu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq1ckghqvwhoxz6uvhiu.webp" alt="A film page: poster, rating and runtime, with Choose a showtime, Add to watchlist, Watch trailer and Compare, and the film's trailer playing behind it." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the stack
&lt;/h2&gt;

&lt;p&gt;I wanted an interface that feels like an app, with no full page reloads, and a backend where routing, authentication and validation stay on the server where I can reason about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel 12 and React 19, joined by Inertia 2,&lt;/strong&gt; gave me both. Every page is a React component, but there's no public JSON API to secure. A controller validates the request, checks who is asking, and hands props to the page. The layout never unmounts, so the navbar, the seat-hold countdown and the assistant survive navigation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Laravel 12, PHP 8.2&lt;/td&gt;
&lt;td&gt;Routing, Eloquent, queues, rate limiting, mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;Inertia 2 + Ziggy&lt;/td&gt;
&lt;td&gt;SPA navigation with server-side routing and auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 19, strict TypeScript, Tailwind CSS 4&lt;/td&gt;
&lt;td&gt;Components, one set of design tokens for two themes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Motion and 3D&lt;/td&gt;
&lt;td&gt;Motion, Lenis, Three.js via @react-three/fiber&lt;/td&gt;
&lt;td&gt;Page motion, smooth scroll, the poster ring and the 3D hall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL 8 / MariaDB&lt;/td&gt;
&lt;td&gt;Foreign keys, unique guards, reporting views, row locks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;JazzCash, Easypaisa, card, pay at counter&lt;/td&gt;
&lt;td&gt;What people in Pakistan actually use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail and jobs&lt;/td&gt;
&lt;td&gt;Resend, database queue&lt;/td&gt;
&lt;td&gt;E-mail never blocks a page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frc9dcp5yk9o74fd1eh7k.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frc9dcp5yk9o74fd1eh7k.webp" alt="How a request flows: React pages in the browser, Laravel middleware, controllers and support services, then MySQL, the cache store, media files and external services." width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule I kept coming back to was &lt;strong&gt;thin controllers, and one class for every set of rules that must hold together&lt;/strong&gt;. Cancelling a booking is a good example. It has to put the seats back on sale, lower the show's sold count, return the coupon use, refill the gift card, reverse the loyalty points, update the payment and tell the waitlist. A customer cancelling from their phone and an admin issuing a refund go through the same &lt;code&gt;BookingLifecycle::unwind()&lt;/code&gt;, so the two can never drift apart.&lt;/p&gt;

&lt;p&gt;Other decisions that paid off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth per concern.&lt;/strong&gt; &lt;code&gt;App\Support\Seo&lt;/code&gt; builds titles, Open Graph and JSON-LD for every page. &lt;code&gt;App\Support\MovieMedia&lt;/code&gt; links artwork and trailers by the film's slug, so adding a trailer is "drop three files in a folder and run a migration".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything slow goes on the queue.&lt;/strong&gt; Receipts, waitlist alerts and web push are jobs, retried on failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scheduler does the boring, important work:&lt;/strong&gt; expiring 10-minute seat holds every minute, sweeping waitlists and draining the queue where no long-running worker exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh0no13ybzaq9n5br0yae.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh0no13ybzaq9n5br0yae.webp" alt="The booking core of the data model: cities, theaters, screens and seats; movies and shows with row prices; users, carts and cart items; bookings, booking seats, payments and splits; reviews and waitlists." width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The schema is 56 tables and 9 reporting views. The shape is simple once you see it: a &lt;strong&gt;city&lt;/strong&gt; has &lt;strong&gt;theaters&lt;/strong&gt;, a theater has &lt;strong&gt;screens&lt;/strong&gt;, a screen has &lt;strong&gt;seats&lt;/strong&gt;. A &lt;strong&gt;show&lt;/strong&gt; is one film on one screen at one time. A customer's &lt;strong&gt;cart&lt;/strong&gt; holds seats for ten minutes; checkout turns it into a &lt;strong&gt;booking&lt;/strong&gt; with one &lt;strong&gt;booking seat&lt;/strong&gt; per ticket.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejtyq637ji12rpimu4zb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejtyq637ji12rpimu4zb.webp" alt="The cinemas page: six cinemas in four cities, 13 screens and 1,224 seats, all read from the same city, theater, screen and seat tables." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What makes it trustworthy is what the database itself refuses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shows (screen_id, show_date, show_time)&lt;/code&gt; is unique: two films can't be scheduled on the same screen at once.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cart_items (seat_id, show_id)&lt;/code&gt; is unique: a seat can sit in only one cart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;booking_seats (show_id, seat_id, seat_lock)&lt;/code&gt; is unique, and &lt;code&gt;seat_lock&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; while a ticket is live and &lt;code&gt;NULL&lt;/code&gt; once it's cancelled. MySQL lets a unique index hold any number of &lt;code&gt;NULL&lt;/code&gt;s, so a cancelled seat can be sold again, but no bug, script or admin can ever insert a second live ticket for the same seat. I have a test that bypasses every piece of PHP and inserts the row by hand; the database throws.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bookings.idempotency_key&lt;/code&gt; is unique: a double-tapped "Confirm" returns the booking it already made.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reviews (user_id, movie_id)&lt;/code&gt; and &lt;code&gt;show_waitlists (show_id, user_id)&lt;/code&gt; are unique: one review per film, one waitlist place per show.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reporting views (&lt;code&gt;v_seat_availability&lt;/code&gt;, &lt;code&gt;v_show_details&lt;/code&gt;, &lt;code&gt;v_daily_revenue&lt;/code&gt; and six more) keep the seat map and every dashboard chart down to a single query each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Booking a seat
&lt;/h2&gt;

&lt;p&gt;Every booking starts from a film's showtimes: one row per cinema, the lowest seat price on each show, the matinée discount in red and a warning once a show is more than 60% sold.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4juqm1ssm3a8agdho96n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4juqm1ssm3a8agdho96n.webp" alt="A film's showtimes: a date strip, city filters and each cinema's shows with the lowest seat price." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsm4q10bf0h8xlqjxbb1d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsm4q10bf0h8xlqjxbb1d.webp" alt="The seat map: row-tier prices, best-seat suggestions, a seat map / 3D hall switch, and the selection summary with the total." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick seats.&lt;/strong&gt; Each row has its own price (front rows cheapest, "Prime Centre" best, recliners at the back), weekday shows before 5 PM carry a 15% matinée discount, and child tickets are about 25% cheaper (not on recliners). "Best 2 together" finds adjacent seats near the centre, about two-thirds of the way back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold.&lt;/strong&gt; Adding seats to the cart runs one transaction: lock the customer, then the show, then the seats, then the cart, check availability, insert the holds. The seats are yours for ten minutes, with a countdown in the navbar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check out.&lt;/strong&gt; The same lock order again. Prices, coupon limits, gift card balance and points are all re-checked inside the transaction, never trusted from the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ticket.&lt;/strong&gt; The booking gets a QR code carrying an HMAC signature, so the box office can tell a real ticket from an edited one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Coupons have their own page, with the minimum spend, the cap and the end date printed on each one, so the discount at checkout is never a surprise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqelh3jf0aeki6tyok3r1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqelh3jf0aeki6tyok3r1.webp" alt="The offers page: live coupon codes with their minimum spend, maximum discount and end date." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai2acdb3w2zh381ogqg2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai2acdb3w2zh381ogqg2.webp" alt="Hold, sell, and the only ways out: available, held, confirmed, then completed, no-show or cancelled. Cancelling puts the seat back on sale and tells the waitlist." width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fixed lock order (user → show → seats → cart) matters as much as the locks. Two requests that take the same locks in a different order can each hold one lock and wait forever for the other. Taking them in one order everywhere means one request simply waits for the other to finish. Deadlocks that still happen are retried up to three times.&lt;/p&gt;

&lt;p&gt;I wrote a separate article about the concurrency and security side, including the bugs: &lt;a href="https://ahmershah.dev/blogs/one-seat-two-buyers-race-conditions-idor-and-keys-in-bookmymovie" rel="noopener noreferrer"&gt;One seat, two buyers: race conditions, IDOR and keys in BookMyMovie&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The back office
&lt;/h2&gt;

&lt;p&gt;The admin side is where most of the day-to-day work happens, so I rebuilt it around one question: "what does this person need to do right now?" The sidebar is grouped by job (Today, Catalogue, People, Reports, Settings), every item has a one-line hint, and every page opens with a short "How this page works" guide that can be tucked away once you know it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7pgcw8w3l9te7rjqsm9s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7pgcw8w3l9te7rjqsm9s.webp" alt="The admin overview: the grouped sidebar, the page guide and the 14-day revenue with a 3D skyline of daily takings." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The overview answers "is tonight going well?" at a glance. The timeline gives each screen its own lane and fills each show's bar by the share of seats sold, with a red line for the time right now. A "Needs you" list links straight to the unpaid bookings and unread messages that want a person.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fayej9xqeztxzk47moq1n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fayej9xqeztxzk47moq1n.webp" alt="Tonight on every screen: one lane per screen, each show filled by the share of seats sold, and a live now-line." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Editing a film is a numbered form (basics, price and rating, artwork, home carousel, search engines) with one save bar that says whether anything is unsaved. Every input shows an example value, so nobody has to guess what "slug" means.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvujdefxc0sgfpikcdqmq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvujdefxc0sgfpikcdqmq.webp" alt="The film editor: a searchable film list on the left and numbered sections with example placeholders on the right." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Refunds are one button. Behind it, the same &lt;code&gt;BookingLifecycle::unwind()&lt;/code&gt; a customer's cancellation uses puts the seats back on sale and returns the coupon use, gift card balance and loyalty points, and the customer is e-mailed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnz4ptyu77mfb6t1iss01.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnz4ptyu77mfb6t1iss01.webp" alt="Bookings and refunds: recent bookings with their payment and booking status and a one-step cancel." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three roles decide who sees what: the owner can do everything, a manager runs films, prices, coupons, members and refunds, and box-office staff see the dashboard and bookings and check tickets at the door. Each person's two-step status is visible at a glance, and banning a member also blocks every IP and browser they used.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F05rvw4al2r1tgnipv2ni.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F05rvw4al2r1tgnipv2ni.webp" alt="Staff and roles: owner, manager and box office described in plain words, and the staff table with each person's role and two-step status." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vowktajjvnp8du07zxn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vowktajjvnp8du07zxn.webp" alt="A member's page: profile, spend and points beside a ban form that can also block their browsers and IP addresses, with a note that shared IPs can catch innocent people." width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Taking these screenshots found two layout bugs I had missed: a shared form-field style that stretched labels away from their inputs whenever a row was taller than the field, and a staff table that cut off its last column. Both are fixed; the first was one line of CSS (&lt;code&gt;align-content: start&lt;/code&gt;) that tidied every form on the site.&lt;/p&gt;

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

&lt;p&gt;The design brief I set myself was "cinema signage": near-black ink, warm paper, and one electric volt accent, with condensed, variable-width Archivo capitals the way a marquee spells a title. Geist Mono carries anything that is data (times, prices, booking numbers). Edges are hard; the only curves are the screen on the seat map.&lt;/p&gt;

&lt;p&gt;A few pieces I'm proud of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trailers everywhere.&lt;/strong&gt; Short silent trailers play behind the home carousel and film pages, with a WebP still first. They only autoplay when motion is allowed, Data Saver is off and the connection is faster than 3G.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;See it from your seat.&lt;/strong&gt; A Three.js model of the hall: sit in any seat, watch the trailer on the screen as the lights dim, and get a view score (distance, how much of your view the screen fills, off-centre angle and neck tilt).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two themes, one set of tokens.&lt;/strong&gt; Every colour is a CSS variable, and dark and light themes redefine them. Both pass WCAG AA contrast. They didn't at first (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Urdu.&lt;/strong&gt; A full English/Urdu switch with a right-to-left layout and Noto Naskh Arabic. Film titles, prices and booking numbers stay in Latin script so they match the ticket in your hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usher.&lt;/strong&gt; A small assistant that answers preset questions ("cheapest seat tonight?", "where can I watch in IMAX?") from live listings. Signed-in customers get personal answers: their next show, their points, which watchlist films are on sale, and a pick based on the genres they book. It has no free-text box on purpose: nothing a visitor types is ever sent anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftzwvjzl36gklgfpw6kis.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftzwvjzl36gklgfpw6kis.webp" alt="Usher answering " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvvrfosryfuv4hamjiz70.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvvrfosryfuv4hamjiz70.webp" alt="The home page in the light theme: the same tokens redefined as warm paper and ink, with the volt accent kept for the one action that matters." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a phone the whole thing collapses to one column without a single sideways scroll, from 320 px up. I check that with a script rather than by eye.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwth7c7nvmbpkcixj91en.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwth7c7nvmbpkcixj91en.webp" alt="The home page on a phone: the trailer carousel with full-width Book tickets and Watch trailer buttons." width="688" height="1400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzbh3mx2jry5uhz9r65h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzbh3mx2jry5uhz9r65h.webp" alt="BookMyMovie's seat map on a phone." width="688" height="1400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO, and search for AI
&lt;/h2&gt;

&lt;p&gt;Inertia renders the page body in the browser, so the server has to do the rest of the work for crawlers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-rendered head.&lt;/strong&gt; Every page's title, description, canonical URL, Open Graph image (generated per film, 1200×1200) and robots rule come from the controller and are written into the HTML before any JavaScript runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD.&lt;/strong&gt; Organization, WebSite with a search action, BreadcrumbList, and for each film a &lt;code&gt;Movie&lt;/code&gt; with its cast, rating and every upcoming &lt;code&gt;ScreeningEvent&lt;/code&gt; with offers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generated sitemap&lt;/strong&gt; (64 URLs with film images) and &lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt; / &lt;code&gt;llms-full.txt&lt;/code&gt;&lt;/strong&gt;, a plain-text description of the site and its whole catalogue for AI assistants, regenerated from the database with &lt;code&gt;php artisan seo:generate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliberate &lt;code&gt;noindex&lt;/code&gt;.&lt;/strong&gt; Search results, password reset and the admin area are kept out of search engines. Lighthouse marks those pages down for it, and I accept that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lighthouse's accessibility, best practices, SEO and agentic-browsing categories score 100 on every public page (the &lt;code&gt;noindex&lt;/code&gt; pages lose SEO points by design, and admin login loses a few best-practice points to Google reCAPTCHA's own iframe).&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What protects it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Passwords and sign-in&lt;/td&gt;
&lt;td&gt;Argon2id; optional e-mailed 6-digit codes for customers; authenticator-app TOTP with recovery codes for staff; sessions regenerated on sign-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access control&lt;/td&gt;
&lt;td&gt;Every customer query is scoped to the signed-in user, so a guessed booking number is a 404; admin roles are mapped per route and anything not in the map needs the highest role (it fails closed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bots&lt;/td&gt;
&lt;td&gt;Honeypot, minimum fill time, a server-drawn single-use image code, invisible reCAPTCHA v3 with the v2 checkbox only as a step-up, rate limits, and bans that cover the account, every IP and every browser it used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;td&gt;Nonce-based Content-Security-Policy, HSTS, &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;, CSRF on every form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Amounts recomputed on the server; JazzCash callbacks verified by signature and amount before anything changes; payment and booking rows locked while settling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tickets&lt;/td&gt;
&lt;td&gt;HMAC-signed QR payloads, checked with a constant-time comparison at the door&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads&lt;/td&gt;
&lt;td&gt;Re-encoded to WebP, which also strips EXIF and GPS data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit&lt;/td&gt;
&lt;td&gt;Every data-changing action by staff and customers is logged with before and after values&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Performance and caching
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images&lt;/strong&gt; ship in two sizes (&lt;code&gt;card-sm.webp&lt;/code&gt; 640 px and &lt;code&gt;card.webp&lt;/code&gt; 1200 px) with &lt;code&gt;srcset&lt;/code&gt;, explicit dimensions and lazy loading. The server tells the browser to preload the one image that is the largest paint, plus the two text fonts. Converting the original artwork from PNG to WebP took it from about 85 MB to 5.4 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailers&lt;/strong&gt; are VP9 WebM first with an H.264 MP4 fallback. The latest one went from 3.4 MB to 1.1 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five cache layers:&lt;/strong&gt; HTTP (hashed assets cached for a year, film art for a month), the service worker (offline tickets, stale-while-revalidate film art), the application cache (home rails and menus served stale while one request rebuilds them under a lock, so an expiry never stampedes the database), a per-request memo, and the database views.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read in someone else's case study.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seat map returned a 500 in production builds.&lt;/strong&gt; The seat map page and the lazy-loaded 3D hall both imported a small geometry file. Rollup quietly merged that file into the page's own chunk. The page then had no entry of its own in Vite's manifest, and Laravel answered every visit to the most important page on the site with "Unable to locate file in Vite manifest". Development mode hid it completely. I found it while taking screenshots for this article. The fix was one line (give the shared file its own chunk). The lesson was a test that checks every page is in the manifest, which now runs with the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The service worker never installed.&lt;/strong&gt; It pre-cached four files with &lt;code&gt;cache.addAll()&lt;/code&gt;, one path was wrong, and &lt;code&gt;addAll&lt;/code&gt; rejects the whole batch if any one file fails. So offline tickets, the feature I talk about most, silently didn't work, and the only symptom was an "Uncaught (in promise)" in the console. Now each file is cached on its own and a missing one is skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two captchas at once.&lt;/strong&gt; When the real reCAPTCHA keys went in, forms showed the v2 checkbox &lt;em&gt;and&lt;/em&gt; v3's floating badge, and the server required both. I redesigned it: v3 runs invisibly on every submit, and the checkbox appears only as a step-up when v3's score is too low or it fails to load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unreadable buttons.&lt;/strong&gt; The selected "Newest / Most helpful / Highest / Lowest" chip got a light background from one rule and accent-coloured text from another. In the dark theme that was volt on off-white; in the light theme it was olive on near-black. Both were unreadable. The quietest grey text was also below 4.5:1 contrast. I fixed the tokens, not the individual screens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15s8vepim6lfdw2ll3og.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15s8vepim6lfdw2ll3og.webp" alt="Film reviews in the dark theme after the fix: the selected Newest chip is dark on light, the others light on dark." width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopgjornfwgicrdnesly6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopgjornfwgicrdnesly6.webp" alt="The same reviews in the light theme: rating breakdown, sort chips and verified-booking badges." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phones scrolled sideways.&lt;/strong&gt; A CSS grid with no column template sizes its one column to its widest content, so on the FAQ a row of category buttons made the page 968 px wide on a 375 px phone. The shared grid component now defaults to &lt;code&gt;minmax(0, 1fr)&lt;/code&gt;, and a script loads every page at 320, 375, 414, 768 and 1024 px and fails if anything scrolls sideways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Old links 404'd.&lt;/strong&gt; Seat maps moved from &lt;code&gt;/movies/{film}/{show}&lt;/code&gt; to &lt;code&gt;/movies/{film}/book/{show}&lt;/code&gt;, and bookmarked or shared links broke. They now redirect permanently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Downsides I'd be honest about in a review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No server-side rendering of the page body.&lt;/strong&gt; The head, JSON-LD and sitemap cover search engines well, but a crawler that doesn't run JavaScript sees an empty body. Inertia SSR would fix that at the cost of a Node process on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile performance is the weak score.&lt;/strong&gt; Lighthouse performance on a throttled phone is low on the film pages: Three.js, a trailer and two variable fonts are a lot for a mid-range phone. The 3D scenes are lazy and skipped on slow connections, but the first load is still heavy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One door for most admin writes.&lt;/strong&gt; Many admin forms post to a single endpoint with an &lt;code&gt;_action&lt;/code&gt; field. The role check maps each action to a permission and fails closed, but a single dispatcher is harder to audit than one route per action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Set the rating by hand."&lt;/strong&gt; The admin can show a hand-set star rating for launch week. It's clearly labelled internally, but it's the kind of feature that becomes a dark pattern in the wrong hands. In a real deployment I'd remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short ticket signatures.&lt;/strong&gt; The QR carries 64 bits of an HMAC-SHA256 (16 hex characters) to keep the code small enough to scan quickly. Guessing one means brute-forcing an endpoint only staff can reach, but it's still a trade-off, not a free lunch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments are sandbox-only.&lt;/strong&gt; The JazzCash and Easypaisa flows, signatures and refunds are built and tested, but no real money has moved through them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests stop at the server.&lt;/strong&gt; 38 PHPUnit feature tests cover booking, payments, bans, roles, waitlists and group bookings on a real MySQL database, and I use scripted Lighthouse and headless-Chrome checks for the front end. There are no committed end-to-end browser tests yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;BookMyMovie is finished as a portfolio project. It isn't hosted anywhere; the code lives on GitHub, archived, with everything needed to run it locally in one &lt;code&gt;migrate --seed&lt;/code&gt;. If I picked it up again for a real cinema, this is the order I'd work in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on Inertia SSR and measure the phone performance again.&lt;/li&gt;
&lt;li&gt;Replace the admin dispatcher with one route per action.&lt;/li&gt;
&lt;li&gt;Add Playwright end-to-end tests for "two people, one seat" in two real browsers.&lt;/li&gt;
&lt;li&gt;Move cache, sessions and the queue to Redis and put the media behind a CDN.&lt;/li&gt;
&lt;li&gt;Go live with one partner cinema and real payments.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;The interesting work in a booking system isn't the booking form. It's the half-second where two people press "Confirm" on the same seat, the payment callback that arrives twice, the admin who refunds a booking the customer is cancelling at the same moment, and the build tool that quietly moves your page into the wrong file. Most of what I'm proud of in BookMyMovie is invisible when it works.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/ahmershahdev/bookmymovie" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under the MIT licence (archived, read-only), with the full data model, locking and caching design and every screen in the README.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;[&lt;strong&gt;ahmershah.dev&lt;/strong&gt;]&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>laravel</category>
      <category>react</category>
      <category>mysql</category>
    </item>
    <item>
      <title>The Discount That Knew You Were Hesitating: Rule-Based Personalisation in an Online Pharmacy</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:07:50 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/the-discount-that-knew-you-were-hesitating-rule-based-personalisation-in-an-online-pharmacy-4dnm</link>
      <guid>https://dev.to/thesiliconarchitect/the-discount-that-knew-you-were-hesitating-rule-based-personalisation-in-an-online-pharmacy-4dnm</guid>
      <description>&lt;p&gt;You open a cough syrup on a pharmacy site. You read the dosage, check the price, close the tab. Two days later you're back on the same page. Then again that evening.&lt;/p&gt;

&lt;p&gt;A good pharmacist behind a counter would notice. They'd say "still thinking about that one? I can do it a bit cheaper." Most online stores don't notice anything, or they notice in the creepiest possible way: an ad for the same syrup following you around the internet for a month.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://ahmershah.dev/blogs/zovita-case-study-online-pharmacy-platform" rel="noopener noreferrer"&gt;Zovita+&lt;/a&gt;, an online pharmacy, on my own with Laravel, React and MySQL. One of the things I wanted from it was a store that notices like the pharmacist does: quietly, inside the store, with offers that are small, time-limited and explainable. This is how that works, why it's rules and not a model, and where the hard parts actually were. Spoiler: the hard part was never deciding on the discount. It was making sure the customer is charged exactly what they were shown, exactly once.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why rules, not a model
&lt;/h2&gt;

&lt;p&gt;The first question people ask is "is it AI?". It isn't, on purpose.&lt;/p&gt;

&lt;p&gt;A pharmacy is a bad place for a black box. If a customer asks "why did I get 8% off this?", the answer has to be a sentence, not a feature-importance chart. If a pharmacist asks "could this ever discount something by 40%?", the answer has to be "no, and here is the line of code that stops it". And with a few hundred test shoppers, there is no data to train anything on.&lt;/p&gt;

&lt;p&gt;So the store learns in the plainest possible way: it counts things, and a handful of rules turn those counts into offers. Every offer carries the reason in plain words ("You've been looking at Prospan Cough Syrup 120Ml"), and the whole system is about 400 lines of PHP with seven tests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s4fiofwlcv33cwwyvbw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s4fiofwlcv33cwwyvbw.webp" alt="The Zovita+ product page: price with the discount, the " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: one key for everyone
&lt;/h2&gt;

&lt;p&gt;Personalisation has to work for guests, because most people browse a pharmacy without signing in. So the first piece is a &lt;code&gt;Visitor&lt;/code&gt; service that gives every request a single string:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;u:42&lt;/code&gt; when someone is signed in, or&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;g:9f1c…&lt;/code&gt; for a guest, from a UUID in a long-lived, HTTP-only, first-party cookie (&lt;code&gt;zv_vid&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every table that personalises (&lt;code&gt;product_interactions&lt;/code&gt;, &lt;code&gt;offers&lt;/code&gt;, &lt;code&gt;experiment_events&lt;/code&gt;) stores that string in a &lt;code&gt;visitor&lt;/code&gt; column, with &lt;code&gt;user_id&lt;/code&gt; alongside it when there is one. Nothing downstream needs to care whether the visitor is signed in. They just ask &lt;code&gt;$visitor-&amp;gt;key()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cookie is first-party, set by the store itself, and never shared with anyone. That is the line between "the shop remembers you" and "the internet follows you".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: count, don't track
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;product_interactions&lt;/code&gt; has one row per visitor and product, with four counters and two timestamps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Bumped when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;views&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the product page is opened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dwell_seconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the page reports how long it was actually visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cart_adds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the product goes into the bag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;purchases&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an order containing it is placed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;last_seen_at&lt;/code&gt;, &lt;code&gt;last_purchased_at&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the last view and the last purchase&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dwell time comes from the browser: the product page measures how long it was visible (not just open in a background tab) and posts it to &lt;code&gt;/signals/dwell&lt;/code&gt;. That endpoint is the one place where the client gets a say, so it gets the least trust. The product must exist, the seconds must be an integer, the route has its own rate limit, and anything above &lt;strong&gt;600 seconds&lt;/strong&gt; is clamped. One report can never be worth more than ten minutes of attention, so a tab left open overnight, or a script posting huge numbers, can't push a product over the "you've been looking at this" line on its own.&lt;/p&gt;

&lt;p&gt;There is deliberately nothing else in that table. No referrer, no search terms, no list of pages in order. Counters are enough for the rules, and data you don't store can't leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: six rules
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;OfferEngine&lt;/code&gt; reads the counters and creates offers. The whole rulebook fits in a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Offer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hesitation&lt;/td&gt;
&lt;td&gt;viewed 3+ times, or 45+ seconds in total, without buying&lt;/td&gt;
&lt;td&gt;8% on that product, 72 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cart rescue&lt;/td&gt;
&lt;td&gt;added to the bag more than a day ago, never bought&lt;/td&gt;
&lt;td&gt;5% on that product, 48 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;regular&lt;/td&gt;
&lt;td&gt;bought 2+ times&lt;/td&gt;
&lt;td&gt;10% on that product, 7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;welcome&lt;/td&gt;
&lt;td&gt;signed up in the last 60 days, no orders yet&lt;/td&gt;
&lt;td&gt;5% off the order, 14 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;loyalty&lt;/td&gt;
&lt;td&gt;3+ orders (8+ for the bigger one)&lt;/td&gt;
&lt;td&gt;5% or 10% off the order, 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;comeback&lt;/td&gt;
&lt;td&gt;has ordered, but not in 45 days&lt;/td&gt;
&lt;td&gt;7% off the order, 10 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq40aeadj81umbbcx1qw6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq40aeadj81umbbcx1qw6.webp" alt="How a visit becomes an offer: interaction counters feed six rules, the rules create time-limited offers, Pricing applies them with a 15% cap to both the bag and the order, and checkout locks and redeems them once." width="800" height="252"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rules are the easy part. The interesting code is the set of guards around them, each of which exists because of a case I could picture going wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never duplicate a live offer.&lt;/strong&gt; Before creating anything, the engine loads the visitor's live offers and skips a rule whose offer already exists for that product. Otherwise every visit would mint a fresh 8% coupon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At most four product offers at a time.&lt;/strong&gt; Someone who opens forty products shouldn't get forty discounts. The engine walks products in order of dwell time, so the four they lingered on longest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only in-stock products with a real photo.&lt;/strong&gt; An offer on something sold out is a tease, and an offer card with a placeholder image looks broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cool-down after redemption.&lt;/strong&gt; If you just bought something with an offer, you don't get another offer on it for 14 days. Without that, "hesitation" would fire again on your next visit and teach you to wait for a discount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttled evaluation.&lt;/strong&gt; Running the rules on every request would be wasteful, so &lt;code&gt;evaluate()&lt;/code&gt; takes a cache lock (&lt;code&gt;Cache::add&lt;/code&gt;) that expires after five minutes and returns early if it's already taken. Placing an order or signing in clears that, so the rules re-run when something important has changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: one function prices both the bag and the charge
&lt;/h2&gt;

&lt;p&gt;This is the part I'd tell anyone building discounts to copy.&lt;/p&gt;

&lt;p&gt;There are two moments when a discount is calculated: when the customer looks at their bag, and when the order is created. If those are two different pieces of code, they will drift. Someone will add a cap to one and forget the other, round differently, or apply the order-wide discount before the product discount in one place and after it in the other. Then the bag says one total and the receipt says another, which is exactly what I promised myself would never happen.&lt;/p&gt;

&lt;p&gt;So there is exactly one function, &lt;code&gt;Pricing::apply($lines, $offers)&lt;/code&gt;, and both moments call it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Each line gets the best live offer for that product.&lt;/li&gt;
&lt;li&gt;The best order-wide offer then applies to what's left.&lt;/li&gt;
&lt;li&gt;The total personal discount is capped at &lt;strong&gt;15%&lt;/strong&gt; of the payable amount.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cap lives in that function and nowhere else, as &lt;code&gt;Pricing::MAX_SHARE = 0.15&lt;/code&gt;. A "regular" 10% plus a "loyalty" 10% could add up to 19%; the cap brings it back to 15%. There is a test for exactly that ("stacked offers are capped"), and another that places an order and checks the charged total equals what the bag showed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: redeem it once, under a lock
&lt;/h2&gt;

&lt;p&gt;Now the concurrency problem. An offer is a small amount of money with a "use once" rule attached, which means two browser tabs can race for it.&lt;/p&gt;

&lt;p&gt;Picture it: the customer has the bag open in two tabs and presses &lt;strong&gt;Place order&lt;/strong&gt; in both. Without care, both checkouts read the same live offer, both apply it, both create an order, and both mark it redeemed. The discount was used twice.&lt;/p&gt;

&lt;p&gt;In Zovita+, offers are handled inside the same database transaction that locks stock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$offers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Offer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$visitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$personal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$offers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// … create the order with $personal['discount'] …&lt;/span&gt;
&lt;span class="nc"&gt;Offer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$personal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'used'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'redeemed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lockForUpdate()&lt;/code&gt; means the second checkout waits at that line until the first one commits. When it gets the rows, the first checkout has already set &lt;code&gt;redeemed_at&lt;/code&gt;, so the &lt;code&gt;active()&lt;/code&gt; scope (not expired, not redeemed) no longer returns that offer and the second order is priced without it. (Each checkout page also carries its own one-time token under a unique index, which stops a double-click in &lt;em&gt;one&lt;/em&gt; tab from creating two orders. Two tabs have two different tokens, so for them the lock is what keeps the offer honest.)&lt;/p&gt;

&lt;p&gt;The offer also records which order used it (&lt;code&gt;order_id&lt;/code&gt;), so the admin can see exactly where every discount went.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: guests become customers without losing anything
&lt;/h2&gt;

&lt;p&gt;A guest browses for a week, builds up history, earns a hesitation offer, and then signs up to check out. If personalisation forgot all of that at sign-in, the offer they were shown would vanish at the worst possible moment.&lt;/p&gt;

&lt;p&gt;So signing in runs &lt;code&gt;Interactions::mergeGuestInto()&lt;/code&gt; in one transaction. For each of the guest's interaction rows (locked), it either adds the counters into the customer's existing row for that product or simply re-labels the row from &lt;code&gt;g:…&lt;/code&gt; to &lt;code&gt;u:42&lt;/code&gt;. Then the guest's live offers move across too, and both visitors' cached offers are cleared. There's a test for it: "guest history follows the customer into their account".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: is any of this working? A/B tests
&lt;/h2&gt;

&lt;p&gt;Personalisation needs to be measured, so the same &lt;code&gt;visitor&lt;/code&gt; key powers a minimal A/B system. Experiments live in config (for example the home page's main button: "browse the shop" or "start from a symptom"), and a visitor's variant is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$variants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$visitor&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$experiment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$variants&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash makes the first assignment deterministic, and adding the experiment name means one visitor's buckets in different experiments are independent. The result is then stored in &lt;code&gt;experiment_assignments&lt;/code&gt;, because the hash alone has a flaw I describe below. Events (exposure, click, add to cart, purchase) go into &lt;code&gt;experiment_events&lt;/code&gt; with &lt;code&gt;insertOrIgnore&lt;/code&gt;, so each visitor counts once per event, and the admin dashboard shows unique conversion rates per variant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0nffzbsonybtu8jaa49.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0nffzbsonybtu8jaa49.webp" alt="The admin dashboard: the shopper funnel and the A/B results per variant, each with a table view." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The assistant reads the same data
&lt;/h2&gt;

&lt;p&gt;Zovita+ has a small assistant, and it's another consumer of all this. It has no free-text box: the shopper picks a question like "Do I have any discounts?" and the server answers from their live offers, their bag and their orders. Because the answer is assembled from the same &lt;code&gt;OfferEngine::active()&lt;/code&gt; the bag uses, the assistant can't promise a discount the checkout won't honour.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycgw91t8zp8qjlcod6me.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycgw91t8zp8qjlcod6me.webp" alt="The assistant answering " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd be honest about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rules only find what I thought of.&lt;/strong&gt; They're explainable and testable, but they won't discover that people who buy antacids on Fridays also buy rehydration salts. A real store with real traffic could add a learned "pairs well with" without touching the offer rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B buckets used to change at sign-in.&lt;/strong&gt; The hash uses the visitor key, and that key changes from &lt;code&gt;g:…&lt;/code&gt; to &lt;code&gt;u:…&lt;/code&gt; when a guest signs in, so a guest could see variant A and then variant B as a customer, and be counted in both. I fixed it after the first version of this article: assignments are now stored, and signing in carries the guest's variant into the account in the same locked transaction as the interaction merge, unless the account already has one from another device. Nobody switches buckets, and nobody is counted twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clearing cookies resets a guest.&lt;/strong&gt; That's a privacy feature as much as a limitation, but it means "hesitation" only works within one browser until someone signs in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dwell time is self-reported.&lt;/strong&gt; It's clamped, rate-limited and only ever worth an 8% offer capped at 15% of an order, which is why it's safe to trust a little. I wouldn't let it drive anything more valuable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;Personalisation sounds like a data-science problem, and the rules really were the easy part: six &lt;code&gt;if&lt;/code&gt; statements. Almost all of the engineering went into keeping the money honest: one function that prices the bag and the order, a cap in one place, offers locked and redeemed inside the checkout transaction, and history that survives a guest signing in. A store that notices you is nice. A store that notices you and then charges you something different from what it showed you is worse than one that never noticed at all.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/ahmershahdev/zovita" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under the MIT licence. The full build story, including the 3D body map, the admin panel and the bugs, is in the &lt;a href="https://ahmershah.dev/blogs/zovita-case-study-online-pharmacy-platform" rel="noopener noreferrer"&gt;Zovita+ case study&lt;/a&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>ecommerce</category>
      <category>backend</category>
    </item>
    <item>
      <title>Zovita+: Building an Online Pharmacy That Adapts to Each Shopper</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:07:49 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/zovita-building-an-online-pharmacy-that-adapts-to-each-shopper-4jj1</link>
      <guid>https://dev.to/thesiliconarchitect/zovita-building-an-online-pharmacy-that-adapts-to-each-shopper-4jj1</guid>
      <description>&lt;p&gt;Buying medicine online in Pakistan usually means one of two things. Either a big pharmacy chain's site that feels like a spreadsheet, or a WhatsApp number you message with a photo of the box. Neither tells you whether the thing is in stock before you pay, neither knows what you bought last month, and neither has an answer when you don't know the name of the medicine, only where it hurts.&lt;/p&gt;

&lt;p&gt;Zovita+ is my attempt at the pharmacy I'd actually want to use. It has 1,186 real medicines, syrups and supplements, a 3D body map you can tap when you don't know what to search for, a store that quietly learns what you need and offers you personal discounts, warnings when two medicines in the bag shouldn't be taken together, refill reminders, pharmacist-reviewed prescriptions, English and Urdu, and card payments or cash on delivery. I designed and built it alone: research, UI, database, backend, admin panel, security, SEO, tests and documentation.&lt;/p&gt;

&lt;p&gt;This is how it's put together, what went wrong, and what I'd change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two versions of the same idea
&lt;/h2&gt;

&lt;p&gt;Zovita started in March 2026 as a set of flat PHP pages with hand-written Tailwind: a home page, a catalogue, a cart and a prescription upload form. It looked fine in screenshots. Underneath, every page included its own header file, the cart lived in JavaScript, and "checkout" was a form that sent an e-mail. By May I was spending more time fixing the off-canvas menu than building features, and I stopped.&lt;/p&gt;

&lt;p&gt;In late September 2026 I rebuilt it from an empty Laravel project. Same name, same brand, nothing else kept. The rebuild is what this case study is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule
&lt;/h2&gt;

&lt;p&gt;"Never sell what isn't on the shelf, and never charge a price you didn't show."&lt;/p&gt;

&lt;p&gt;A shop can apologise for a late parcel. Telling someone with a fever that the paracetamol they paid for doesn't exist, or charging them more than the bag said, is a different kind of failure. Most of the design decisions below come back to that sentence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp38jpgiftgjrp7a12aoe.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp38jpgiftgjrp7a12aoe.webp" alt="The Zovita+ home page: a 3D pill in the hero, " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who uses it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shoppers&lt;/strong&gt; browse 8 departments, 118 categories and 212 brands, filter by form, prescription status, stock and price, search with ⌘K, tap the body map when they only know the symptom, ask the assistant where their order is, upload a prescription, and pay by card or cash on delivery. Guests can do all of it; signing in merges the guest wishlist and history into the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pharmacy staff&lt;/strong&gt; sign in at a separate, unlinked &lt;code&gt;/admin&lt;/code&gt; login, as an &lt;strong&gt;owner&lt;/strong&gt;, &lt;strong&gt;pharmacist&lt;/strong&gt; or &lt;strong&gt;support&lt;/strong&gt;, each with its own permissions. They review prescriptions, confirm and dispatch orders, refund card payments, watch stock, read each customer's activity timeline and, when it's needed, ban someone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxx3tzy1b1tl637b8b9yg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxx3tzy1b1tl637b8b9yg.webp" alt="The shop: department and category filters, prescription and stock toggles, price range, sorting and 1,000+ products with real prices and discounts." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the stack
&lt;/h2&gt;

&lt;p&gt;I wanted an interface that feels like an app, with no full page reloads, and a backend where routing, authentication and validation stay on the server where I can reason about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel 12 and React 19, joined by Inertia 2,&lt;/strong&gt; gave me both. Every page is a React component, but there is no public JSON API to secure. A controller validates the request, checks who is asking and hands props to the page. The store layout never unmounts, so the header, the bag count and the assistant survive navigation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Laravel 12, PHP 8.2&lt;/td&gt;
&lt;td&gt;Routing, Eloquent, queues, scheduler, rate limiting, mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;Inertia 2 + Ziggy&lt;/td&gt;
&lt;td&gt;SPA navigation with server-side routing and auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 19, Tailwind CSS 4&lt;/td&gt;
&lt;td&gt;Components and one set of design tokens for two themes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Motion and 3D&lt;/td&gt;
&lt;td&gt;GSAP (ScrollTrigger, SplitText), Lenis, Three.js via @react-three/fiber&lt;/td&gt;
&lt;td&gt;Page motion, smooth scroll, the pill hero and the body map&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL / MariaDB&lt;/td&gt;
&lt;td&gt;Foreign keys, unique guards, row locks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maps&lt;/td&gt;
&lt;td&gt;Leaflet, OpenStreetMap, Nominatim&lt;/td&gt;
&lt;td&gt;The address picker and the contact map, with no API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Stripe Checkout (a sandbox gateway locally)&lt;/td&gt;
&lt;td&gt;Card payments confirmed only by signed webhooks; cash on delivery always works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail and bots&lt;/td&gt;
&lt;td&gt;Resend, reCAPTCHA v3 + v2&lt;/td&gt;
&lt;td&gt;Transactional e-mail; invisible bot scoring with a checkbox fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rendering and scale&lt;/td&gt;
&lt;td&gt;Inertia SSR, Redis, ClamAV&lt;/td&gt;
&lt;td&gt;Full HTML for crawlers, shared rate limits and bans across servers, scanned uploads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81e6ujins2qcfabpws0n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81e6ujins2qcfabpws0n.webp" alt="How a request flows: React pages in the browser, Laravel middleware (request guard, security headers, locale, ban check), thin controllers, actions and services, then MySQL, the private prescription disk and external services." width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule I kept coming back to was &lt;strong&gt;thin controllers, and one class for every set of rules that must hold together&lt;/strong&gt;. Placing an order has to lock stock, check prescriptions, store the prescription file, apply personal offers, create the order, mark offers as redeemed, and only after that commits, clear the bag, record the purchase and send e-mail. All of that lives in one &lt;code&gt;PlaceOrder&lt;/code&gt; action, so there is exactly one place where an order can come into existence.&lt;/p&gt;

&lt;p&gt;Other decisions that paid off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth per concern.&lt;/strong&gt; &lt;code&gt;App\Support\Seo&lt;/code&gt; builds titles, Open Graph and JSON-LD for every page. The FAQ and policies live in &lt;code&gt;resources/content/*.json&lt;/code&gt; and feed the pages, their JSON-LD and &lt;code&gt;llms-full.txt&lt;/code&gt; from the same file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personalisation is a set of small services&lt;/strong&gt;, not a model: &lt;code&gt;Visitor&lt;/code&gt; (who is this, signed in or not), &lt;code&gt;Interactions&lt;/code&gt; (what did they do), &lt;code&gt;OfferEngine&lt;/code&gt; (what does that earn them), &lt;code&gt;Pricing&lt;/code&gt; (what does it cost now) and &lt;code&gt;Recommender&lt;/code&gt; (what should they see next).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scheduler does the boring, important work.&lt;/strong&gt; Every ten minutes it accepts prescriptions nobody has reviewed in 24 hours, so a customer is never stuck because the pharmacist was busy; every five it cancels and restocks card orders nobody paid for; every morning it sends refill reminders.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F75wbxzw66ev7bhotl4nw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F75wbxzw66ev7bhotl4nw.webp" alt="Zovita+'s entity relationship diagram: 22 domain tables in six groups (catalogue, orders and payments, customers and staff, personalisation, security and inbox) with every column and the 27 foreign keys between them." width="799" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The schema is 22 domain tables and 27 foreign keys. The catalogue is simple: a &lt;strong&gt;department&lt;/strong&gt; has &lt;strong&gt;categories&lt;/strong&gt;, and a &lt;strong&gt;product&lt;/strong&gt; belongs to a department, a category and (optionally) a &lt;strong&gt;brand&lt;/strong&gt;. A product carries real pharmacy data: price and sale price, stock, a per-order cap, whether it needs a prescription, its active ingredients (&lt;code&gt;generics&lt;/code&gt;), uses, dosage, precautions and warnings.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;order&lt;/strong&gt; has &lt;strong&gt;order items&lt;/strong&gt; that copy the product's name, price and image at the moment of sale, so editing a product later never rewrites someone's receipt. An order can point to a &lt;strong&gt;prescription&lt;/strong&gt;, which points to the staff member who reviewed it.&lt;/p&gt;

&lt;p&gt;What makes it trustworthy is what the database itself refuses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;orders.number&lt;/code&gt; is unique, and so is &lt;code&gt;orders.checkout_token&lt;/code&gt;: a double-tapped "Place order" can only ever create one row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prescriptions.reference&lt;/code&gt;, &lt;code&gt;products.slug&lt;/code&gt;, &lt;code&gt;users.username&lt;/code&gt; and &lt;code&gt;users.email&lt;/code&gt; are unique.&lt;/li&gt;
&lt;li&gt;Foreign keys decide what happens on delete: removing a product cascades its wishlist entries and interaction history but only nulls &lt;code&gt;order_items.product_id&lt;/code&gt;, because the order has to survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Money has its own tables: a &lt;strong&gt;payment&lt;/strong&gt; per checkout attempt and a &lt;strong&gt;refund&lt;/strong&gt; per refund, with &lt;code&gt;webhook_events&lt;/code&gt; storing every gateway event id once so a replayed webhook is a no-op.&lt;/p&gt;

&lt;p&gt;The other half of the schema is about people: &lt;code&gt;product_interactions&lt;/code&gt; (one row per visitor and product), &lt;code&gt;offers&lt;/code&gt;, &lt;code&gt;refill_reminders&lt;/code&gt;, &lt;code&gt;experiment_events&lt;/code&gt; and &lt;code&gt;experiment_assignments&lt;/code&gt;, &lt;code&gt;login_codes&lt;/code&gt;, &lt;code&gt;bans&lt;/code&gt;, &lt;code&gt;ban_identifiers&lt;/code&gt; and &lt;code&gt;user_activities&lt;/code&gt;. I'll come back to those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordering
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs517ei3l3y3k8y4lp721.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs517ei3l3y3k8y4lp721.webp" alt="A product page: photo and 3D pack viewer, price with the discount, the " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bag is a session, and every price in it is re-read from the database on each request. Nothing the browser says about price is ever trusted. Checkout, whether cash on delivery or card, is a single transaction:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxh0gzfctup3seryex8f7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxh0gzfctup3seryex8f7.webp" alt="Checkout as one transaction: lock the products in id order, re-check stock and caps, decrement with a conditional update, require a prescription for Rx items, lock the visitor's live offers, price with the same function as the bag, create the order with a unique number and token, redeem the offers, and only after commit clear the bag and send e-mail." width="799" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lock.&lt;/strong&gt; The products in the bag are read with &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;, in ascending id order. Two checkouts that share products queue behind each other instead of deadlocking, because they always take locks in the same order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check and decrement.&lt;/strong&gt; Stock is checked under the lock and then decremented with &lt;code&gt;UPDATE … WHERE stock &amp;gt;= qty&lt;/code&gt;. If that affects no row, the whole transaction rolls back. Stock can't go negative even if a future change forgets the lock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prescriptions.&lt;/strong&gt; If any item needs one and no file is attached, the order stops there. The file is stored on a private disk; if the transaction rolls back, the file is deleted again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offers.&lt;/strong&gt; The visitor's live offers are locked too, so one offer can't be redeemed by two parallel checkouts, and the discount is computed by &lt;code&gt;Pricing::apply()&lt;/code&gt;, the same function that priced the bag preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create.&lt;/strong&gt; The order gets a random number under a unique index (a collision just retries inside a savepoint) and the checkout token under another one. Deadlocks retry up to three times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after the commit does the bag clear, the purchase get recorded and the e-mails go out. The loser of a "one unit left, two buyers" race sees a normal message asking them to review the bag, not an error page.&lt;/p&gt;

&lt;p&gt;When something is sold out, the product page offers in-stock alternatives with the same active ingredient first, then the same category at a similar price, with a one-tap swap in the bag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paying by card
&lt;/h3&gt;

&lt;p&gt;Card payments go through Stripe Checkout in production and a built-in sandbox gateway locally, so the whole flow can be tested without keys. The order is created &lt;em&gt;awaiting payment&lt;/em&gt; with its stock reserved for 30 minutes. The page the customer returns to never marks anything paid: only a webhook does, and only if its HMAC-SHA256 signature checks out in constant time, its timestamp is under five minutes old, its event id has never been seen, and the amount and currency match the order. If nobody pays, &lt;code&gt;payments:expire&lt;/code&gt; cancels the order, puts the stock back and releases its offers; money that arrives after that is refunded automatically. Owners refund part or all of an order from the admin panel, and refunds can never exceed what was captured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Medicine safety
&lt;/h3&gt;

&lt;p&gt;Every product stores its active ingredients, so the bag can do what a careful pharmacist would: notice two medicines that shouldn't be taken together. An &lt;code&gt;InteractionChecker&lt;/code&gt; maps ingredients to drug classes (NSAIDs, blood thinners, SSRIs, nitrates, macrolides…) with tolerant patterns, so the catalogue's own spellings still match, and checks them against 24 conservative, well-established rules: two paracetamol products, a blood thinner with an anti-inflammatory painkiller, sildenafil with a nitrate, and so on. Creams, shampoos and drops are ignored, so a ketoconazole shampoo never warns about a statin. Each warning says what to do; a serious one must be acknowledged before the order goes through, and the pharmacist sees it on the order. It's an automatic check, not medical advice, and the page says so.&lt;/p&gt;

&lt;p&gt;The same purchase history drives &lt;strong&gt;refill reminders&lt;/strong&gt;. For a medicine someone buys again and again, the median gap between their real orders (7 to 120 days) gives the rhythm, and an e-mail goes out three days before they should run out, once per purchase cycle, with a signed one-tap link that puts it back in the bag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3D body map
&lt;/h2&gt;

&lt;p&gt;Plenty of people know where it hurts but not what to search for. The body map is for them: rotate a mannequin, tap where it hurts, pick a symptom, and get self-care tips, red flags and pharmacist picks from the catalogue. Emergency symptoms (chest pain, trouble breathing) show urgent-care guidance instead of products.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwha3j467xf9eyt9gtr3b.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwha3j467xf9eyt9gtr3b.webp" alt="The body map: a sculpted mannequin with glowing regions, symptom chips for the tapped region, and pharmacist picks with self-care advice and red flags." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I didn't want to ship a stock 3D model I couldn't control, so the figure is sculpted in code. A Node script defines the body as a signed distance field (ellipsoids and tapered capsules blended with a smooth minimum so joints flow without seams), meshes it with surface nets, and bakes a binary file where every vertex carries its body region id. The shader uses that id for the region glow and the picking. The result is 775 KB raw and 463 KB with Brotli, and it only loads on the body map page.&lt;/p&gt;

&lt;h2&gt;
  
  
  A store that notices
&lt;/h2&gt;

&lt;p&gt;Every visitor, signed in or not, builds a quiet profile: how many times they viewed each product, how long they stayed, what they added to the bag and what they bought. From that, an offer engine creates personal discounts: 8% on something you keep coming back to, 5% on something left in the bag, 10% on something you buy every month, a welcome or loyalty discount on the whole order. Every offer is time-limited, the total is capped at 15%, and it's locked and redeemed inside the checkout transaction.&lt;/p&gt;

&lt;p&gt;It also powers "picked for you", "buy again" and "pairs well with" rails, and a guided assistant. The assistant has &lt;strong&gt;no free-text box&lt;/strong&gt;: you pick from preset questions ("Where is my order?", "Do I have any discounts?", "What's in my bag?") and the answer is built on the server from your data. Nothing a visitor types is sent anywhere, so there is nothing to prompt-inject and no made-up medical advice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frlwdq1phpi660hn3r8ws.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frlwdq1phpi660hn3r8ws.webp" alt="The assistant: preset questions, and a personal answer listing the visitor's live offers with links to the products." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote a separate article about how the offers work and the bugs I found in them: &lt;a href="https://ahmershah.dev/blogs/the-discount-that-knew-you-were-hesitating-rule-based-personalisation-in-zovita" rel="noopener noreferrer"&gt;The discount that knew you were hesitating&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The admin panel
&lt;/h2&gt;

&lt;p&gt;The admin panel is for pharmacy staff, not developers, so I wrote it around one question: "what does this person need to do right now?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It hides.&lt;/strong&gt; It has its own sign-in at &lt;code&gt;/admin/login&lt;/code&gt;, every admin URL answers &lt;strong&gt;404&lt;/strong&gt; to anyone who isn't staff, sessions end after 30 minutes idle, and staff login locks for 15 minutes after five failures. Each staff member can turn on two-step sign-in with an authenticator app from &lt;strong&gt;My security&lt;/strong&gt;; from then on every sign-in needs its code, a used code can't be replayed, and hashed single-use recovery codes cover a lost phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles decide what you see.&lt;/strong&gt; Owners can do everything, including refunds, bans and managing staff; pharmacists handle prescriptions, stock and prices; support sees orders and customers but can't refund or ban. Permissions are checked on every route and the sidebar only shows what a role can open. The last owner can never be demoted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Today at Zovita"&lt;/strong&gt; opens with to-do cards (prescriptions to review, orders to confirm, low and sold-out stock), then revenue, orders, average order and new customers against the previous period, and charts for revenue per day, the shopper funnel, top products, departments and A/B results. Every chart has a table view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It explains itself.&lt;/strong&gt; Every page has a "How this page works" guide, every heading has a &lt;strong&gt;?&lt;/strong&gt; with a plain-language explanation, and statuses are big coloured pills, not codes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnr5i9r823k7vjh656rd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnr5i9r823k7vjh656rd.webp" alt="The admin dashboard: to-do cards, revenue and orders against the previous period, and the revenue, funnel and A/B charts." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prescriptions&lt;/strong&gt; can be accepted, rejected or left pending with a note that is e-mailed to the customer. Anything not reviewed within 24 hours is accepted automatically and labelled "auto-approved", so it's obvious which ones a person actually looked at.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbc50ej528bq17xxbh2j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbc50ej528bq17xxbh2j.webp" alt="Prescription review: pending, accepted and auto-approved prescriptions with the file, the customer and a note field." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customers&lt;/strong&gt; have a full activity timeline (sign-ins, views, bag, orders, prescriptions, profile changes, with device and IP) and a ban wizard with three levels: &lt;strong&gt;temporary&lt;/strong&gt; (until a date), &lt;strong&gt;permanent&lt;/strong&gt; (the email, phone and device can't register again) and &lt;strong&gt;deep&lt;/strong&gt; (also the IP, its /24 or /64 network and the browser fingerprint, across the whole site). E-mail matching is canonical, so Gmail dots and &lt;code&gt;+aliases&lt;/code&gt; are folded and &lt;code&gt;j.o.h.n+1@gmail.com&lt;/code&gt; is the same person as &lt;code&gt;john@gmail.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5froj3tkuijar6ltm02.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5froj3tkuijar6ltm02.webp" alt="A customer's page: profile, recent activity with devices, the admin-only username field and the three-step ban wizard." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Design and language
&lt;/h2&gt;

&lt;p&gt;The brief I set myself was "calm": deep navy ink, a soft mint accent, warm paper, and generous space, because people buying medicine are often unwell or worried. Motion is there to explain, not to impress: an item you add to the bag flies into it as a little parcel with a trail, moving it to "saved for later" flies it back, and un-saving from the wishlist plays a small heart-break. All of it switches off under &lt;code&gt;prefers-reduced-motion&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtp01hcr1nwu25mhpwxo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtp01hcr1nwu25mhpwxo.webp" alt="The home page in the dark theme: the same tokens redefined for night, with the mint accent kept for the one action that matters." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Urdu&lt;/strong&gt; is a first-class language, not a translation layer bolted on. Switching is instant with no reload, the whole layout flips right-to-left, and the type changes to Noto Nastaliq Urdu. The dictionary has about 1,460 entries and covers the storefront, policies, FAQ, checkout, the admin panel and the error pages; values that change (counts, prices, names) go through placeholders, and dates use Urdu month names. Product names, prices and order numbers stay in Latin script so they match the box and the receipt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5i4jnptownt1imauf80y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5i4jnptownt1imauf80y.webp" alt="The home page on a phone in English and in Urdu, with the right-to-left layout and Nastaliq type." width="800" height="788"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Even the 404 is useful: it has search and shortcuts instead of a dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO, and search for AI
&lt;/h2&gt;

&lt;p&gt;Every storefront page is server-rendered (Inertia SSR) and hydrates without a mismatch, and the server does the rest of the work for crawlers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-rendered head.&lt;/strong&gt; Every page gets a unique title and description, a canonical URL, Open Graph and Twitter cards, a robots rule, and &lt;code&gt;hreflang&lt;/code&gt; alternates for English and Urdu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD.&lt;/strong&gt; &lt;code&gt;Pharmacy&lt;/code&gt; and &lt;code&gt;OnlineStore&lt;/code&gt; site-wide, &lt;code&gt;WebSite&lt;/code&gt; with a search action, and per page &lt;code&gt;Product&lt;/code&gt; (offers, availability, brand), &lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;, &lt;code&gt;ItemList&lt;/code&gt; and &lt;code&gt;ContactPage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generated sitemap&lt;/strong&gt; with every product, department, category, brand and page, and &lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt; / &lt;code&gt;llms-full.txt&lt;/code&gt;&lt;/strong&gt; for AI assistants. &lt;code&gt;robots.txt&lt;/code&gt; lets named AI crawlers in and keeps everyone out of the bag, checkout, account and admin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lighthouse 13 scores &lt;strong&gt;100 for accessibility, best practices and SEO&lt;/strong&gt; on the home, shop, product, body map, FAQ, contact, policy, login, register, prescription and about pages. The bag and checkout are &lt;code&gt;noindex&lt;/code&gt; on purpose and lose SEO points for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What protects it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL injection&lt;/td&gt;
&lt;td&gt;Eloquent and the query builder with bound parameters everywhere; sort and filter values are allow-listed. Tests send classic payloads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS&lt;/td&gt;
&lt;td&gt;React escaping plus a nonce-based Content-Security-Policy, so no inline script runs without the per-request nonce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSRF and clickjacking&lt;/td&gt;
&lt;td&gt;CSRF tokens on every state change, &lt;code&gt;SameSite=Lax&lt;/code&gt; cookies, &lt;code&gt;frame-ancestors 'self'&lt;/code&gt;, HSTS in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brute force&lt;/td&gt;
&lt;td&gt;Per-route throttles, each with its own counter; 5 failed sign-ins lock for a minute (15 on the staff login); reCAPTCHA v3 with the v2 checkbox as a fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Floods&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;RequestGuard&lt;/code&gt; middleware rejects unexpected methods, huge URLs, parameter floods, oversized bodies and NUL bytes before any session or database work; a per-client budget; nginx &lt;code&gt;limit_req&lt;/code&gt; at the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Races&lt;/td&gt;
&lt;td&gt;Row-locked stock and offers, conditional decrements, idempotency tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enumeration&lt;/td&gt;
&lt;td&gt;Password reset, login and order tracking answer the same way whether or not the account or order exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads&lt;/td&gt;
&lt;td&gt;MIME and extension allow-list, size cap, UUID names, a private disk, streamed only to staff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;404 for non-staff, a separate login, optional authenticator-app two-step sign-in, role permissions on every route, idle timeout, every admin action in the activity log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Server-computed amounts; webhooks verified by HMAC with a 5-minute window, each event applied once, amount and currency must match; refunds capped at what was captured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malicious files&lt;/td&gt;
&lt;td&gt;Prescriptions and avatars are streamed to ClamAV before they're stored; if the scanner is down, the upload is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PHPUnit&lt;/td&gt;
&lt;td&gt;Checkout (stock locking, idempotency, throttle isolation), auth, TOTP against the RFC 6238 vectors, roles, bans and evasion, offers, assistant, A/B, admin gating, prescriptions and the 24 h auto-accept, payments (forged, stale, duplicate and wrong-amount webhooks, expiry, late-payment refunds), interactions, refills, ClamAV, SQLi/XSS/CSP/CSRF, SEO outputs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;145 passed&lt;/strong&gt;, 973 assertions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright&lt;/td&gt;
&lt;td&gt;Every page on desktop and a phone (no console errors, one &lt;code&gt;h1&lt;/code&gt;, no sideways scroll), fly-to-bag, wishlist and bag moves, guest checkout, keyboard use of the themed dropdowns, body map, assistant, Urdu round-trip, A/B exposure, prefetch, admin review, admin pages on a phone, interaction warnings, a card payment through the sandbox gateway&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56 passed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load&lt;/td&gt;
&lt;td&gt;autocannon on a single XAMPP dev box: shop and product pages about 32 requests a second, FAQ about 55, static images about 830&lt;/td&gt;
&lt;td&gt;No errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abuse&lt;/td&gt;
&lt;td&gt;25 connections flooding the shop from one client for 20 s&lt;/td&gt;
&lt;td&gt;293 served, then 429 for the rest, no 5xx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CI runs Pint, the production build and the PHPUnit suite on every push. The repository has a single branch, &lt;code&gt;main&lt;/code&gt;, protected by rulesets: no force-pushes or deletion, linear history, signed commits only, and no other branch can be created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read in someone else's case study.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shopping used up the checkout limit.&lt;/strong&gt; Every write route had a throttle, and I was proud of that. But Laravel's &lt;code&gt;throttle:60,1&lt;/code&gt; keys the counter by user or IP, not by route, so all of them shared one bucket. A shopper who added and removed a few things from the bag, toggled the wishlist and changed the language could hit "Too many requests" on &lt;strong&gt;Place order&lt;/strong&gt;, the one request that matters. Every throttle now names its own counter (&lt;code&gt;throttle:6,1,checkout.store&lt;/code&gt;), and a test proves bag activity can't exhaust checkout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mobile menu closed itself.&lt;/strong&gt; Hovering a link prefetches its page, so clicks feel instant. The mobile menu and the overlays closed themselves on Inertia's &lt;code&gt;start&lt;/code&gt; event, so they could close themselves the moment a prefetch began, which is the opposite of what anyone wants from a menu they just opened. Overlays now ignore visits that are prefetches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The header pushed its own menu button off-screen.&lt;/strong&gt; Between 320 and 375 px, and again at 1024 px, the header was wider than the screen and the menu button sat past the right edge, so on the smallest phones you couldn't open the menu at all. The smoke test that checks "no sideways scroll" now runs on a phone profile for every page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nastaliq got its tops cut off.&lt;/strong&gt; Nastaliq is a tall, joined script: the strokes of letters like کاف and گاف rise well above the line box. The text reveals mask each line with &lt;code&gt;overflow: hidden&lt;/code&gt;, and the rolling button labels clip to one line height, so both sliced the tops off Urdu letters. Under &lt;code&gt;html[lang='ur']&lt;/code&gt;, line masks and labels now leave room for the strokes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The admin dashboard scrolled sideways on phones.&lt;/strong&gt; I found this one while writing this case study. The dashboard's chart grid had no column template below the &lt;code&gt;xl&lt;/code&gt; breakpoint, so its single implicit column grew to fit the widest chart and the page became 698 px wide on a 375 px phone. The customers list had the same problem. Adding &lt;code&gt;grid-cols-1&lt;/code&gt; (which is &lt;code&gt;minmax(0, 1fr)&lt;/code&gt;) fixed both. The same bug bit me on BookMyMovie, and the lesson is the same: a check that runs every page at 320, 375, 414, 768, 1024, 1280 and 1920 px catches what my eyes don't. It now covers the admin pages too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A section that was invisible everywhere.&lt;/strong&gt; The "Shop by concern" grid never appeared, on any screen. Its cards fade in on scroll with &lt;code&gt;gsap.from()&lt;/code&gt;, which animates &lt;em&gt;from&lt;/em&gt; a value &lt;em&gt;to&lt;/em&gt; whatever the element's opacity is when the tween is set up. The cards also had a CSS &lt;code&gt;transition&lt;/code&gt; on opacity, so when ScrollTrigger refreshed after the web fonts loaded, GSAP re-read the opacity in the middle of that CSS transition, got roughly zero, and animated from zero to zero. Giving the tween explicit end values with &lt;code&gt;fromTo()&lt;/code&gt; fixed it. The lesson: never let CSS transitions and GSAP fight over the same property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hundred and fifty products called "None".&lt;/strong&gt; The catalogue source writes &lt;code&gt;None&lt;/code&gt; when a product has no listed ingredient. Stored as-is, it showed up as an ingredient chip on product cards, and worse, "same salt, other brand" treated all 150 of those products as substitutes for each other. A migration now stores it as unknown, and the importer refuses the placeholder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;English text, flipped right to left.&lt;/strong&gt; The Urdu layer lived in the store layout, but the admin panel, the admin login and the error pages each have their own layout. In Urdu they rendered English text with &lt;code&gt;dir="rtl"&lt;/code&gt;, which is worse than either language. Monospace labels had a subtler problem: the mono font has no Urdu glyphs, and the browser's fallback drew the letters unjoined. Every layout now mounts the language layer, and every font stack falls back to Nastaliq.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI that couldn't start.&lt;/strong&gt; CI copies &lt;code&gt;.env.example&lt;/code&gt;, where a few optional settings are blank. &lt;code&gt;env('CACHE_LIMITER_STORE')&lt;/code&gt; returns an empty string, not null, so Laravel looked for a cache store named &lt;code&gt;""&lt;/code&gt; and crashed before the first test ran. Empty now means "not set".&lt;/p&gt;

&lt;h2&gt;
  
  
  Downsides I'd be honest about in a review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cards, but no local wallets.&lt;/strong&gt; Stripe covers cards; most Pakistani shoppers would also expect JazzCash and Easypaisa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-step sign-in is opt-in for staff.&lt;/strong&gt; The demo accounts need to work with a password, so the panel nudges each staff member to turn it on rather than forcing it. A real pharmacy should make it mandatory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-accepting prescriptions is a product decision, not a medical one.&lt;/strong&gt; It keeps customers from waiting on a busy pharmacist, and it's clearly labelled, but a real pharmacy would need a regulator's view on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The catalogue is borrowed.&lt;/strong&gt; Names, prices and images come from a public pharmacy storefront for demonstration only. A real launch needs a licensed catalogue and live stock from the pharmacy's own system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personalisation is rules, not learning.&lt;/strong&gt; The rules are explainable and testable, which I wanted, but they won't find patterns I didn't think of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interaction checks are deliberately narrow.&lt;/strong&gt; Twenty-four well-established rules catch the common, dangerous pairs; they are not a clinical decision-support system and don't know the patient's other medicines.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Zovita+ is finished as a portfolio project. The code is on GitHub with a step-by-step setup guide; it runs locally with no third-party keys at all, and one &lt;code&gt;migrate --seed&lt;/code&gt; loads the catalogue and the demo accounts.&lt;/p&gt;

&lt;p&gt;The first version of this case study ended with a list of what I'd build next. All of it has since shipped: staff roles with two-step sign-in, card payments with signed webhooks and refunds, refill reminders, drug-interaction warnings, server-side rendering, Redis-ready rate limits and bans, and malware scanning on uploads, along with e-mail automation and a full Urdu pass over every page. If I picked it up for a real pharmacy, the next list would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;JazzCash and Easypaisa next to cards, and mandatory two-step sign-in for staff.&lt;/li&gt;
&lt;li&gt;Passkeys, and SMS or WhatsApp updates for orders and refills.&lt;/li&gt;
&lt;li&gt;A pharmacist chat handoff from the assistant.&lt;/li&gt;
&lt;li&gt;Back-in-stock alerts and verified-purchase reviews.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;The interesting work in a pharmacy isn't the product grid. It's the rate limit that quietly blocks checkout, the offer that two tabs try to redeem at once, the menu that closes because a link was hovered, and the Urdu letter that loses its top to an animation mask. Almost everything I'm proud of in Zovita+ is invisible when it works.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/ahmershahdev/zovita" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under the MIT licence, with the full data model, security design, test results and every screen in the README.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>laravel</category>
      <category>react</category>
      <category>mysql</category>
    </item>
    <item>
      <title>The Last Mango Problem: Race Conditions in a Real Marketplace</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:38:11 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/the-last-mango-problem-race-conditions-in-a-real-marketplace-2icl</link>
      <guid>https://dev.to/thesiliconarchitect/the-last-mango-problem-race-conditions-in-a-real-marketplace-2icl</guid>
      <description>&lt;p&gt;There's one Sindhri mango left at the Latifabad stall. Two people tap "Place order" at the same moment. Who gets it?&lt;/p&gt;

&lt;p&gt;If your answer is "whoever was first", you've already made the mistake this post is about. Computers don't do "first" the way people do. Two requests can land in the same millisecond, both read the stock, both see &lt;code&gt;1&lt;/code&gt;, both subtract one, and both leave happy. The farmer now owes somebody a mango that doesn't exist.&lt;/p&gt;

&lt;p&gt;I ran into this properly while building &lt;a href="https://dev.to/blogs/gleangrid-case-study-farmers-market-preorder-platform"&gt;GleanGrid&lt;/a&gt;, a pre-order marketplace for Hyderabad's farmers markets that I built on my own. This is how it handles the moments where people collide: the last item, the single-use coupon, the nearly full pickup window, the double-tapped button, the payment that arrives late. It also covers a MySQL detail that fooled code which &lt;em&gt;looked&lt;/em&gt; perfectly correct to me.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "check, then write" is broken
&lt;/h2&gt;

&lt;p&gt;Here's the naive version. I'd bet most first checkouts look like it; mine did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock_quantity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock_quantity&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that from two requests at once and both pass the &lt;code&gt;if&lt;/code&gt; before either one saves. The gap between the read and the write is tiny, but a busy Saturday morning is exactly when a lot of people land in it together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: lock the thing you're about to change
&lt;/h2&gt;

&lt;p&gt;The fix is to make the read and the write one indivisible step. In MySQL that means a transaction plus a &lt;strong&gt;locking read&lt;/strong&gt;, &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;. The first request to get the lock holds it until it commits. Everyone else waits in line and then sees the updated number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'farmer_profile_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$farmer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock in a fixed order.&lt;/strong&gt; A basket can hold several products. If one checkout locks the mangoes and then the honey while another locks the honey and then the mangoes, each ends up waiting for the other forever. That's a deadlock. Sorting by &lt;code&gt;id&lt;/code&gt; means everyone queues in the same order, so the cycle can't form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merge duplicate lines first.&lt;/strong&gt; If a basket somehow holds the same product twice, checking each line against the stock separately lets &lt;code&gt;2 + 2&lt;/code&gt; sneak past a stock of &lt;code&gt;3&lt;/code&gt;. GleanGrid sums the lines per product before it checks anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule two: the same goes for coupons
&lt;/h2&gt;

&lt;p&gt;A coupon with a usage limit of one is just another counter that fifty people want to decrement. Same medicine: lock the coupon row, check it, record the redemption and bump the counter, all inside the checkout transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$coupon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Coupon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUsable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$coupon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;farmer_profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;CouponRedemption&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$coupon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'used_count'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because I don't fully trust myself, the database gets the last word too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;coupons&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;chk_coupon_usage&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage_limit&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;used_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;usage_limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future change ever forgets the lock, MySQL refuses the write instead of quietly giving away a discount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule three: the pickup window, and the bug I didn't see
&lt;/h2&gt;

&lt;p&gt;Each farmer offers pickup windows with a capacity, say three orders between 7 and 8 am, so the stall doesn't get swamped. This code looked right to me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$farmer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pickupSlots&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slotId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$booked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pickup_slot_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pickup_date'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OPEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$booked&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'pickup'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'That pickup window is fully booked.'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lock the slot, count the bookings, reject if it's full. The unit test passed. Then I wrote a stress script that fires real, separate PHP processes at checkout at the same instant, and it said this:&lt;/p&gt;

&lt;p&gt;Twenty orders in a window built for three. The lock was there. So what went wrong?&lt;/p&gt;

&lt;h3&gt;
  
  
  Snapshots under REPEATABLE READ
&lt;/h3&gt;

&lt;p&gt;MySQL's InnoDB engine runs at the &lt;strong&gt;REPEATABLE READ&lt;/strong&gt; isolation level by default. Inside a transaction, a normal &lt;code&gt;SELECT&lt;/code&gt; doesn't read the latest data. It reads a &lt;strong&gt;snapshot&lt;/strong&gt;, and that snapshot is fixed at the transaction's &lt;em&gt;first&lt;/em&gt; ordinary read.&lt;/p&gt;

&lt;p&gt;My checkout's first read was loading the farmer's profile, &lt;em&gt;before&lt;/em&gt; it asked for the slot lock. So each request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;read the farmer, which fixed its snapshot at that moment;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;waited in line for the slot lock while other checkouts committed their orders;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;got the lock and ran &lt;code&gt;count()&lt;/code&gt; against its &lt;strong&gt;old snapshot&lt;/strong&gt;, which contained none of those new orders.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lock serialised the requests perfectly. It just didn't change what they could &lt;em&gt;see&lt;/em&gt;. Stock and coupons were never affected, because I read those rows with &lt;code&gt;FOR UPDATE&lt;/code&gt;, and &lt;strong&gt;locking reads always see the latest committed data&lt;/strong&gt;, not the snapshot.&lt;/p&gt;

&lt;p&gt;I'll admit I stared at this one for a while. What finally cracked it was writing down, step by step, what each transaction could see at each moment. I talked it through with Claude as well, and it asked the question that mattered: &lt;em&gt;when does this transaction take its snapshot?&lt;/em&gt; The answer was in my own code, three lines above the lock.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one-line fix
&lt;/h3&gt;

&lt;p&gt;Make the count a locking read too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$booked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pickup_slot_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$slot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pickup_date'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OPEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran it again, this time with fifty buyers:&lt;/p&gt;

&lt;p&gt;The lesson I'm keeping: &lt;strong&gt;inside a transaction, a lock decides who goes next. It doesn't decide what they see.&lt;/strong&gt; Anything you use to make a decision under a lock should itself be read with a lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule four: the double-tap
&lt;/h2&gt;

&lt;p&gt;The most common race isn't fifty strangers. It's one person on a slow connection pressing "Place order" twice, or checking out in two tabs. GleanGrid wraps the whole checkout in a short, per-customer atomic lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"checkout:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'checkout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Your previous checkout is still being processed. Please wait a moment.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the stress test, ten simultaneous submissions from one account produce exactly one order and nine polite messages. While writing this I also noticed that the "too many open orders" check ran &lt;em&gt;before&lt;/em&gt; that lock was taken, which left a small window for going one over the limit. It now runs inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule five: the races that don't look like races
&lt;/h2&gt;

&lt;p&gt;Once you start looking, they're everywhere. These three never showed up in a stress test, because they need a person and a clock rather than fifty processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The farmer's stale form.&lt;/strong&gt; A farmer opens the stock form at 7:00, when there are 20 bunches of spinach. Customers buy five while the form sits open. At 7:10 the farmer saves the form, still showing 20, and quietly erases five sales. That's a lost update. Now the form sends back the number it &lt;em&gt;showed&lt;/em&gt; along with the new one, and the server works out what happened in between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reconcileStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Product&lt;/span&gt; &lt;span class="nv"&gt;$locked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$submitted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$seen&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$seen&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$submitted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$submitted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$locked&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock_quantity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// field untouched: keep what's really there&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// subtract whatever sold since the form was opened&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$submitted&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$seen&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$locked&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock_quantity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Monday restock.&lt;/strong&gt; "Apply weekly template" used to read every product, then write its usual quantity back. A reservation made between the read and the write was overwritten. It's now one atomic statement per run, with no read-modify-write loop to fall into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock_quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weekly_quantity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two restocks, two e-mails.&lt;/strong&gt; When a sold-out item comes back, everyone who asked gets one alert. If two restocks fire at once, both could see the same pending alerts. Each alert is now &lt;em&gt;claimed&lt;/em&gt; with a conditional update, and only the process that wins the row sends the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;StockAlert&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$alert&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'notified_at'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'notified_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// only one process ever gets a 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trick, "update where it's still in the state I expect, then check how many rows changed", turns up all over GleanGrid. It's the cheapest correct lock there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule six: money is where races get expensive
&lt;/h2&gt;

&lt;p&gt;Online payments came later, and I treated them as the riskiest code in the project. A double charge isn't an awkward message at a stall; it's someone's money.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One attempt at a time.&lt;/strong&gt; The payment row is locked and moves from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;processing&lt;/code&gt; exactly once. A second tap while the first is in flight gets "payment in progress", not a second charge.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Replays are harmless.&lt;/strong&gt; Every attempt carries a one-time idempotency key, stored under a unique index. If a flaky connection makes the browser resend the same request, the server recognises the key and returns the existing result.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locked&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idempotency_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already handled: answer with what happened, charge nothing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$locked&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'payment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'payment.in_progress'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Late confirmations are refunded, not lost.&lt;/strong&gt; If the gateway confirms after the 20-minute window has closed, the stock may already be back on sale. GleanGrid notices the payment is no longer &lt;code&gt;processing&lt;/code&gt;, records a &lt;code&gt;late_capture&lt;/code&gt; event and refunds automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Refunds can't run twice&lt;/strong&gt; either: they go through a conditional &lt;code&gt;UPDATE … WHERE payment_status = 'paid'&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule seven: let the loser see a form error, not a crash
&lt;/h2&gt;

&lt;p&gt;Some races end at a unique index rather than a lock. Two people pick the same username at the same moment, and both pass the "is this taken?" check because neither has saved yet. The database lets one through and rejects the other with a duplicate-key error, which by default becomes a server error page.&lt;/p&gt;

&lt;p&gt;GleanGrid maps that one exception, everywhere, to an ordinary validation message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$exceptions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UniqueConstraintViolationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'form'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'validation.unique'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attribute'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second person sees "The username has already been taken" under the field, exactly as if they'd been a second slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about thousands of people at once?
&lt;/h2&gt;

&lt;p&gt;Correctness under concurrency and survival under load are different problems, so they get different tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates that don't compete are cheap.&lt;/strong&gt; A thousand people editing their own profiles touch a thousand different rows. InnoDB locks rows, not tables, so those writes never wait on each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hot rows are the real bottleneck.&lt;/strong&gt; A thousand people buying &lt;em&gt;the same&lt;/em&gt; product queue on that one row. Each transaction is short (lock, check, write, commit, a few milliseconds), and the rare deadlock is retried automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* place the order */&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Floods get turned away early.&lt;/strong&gt; Every page sits behind a site-wide rate limiter, 300 requests a minute per IP for guests and 600 per signed-in account, with much tighter limits on sign-in, sign-up, password resets, search, checkout and payments. Writes are capped at sixty a minute per account, while page views stay unrestricted so browsing never feels throttled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'global'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'u:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ip:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bots don't get to race at all.&lt;/strong&gt; Every public form carries a signed ticket issued with the page, so a script can't hammer the sign-up form without first loading it, waiting, and passing a Turnstile or reCAPTCHA check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real denial-of-service protection belongs in front of the app.&lt;/strong&gt; No PHP code can absorb a flood that saturates the network. In production GleanGrid is meant to sit behind Cloudflare, which soaks up volumetric attacks before they reach the server. Because the app only trusts &lt;code&gt;X-Forwarded-For&lt;/code&gt; from proxies listed in &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt;, rate limits apply to the real visitor rather than the proxy, and nobody can dodge them by faking the header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it the way it breaks
&lt;/h2&gt;

&lt;p&gt;The most useful thing I built for all this wasn't a lock. It was the script that fires fifty real processes at the same instant against the real database, checks the numbers afterwards and cleans up after itself. Unit tests run in one process, one request at a time, so they'll happily pass code that falls over the moment two requests overlap.&lt;/p&gt;

&lt;p&gt;If your application sells anything that can run out, write the test that makes things overlap on purpose. Then ask the question that caught my bug: &lt;em&gt;at this line, what can this transaction actually see?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The whole project, stress script included, is on &lt;a href="https://github.com/ahmershahdev/GleanGrid" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you've been bitten by a race condition of your own, I'd love to hear the story.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;em&gt;&lt;a href="https://ahmershah.dev/blogs/the-last-mango-problem-race-conditions-in-a-real-marketplace" rel="noopener noreferrer"&gt;https://ahmershah.dev/blogs/the-last-mango-problem-race-conditions-in-a-real-marketplace&lt;/a&gt;&lt;/em&gt; &lt;em&gt;— © Syed Ahmer Shah&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Find me across the&amp;nbsp;web:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>GleanGrid: Building a Pre-Order Marketplace for Farmers Markets</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Tue, 29 Sep 2026 14:13:50 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/gleangrid-building-a-pre-order-marketplace-for-farmers-markets-4ih1</link>
      <guid>https://dev.to/thesiliconarchitect/gleangrid-building-a-pre-order-marketplace-for-farmers-markets-4ih1</guid>
      <description>&lt;p&gt;Every week, growers from Tando Jam, Kotri, Thatta and Mirpurkhas bring vegetables, mangoes, dairy, honey and bread into Hyderabad. The markets themselves are great. Everything around them is guesswork. You rarely know which farmer will turn up, what they'll bring or what it'll cost, and the Sindhri mangoes you crossed the city for are often gone by nine. Farmers have the opposite problem: they can't take an order before market day, so they either pick too much and throw some away, or pick too little and lose the sale.&lt;/p&gt;

&lt;p&gt;GleanGrid is my answer to that. I built it for Aptech's TechWiz 7 competition, against a brief called &lt;em&gt;MarketLink: eGreen Basket&lt;/em&gt;, and I built all of it myself: the research, the UI, the database, the backend, payments, security, testing and the documentation. Farmers publish their weekly stock, prices and pickup windows. Customers find a market on a map, reserve produce against real stock, pay online or in cash at the stall, and collect it on market day. There's no delivery, on purpose. The market stays the market; the guesswork goes.&lt;/p&gt;

&lt;p&gt;This is how it's put together, what I got wrong along the way, and what I'd change next.&lt;/p&gt;




&lt;h2&gt;
  
  
  The brief, in one sentence
&lt;/h2&gt;

&lt;p&gt;"Let people reserve this week's harvest from a specific farmer, at a specific market, for a specific pickup window, and never promise something the farmer doesn't have."&lt;/p&gt;

&lt;p&gt;That last clause shaped almost everything. A shop can apologise for a late parcel. A farmer who drove in at six in the morning with forty kilos of tomatoes cannot sell forty-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who uses it
&lt;/h2&gt;

&lt;p&gt;Three roles, each with a workspace of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Customers&lt;/strong&gt; browse markets, stalls and produce, fill a basket without an account, then check out with a pickup window per stall. They can change or cancel until the farmer's cut-off, carry a QR pickup pass, save favourites, ask to be told when something sold out comes back, share a family account, and review a farmer after pickup, with photos if they like.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Farmers&lt;/strong&gt; register a stall (an admin approves it), list weekly stock with photos, set pickup windows with a capacity and a cut-off, and move orders through accepted, ready and completed. At the stall they scan the customer's QR code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Admins&lt;/strong&gt; approve or suspend stalls, manage markets and categories, moderate listings, reviews and review photos, tune the stall-badge rules, edit the seasonal calendar, refund payments, answer the contact inbox and read reports across every market.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Choosing the stack
&lt;/h2&gt;

&lt;p&gt;I wanted two things that usually pull against each other: an interface that feels like a modern app, and a backend where routing, auth and validation stay on the server, where I can reason about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel 12 and React 19, joined by Inertia.js,&lt;/strong&gt; gave me both. Every page is a React component, but there's no public API to secure. A controller validates the request, checks who's asking, and hands props to the page. Navigation feels instant because links prefetch on hover and the layout never unmounts; only the page in the middle swaps. Inertia's server-side rendering means crawlers and slow phones get real HTML before any JavaScript runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Laravel 12, PHP 8.2&lt;/td&gt;
&lt;td&gt;Routing, Eloquent, queues, notifications, rate limiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;Inertia.js 3 with SSR&lt;/td&gt;
&lt;td&gt;SPA feel, server-side routing and auth, real HTML first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 19, Tailwind CSS 4, Vite&lt;/td&gt;
&lt;td&gt;Components, design tokens, fast builds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Motion&lt;/td&gt;
&lt;td&gt;Motion, Lenis&lt;/td&gt;
&lt;td&gt;Spring physics, layout animation, smooth scroll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maps&lt;/td&gt;
&lt;td&gt;Leaflet + OpenStreetMap, OSRM&lt;/td&gt;
&lt;td&gt;Free, keyless maps and routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL 8 / MariaDB&lt;/td&gt;
&lt;td&gt;Foreign keys, CHECK constraints, views, triggers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;Branded e-mail, sent only after the database commits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The rule I kept returning to was &lt;strong&gt;thin controllers, services for anything with rules in it&lt;/strong&gt;. Placing, changing and cancelling an order, moving it between states, redeeming a coupon, taking a payment, awarding a badge, checking a one-time code: all of that lives in &lt;code&gt;OrderService&lt;/code&gt;, &lt;code&gt;PaymentService&lt;/code&gt;, &lt;code&gt;CouponService&lt;/code&gt;, &lt;code&gt;BadgeService&lt;/code&gt; and &lt;code&gt;AccountSecurity&lt;/code&gt;. They can be tested without HTTP and reused from the customer area, the farmer panel, the admin panel and the scheduler.&lt;/p&gt;

&lt;p&gt;A few decisions paid for themselves many times over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One source of truth per concern.&lt;/strong&gt; SEO copy lives in one PHP class, rendered on the server and reused by React. Policy and FAQ text lives in one place and feeds the pages, the FAQ structured data and &lt;code&gt;llms.txt&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Notifications are queued and sent after commit.&lt;/strong&gt; If an order rolls back, nobody gets an "order placed" e-mail for something that never happened.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The web tier is stateless.&lt;/strong&gt; Sessions, cache and queue sit behind drivers, so moving them to Redis and adding servers is configuration, not a rewrite.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The scheduler does the boring, important work:&lt;/strong&gt; expiring unpaid checkouts, evening-before pickup reminders, the Monday restock, and the nightly badge recalculation.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;The core schema is 26 tables and four reporting views, with a handful more added later for payments, price history, badges and the seasonal calendar. The shape is simple once you see it: a &lt;strong&gt;farmer profile&lt;/strong&gt; belongs to a user and trades at several &lt;strong&gt;markets&lt;/strong&gt;. It lists &lt;strong&gt;products&lt;/strong&gt; and offers &lt;strong&gt;pickup slots&lt;/strong&gt; at each market. An &lt;strong&gt;order&lt;/strong&gt; belongs to one customer, one farmer, one market and one pickup slot, and holds &lt;strong&gt;order items&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What makes it trustworthy is what the database refuses to accept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Every relationship is a real foreign key with a deliberate &lt;code&gt;ON DELETE&lt;/code&gt; rule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;22 CHECK constraints&lt;/strong&gt; reject impossible data even if a bug slips past PHP: ratings outside 1–5, negative prices, a closing time before an opening time, a coupon used more often than its limit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;order_items&lt;/code&gt; keeps a &lt;strong&gt;snapshot&lt;/strong&gt; of the product name, unit and price, so last month's order still says what it said when a farmer edits the listing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Triggers write every status change into &lt;code&gt;order_status_history&lt;/code&gt;, and views like &lt;code&gt;v_market_revenue&lt;/code&gt; pre-join the heavy report queries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Every price change lands in &lt;code&gt;product_price_history&lt;/code&gt;, one row per product per day, which is what the price charts read.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One order, many states
&lt;/h2&gt;

&lt;p&gt;An order is a small state machine, and the server is the only place that decides whether a move is legal. A farmer can't mark a cancelled order as ready. A customer can't cancel after the cut-off. Every transition re-reads the order under a row lock first, so a customer cancelling at the exact moment the farmer taps "accept" can't leave it half one thing and half the other. The allowed transitions are unit-tested on their own, separately from the HTTP flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never selling what isn't there
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of, and it has its own article: &lt;a href="https://dev.to/blogs/the-last-mango-problem-race-conditions-in-a-real-marketplace"&gt;The last mango problem&lt;/a&gt;. The short version: stock, coupons and pickup-window capacity are all decided &lt;strong&gt;inside one transaction, under row locks&lt;/strong&gt;, taken in a fixed order so two checkouts can't deadlock each other.&lt;/p&gt;

&lt;p&gt;Unit tests can't really prove that, because they run in a single process. So I wrote a script that launches fifty separate PHP processes at checkout at the same instant, against the real MySQL database.&lt;/p&gt;

&lt;p&gt;The first run failed. A pickup window with room for three accepted twenty orders. The cause was a MySQL isolation detail I explain properly in the other article, and the fix was one line. I'd much rather find that with a script than with a queue of annoyed customers at a stall.&lt;/p&gt;

&lt;p&gt;The same thinking later caught two quieter bugs. A farmer saving the stock form while customers were buying could overwrite their purchases with the number the form had loaded minutes earlier. And the Monday restock read each product, then wrote it back, which could wipe a reservation made in between. Both are fixed; the article has the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payments, added later, done carefully
&lt;/h2&gt;

&lt;p&gt;The brief said "pay at pickup, no gateway", and cash at the stall is still always there. But a lot of people in Pakistan would rather tap Easypaisa or JazzCash than carry cash, so I added online payment on top of the brief, and I treated it as the riskiest feature in the project.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Placing an online order &lt;strong&gt;reserves the stock and opens a payment window&lt;/strong&gt; (20 minutes by default). Farmers aren't notified and can't accept until it's paid. Unpaid checkouts are released by a scheduled job, which puts the stock back and returns the coupon.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The amount is always recomputed on the server&lt;/strong&gt; from the orders. The browser never tells the server how much to charge.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A double tap can't charge twice.&lt;/strong&gt; The payment row is locked, it moves from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;processing&lt;/code&gt; exactly once, and every attempt carries a one-time idempotency key under a unique index, so a replayed request returns the existing result.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Late confirmations are refunded automatically.&lt;/strong&gt; If the gateway confirms after the window has closed and the stock has gone back on sale, GleanGrid records a &lt;code&gt;late_capture&lt;/code&gt; event and refunds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Card numbers and CVCs are never stored, logged or flashed back to the session.&lt;/strong&gt; Only the brand and the last four digits are kept.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;JazzCash's hosted checkout is wired in with its &lt;code&gt;pp_SecureHash&lt;/code&gt; HMAC, checked again on the signed callback in constant time. In sandbox mode a built-in simulator runs the whole flow with test cards, so the competition judges could try it without real money.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Signing in, and not getting taken over
&lt;/h2&gt;

&lt;p&gt;Google and Facebook sign-in were the other big addition. The interesting part isn't the button, it's the edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An e-mail is only trusted if the provider says it's verified.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pre-account takeover&lt;/strong&gt; is blocked. If someone registered your e-mail with a password but never verified it, and you then sign in with Google, the unverified password is wiped and its sessions are killed. Otherwise the squatter would still hold a way in.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Profile photos are downloaded once, only over HTTPS and only from Google or Facebook hosts, with every redirect re-checked (no SSRF), then re-encoded to WebP.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No access or refresh tokens are stored, and you can't disconnect your last way of signing in.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security, layer by layer
&lt;/h2&gt;

&lt;p&gt;A marketplace holds people's names, phone numbers and addresses, so security was part of the build from the first migration, not a feature bolted on at the end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Passwords&lt;/strong&gt; are hashed with Argon2id, and old bcrypt hashes upgrade on the next sign-in.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sign-in&lt;/strong&gt; has per-account and per-IP lockouts, six-digit e-mail codes, alerts for new devices, and "sign out everywhere else".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bots&lt;/strong&gt; meet four layers. A honeypot field. A &lt;strong&gt;signed form ticket&lt;/strong&gt;: every page render carries a fresh, encrypted issue time, and a form that comes back too fast, hours later, or with a forged ticket is refused. (My first version trusted a timestamp from the browser, which a script can simply fake.) Invisible &lt;strong&gt;reCAPTCHA v3&lt;/strong&gt; scoring. And a visible challenge, &lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; or the reCAPTCHA v2 box, on sign-up, contact and password resets, and on sign-in after a low score.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CSRF&lt;/strong&gt;: every state-changing request carries a token, the token is &lt;strong&gt;regenerated on every full page load&lt;/strong&gt;, and any write the browser marks as cross-site is refused before it reaches a controller.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;XSS&lt;/strong&gt; is handled by React's escaping plus a nonce-based Content-Security-Policy with &lt;strong&gt;a new nonce on every response&lt;/strong&gt;, &lt;code&gt;strict-dynamic&lt;/code&gt;, &lt;code&gt;object-src 'none'&lt;/code&gt; and &lt;code&gt;frame-ancestors 'self'&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SQL injection&lt;/strong&gt; has nowhere to go: every query runs through prepared statements.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Uploads&lt;/strong&gt; are shrunk and converted to WebP in the browser, then decoded and re-encoded on the server, which strips EXIF and GPS data and anything hidden after the pixels. SVG is refused.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Basket Buddy&lt;/strong&gt;, the on-site assistant, is tap-only. It accepts whitelisted intent keys, never free text, so there's nothing to prompt-inject, and every personal answer is built from the signed-in user alone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Floods&lt;/strong&gt; hit a site-wide rate limiter on every page, with much tighter limits on sign-in, sign-up, search, checkout, payments and every write.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Designing for a real market morning
&lt;/h2&gt;

&lt;p&gt;People open GleanGrid on a phone, often on mobile data, usually in a hurry. That set the priorities.&lt;/p&gt;

&lt;p&gt;On phones the search bar gets its own row, categories sit in a scrollable rail with item counts, and the other filters open in a bottom sheet with &lt;strong&gt;Reset&lt;/strong&gt; and &lt;strong&gt;Show results&lt;/strong&gt;, so you set three filters and load once instead of three times. Every filter, search and page is a readable path rather than a query string (&lt;code&gt;/products/category/fruits/price/100-500/sort/price-low&lt;/code&gt;), which is friendlier to share and to search engines.&lt;/p&gt;

&lt;p&gt;Every product has a real photograph paired with a 3D illustration. The photo tells you what you're actually buying; the illustration keeps the catalogue recognisable at a glance. Product pages also chart the price over one, three or six months and say when today's price is below its 30-day average, and a seasonal calendar shows what's at its peak in Sindh this month.&lt;/p&gt;

&lt;p&gt;The basket groups items by stall, because each stall becomes its own pre-order with its own pickup window. It shows the next pickup for every stall, lets you undo a removal, and keeps the total and the checkout button in a sticky bar on phones.&lt;/p&gt;

&lt;p&gt;A few more details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Eight languages&lt;/strong&gt;, including Urdu and Arabic, which flip the whole layout to right-to-left. Every locale has every key, checked by a script.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Search&lt;/strong&gt; opens anywhere with Ctrl K, groups results into produce, farmers and markets, highlights matches and suggests "tomatoes" when you type "tomatos".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stall badges&lt;/strong&gt; (Top Rated, Reliable Pickups, Rising Star, Customer Favourite) are recalculated nightly. Top Rated uses a Bayesian average, so a stall with two five-star reviews can't outrank one with fifty 4.8s.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Motion with manners&lt;/strong&gt;: headlines reveal word by word, items fly into the basket, two galleries slide sideways as you scroll. All of it switches off for anyone who prefers reduced motion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dark mode&lt;/strong&gt; and an installable &lt;strong&gt;PWA&lt;/strong&gt; with an offline page.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The farmer and admin side
&lt;/h2&gt;

&lt;p&gt;Farmers aren't power users, so their panel is built around the three things they do every week: update stock, see what's been ordered, and hand it over.&lt;/p&gt;

&lt;p&gt;Admins get a control room: today's orders, 30-day trends, pickups for the next week, revenue by market, catalogue health, a payments console with every payment's timeline and one-click refunds, and a sign-in security panel that flags IP addresses with repeated failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO and performance
&lt;/h2&gt;

&lt;p&gt;Every page has a hand-written title and description, rendered on the server. Public pages carry linked structured data: &lt;code&gt;Product&lt;/code&gt; with an offer that points at the stall selling it, &lt;code&gt;LocalBusiness&lt;/code&gt; for stalls, &lt;code&gt;Place&lt;/code&gt; with opening hours for markets, &lt;code&gt;FAQPage&lt;/code&gt; for the help page, and a site search action. There's a live sitemap, sensible &lt;code&gt;robots&lt;/code&gt; rules, and &lt;code&gt;llms.txt&lt;/code&gt; / &lt;code&gt;llms-full.txt&lt;/code&gt; for AI assistants that read the web.&lt;/p&gt;

&lt;p&gt;Only English ships in the main bundle; every other language loads on first use. Maps load as they approach the viewport, images are WebP with responsive sizes, and fonts never block the first paint. Lighthouse gives the public pages &lt;strong&gt;100 for accessibility, best practices and SEO&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;133 PHPUnit tests with 779 assertions&lt;/strong&gt; run against a real MySQL database and cover the order flow, payments, coupons, security headers, the bot and CSRF defences, structured data, race conditions and every role's pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Playwright end-to-end tests&lt;/strong&gt; drive the real site as a visitor, a customer and an admin, on desktop and on a phone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &lt;strong&gt;stress script&lt;/strong&gt; proves the concurrency guarantees with real parallel processes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; runs all of it on PHP 8.2 and 8.3, plus a code-style check and a dependency audit, on every push.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I worked, and where AI helped
&lt;/h2&gt;

&lt;p&gt;I'll be straight about this, because I think it matters. I designed and wrote GleanGrid myself, and every decision in this article is one I made and can defend. I did use Claude as a second pair of eyes. It was genuinely useful for debugging, when I was staring at a failing test and needed someone to question my assumptions. It was useful for finding improvements, like pointing at a place where a lock was taken too late or a check trusted the browser. And it helped me tighten the documentation, which is the part of a project I'm most tempted to rush.&lt;/p&gt;

&lt;p&gt;What it didn't do was replace understanding. The REPEATABLE READ bug is a good example. A suggestion is only worth something if you can explain why it's right, and I only trusted a fix once I could reproduce the failure with fifty processes and watch it disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;p&gt;If GleanGrid grew beyond a handful of markets, the next steps are clear. Redis for sessions, cache and rate-limit counters. A CDN in front of the images, with Cloudflare absorbing floods before they reach the server. WhatsApp alerts when an order is ready, because that's where people here actually read messages. And live Easypaisa and card acquiring, which plug into the same gateway interface JazzCash already uses once there's a merchant agreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;The interesting problems were never the ones on the feature list. They were the questions in between. What happens when two people want the last item? What does the database see while a transaction is waiting? What happens if the payment gateway says yes twenty-one minutes later? What does a farmer need at six in the morning, with wet hands and one bar of signal? Building GleanGrid made me answer those properly instead of hoping they wouldn't come up.&lt;/p&gt;

&lt;p&gt;If you run a market, sell at one, or just have thoughts on any of this, I'd genuinely like to hear from you. The source is on &lt;a href="https://github.com/ahmershahdev/GleanGrid" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and my inbox is open.&lt;/p&gt;




&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A Home on the Internet</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Fri, 25 Sep 2026 15:47:36 +0000</pubDate>
      <link>https://dev.to/syedahmershah/a-home-on-the-internet-3no8</link>
      <guid>https://dev.to/syedahmershah/a-home-on-the-internet-3no8</guid>
      <description>&lt;p&gt;For most of this year my writing lived in other people’s houses. An article went up on Hashnode, then DEV, then Medium, and my portfolio pointed at all of them with a small stack of cards that said “read more” and sent you somewhere else. I was a guest everywhere, and the one place with my name on the door had no room for the words.&lt;/p&gt;

&lt;p&gt;That changed this month. I rebuilt &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt; from an empty folder and gave my writing a permanent address on it. Somewhere in the middle of that, Google started showing a knowledge panel when you search my name. This post covers all three, as plainly as I can, including the parts that aren’t finished yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the old site&amp;nbsp;was
&lt;/h3&gt;

&lt;p&gt;The previous version is still public on GitHub as &lt;a href="https://github.com/ahmershahdev/old_portfolio_v2" rel="noopener noreferrer"&gt;old_portfolio_v2&lt;/a&gt;. It was a static site: hand-written HTML, vanilla JavaScript with some jQuery, Bootstrap 5 for the grid, and Three.js loading a 3D Iron Man model into the hero. Some parts I was proud of at the time. There was a certificate flipbook built on Turn.js that you could actually page through, a dark and light theme toggle, a service worker so it kept working offline, and a contact form wired to EmailJS.&lt;/p&gt;

&lt;p&gt;It also had the problems every hand-rolled static site collects. Forty-one stylesheets, each one patching something another one broke. More than thirty scripts that had to load in the right order or the page fell over. Every section was its own island, so changing the brand green meant a find-and-replace across the project and hoping nothing else matched.&lt;/p&gt;

&lt;p&gt;And the blog wasn’t really a blog. It was a card stack of links to articles hosted somewhere else. The README listed “Blog integration with real articles (not just links)” under &lt;em&gt;Upcoming Features&lt;/em&gt;. It never left that list.&lt;/p&gt;

&lt;p&gt;None of that makes it a bad site. It was what I could build at the time, and it taught me exactly what I didn’t want to maintain for another year.&lt;/p&gt;

&lt;h3&gt;
  
  
  What &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt; is&amp;nbsp;now
&lt;/h3&gt;

&lt;p&gt;The new site is a Next.js 16 app on React 19, written in TypeScript and styled with Tailwind CSS v4. Motion runs on GSAP with ScrollTrigger, smooth scrolling on Lenis, and the hero scene is three.js through React Three Fiber. It deploys to Vercel from a private repository.&lt;/p&gt;

&lt;p&gt;The stack isn’t what makes it better, though. The structure is.&lt;/p&gt;

&lt;p&gt;One source of truth. My name, role, education, every certificate with its credential ID, the services, the client reviews: each lives once, in a typed content file. The pages render from those files. So does the structured data search engines read, and so does the small assistant in the corner of the site. When something changes, it changes everywhere, and I can’t accidentally tell Google one thing and a visitor another.&lt;/p&gt;

&lt;p&gt;Phones get their own layout, not a shrunken one. Below the desktop breakpoint, three.js never downloads at all. The hero turns into a poster layout, the education timeline runs vertically, and the loader gets shorter. A phone on a slow connection shouldn’t pay for a 3D scene it can barely show.&lt;/p&gt;

&lt;p&gt;Honest numbers. Lighthouse gives accessibility, best practices and SEO a score of 100 on every page type I tested, on mobile and desktop. Performance is where I have to be straight with you. The blog, article and legal pages score between 95 and 99 on desktop. The home page sits around 55 on desktop, and the mobile scores land in the high 50s and low 60s. The home page is one long document with every section in it, plus a WebGL scene. I chose that design knowing what it costs, and I’d rather show you the number than claim a perfect score.&lt;/p&gt;

&lt;p&gt;There are small things too: interface sounds synthesized live in the browser instead of loaded from audio files, and a scroll-to-top button shaped like a pistol. I left a few jokes in for people who look closely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The blog moved&amp;nbsp;home
&lt;/h3&gt;

&lt;p&gt;This is the part I care about most.&lt;/p&gt;

&lt;p&gt;All 44 of my articles now live at &lt;a href="https://ahmershah.dev/blogs" rel="noopener noreferrer"&gt;ahmershah.dev/blogs&lt;/a&gt; as Markdown files inside the site itself. Code blocks are highlighted at build time. Each article has a table of contents, a reading time, tags, and a series if it belongs to one. There’s an RSS feed, a save-for-later list, a read-aloud mode that highlights each paragraph as it’s spoken, and a button to download any article as Markdown. This week I added a featured slot, pinned articles, sorting (latest, oldest, longest, quickest, A to Z) and an index view for scanning the whole archive at once.&lt;/p&gt;

&lt;p&gt;Why bother, when free platforms already exist? Because the words are mine and should live at an address I control. Platforms change their rules, their paywalls and their feeds. If any one of them shut down tomorrow, nothing on &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt; would break, and no link I’ve shared would go dead.&lt;/p&gt;

&lt;h3&gt;
  
  
  How cross-posting works&amp;nbsp;now
&lt;/h3&gt;

&lt;p&gt;I’m not leaving the other platforms. DEV, Medium, Hashnode, CoderLegion and AWS Builder Center have readers I’d never reach alone, so every article still goes to them, sometimes reworded for the audience there.&lt;/p&gt;

&lt;p&gt;What changed is the direction. New articles are published on &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt; first, and each copy elsewhere carries a canonical link back to the original. A canonical link is one line in a page’s head that tells search engines “the real version of this lives over there.” Without it, five copies of the same article compete with each other in search results. With it, the original gets the credit and the copies still get read.&lt;/p&gt;

&lt;p&gt;The older articles are a different story, and I’ve kept them honest. Before this site had a blog, each piece was published first on DEV, Medium or Hashnode, and that’s where its canonical link still points. Its page here says so under the title, links to the original, and lists the other places it was shared. Relabelling those older posts as “originally on &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt;” would be rewriting history, and search engines would see through it anyway. The rule only runs forward: from this article on, the original lives here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it isn’t open source&amp;nbsp;(yet)
&lt;/h3&gt;

&lt;p&gt;The old portfolio is public. This one isn’t, and that’s deliberate.&lt;/p&gt;

&lt;p&gt;The site is new, and I’m still watching how it behaves on real phones, slow networks and browsers I don’t own. This week alone I fixed testimonial arrows that didn’t respond to taps on phones, service cards whose buttons were cut off on small screens, and a 404 page that deserved better than it had.&lt;/p&gt;

&lt;p&gt;The biggest fix was email. The contact form used to hand messages to EmailJS from your browser, and it wasn’t reliable enough. It now goes through &lt;a href="https://resend.com/" rel="noopener noreferrer"&gt;Resend&lt;/a&gt;: the email is sent from the server, from my own domain, after the form has been checked for spam. When you write to me, you get one confirmation that your message arrived and that I’ll reply within 2 to 4 working days. One email, not a newsletter.&lt;/p&gt;

&lt;p&gt;Once the site has been stable for a while and I’ve cleaned up the parts I’m not proud of yet, I’ll think about opening the code. I’m not promising a date.&lt;/p&gt;

&lt;h3&gt;
  
  
  A knowledge panel with my name on&amp;nbsp;it
&lt;/h3&gt;

&lt;p&gt;The second piece of news is smaller than it sounds, and I want to describe it accurately.&lt;/p&gt;

&lt;p&gt;When you search my name, Google now shows a knowledge panel: &lt;a href="https://share.google/PzrJo1B8KbhAWnwvb" rel="noopener noreferrer"&gt;here it is&lt;/a&gt;. A knowledge panel is the information box Google shows for a person, place or organization it’s confident it understands, assembled from sources across the web. You can’t buy one or apply for one.&lt;/p&gt;

&lt;p&gt;Google doesn’t explain why it creates a panel, so I can’t tell you exactly what did it. Here’s what I did that I believe helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The same name, photo and description everywhere. LinkedIn, GitHub, the writing platforms and the business listings all describe the same person in the same words.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One identity in the site’s structured data. Every page on &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;ahmershah.dev&lt;/a&gt; refers to a single &lt;code&gt;Person&lt;/code&gt; entry, and that entry lists every profile that belongs to me.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Consistent handles. &lt;code&gt;@syedahmershah&lt;/code&gt; and &lt;code&gt;@ahmershahdev&lt;/code&gt; on every platform where I could get them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Writing under my own name, regularly, so there was something real for all of those profiles to point at.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ve since claimed the panel. Claiming means Google checked that I really am the person it describes, through my own verified accounts. After that I can suggest corrections when something in the panel is wrong or out of date.&lt;/p&gt;

&lt;p&gt;What it doesn’t mean matters just as much. It doesn’t make me famous, and it isn’t an endorsement of my work. It means Google is confident who I am and which profiles are mine, and it lets me keep that description accurate. For someone early in their career that’s genuinely useful: a client who searches my name finds me, my work and my writing in one place, not a guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Watch the new contact form deliver for a few weeks, and fix whatever real visitors find.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep every new article’s copies on DEV, Medium and Hashnode pointing back here.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep writing. The blog has room now.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Decide, later and without hurry, whether to open-source the site.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A portfolio used to be a brochure to me: something you glance at and leave. This one is closer to a home. The work lives here, the writing lives here, and everything else I have on the internet points back to it.&lt;/p&gt;

&lt;p&gt;If something on the site breaks for you, tell me through the &lt;a href="https://ahmershah.dev/#contact" rel="noopener noreferrer"&gt;contact form&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Art of Object-Oriented Programming</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Thu, 17 Sep 2026 18:31:03 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/the-art-of-object-oriented-programming-2b9g</link>
      <guid>https://dev.to/thesiliconarchitect/the-art-of-object-oriented-programming-2b9g</guid>
      <description>&lt;p&gt;Most developers learn OOP backwards — memorize four buzzwords, pass the exam, then go write procedural code inside classes. That’s not object-oriented programming. That’s just spaghetti with labels on it.&lt;/p&gt;




&lt;p&gt;OOP is a design philosophy before it’s a syntax. It’s a way of breaking a complex system into smaller, self-contained pieces — pieces that each know their job, protect their own state, and can talk to each other without the whole thing falling apart. The syntax is easy. The thinking is what takes years.&lt;/p&gt;




&lt;h3&gt;
  
  
  Classes &amp;amp;&amp;nbsp;Objects
&lt;/h3&gt;

&lt;p&gt;A class is a blueprint. An object is the thing built from it.&lt;/p&gt;

&lt;p&gt;Think of an architect’s floor plan for a house. The blueprint describes two bedrooms, a kitchen, a front door. But you can’t live in the blueprint. When a builder follows it and constructs an actual house — that’s the object. Build fifty houses from the same blueprint and each one is its own thing: different paint, different furniture, different family inside.&lt;/p&gt;

&lt;p&gt;Same class. Two separate objects. This is the starting point.&lt;/p&gt;




&lt;h3&gt;
  
  
  State, Behavior &amp;amp;&amp;nbsp;Identity
&lt;/h3&gt;

&lt;p&gt;Every object carries three things: what it &lt;em&gt;knows&lt;/em&gt;, what it &lt;em&gt;can do&lt;/em&gt;, and what makes it &lt;em&gt;uniquely itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State&lt;/strong&gt; is the data — a bank account’s balance, a user’s email address. &lt;strong&gt;Behavior&lt;/strong&gt; is the methods — a bank account can deposit, withdraw, generate a statement. &lt;strong&gt;Identity&lt;/strong&gt; is what separates one object from another even if their data is identical. Two bank accounts with the same zero balance are still two separate accounts.&lt;/p&gt;

&lt;p&gt;A constructor is where this all begins. It’s the object’s moment of birth — where you set up valid, usable initial state before anyone else touches it.&lt;/p&gt;

&lt;p&gt;Good constructors do one thing: produce a valid object and get out. No database calls, no network requests, no side effects. Just state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Methods&lt;/strong&gt; are where responsibility lives — but the most important question about any method isn’t “does it work?” It’s “does it belong here?” A &lt;code&gt;User&lt;/code&gt; class should not have a method called &lt;code&gt;sendInvoice()&lt;/code&gt;. That's not the user's job. When a method doesn't belong, it's a signal that your boundaries are wrong.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Four Pillars — What They Actually&amp;nbsp;Mean
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Encapsulation
&lt;/h3&gt;

&lt;p&gt;Encapsulation is not “make things private.” That’s the mechanism. The principle is protecting your state from being changed in ways you didn’t intend.&lt;/p&gt;

&lt;p&gt;Think of a vending machine. You can see the snacks through the glass, put money in, and press a button. But you cannot reach inside and grab whatever you want. The machine controls its own internals. You interact through a defined interface.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;stock&lt;/code&gt; field is private. No external code can set &lt;code&gt;stock = 999&lt;/code&gt;. All changes go through &lt;code&gt;purchase()&lt;/code&gt;, which enforces the rules. That's the point — not hiding data for its own sake, but controlling how it changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Abstraction
&lt;/h3&gt;

&lt;p&gt;Abstraction hides complexity behind a simple interface.&lt;/p&gt;

&lt;p&gt;When you drive a car, you don’t need to understand internal combustion to press the accelerator. The car abstracts all of that away. You get a pedal. You press it. The machinery underneath is none of your business.&lt;/p&gt;

&lt;p&gt;The calling code just calls&amp;nbsp;&lt;code&gt;.charge()&lt;/code&gt;. It doesn't care how. Tomorrow you add a PayPal gateway. The calling code doesn't change. That's abstraction doing its job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inheritance
&lt;/h3&gt;

&lt;p&gt;Inheritance captures the “is-a” relationship. A &lt;code&gt;Dog&lt;/code&gt; is an &lt;code&gt;Animal&lt;/code&gt;. A &lt;code&gt;SavingsAccount&lt;/code&gt; is a &lt;code&gt;BankAccount&lt;/code&gt;. You inherit the shared structure, then specialize.&lt;/p&gt;

&lt;p&gt;Here’s where most people go wrong: they use inheritance for code reuse rather than for actual “is-a” relationships. If you’re inheriting from a class just to borrow two methods, you’ve built a dependency that’ll betray you when the parent class needs to change. Inheritance should model a genuine relationship — not be a shortcut.&lt;/p&gt;

&lt;h3&gt;
  
  
  Polymorphism
&lt;/h3&gt;

&lt;p&gt;Polymorphism means the same interface, different behavior. One method name, many implementations depending on which object you’re actually calling it on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;printArea&lt;/code&gt; doesn't know or care whether it's working with a circle or a rectangle. It calls&amp;nbsp;&lt;code&gt;.area()&lt;/code&gt; and trusts the object to do the right thing. Add a &lt;code&gt;Triangle&lt;/code&gt; tomorrow — &lt;code&gt;printArea&lt;/code&gt; doesn't need to touch it. This is what makes systems extensible without becoming fragile.&lt;/p&gt;




&lt;h3&gt;
  
  
  Composition vs. Inheritance — The Choice Nobody Teaches&amp;nbsp;Properly
&lt;/h3&gt;

&lt;p&gt;Real OOP design lives in this question: should this object &lt;em&gt;be&lt;/em&gt; the other thing, or should it &lt;em&gt;have&lt;/em&gt; it?&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Car&lt;/code&gt; doesn't inherit from &lt;code&gt;Engine&lt;/code&gt;. That would be absurd. A &lt;code&gt;Car&lt;/code&gt; has an engine. It &lt;em&gt;uses&lt;/em&gt; an engine. That's composition.&lt;/p&gt;

&lt;p&gt;If tomorrow you need an &lt;code&gt;ElectricEngine&lt;/code&gt;, you swap it in. The &lt;code&gt;Car&lt;/code&gt; class doesn't change at all. Inheritance would have made that painful. Composition keeps it trivial.&lt;/p&gt;

&lt;p&gt;The general rule: prefer composition. Use inheritance only when you have a genuine “is-a” relationship and you want to express that relationship explicitly in the design.&lt;/p&gt;




&lt;h3&gt;
  
  
  Association, Aggregation &amp;amp; Composition
&lt;/h3&gt;

&lt;p&gt;These three describe how tightly objects are bound to each other — and it matters more than most tutorials acknowledge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Association&lt;/strong&gt; is the loosest connection. A &lt;code&gt;Doctor&lt;/code&gt; treats &lt;code&gt;Patients&lt;/code&gt;. They know about each other. Neither owns the other. They can both exist independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregation&lt;/strong&gt; is a whole-part relationship where the parts survive without the whole. A &lt;code&gt;Team&lt;/code&gt; has &lt;code&gt;Players&lt;/code&gt;. Dissolve the team, the players still exist somewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composition&lt;/strong&gt; is the tightest bond — the part cannot exist without the whole. A &lt;code&gt;House&lt;/code&gt; has &lt;code&gt;Rooms&lt;/code&gt;. Destroy the house, the rooms aren't standalone things anymore.&lt;/p&gt;

&lt;p&gt;Getting this right isn’t academic. It determines how you structure your database schemas, how you handle object lifecycles, and what breaks when something gets deleted.&lt;/p&gt;




&lt;h3&gt;
  
  
  Coupling &amp;amp; Cohesion — The Two&amp;nbsp;Dials
&lt;/h3&gt;

&lt;p&gt;Every system has two dials you’re constantly tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coupling&lt;/strong&gt; measures how much one class depends on another. High coupling means a change in one class ripples into others, sometimes into five others you didn’t expect. Low coupling means classes can change independently. You want low coupling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cohesion&lt;/strong&gt; measures how focused a class is on a single purpose. A class that handles user registration, sends confirmation emails, generates PDFs, and logs activity — that’s low cohesion. It’s doing four different jobs. When you split that into four focused classes, each becomes easier to test, easier to change, and easier to understand.&lt;/p&gt;

&lt;p&gt;High cohesion and low coupling together produce modular code. A &lt;code&gt;UserRegistrationService&lt;/code&gt; that only handles registration and receives its email sender and logger through injection rather than creating them — that's what well-designed OOP looks like in practice.&lt;/p&gt;

&lt;p&gt;The question to ask every time you write a class: if I had to change this one thing, how many other places would break?&lt;/p&gt;




&lt;h3&gt;
  
  
  Common OOP Design&amp;nbsp;Mistakes
&lt;/h3&gt;

&lt;p&gt;A few patterns that feel right until they don’t:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The God Object.&lt;/strong&gt; One massive class that knows everything and controls everything. Usually named &lt;code&gt;Manager&lt;/code&gt;, &lt;code&gt;Handler&lt;/code&gt;, or &lt;code&gt;Utilities&lt;/code&gt;. This class grows without limit because "it's already there." Split it ruthlessly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anemic domain models.&lt;/strong&gt; Objects that are just bags of data — nothing but getters and setters — with all the actual logic sitting in a separate service. This is procedural code wearing OOP clothes. Behavior belongs with the data it operates on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep inheritance chains.&lt;/strong&gt; Five levels deep, the sixth developer can’t understand what they’re actually working with. The deeper the chain, the harder it breaks. Flatten it with composition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Abstracting too early.&lt;/strong&gt; Building interfaces and abstract base classes before you have two real concrete variations. Write the concrete thing first. Abstract only when you see the pattern emerge from actual code — not from prediction.&lt;/p&gt;




&lt;h3&gt;
  
  
  A Small Real-World Example
&lt;/h3&gt;

&lt;p&gt;Consider an order system. A bad design puts everything into &lt;code&gt;Order&lt;/code&gt;: it calculates totals, applies discounts, sends emails, and updates inventory. Every time any one of those things changes, you're editing this one class.&lt;/p&gt;

&lt;p&gt;A better design:&lt;/p&gt;

&lt;p&gt;Three classes. Three clear jobs. &lt;code&gt;Order&lt;/code&gt; knows its own data. &lt;code&gt;DiscountService&lt;/code&gt; knows how pricing works. &lt;code&gt;OrderNotifier&lt;/code&gt; knows how to reach the customer. None of them know too much about the others. Swap email notifications for SMS tomorrow — &lt;code&gt;Order&lt;/code&gt; doesn't care, &lt;code&gt;DiscountService&lt;/code&gt; doesn't care. Only &lt;code&gt;OrderNotifier&lt;/code&gt; changes.&lt;/p&gt;

&lt;p&gt;This is low coupling and high cohesion working together. It’s not clever. It’s disciplined.&lt;/p&gt;




&lt;h3&gt;
  
  
  Conclusion: OOP as a Design&amp;nbsp;Mindset
&lt;/h3&gt;

&lt;p&gt;OOP isn’t about memorizing four words. It’s not a style guide. It’s a discipline for managing complexity — for breaking a system that’s too big to hold in your head into pieces that each have a clear owner and a clear job.&lt;/p&gt;

&lt;p&gt;The programmers who use it well think before they type. They ask: where does this knowledge live? Who is responsible for this behavior? What happens to the rest of the system if this changes? They treat their objects the way an architect treats walls — with respect for what they hold up.&lt;/p&gt;

&lt;p&gt;Learn the concepts once. Then spend the rest of your time learning how they work together. The syntax is a day’s work. The design sense takes years. That’s where the actual art is.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;References&lt;/strong&gt;
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Gamma, Helm, Johnson, Vlissides — &lt;a href="https://www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented/dp/0201633612" rel="noopener noreferrer"&gt;Design Patterns: Elements of Reusable Object-Oriented Software&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Martin Fowler — &lt;a href="https://martinfowler.com/books/refactoring.html" rel="noopener noreferrer"&gt;Refactoring: Improving the Design of Existing Code&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Robert C. Martin — &lt;a href="https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882" rel="noopener noreferrer"&gt;Clean Code: A Handbook of Agile Software Craftsmanship&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;MDN Web Docs — &lt;a href="https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object-oriented_programming" rel="noopener noreferrer"&gt;Object-Oriented Programming in JavaScript&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mattias Petter Johansson — &lt;a href="https://www.youtube.com/watch?v=wfMtDGfHWpA" rel="noopener noreferrer"&gt;Composition over Inheritance (FunFunFunction, YouTube)&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>GPT-6 Astra: A New Era of Machine Intelligence</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:51:57 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/gpt-6-astra-a-new-era-of-machine-intelligence-1h30</link>
      <guid>https://dev.to/thesiliconarchitect/gpt-6-astra-a-new-era-of-machine-intelligence-1h30</guid>
      <description>&lt;p&gt;Before I get into this, a quick apology — I’m a bit late to the party on writing this. My university semester just kicked back in and the first few weeks are always the kind of chaos that makes you forget you had things to write. But better late than honest, so here we go.&lt;/p&gt;




&lt;h3&gt;
  
  
  What Is GPT-6&amp;nbsp;Astra?
&lt;/h3&gt;

&lt;p&gt;GPT-6 Astra is OpenAI’s newest and most powerful AI model. It was released on September 3, 2026, and OpenAI describes it as the most capable and aligned model they have ever broadly deployed. If you’re not familiar with the GPT model family, think of it like this — GPT-4 was the model that got everyone talking about ChatGPT, GPT-5 made it actually useful for serious work, and GPT-6 Astra is where OpenAI is now making a case that we might be at the edge of something called AGI, or Artificial General Intelligence. That’s a loaded term and we’ll get to it.&lt;/p&gt;

&lt;p&gt;In OpenAI’s model lineup, Astra sits at the very top. Its predecessor, GPT-5.6 Sol, was released just weeks before in July 2026. Astra replaces it as the flagship.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Launch
&lt;/h3&gt;

&lt;p&gt;Astra launched September 3, 2026. Access started with a limited set of organizations on day one, then rolled out to ChatGPT Plus, Pro, Business, and Enterprise users over the following days. It’s also available through the OpenAI API, Microsoft Azure, and AWS Bedrock. The API model identifier is &lt;code&gt;gpt-6-astra&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OpenAI president Greg Brockman called it a “generational leap” at launch and said it could eventually be seen as the arrival of AGI. That’s a bold thing to say out loud, and the tech community has been both excited and skeptical about it.&lt;/p&gt;




&lt;h3&gt;
  
  
  What Makes Astra Different?
&lt;/h3&gt;

&lt;p&gt;The most technically interesting thing about Astra is something called &lt;strong&gt;recurrent depth&lt;/strong&gt;, sometimes also described as “looped transformers.” Without going too deep into the math, traditional models predict the next word one step at a time. Recurrent depth allows the model to think internally before it outputs anything, looping through computations that aren’t visible as text. The result is that Astra uses roughly 65% fewer output tokens than its predecessor to do the same work, which makes it both faster and more cost-efficient on certain tasks.&lt;/p&gt;

&lt;p&gt;The tradeoff? This internal reasoning is harder to monitor. OpenAI acknowledges that parts of Astra’s thought process are obscured, which is a legitimate safety concern some researchers have flagged.&lt;/p&gt;

&lt;p&gt;The other major shift is that Astra is designed to be a &lt;strong&gt;computer operator&lt;/strong&gt;, not just a text generator. It can open browsers, navigate interfaces, click through software, run code in live environments, and complete tasks that span multiple steps without you holding its hand through each one. This is what people mean when they say “agentic AI.”&lt;/p&gt;

&lt;p&gt;Context window sits at 1,050,000 tokens with a max output of 128,000 tokens — same as GPT-5.6 Sol, so no expansion there, but the way Astra uses that context is reportedly more efficient. Knowledge cutoff is April 30, 2026.&lt;/p&gt;




&lt;h3&gt;
  
  
  Core Capabilities
&lt;/h3&gt;

&lt;p&gt;Astra is described as state-of-the-art in several areas:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning and Planning&lt;/strong&gt; — Astra can break down complex multi-step problems and actually carry them through. The UK AI Security Institute found Astra’s math time horizon without chain-of-thought monitoring was about 31 minutes, compared to 3.6 minutes for its predecessor. That’s a sign of significantly deeper planning ability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coding and Software Engineering&lt;/strong&gt; — Astra is strong here, especially in agentic workflows where it can work inside a coding environment, test its own output, and fix errors without prompting. OpenAI calls it their best model for software engineering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Computer and Browser Use&lt;/strong&gt; — This is arguably where Astra has made the biggest jump. On the OSWorld 2.0 benchmark, which tests how well an AI can operate a computer, Astra completed tasks in around 40 minutes versus 75 minutes for GPT-5.6 Sol — roughly 47% faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Science and Mathematics&lt;/strong&gt; — Astra scored 97.6% on FrontierMath Tier 4, which is a graduate-level math benchmark designed specifically to be hard for AI systems. It hit 96.0% on GPQA Diamond, covering biology, chemistry, and physics at the doctoral-question level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Professional Work&lt;/strong&gt; — Generating reports, building presentations, doing document analysis across hundreds of pages — Astra handles these workflows with more reliability than previous models.&lt;/p&gt;




&lt;h3&gt;
  
  
  Benchmarks: The Honest&amp;nbsp;Picture
&lt;/h3&gt;

&lt;p&gt;OpenAI’s own numbers are impressive. Astra scores 99.9% on ARC-AGI-3, 97.6% on FrontierMath Tier 4, and 100% on ExploitBench (the cybersecurity exploitation benchmark). Those headline numbers traveled everywhere when Astra launched.&lt;/p&gt;

&lt;p&gt;Here’s the part most articles skip: the independent benchmarks tell a more complicated story.&lt;/p&gt;

&lt;p&gt;On the Artificial Analysis Intelligence Index, an independent third-party aggregate, Astra scores 61.2 — effectively the same as GPT-5.6 Sol at 60.9, and behind Claude Fable 5.1 at 65.7. On coding specifically, Astra’s DeepSWE score sits around 74.1%, which is only about 1.4 points better than its own predecessor, and roughly tied with Claude Opus 5 and even some Gemini models.&lt;/p&gt;

&lt;p&gt;The ARC-AGI-3 99.9% score also comes with a footnote — it was achieved using OpenAI’s own adapter harness, which preserves reasoning state between actions in a way the standard neutral testing harness doesn’t. On the standard harness, the same model scored 62.7%.&lt;/p&gt;

&lt;p&gt;On Humanity’s Last Exam with tools, Astra scored 57.2%, behind Claude Fable 5.1 at 65.0%.&lt;/p&gt;

&lt;p&gt;So what does this actually mean? Astra genuinely leads in computer use, advanced mathematics, cybersecurity, and speed. It does not dominate every single category, and in some areas Claude Fable 5.1 still holds the edge. The “world’s most intelligent model” claim is specifically OpenAI’s claim on OpenAI’s tables. Third parties tell you it’s competitive, not a clean sweep.&lt;/p&gt;




&lt;h3&gt;
  
  
  Real-World Uses
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;For developers&lt;/strong&gt; — Astra inside Codex can write, test, debug, and iterate on code with less hand-holding. If you’re building something and want to offload repetitive engineering work, this is the practical use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For students and researchers&lt;/strong&gt; — Long-context retrieval at over a million tokens means you can feed Astra entire research papers, textbooks, or codebases and ask it to synthesize, analyze, or answer questions across all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For businesses&lt;/strong&gt; — Multi-step professional workflows. Think document review, report drafting, slide creation, compliance checking — tasks that previously needed multiple separate tools or a lot of human time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For automation&lt;/strong&gt; — Astra’s computer-use capabilities mean it can be set loose on repetitive browser tasks, data entry, QA testing, and similar work that doesn’t need constant supervision.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pricing and&amp;nbsp;Access
&lt;/h3&gt;

&lt;p&gt;API pricing is $10 per million input tokens and $50 per million output tokens. There’s also a Fast Mode that costs 2x the standard rate but delivers up to 2x the speed. Cached input (useful for developers sending the same context repeatedly) drops to $1 per million tokens.&lt;/p&gt;

&lt;p&gt;For comparison, this is about 2.5x the price of GPT-5.6 Sol at its current promotional rate, and roughly on par with Claude Fable 5.1. For ChatGPT subscribers, Astra is included in existing Plus, Pro, Business, and Enterprise plans, with extra usage available via credits.&lt;/p&gt;




&lt;h3&gt;
  
  
  Where It Genuinely Shines — and Where It&amp;nbsp;Doesn’t
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Astra is strong in:&lt;/strong&gt; computer and browser use, advanced math, long-context recall, speed over its predecessor, agentic multi-step tasks, and cybersecurity (with restrictions). On OpenAI’s internal hallucination benchmark, Astra’s error rate dropped from 12.2% (GPT-5.6 Sol) to 4.2% — that’s a meaningful reduction in the “making things up” problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Astra still struggles with:&lt;/strong&gt; raw aggregate intelligence by independent metrics, some reasoning benchmarks where Claude Fable 5.1 leads, and tasks requiring complex creative judgment or deeply human nuance. Cost is also a real limitation — at $50 per million output tokens, running Astra through heavy API workflows adds up fast. And the cybersecurity capabilities that are most powerful are locked behind OpenAI’s vetted access program, not freely available.&lt;/p&gt;

&lt;p&gt;The recurrent depth architecture also raises a monitoring concern that OpenAI itself acknowledges — some reasoning is hidden, making it harder to audit what the model was actually “thinking” when it made a decision.&lt;/p&gt;




&lt;h3&gt;
  
  
  Astra vs GPT-5.6&amp;nbsp;Sol
&lt;/h3&gt;

&lt;p&gt;The honest upgrade: Astra is faster, hallucinates less, and handles agentic/computer-use tasks significantly better. On coding and general intelligence by independent metrics, the gap is small. If you’re a developer doing agentic work, the upgrade is worth it — Astra matches Sol’s task performance at lower token cost due to efficiency gains. If you’re doing conversational or standard writing tasks, the difference isn’t dramatic.&lt;/p&gt;




&lt;h3&gt;
  
  
  My Experience: The Video Editing&amp;nbsp;Test
&lt;/h3&gt;

&lt;p&gt;I had a chance to test Astra’s video editing capability. And I’ll say this straight — as an AI doing video editing, it’s honestly impressive that we’re even having this conversation. The ability to handle effects, add text overlays, composite images onto video, manage layers — it does all of that at a level that would have seemed like science fiction just a couple of years ago.&lt;/p&gt;

&lt;p&gt;But there are gaps. The one that stood out most to me was sound effects syncing. I had a keyboard typing segment in a video, and the sound effect it placed for that just didn’t match — timing was off, the character of the sound didn’t fit the visual. It got the visual layer right. The audio layer was clumsy.&lt;/p&gt;

&lt;p&gt;More broadly, I felt Astra handles introductory-to-intermediate video editing reasonably well, but mid-complex work — anything where timing precision, layered audio mixing, or scene-specific judgment calls matter — it starts showing its limits. It’s not a replacement for a real video editor with experience. It’s a surprisingly capable assistant that will save you time on the parts it can handle, while still needing you for the parts it can’t.&lt;/p&gt;

&lt;p&gt;That gap is important. And it points to the broader message I want to land.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Future — and Why You Shouldn’t Be&amp;nbsp;Scared
&lt;/h3&gt;

&lt;p&gt;The conversation around AI “taking over jobs” gets loud every time a model like this drops. I want to push back on that framing.&lt;/p&gt;

&lt;p&gt;AI is a tool. A very powerful, increasingly capable tool — but a tool. The people who will lose ground are not the ones who use AI, but the ones who refuse to understand it. There’s a difference between someone who pastes code from ChatGPT without reading it, and someone who reads the code, understands what it’s doing and why, and then uses AI to move faster. The second person builds real skill. The first one just gets dependent.&lt;/p&gt;

&lt;p&gt;My recommendation, especially if you’re a student or a developer just starting out — use AI consistently, but stay in the driver’s seat. When AI writes code for you, read it. When it generates a plan, interrogate it. When it makes an error, understand why it made it. The understanding is what compounds. The AI just accelerates it.&lt;/p&gt;

&lt;p&gt;Astra, and whatever comes after it, represents genuine progress. AGI-level or not, these systems are getting better at autonomous, multi-step professional work at a pace that’s hard to ignore. The right response isn’t fear. It’s to get faster, sharper, and more grounded in what you actually know.&lt;/p&gt;




&lt;h3&gt;
  
  
  Final Verdict
&lt;/h3&gt;

&lt;p&gt;GPT-6 Astra is a real step forward, particularly for agentic work, computer use, and advanced mathematics. It’s not the clean sweep the launch headlines suggested — Claude Fable 5.1 holds its own in aggregate intelligence, and the independent benchmarks paint a more competitive picture than OpenAI’s own tables.&lt;/p&gt;

&lt;p&gt;If you’re a developer doing agentic or automation work, Astra is worth using. If you’re on ChatGPT Plus or Pro, explore it — the hallucination reduction alone is noticeable in daily use. If you’re evaluating it for heavy API workloads, the pricing is steep and should be modeled carefully before committing.&lt;/p&gt;

&lt;p&gt;Is this a “new era of machine intelligence”? That depends on what the next 12 months look like. What I’ll say is that we’re somewhere different from where we were a year ago, and things are moving fast enough that the question might answer itself by the time anyone finishes reading this.&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://openai.com/index/gpt-6-astra/" rel="noopener noreferrer"&gt;GPT-6 Astra: A new generation of intelligence — OpenAI&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/GPT-6_Astra" rel="noopener noreferrer"&gt;GPT-6 Astra — Wikipedia&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.datacamp.com/blog/gpt-6-astra" rel="noopener noreferrer"&gt;GPT-6 Astra Features, Benchmarks, and Pricing — DataCamp&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://artificialanalysis.ai/articles/benchmarking-gpt-6-astra" rel="noopener noreferrer"&gt;Benchmarking GPT-6 Astra — Artificial Analysis&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.mindstudio.ai/blog/gpt-6-astra-benchmarks-analysis" rel="noopener noreferrer"&gt;GPT-6 Astra Benchmarks: Is It Really Better Than Fable 5.1? — MindStudio&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The New Era of Software Engineering</title>
      <dc:creator>Syed Ahmer Shah</dc:creator>
      <pubDate>Fri, 04 Sep 2026 12:52:17 +0000</pubDate>
      <link>https://dev.to/thesiliconarchitect/the-new-era-of-software-engineering-1211</link>
      <guid>https://dev.to/thesiliconarchitect/the-new-era-of-software-engineering-1211</guid>
      <description>&lt;p&gt;I'm Syed Ahmer Shah. I'm 19, I'm from Pakistan, and I'm still an undergraduate studying Software Engineering. I want to say that clearly before anything else, because I'm not writing this as some expert looking down from a mountain of experience. I'm writing this as someone who is &lt;em&gt;in&lt;/em&gt; it right now, watching the ground shift under his own feet while still learning how to walk on it.&lt;/p&gt;

&lt;p&gt;This is not a "10 years from now, robots will take your job" article. I don't know what happens in 10 years. Nobody does, not even the people running the biggest AI labs in the world, no matter how confident they sound on stage. What I do know is what happened to me in the last year, what I saw happen to a senior developer I worked under during my internship, and what I've watched happen across the entire industry I'm trying to build a career in. That's what I want to tell you about.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Used to Work
&lt;/h2&gt;

&lt;p&gt;Before I get into AI, I want you to understand what software engineering actually was, because a lot of non-technical people imagine it as some kind of typing wizardry — fingers flying across a keyboard, green text on a black screen, hacker-movie stuff.&lt;/p&gt;

&lt;p&gt;It was never that. Software engineering was closer to construction work, except the building was invisible.&lt;/p&gt;

&lt;p&gt;You start with a problem. A bank wants an app so people can send money without visiting a branch. A restaurant wants a system to manage orders. A hospital wants patient records that don't get lost in a filing cabinet. The engineer's job was to take that vague human need and turn it into thousands, sometimes millions, of precise instructions that a machine could follow without ever getting confused, because machines don't tolerate confusion. A single misplaced comma could crash the entire system.&lt;/p&gt;

&lt;p&gt;This meant a developer spent years learning the "grammar" of programming languages — Python, Java, JavaScript, C++ — the same way you'd spend years learning a foreign language before you could write a novel in it. Then on top of that grammar, you had to learn logic: how to break a huge problem into small enough pieces that a computer could handle each piece one at a time. Then you had to learn the tools around it — databases to store information, servers to run your program, version control so ten people could work on the same project without overwriting each other's work.&lt;/p&gt;

&lt;p&gt;And then, once all of that was written, you had to test it, break it on purpose, find where it failed, and fix it. Debugging — hunting for the one wrong line in ten thousand — was often the most painful and most respected skill in the field. Senior engineers weren't senior because they typed fast. They were senior because they had, over years, built an instinct for where problems hide and how systems fail.&lt;/p&gt;

&lt;p&gt;That was the job. Slow, deliberate, deeply manual. You earned your competence one bug, one late night, one confusing error message at a time. That was the world I entered when I chose this degree.&lt;/p&gt;

&lt;p&gt;That world is not gone. But it is no longer the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the Machines Started Writing Code Too
&lt;/h2&gt;

&lt;p&gt;Somewhere in the last two or three years, something changed that most people outside tech still haven't fully registered. AI coding tools stopped being toys that could autocomplete a line of code, and started becoming something closer to junior engineers you could actually talk to.&lt;/p&gt;

&lt;p&gt;GitHub Copilot was one of the first tools that showed people this was even possible — it sat inside your code editor and suggested entire chunks of code as you typed, trained on an enormous amount of publicly written code. Then came tools like Cursor, which is basically an entire code editor rebuilt around AI from the ground up, letting you describe what you want in plain English and watch it write, edit, and restructure real files across a real project. Then Claude Code and OpenAI's Codex arrived, and these went even further — they don't just suggest code, they can plan a task, write across multiple files, run commands in a terminal, test what they built, notice their own mistakes, and fix them, almost like an actual teammate sitting next to you.&lt;/p&gt;

&lt;p&gt;This is the part I need you to sit with for a second, especially if you're not from a tech background: these tools are no longer just "helping you type faster." They can take a task like "add a login page with email verification" and actually go do it — write the files, connect the pieces, and hand you something that mostly works, in minutes.&lt;/p&gt;

&lt;p&gt;And the people who built this world are the ones telling us how far it's already gone. Google's CEO, Sundar Pichai, has said that well over 30% of the code at Google is now written by AI. Microsoft's CEO, Satya Nadella, has said something similar about his own company's codebase. Anthropic's CEO, Dario Amodei, predicted in early 2025 that the vast majority of code could be AI-written within a matter of months. Google's own AI leadership has talked about AI performing like a junior software engineer within a year. These aren't random influencers making noise for clicks. These are the people who literally run the companies building this technology, telling us — in public, on the record — that the ground has already moved. [1][2][3]&lt;/p&gt;

&lt;p&gt;And then there's Ryan Dahl. If you're not from tech, that name might mean nothing to you, but in this industry it means a lot. Dahl created Node.js in 2009, a piece of technology that quietly powers a massive chunk of the modern internet — it's one of the reasons so many websites and apps you use every day even work. This isn't some junior developer with a hot take. This is one of the most respected engineers alive. And in early 2026, he posted something that spread across the entire developer world within hours: that the era of humans writing code by hand, line by line, is effectively over. He was careful to say this doesn't mean engineers have nothing left to do — but that the "writing syntax word by word" part of the job, the part most of us spent years training for, is no longer where the real work lives. [4][5]&lt;/p&gt;

&lt;p&gt;When I read that, sitting in my hostel room in Pakistan, I felt something between excitement and a small, quiet panic. Because I knew exactly what he was talking about. I had already started feeling it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Own Trap
&lt;/h2&gt;

&lt;p&gt;I want to be completely honest here, because I think this is the part that actually matters for anyone reading this who is learning to code right now, anywhere in the world.&lt;/p&gt;

&lt;p&gt;When these tools got good, I got lazy. Not lazy in the sense of not working — I was working all the time, shipping code, finishing assignments, building projects fast. But lazy in a much more dangerous way. I would open an AI tool, describe what I wanted, and let it write the whole thing. It worked. It ran. I moved to the next task. I didn't read the code line by line. I didn't ask myself why a particular function was structured that way, or what a specific keyword was actually doing under the hood, or why the AI chose this approach over another one that might have existed.&lt;/p&gt;

&lt;p&gt;For a while, this felt like progress. I was building things faster than my classmates. I was getting things done. But somewhere in the back of my mind, a quiet alarm kept going off. If someone asked me to explain the code I had just "written," could I actually explain it? Sometimes I could. But more and more often, the honest answer was no. I would stare at my own project and realize I understood &lt;em&gt;what&lt;/em&gt; it did, but not &lt;em&gt;how&lt;/em&gt; or &lt;em&gt;why&lt;/em&gt;, not really — because I hadn't done the thinking. The AI had done the thinking. I had just done the asking and the copying.&lt;/p&gt;

&lt;p&gt;That is a terrifying place to be as an engineer. Because the moment something breaks in a way the AI can't fix — and it will happen, because AI still makes confident mistakes, still misunderstands context, still writes code that looks correct but hides a subtle flaw — you're stuck. You don't have the foundation to even know where to start looking. I remember one specific night where a project I'd mostly "AI-built" broke in production-like conditions, and I sat there for almost two hours completely lost, not because the problem was that hard, but because I had never built the muscle to think through it myself. I had outsourced the muscle before I'd even built it.&lt;/p&gt;

&lt;p&gt;That night was my turning point. I understood, very clearly, that if I kept going like this, I wasn't becoming a software engineer who uses AI. I was becoming a person who prompts AI and hopes for the best. And those are not the same thing, even if they can look identical from the outside for a while.&lt;/p&gt;

&lt;p&gt;So I changed how I worked, deliberately and stubbornly. Now, before I let an AI tool write a single line for me, I make it explain itself first. What is it about to do? How is it planning to do it? Why this approach and not another? I go through this with it — sometimes it takes thirty, forty minutes before any actual code gets written — until I genuinely understand the plan well enough that I could, in theory, write it myself, just slower. Only then do I let the AI generate the code, and even then, I read every part of it like I'm grading someone else's homework, because in a sense, I am. It's slower. It's less flashy. But I walk away from every session actually knowing something I didn't know before, instead of just having a folder full of code I can't defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Saw During My Internship
&lt;/h2&gt;

&lt;p&gt;This brings me to something that crystallized all of this for me — my internship at a software house called XCL.&lt;/p&gt;

&lt;p&gt;There was a senior Laravel developer there, someone with real years of experience, working on a genuinely large, complicated codebase — the kind of project with so many moving parts that a beginner would get lost just trying to find where one feature lived. One day I asked him if he could walk me through how he actually works now, day to day. He said sure, and showed me.&lt;/p&gt;

&lt;p&gt;What I watched was, honestly, a little surreal. He used Cursor almost constantly to write the actual code — describing what he needed, letting it generate large sections, adjusting as he went. Then, once a chunk of work was done, he would switch over to Claude specifically to review it — hunting for bugs, logic errors, things that didn't quite fit the rest of the system. He told me straightforwardly: this is what works for him now. Write with one tool, review with another, move fast, ship fast.&lt;/p&gt;

&lt;p&gt;I remember standing there thinking two things at the same time, almost fighting each other in my head. The first was pure amazement — this man was moving through a massive codebase at a speed that would have taken a team of people years earlier. The second feeling was something closer to dread. Because if this is how a senior engineer with real, hard-earned experience works now — AI writes, AI reviews, AI fixes — then what exactly is left for someone like me, who's still learning, still building that foundation? Are we all just going to become prompt engineers, typing instructions into a box and hoping the box understands us well enough?&lt;/p&gt;

&lt;p&gt;I sat with that question for a long time. I still don't have a perfect answer. But I've come to believe something, and I want to explain why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will Engineers Be Replaced?
&lt;/h2&gt;

&lt;p&gt;Here's what I actually think, based on what I've read, what I've watched with my own eyes, and what I've lived through myself: software engineers are not being replaced. But the &lt;em&gt;way&lt;/em&gt; we work is already changing, and it started changing a while ago, not in some far future.&lt;/p&gt;

&lt;p&gt;Think about it like this. When calculators became common, accountants didn't disappear — but an accountant who could only add and subtract by hand, with nothing else to offer, became far less valuable than one who understood what the numbers actually meant for a business. The tool didn't remove the need for judgment. It removed the need for manual labor that judgment used to require. Something similar, but far more dramatic, is happening in software.&lt;/p&gt;

&lt;p&gt;The parts of the job that were pure manual labor — typing out boilerplate code, remembering exact syntax, writing repetitive functions that follow known patterns — are being absorbed by AI, and honestly, good riddance to some of it. What's rising in value instead is exactly what AI still can't reliably do on its own: understanding what a business or a person actually needs and translating vague, messy human wants into a real system; making architectural decisions about how a large system should be structured so it doesn't collapse under its own weight two years later; knowing when the AI-generated solution is subtly wrong even when it looks perfectly fine on the surface; and taking responsibility when something breaks, because AI cannot be held accountable — you can.&lt;/p&gt;

&lt;p&gt;Ryan Dahl himself made this same point when his statement went viral — he wasn't saying engineers are finished, he was saying the part of the job tied to typing syntax by hand is finished, and what remains is design thinking, decision-making, and reviewing and steering what the machine produces. That distinction matters enormously. [5][6]&lt;/p&gt;

&lt;p&gt;So here's the honest, unglamorous truth I've landed on, and I say this as someone still figuring it out, not as someone who has it all solved: AI will not replace the engineer. But it will absolutely replace two specific kinds of people. First, it will replace the engineer who refuses to touch it at all, who insists on doing everything the old manual way out of pride or fear, because they will simply be too slow compared to everyone else in the industry. And second — and this is the one that scares me more, because I almost became this person — it will replace the engineer who depends on it completely, who never builds real understanding, who can prompt but can't think, and who becomes helpless the moment the AI gets something wrong, which it still does, regularly.&lt;/p&gt;

&lt;p&gt;The survivors, I think, will be the people in the middle. People who use AI aggressively, without guilt, because refusing to use a tool this powerful would be its own kind of foolishness. But people who never let it replace the actual understanding in their own heads — who still ask why, who still read the code line by line, who still know enough to catch the AI when it confidently walks off a cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Telling Myself, and Maybe You Too
&lt;/h2&gt;

&lt;p&gt;I don't know exactly what software engineering will look like in five years. I don't think anyone genuinely does, no matter how certain they sound in interviews or on X. But I know what I'm choosing to do with my own hands, right now, at 19, while I still have time to build the right habits before they calcify into bad ones.&lt;/p&gt;

&lt;p&gt;I use AI every single day. I'm not going to pretend otherwise, and I don't think anyone in this field honestly can anymore. But I use it &lt;em&gt;after&lt;/em&gt; I understand the plan, not instead of understanding it. I let it write the boring parts fast, so I have more time to think about the parts that actually require thinking. And when something feels like magic — when the AI produces something that just works and I don't quite know why — I stop and I dig until I do know why, even if it costs me an extra half hour I didn't plan for.&lt;/p&gt;

&lt;p&gt;That senior developer at XCL wasn't wrong to work the way he does. He's fast, he's effective, and he ships real, working software for real clients. But he got to that speed &lt;em&gt;after&lt;/em&gt; years of understanding what "good code" even means, after years of debugging things by hand, after building the judgment that now lets him trust and check an AI's work in seconds instead of hours. I think that's the actual lesson hiding underneath all of this. The tools have changed. The starting point has not. You still have to understand before you can supervise. You just get to supervise something a lot faster and more capable than yourself, if you've earned the right to.&lt;/p&gt;

&lt;p&gt;We're not becoming obsolete. We're becoming something else — something that doesn't fully have a name yet. Call it what you want. I just know I'd rather walk into that future understanding every step I take, even if it's slower, than sprint into it blind and find out too late that I never actually learned to walk on my own.&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Reuters/company statements on AI-generated code at Google and Microsoft, as reported via CEO remarks from Sundar Pichai and Satya Nadella (2025).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dario Amodei, Anthropic CEO, public remarks on projected AI coding capability, March 2025.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Jeff Dean, Google AI leadership, public remarks on AI performing at a junior-engineer level, May 2025.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ryan Dahl (creator of Node.js and Deno), public post on X, January 2026 — reported by &lt;em&gt;OfficeChai&lt;/em&gt;, "Era Of Humans Writing Code Is Over: Node.js Creator Ryan Dahl," &lt;a href="https://officechai.com/ai/era-of-humans-writing-code-is-over-node-js-creator-ryan-dahl/" rel="noopener noreferrer"&gt;https://officechai.com/ai/era-of-humans-writing-code-is-over-node-js-creator-ryan-dahl/&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="http://Reading.sh" rel="noopener noreferrer"&gt;&lt;em&gt;Reading.sh&lt;/em&gt;&lt;/a&gt;, "The creator of Node.js says the era of writing code is over," &lt;a href="https://reading.sh/the-creator-of-node-js-says-the-era-of-writing-code-is-over-8320c868043b" rel="noopener noreferrer"&gt;https://reading.sh/the-creator-of-node-js-says-the-era-of-writing-code-is-over-8320c868043b&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;EducationPost&lt;/em&gt;, "Era of humans writing code is over, says NodeJS creator Ryan Dahl," &lt;a href="https://educationpost.in/news/technology/era-of-humans-writing-code-is-over-says-nodejs-creator-ryan-dahl" rel="noopener noreferrer"&gt;https://educationpost.in/news/technology/era-of-humans-writing-code-is-over-says-nodejs-creator-ryan-dahl&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Tools referenced: GitHub Copilot (GitHub/Microsoft), Cursor (Anysphere), Claude Code (Anthropic), OpenAI Codex (OpenAI).&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Find me across the&amp;nbsp;web:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/person/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a href="https://www.crunchbase.com/organization/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a href="https://clutch.co/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a href="https://techbehemoths.com/company/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a href="https://www.designrush.com/agency/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Edverise: &lt;a href="https://edverise.com/profile/syed-ahmer-shah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Trust Pilot: &lt;a href="https://www.trustpilot.com/review/ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;@&lt;/strong&gt;&lt;/a&gt;&lt;a href="http://ahmershah.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a href="https://builder.aws.com/community/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a href="https://dev.to/syedahmershah"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a href="https://medium.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a href="https://hashnode.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://substack.com/@syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a href="https://hackernoon.com/u/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://syedahmershah.substack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://www.facebook.com/ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a href="https://linkedin.com/company/syedahmershah" rel="noopener noreferrer"&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a href="https://www.instagram.com/ahmershahdev/" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@ahmershahdev" rel="noopener noreferrer"&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>automation</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
