Vector Pola — flooring store
A live flooring store — laminate, SPC and rigid vinyl, engineered and parquet board — for a retailer with two showrooms, in Moscow and Krasnogorsk. The catalog holds around 7,600 active products: 3,300 laminate items, 3,900 in SPC and rigid vinyl, plus engineered and parquet board. The storefront is written in plain PHP with no CMS and no frameworks, runs on cheap shared hosting and needs no build step at all. The key engineering decision is a two-layer store: a canonical 17.5 MB JSON file and a derived SQLite cache that the storefront actually reads from. Orders arrive as requests in Telegram rather than online payments — that is how the client's sales process works.
What was neededThe client runs two showrooms and a catalogue of 7,600 items. It had to go online without collapsing under its own weight or demanding an expensive server.
What came out of itThe store runs on ordinary cheap hosting, products update from a data file rather than by hand, and there is no monthly fee for an engine.
Data architecture
The core idea: products.json is the source of truth, catalog.sqlite is a derived read cache. The storefront no longer decodes 17.5 MB of JSON on every request — it queries a ready-made database over PDO.
- The admin panel writes to JSON, and the database is rebuilt once per request via register_shutdown_function — essential for the importer, which saves products in a loop and would otherwise trigger a rebuild on every row.
- The rebuild is atomic: it writes catalog.sqlite.tmp and swaps it in with rename. On failure the temporary file is deleted and the old database stays alive — live requests never hit a half-written base.
- Two tables: products (22 columns, active/in_stock/popular/promo flags and a search_text column = lower(name + brand) for case-insensitive search in Cyrillic) and product_facets, holding the filter values expanded per product. Five indexes on products, three on facets.
- Resilience to schema drift: queries against the newer columns are wrapped in try/catch. If the PHP is deployed before the database is rebuilt, neither the storefront nor the admin panel returns a 500 — they degrade gracefully to an empty block or a JSON fallback.
Catalog and filters
Around 7,600 active products across 4 of the 9 categories; the rest show a “section being filled” state instead of an empty page.
- Faceted filters configured against the real specifications of each category: brand, abrasion class, thickness, installation type, wood species and finish. The logic is OR inside a facet and AND between facets, with a product count next to every value.
- A price range that hints at the real bounds for the category, an “in stock only” filter, price sorting and server-side pagination at 24 products per page.
- Filters auto-submit: instantly on click on desktop, on the “Show” button in the mobile drawer, and on Enter for the price fields.
- Search across name and brand with multi-word queries: every word has to be found in the normalised search_text.
- The catalog data is normalised: no zero prices, no duplicate slugs, brands filled in and specification keys canonicalised.
Product page and ordering
Flooring comes with its own quirk: it is sold by the pack, while the buyer thinks in square metres.
- An area calculator: enter the square metres and it works out the number of packs (rounded up), the actual area covered and the final total. The first calculation is rendered on the server, so the page still makes sense with JavaScript off.
- A gallery with photo switching, specifications in a table, “Add to cart” and “One-click purchase” buttons, WhatsApp and Telegram share links and copy-to-clipboard.
- A client-side cart (localStorage) that recalculates packs for every line; checkout submits a request rather than a payment — the order contents and total go to Telegram as text.
- A two-layer image placeholder: an empty images array falls back on the server, a broken link falls back through onerror on the client.
Requests and notifications
- Every form — consultation, one-click purchase, cart, delivery, designer partnership — goes through a single handler into a Telegram bot, tagged with the source of the request.
- Separate timeouts for connecting and for the whole operation (5 s and 15 s). If the API fails, the request is appended to a closed data/leads-failed.log — the lead survives even when Telegram is unavailable.
- The promo subscription is written to JSON under flock, deduplicated by email: submitting again updates the name, phone and date instead of adding another row.
- On the client, a single form initialiser reads the endpoint and success message from data attributes; the phone mask and validation are reused by every form.
Custom admin panel
Entirely hand-written, behind a login on PHP sessions.
- Catalog: product CRUD, server-side search and pagination, and “List” / “Popular” / “On sale” tabs — checkboxes on the product drive the carousels on the homepage, and an empty section simply is not rendered.
- XLSX import and export through a hand-written reader and writer built on ZipArchive and manual XML — no Composer, no external libraries. The export expands every specification key into its own column, the template ships with an example row, and fixed versus additional columns are colour-coded.
- The importer upserts: first by slug, then by SKU; it repairs SKUs like “1.0” that Excel and Google Sheets produce from whole numbers, and it can pull photos in by URL.
- Image uploads: a centre crop to an 800×800 square and conversion to WebP on the server (GD), accepting both files and URLs, with a MIME check.
- Subscriptions: a list with the newest first, deletion over POST with a confirmation and a PRG redirect, and export to CSV (with a BOM for Excel) and XLSX.
- Login protection: an arithmetic captcha, a 5-minute lockout after 5 failed attempts, and password recovery over Telegram and email. A manual database rebuild sits on its own page.
SEO
- A dynamic sitemap.xml straight from SQLite: static pages, only the non-empty categories and every active product with lastmod from its update date — around 7,600 URLs today.
- Schema.org / JSON-LD: Product + Offer with priceValidUntil, itemCondition and dynamic availability on product pages, and BreadcrumbList on products, categories and the catalog index.
- Deliberate index control: clean pagination is indexable with a self-canonical and rel prev/next, while filter and sort combinations go to noindex,follow so they do not breed duplicates.
- SEO titles and descriptions are set per product in the admin panel, with a fallback template built from the name. Human-readable URLs /catalog/{category}/{slug}/ via mod_rewrite, with Cyrillic transliterated into slugs and uniqueness guaranteed.
- Internal search and the cart are closed to indexing, and robots.txt and the sitemap agree with each other.
Performance and hosting
Speed comes from the storage architecture rather than the infrastructure: no CDN, no cache layer, no background daemons — cheap Beget shared hosting and deployment over rsync.
- Dropping the 17.5 MB JSON parse on every request in favour of indexed SQL queries is the main win in response time.
- All ~7,800 product photos are normalised to WebP 800×800 and the brand logos to WebP 320×180. The preparation pipeline is local Python/PIL scripts that trim white margins and cap upscaling.
- Preload and fetchpriority on the hero image (LCP), lazy loading for the rest and explicit width/height — zero layout shift.
- Gzip and static caching headers in .htaccess, with asset cache-busting through ?v=N.
- Analytics: Yandex.Metrica with webvisor and clickmap.
Security and robustness
- The /data/ folder with the product JSON and the subscriber list is closed off both in .htaccess (a RewriteRule with [F]) and in robots.txt; config.php with the bot token is denied direct access.
- PHP execution is disabled in /uploads/ and only images are served; the admin panel's internal files are blocked through FilesMatch, and each one additionally checks that it was not called directly.
- Meaningful 404s for missing products and categories: the check covers not only the slug but also that the category in the URL matches the product's own category.
What makes it interesting
- A 7,600-product catalog running on shared hosting with no CMS, no MySQL, no npm and no Composer — thanks to a considered storage design rather than raw capacity.
- JSON as the source of truth with SQLite as a derived read cache: rare writes, frequent reads — a trade-off that fit this project better than a full DBMS.
- The engineering details that only show up in production: atomic database rebuilds, one rebuild per request instead of thousands during an import, graceful degradation on schema drift, and a fallback log for lost requests.
- A hand-written XLSX reader and writer instead of a heavy library — the client maintains the product range in Excel, and it works with zero external dependencies.
- The full cycle by one person: data architecture → backend → frontend → admin panel → image pipeline → SEO → deployment.