<?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: Kimusta</title>
    <description>The latest articles on DEV Community by Kimusta (@kimusta).</description>
    <link>https://dev.to/kimusta</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%2F4160658%2F763709e5-c5ce-4684-9d16-d1bacd5b93b5.png</url>
      <title>DEV Community: Kimusta</title>
      <link>https://dev.to/kimusta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kimusta"/>
    <language>en</language>
    <item>
      <title>Running a two-sided marketplace on shared hosting: PHP 7.4, no framework, no cron</title>
      <dc:creator>Kimusta</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:08:57 +0000</pubDate>
      <link>https://dev.to/kimusta/running-a-two-sided-marketplace-on-shared-hosting-php-74-no-framework-no-cron-521n</link>
      <guid>https://dev.to/kimusta/running-a-two-sided-marketplace-on-shared-hosting-php-74-no-framework-no-cron-521n</guid>
      <description>&lt;p&gt;I run &lt;a href="https://www.kimusta.com" rel="noopener noreferrer"&gt;Kimusta&lt;/a&gt;, a marketplace for local services in Turkey. Someone posts what they need (a move, a deep clean, a broken boiler), vetted service providers send quotes, and the customer compares them.&lt;/p&gt;

&lt;p&gt;The interesting part is not the product. The interesting part is that it runs on &lt;strong&gt;cheap shared hosting&lt;/strong&gt;: PHP 7.4 behind LiteSpeed, MariaDB 10.3, no framework, no Node runtime, no Redis, no queue worker, and no guarantee that cron will actually fire. Here is what that constraint forced me to build, and the bugs it produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. No trust in cron → "trigger on request" background jobs
&lt;/h2&gt;

&lt;p&gt;Shared hosting cron is unreliable (and sometimes not available at all). Instead of a scheduler, the app runs background work &lt;strong&gt;inside normal web requests&lt;/strong&gt;, gated so that only roughly 1 in 20 requests actually executes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// called early in the request lifecycle; cheap gate, no locks needed at this volume&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_int&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="mi"&gt;20&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;match_auto_tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;        &lt;span class="c1"&gt;// pair new requests with suitable providers&lt;/span&gt;
    &lt;span class="nf"&gt;davet_hatirlatma_tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// remind providers whose invitations expire soon&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules make this safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every job must be idempotent.&lt;/strong&gt; If the same tick runs twice, nothing changes twice. The reminder job writes a &lt;code&gt;reminder_sent_at&lt;/code&gt; marker so an invitation is reminded exactly once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jobs must be short.&lt;/strong&gt; If a tick takes two seconds, one unlucky user pays for it. All queries are indexed and the batches are small.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Never send email inside a database transaction
&lt;/h2&gt;

&lt;p&gt;This was a real, user-visible bug. Matching wrote invitation rows and updated the request status inside a transaction — and notifications were simply missing from the flow. Providers had invitations sitting in the database that they never saw.&lt;/p&gt;

&lt;p&gt;The fix was structural: collect the affected provider IDs inside the transaction, &lt;code&gt;COMMIT&lt;/code&gt;, &lt;strong&gt;then&lt;/strong&gt; notify.&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;$etkilenenServisler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beginTransaction&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ... insert invitations, update request status, collect provider ids ...&lt;/span&gt;
&lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$etkilenenServisler&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$servisId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// outside the transaction, on purpose&lt;/span&gt;
    &lt;span class="nf"&gt;davet_bildirimlerini_gonder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$talepId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$servisId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the mail server is slow or down, the data is already committed. The user never waits on SMTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deliverability: a missing header can put you in spam
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mail()&lt;/code&gt; is disabled on this host, so everything goes through SMTP on &lt;code&gt;localhost&lt;/code&gt;. Mail was being &lt;em&gt;accepted&lt;/em&gt; (&lt;code&gt;250 OK&lt;/code&gt;) — but landing in spam. The spam scanner report was blunt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MISSING_MID 1.60
DOS_BODY_HIGH_NO_MID 4.00
=&amp;gt; score 9.06 (required 5)  → spam
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app never set &lt;code&gt;Message-ID&lt;/code&gt;. Adding &lt;code&gt;Message-ID&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;To&lt;/code&gt; headers dropped the score to &lt;strong&gt;3.461 — not spam&lt;/strong&gt;, and the subject lost its &lt;code&gt;{Spam?}&lt;/code&gt; prefix.&lt;/p&gt;

