A headless data service that aggregates daily-published Croatian grocery prices into a single queryable API. A Laravel ingestion pipeline normalises raw retailer price files into a canonical product catalog, tracks every price change over time, and answers basket, price-history and stores-near-me queries — the data backbone behind the Household app's price comparison.
Prices is the data backbone behind the Household app’s price comparison, and it’s built like infrastructure rather than a product: a Laravel 13 API with no consumer UI, backed by PostgreSQL and PostGIS. Under the 2025 Croatian price-publishing law, large retailers publish daily machine-readable price files; this service ingests them, reconciles them into one catalog, and serves the result over a small authenticated API.
The interesting work is in the pipeline and the data model. A per-chain adapter turns each retailer’s format into a common DTO, and an adapter-agnostic importer bulk-upserts into a canonical catalog — 23.6k products resolved by barcode across 21 chains, with a classification pass mapping messy chain names onto normalized categories. Every price change is appended to a month-partitioned history table, and 294 geocoded stores make “cheapest at my stores” a real PostGIS radius query rather than a guess.
The admin is deliberately utilitarian — a watch-it-yourself view of crawl runs and classification, not a product. That’s the point: Prices exists to keep one job done well, and to hand clean data to Household over an honest API boundary. The counts and claims above are read straight from the source and the production database, not estimated.
Each chain publishes a different price-file format under the 2025 Croatian price-publishing law. A per-chain adapter parses its format into a common PriceRowDTO; an adapter-agnostic Importer then bulk-upserts those rows into products, chain_products, prices and price_history in chunked transactions.
Every chain names and codes products its own way. Canonical products are keyed by barcode, with each chain's raw entry linked as a chain_product, and a classification pass maps messy chain names onto normalized categories — so a basket can compare like with like across retailers.
Current prices live in a hot prices table; every observed change is appended to a partitioned price_history. Monthly Postgres partitions keep the history table fast as it grows, and a rotation command provisions upcoming months and drops expired ones on a schedule.
Stores carry coordinates as a PostGIS point, so a stores/near query returns shops within a radius ordered by actual distance — not a bounding box. This is what lets a consumer ask 'cheapest at my stores' rather than 'cheapest nationally'.
The whole service is API-first: nine authenticated endpoints covering chains, stores, basket, product search, current prices, price history and a status feed. Household is the primary consumer, calling it over an authenticated HTTP boundary with no shared database.
Each retailer publishes its own product names, codes and category labels. Without a shared identity, a 'cheapest basket' comparison would be meaningless — the same milk looks like 21 different products.
Products are keyed canonically by barcode, with each chain's raw row linked as a chain_product. A classification pass (batched SQL + an Artisan command) maps chain names onto normalized categories with a confidence score and a manual-override flag, so comparisons line up across chains while leaving low-confidence matches reviewable.
Tracking every price change across thousands of products and hundreds of stores means a history table that grows without bound — and slows every query as it does.
price_history is range-partitioned by month in Postgres. A scheduled RotatePartitionsCommand provisions upcoming partitions and drops ones past the retention window, so reads stay fast and old data ages out cleanly without a manual migration.
The 2025 law only covers large-format stores. On Korčula, the physical Konzums are all small-format and publish nothing — so naive 'no data' answers would be both wrong and unhelpful.
Stores are geocoded and queried by real PostGIS distance, and the model keeps the format gap explicit: the service can answer 'nearest published store' rather than pretending coverage it doesn't have. The honesty is built into the data, not bolted on at the UI.
Household needs grocery prices, but a large, daily-updated price dataset has no business living inside a home-management app's schema.
Prices is a standalone Laravel + Postgres service with its own ingestion and lifecycle. Household reaches it only through an authenticated HTTP API (basket, search, history, stores-near), so the two stay independently deployable and neither owns the other's data.