MosKomDez — pest control service website
A live website for MosKomDez, a disinfection, pest and rodent control company: a homepage, 27 service pages, contacts and a privacy policy, plus a custom admin panel. A complete replacement for a Tilda landing page — content migrated, the donor's technical flaws cleaned up and the SEO preserved. The key feature: a single physical site serves 80 city versions on wildcard subdomains — Moscow on the main domain and 79 towns of the Moscow region, without a single copy of the files. Requests go into the client's amoCRM and into Telegram at the same time. Everything is written in plain PHP with no CMS and no database, runs on ordinary shared hosting and needs no build step on the server.
What was neededThe site sat on Tilda, kept running into its limits and could not have separate pages for the towns around the capital.
What came out of itOne site now serves 80 city versions, requests go to amoCRM and Telegram, and search positions survived the move.
Multi-city architecture
The client wanted city landing pages covering the whole Moscow region. Copying the site 80 times was not an option: the content is edited through the admin panel and the copies would have drifted apart immediately.
- The city registry is a single JSON file: the slug (which doubles as the subdomain), the name and four grammatical case forms, plus the “main”, “active” and “indexable” flags. The same file feeds the city list on the page and the version switcher.
- City names are injected through placeholders — {CITY}, {CITY_IN}, {CITY_TO} and others — replaced in the finished HTML by an output-buffer callback. That makes them work everywhere at once: in templates, in copy coming from the admin panel, in alt and title attributes, in hidden form fields and in structured data. There is physically nowhere to forget to wire the substitution up in a new block.
- Personalised: title, description, keywords, H1–H3 headings, the main and SEO copy, the FAQ, image captions and forms. Deliberately not personalised: the office address and the real locations in the work examples.
- Host parsing is resolved before any output: an unknown subdomain returns an honest 404, and the subdomain of the main city 301s to the main domain so no duplicates are bred.
- Canonical and og:url are computed from the current host, so they stay correct on a subdomain by themselves. robots.txt is generated by PHP: the Sitemap line points at the current host, and on city versions it is absent altogether.
- All the subdomain logic is locked inside a single file — the rest of the code knows nothing about it.
- The admin panel returns 404 on subdomains: 79 extra login entry points are not needed.
Content and a custom admin panel
There is a lot of content — 27 service pages, nine price tables, 80 cities — and all of it lives in JSON and is edited through a hand-written panel.
- A tabbed editor for a service page: SEO, hero (heading, checklist, image, price), copy, a price block made of several tables, FAQ and the set of page blocks — the gallery, methods, certificates, quiz and the rest can be switched off per service.
- Separate screens: the homepage hero, the homepage price block, the services slider, before/after work examples, cities, settings and a recycle bin that restores deleted services.
- Every write follows the same path: validation → a backup of the previous version (10 copies per file) → a control re-parse of what is about to be written → a temporary file → an atomic rename. An interrupted request cannot leave broken JSON behind, and an error comes back as human-readable text rather than a 500.
- Texts are cleaned through a tag whitelist with all attributes stripped: a copy-paste from Word cannot break the layout or smuggle in a script.
- Photo upload: the file is accepted with a type check, then cropped and converted to WebP on the server through GD.
- The city table deliberately has no “Main” column: the server takes that flag from the file in use and ignores whatever was submitted, and the row of the main city can be neither deleted nor disabled — the client must not be able to reassign the main domain by accident.
Requests, quiz and the amoCRM integration
There are six request entry points on the site: the promo bar, the hero form, “assign a specialist”, a modal window, two forms on the contacts page and the quiz calculator.
- A four-step quiz plus a final screen. On a service page the matching option of the “problem” step is pre-selected — the visitor has just read the heading, so there is no point making them click the obvious. The client sends only answer indexes; the server takes the texts from its own JSON, so an arbitrary string cannot be submitted.
- The city of a request is derived from the host, not from a form field — it cannot be forged.
- Endpoint protection: a honeypot, server-side validation of the phone number and the consent checkbox, per-character field trimming, and a limit of 5 requests per IP in 10 minutes kept in a file under flock.
- Integration with the client's live amoCRM, with no staging account: a request lands in the “Unsorted” inbox of the right pipeline — individuals and companies are split by the first quiz step. Quiz answers are mapped onto real CRM fields, ad tags onto service API fields, and everything else goes in as a note.
- Duplicate lookup by phone is mandatory rather than optional: the telephony system and a callback service already write into the same CRM. amo's search is a plain string search across all fields at once, so the lookup uses the last ten digits of the number and then re-checks the contact it found — otherwise a request sticks to somebody else's card.
- Not a single field ID is written by hand: the mapping is generated by a script from an account dump, verified by a separate command, and the field layout of a request is dry-run locally without touching the network.
- Telegram was kept as a second channel at the client's request: a request counts as accepted if at least one channel worked, and if none did, it is appended to a private log on disk. A lead must not be lost — nobody writes in a second time.
- A reverse webhook: when a deal moves to its final stage, a message goes into a Telegram group with the city, the problem, the contacts and a link to the deal.
- First-touch ad tags (utm, gclid, yclid, referrer) are stored in a cookie on the base domain — it survives the geo-redirect to a subdomain, so the source is not overwritten and paid traffic does not turn into direct visits.
Detecting the visitor's city
A separate requirement from the client: a visitor should land on their own city's version straight away.
- The browser is asked for geolocation; on refusal or timeout the city is resolved by IP — both branches go through DaData.
- The name is normalised (the city from the service and the city from the registry are compared without the “g.” prefix, without case and without the letter “ё”) and looked up in the registry; a city that is not listed means staying on Moscow.
- A listed city means moving to its subdomain along the same path, followed by a pop-up: “Need treatment in …?” — “Yes” stays, “No” opens a full-screen city list with live search.
- The result and the confirmation are stored in cookies on the base domain so subdomains can see each other's — detection runs once per visitor.
- The service's free tier allows 10,000 requests a day, so the endpoint keeps its own limit — 20 lookups per IP per hour, with the counter in a file under flock.
SEO
- Unique metadata for each of the 27 services, personalised per city.
- JSON-LD: LocalBusiness with phone numbers, an address broken into parts and the service area of the current city, plus FAQPage — but only where the questions block is actually rendered on the page.
- sitemap.xml is assembled from the service index; the modification date is taken from the content file and updates itself whenever something is edited in the admin panel.
- Flat human-readable URLs, with the canonical address enforced by a 301 that preserves UTM tags.
- City versions are closed to indexing by a meta tag and an HTTP header, while robots.txt does not disallow them — the crawler has to come in and see the noindex. Letting a city into search is a switch in the admin panel.
- Open Graph and Twitter cards on every page, and a meaningful 404 page with noindex.
Performance and accessibility
PageSpeed Insights, production domain, August 2026. Service page: Performance 100, Accessibility 100, Best Practices 96, SEO 100 on mobile, and 99/100/96/100 on desktop. Homepage: 92/100/88/100 on mobile.
- The speed comes from markup and assets, not from infrastructure: no CDN, no cache layer, no bundlers on the server — ordinary shared hosting.
- Self-hosted Roboto instead of Google Fonts removed about 2 seconds of render blocking and a chain of five external requests: three variable woff2 files by subset, with Cyrillic and Latin preloaded.
- In-house minification and asset versioning by file modification time: CSS 91.6 → 54.6 KB (10.2 KB over the wire), JS 33.1 → 14.3 KB (4.7 KB). If a minified file is stale, the source is served instead — the site does not break.
- All images are WebP with explicit dimensions, srcset and fetchpriority on the LCP image, lazy loading for the rest and content-visibility on the lower sections. CLS is close to zero.
- Accessibility 100: AA contrast everywhere coloured text sat on a light background, aria labels in forms, touch targets no smaller than 24 px, modals closing on Esc and a prefers-reduced-motion branch for the animations.
- The four points lost in Best Practices are the price of requesting geolocation on page load: Lighthouse wants that permission behind a click, but then the automatic jump to the right city becomes impossible. A deliberate trade of points for functionality, agreed with the client. Before geolocation was added the site scored 100 in all four categories.
Security and reliability
- Secrets live only in a config outside the repository; not a single token reaches the HTML or the JS.
- The folders with content, service PHP, template fragments and developer scripts are closed to direct access, with a fallback in case mod_rewrite is switched off.
- Output is escaped everywhere data enters the markup; structured data is encoded so that a stray </script> in the text cannot tear the page apart; directory traversal is cut off on every path assembled from input.
- The admin panel: a password hash, timing-safe comparison, a delay after a failed attempt, session regeneration, a CSRF token on every writing action, and a cookie scoped to the admin section with httponly and secure set from the actual protocol.
- The CRM webhook is protected by a secret in the URL — amoCRM webhooks come with no signature.
- Graceful degradation everywhere: no config — the template is used; no minified file — the source; CRM unreachable — Telegram takes over; nothing worked at all — the request goes into the log.
What makes it interesting
- 80 city versions of the site without a single copy of the files — substituting grammatical cases through the output buffer turned out to be both simpler and more reliable than template functions.
- Integration with somebody else's live CRM with no staging environment: a field map generated by a script from an account dump, duplicate merging by the last ten digits of the phone number, and a local dry run of the request layout without a single outbound call.
- A request is never lost: CRM → Telegram → a log on disk, counted as a success if any link in the chain works.
- 100/100/96/100 on a service page with an entirely hand-written stack, no CDN, no cache layer and no bundlers — on cheap shared hosting.
- The full cycle by one person: content migration from Tilda → architecture → backend → frontend → admin panel → CRM integration → preparing 100+ images → SEO → optimisation → deployment to the production domain.