&lt;p&gt;Deliverability is not "did SMTP return 250". It is "did the message arrive, and does the scanner like it". Test with real external addresses, and read the actual headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Single-page apps hand out soft 404s for free
&lt;/h2&gt;

&lt;p&gt;The client-side router meant the fallback rule sent &lt;strong&gt;every unknown URL&lt;/strong&gt; to the app shell with HTTP &lt;code&gt;200&lt;/code&gt;. Google sees an infinite supply of URLs that all return the front page — the classic soft 404.&lt;/p&gt;

&lt;p&gt;The fix was a small server-side router that returns honest status codes, driven by the &lt;strong&gt;same route manifest the client uses&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;known route or matching pattern → &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;unknown → &lt;code&gt;404&lt;/code&gt; (still rendering the shell, but with the correct status)&lt;/li&gt;
&lt;li&gt;if the manifest cannot be read → fail open, never fail closed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One source of truth for both sides means the server and the SPA cannot drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Programmatic pages: volume is easy, uniqueness is the work
&lt;/h2&gt;

&lt;p&gt;The catalogue has 139 service categories, plus city-level pages, which produced a &lt;strong&gt;406-URL sitemap&lt;/strong&gt;. Generating the pages took an afternoon; making them &lt;em&gt;not&lt;/em&gt; thin content is the ongoing part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unique title, description and canonical per page&lt;/li&gt;
&lt;li&gt;breadcrumbs and real internal links between parent/child categories&lt;/li&gt;
&lt;li&gt;city pages only for cities that actually have districts in the database&lt;/li&gt;
&lt;li&gt;non-existent combinations return &lt;code&gt;404&lt;/code&gt; instead of an empty page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A page that nobody can rank for is not neutral — it dilutes the crawl budget for the pages that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Instant indexing (IndexNow) has one annoying prerequisite
&lt;/h2&gt;

&lt;p&gt;IndexNow is easy once ownership is verified and infuriating before that: submissions fail with &lt;code&gt;403 SiteVerificationNotCompleted&lt;/code&gt; and no further explanation. The key file has to be reachable at the root, and verification has to be completed on each search engine's side first. After that, 406 URLs submitted in one go.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The cold-start bug nobody warns you about
&lt;/h2&gt;

&lt;p&gt;A two-sided marketplace can be technically perfect and still dead, because &lt;strong&gt;the supply side never learns that demand exists&lt;/strong&gt;. Our first real customer request produced three invitations — and zero quotes, because none of the providers ever opened the panel to look.&lt;/p&gt;

&lt;p&gt;Matching is not the feature. &lt;strong&gt;Notifying&lt;/strong&gt; is the feature. The notification has to arrive where the provider already is (email), and it has to say something concrete: what the job is, which district, what time window, when the invitation expires — without leaking personal data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build notifications before the matching engine.&lt;/strong&gt; Data in the database that nobody is told about is worthless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return honest HTTP status codes from day one.&lt;/strong&gt; Retrofitting a router is more work than writing it first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test email deliverability against a real mailbox before launch&lt;/strong&gt;, not after the first customer asks why nothing arrived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume there is no cron.&lt;/strong&gt; "Trigger on request" is not a hack if you design the jobs to be short and idempotent — it is a deployment model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this needs a framework, a VPS, or a queue. It needs deciding what the constraints are and then designing inside them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Kimusta is live at &lt;a href="https://www.kimusta.com" rel="noopener noreferrer"&gt;kimusta.com&lt;/a&gt; if you want to see the result.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
    </item>
  </channel>
</rss>
