DEV Community

Kimusta
Kimusta

Posted on

Running a two-sided marketplace on shared hosting: PHP 7.4, no framework, no cron

I run Kimusta, 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.

The interesting part is not the product. The interesting part is that it runs on cheap shared hosting: 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.

1. No trust in cron → "trigger on request" background jobs

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

// called early in the request lifecycle; cheap gate, no locks needed at this volume
if (random_int(1, 20) === 1) {
    match_auto_tick();        // pair new requests with suitable providers
    davet_hatirlatma_tick(5); // remind providers whose invitations expire soon
}
Enter fullscreen mode Exit fullscreen mode

Two rules make this safe:

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

2. Never send email inside a database transaction

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.

The fix was structural: collect the affected provider IDs inside the transaction, COMMIT, then notify.

$etkilenenServisler = [];
$pdo->beginTransaction();
// ... insert invitations, update request status, collect provider ids ...
$pdo->commit();

foreach ($etkilenenServisler as $servisId) {   // outside the transaction, on purpose
    davet_bildirimlerini_gonder($talepId, $servisId);
}
Enter fullscreen mode Exit fullscreen mode

If the mail server is slow or down, the data is already committed. The user never waits on SMTP.

3. Deliverability: a missing header can put you in spam

mail() is disabled on this host, so everything goes through SMTP on localhost. Mail was being accepted (250 OK) — but landing in spam. The spam scanner report was blunt:

MISSING_MID 1.60
DOS_BODY_HIGH_NO_MID 4.00
=> score 9.06 (required 5)  → spam
Enter fullscreen mode Exit fullscreen mode

The app never set Message-ID. Adding Message-ID, Date and To headers dropped the score to 3.461 — not spam, and the subject lost its {Spam?} prefix.

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.

4. Single-page apps hand out soft 404s for free

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

The fix was a small server-side router that returns honest status codes, driven by the same route manifest the client uses:

  • known route or matching pattern → 200
  • unknown → 404 (still rendering the shell, but with the correct status)
  • if the manifest cannot be read → fail open, never fail closed

One source of truth for both sides means the server and the SPA cannot drift apart.

5. Programmatic pages: volume is easy, uniqueness is the work

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

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

A page that nobody can rank for is not neutral — it dilutes the crawl budget for the pages that matter.

6. Instant indexing (IndexNow) has one annoying prerequisite

IndexNow is easy once ownership is verified and infuriating before that: submissions fail with 403 SiteVerificationNotCompleted 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.

7. The cold-start bug nobody warns you about

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

Matching is not the feature. Notifying 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.

What I would do differently

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

None of this needs a framework, a VPS, or a queue. It needs deciding what the constraints are and then designing inside them.

Kimusta is live at kimusta.com if you want to see the result.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Building notifications before matching is the useful product call. When you invent staging providers to prove the invite path, use reserved fiction phones (US 555-0100 to 555-0199, UK 020 7946 0xxx) and domains you control. Treat MX / Null MX as a separate contact check before you count a quiet inbox as "provider ignored the invite" (timeout = unchecked).