Appearance
Product Browsing (Category Page)
Flow ID: CF-01 | Module(s): eshop | Complexity: High | Last Updated: 2026-04-30
Business Overview
The category page is the primary product discovery experience on the storefront. When a customer navigates to a product category — either through the navigation menu, a direct link, or search — they see a paginated grid of products with interactive filtering options.
What customers experience:
- A grid of products organized by category (e.g., "Electronics", "Clothing")
- Sidebar filters to narrow results: price range, brand/vendor, tags, product attributes (size, color), and variation filters
- Sorting options: by price, popularity, newest, or custom sort keys
- Pagination to browse large catalogs (configurable items per page: 12, 20, or 30)
Key business behaviors:
- Browsing a parent category automatically includes all subcategories — legacy storefront: hierarchy traversal is capped at 5 levels deep. REST API consumers using the
categoryDescendantIdfilter on/rest/productsget unlimited depth viaCategory\Service::getDescendantIds() - Filter availability is depth-dependent — tags, attributes, and variation filters only appear when the category is deep enough in the hierarchy (configurable thresholds)
- All product queries are cached with configurable TTL for performance
- Only active products with price > 0 are displayed; soft-deleted and zero-price items are hidden
API Reference
REST Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /rest/product/category | Guest | List categories with filtering, sorting, pagination |
| GET | /rest/product/category/{id} | Guest | Get category by ID |
| GET | /rest/product/category/item | Guest | Get single category by filter |
| POST | /rest/product/category | Backend (Products) | Create category |
| POST | /rest/product/category/{id} | Backend (Products) | Update category |
| DELETE | /rest/product/category/{id} | Backend (Products) | Delete category |
Available filters: id, parentId, published, isSensitive, name.{locale}, slug.{locale}
Available sorts: id, parentId, order, published, name.{locale}, slug.{locale}
Available relations: translations, parent, children, tagGroups, relativeCategories, articles
Browse endpoints interactively in the API Reference.
Legacy Storefront
| URL Pattern | Controller | Method |
|---|---|---|
/{category-slug} | Adv_eshop.php → Adv_product_categories.php | index($slug, $page) |
/{category-slug}/{page} | same | same (with pagination offset) |
Code Flow
Step 1: URL Resolution
File: ecommercen/eshop/controllers/Adv_eshop.php — _remap() → baseCategory() (line 91)
The universal dispatcher tries category slug resolution after product slug fails. parseCategorySlugAndOffset() splits the URL — if the last segment is numeric, it's a page number.
Category existence verified via PSCache: product_category_model->existsBySlug($slug) — uses slug_lang composite index on shop_product_category_mui.
Step 2: Category & Hierarchy Loading
File: ecommercen/eshop/controllers/Adv_product_categories.php — index() (line 63-440)
- Load category (line 68): Cached fetch by slug. Returns 404 if not found or unpublished.
- Calculate depth (line 90):
calcCategoryDepth()traverses parent chain viagetCategoryParentMap(). Depth controls which sidebar filters appear. - Expand to all children:
getCatProductsBase()executes a 5-level self-join onshop_product_categoryto collect ALL descendant category IDs (onlypublished = 1included).
Step 3: Filter & Sort Application
File: same controller (line 227-296)
Filters loaded from session (persisted across interactions) and URL query params:
| Filter | Config Gate | Query Mechanism |
|---|---|---|
| Price range | Registry ENABLE_PRODUCT_PRICE_RANGES | shop_prices_view.final_price >= / <= |
| Vendor | Always available | shop_product.vendor_id = |
| Tags | category_level_for_tags_in_sidebar vs depth | JOIN shop_product_product_tags with AND/OR logic per tag category |
| Attributes | category_level_for_attributes_in_sidebar vs depth | JOIN product_code_attributes by group ID |
| Variations | category_level_for_variations_in_sidebar + enableProductVariations | JOIN product_variation_values |
Sorting uses session values or defaults from registry (DEFAULT_FILTER_FRONT_ORDER, DEFAULT_FILTER_FRONT). Out-of-stock products pushed to end when LOW_STOCK_END_OF_LIST is enabled.
Step 4: Product Query
File: ecommercen/eshop/models/Adv_product_model.php — getCatProductsProducts() (line 825-957)
Complex SQL joining: shop_product → shop_product_mui → shop_product_category_lp → shop_product_vats → shop_vendor → shop_vendor_mui, plus conditional joins for active filters. WHERE clause enforces: soft_delete = 0, lang = current, category IDs from hierarchy. Results cached via PSCache.
Step 5: View Assembly
Product data enriched with live pricing, product codes (SKUs), and images via defaultProductParseWithProductCodes() (inherited from Front_c). Sidebar built by catDynExtra() (line 532): category menu, vendor list, tag filters, variation filters, attribute filters, category slider, child categories. Block builder content rendered for category descriptions.
Domain & REST Architecture
Domain Layer
Category domain
| Component | Path | Purpose |
|---|---|---|
| Service | src/Domains/Product/Category/Service.php | Read service with specification-based queries |
| Repository | src/Domains/Product/Category/Repository/Repository.php | match(), matchOne(), get(), count() |
| Entity | src/Domains/Product/Category/Repository/Entity.php | Maps shop_product_category columns |
| MUI Repository | src/Domains/Product/Category/Repository/MuiRepository.php | Handles shop_product_category_mui translations |
| WriteService | src/Domains/Product/Category/WriteService.php | CRUD operations via REST |
| Configurator | src/Domains/Product/Category/Repository/RepositoryConfigurator.php | Declares relationships (parent, children, tags, articles) |
Product domain (category-filtered listing)
| Component | Path | Purpose |
|---|---|---|
| Product Service | src/Domains/Product/Product/Service.php | Injects Category\Service; intercepts categoryDescendantId filter via buildCategoryDescendantFilter() |
| Product ListRequest | src/Domains/Product/Product/ListRequest.php | Exposes categoryId (direct) and categoryDescendantId (BFS recursive) filters |
| FilterByCategory | src/Domains/Product/Product/Repository/Specification/FilterByCategory.php | Subquery spec: shop_product.id IN (SELECT product_id FROM shop_product_category_lp WHERE category_id IN (...)) |
Legacy ↔ Domain Mapping
| Legacy Model Method | Domain Equivalent |
|---|---|
get_content($slug) | Service->item(ListRequest with slug filter) |
existsBySlug($slug) | Repository->count(Filter('slug', $slug)) |
get_arr_path($catId) | No domain equivalent (breadcrumbs stay in legacy) |
getCatProductsBase() (hierarchy expand) | Category\Service::getDescendantIds($rootId) — cycle-safe BFS, no depth cap |
getCatProductsProducts() (product query) | Product Service::all() with categoryDescendantId filter |
| Combined (hierarchy + product query) | Product Service::all(ListRequest with filter[categoryDescendantId]=$rootId) |
Client Extension Points
Controller Hooks
| Hook | Location | Purpose |
|---|---|---|
catDynExtra() | Adv_product_categories.php line 532 | Sidebar components (menus, vendor list, filters) |
Override in: application/modules/eshop/controllers/Adv_product_categories.php
Model Overrides
| Model | Key Methods | Override In |
|---|---|---|
Adv_product_model | getCatProductsBase() (hierarchy), getCatProductsProducts() (query) | application/modules/eshop/models/ |
Adv_product_category_model | get_arr_path() (breadcrumbs), getRecordsTree() (tree) | application/modules/eshop/models/ |
DI Container Overrides (Client Repos)
Override Domain layer via custom/Domains/container.php:
php
$services->alias(
Advisable\...\RepositoryConfigurator::class,
Custom\...\RepositoryConfigurator::class
);Configuration (Registry / Config)
| Key | Purpose |
|---|---|
ENABLE_PRODUCT_PRICE_RANGES | Enable price range filter sidebar |
enableProductVariations | Enable variation filter sidebar |
category_level_for_tags_in_sidebar | Min depth to show tag filters |
category_level_for_attributes_in_sidebar | Min depth for attribute filters |
category_level_for_variations_in_sidebar | Min depth for variation filters |
DEFAULT_FILTER_FRONT_ORDER / DEFAULT_FILTER_FRONT | Default sort direction and field |
LOW_STOCK_END_OF_LIST | Push out-of-stock products to end |
ENABLE_SPECIAL_DISCOUNTS | Enable special discount calculation |
category_product_lists | Enable curated product lists on categories |
Data Model
shop_product_category — Category hierarchy
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
parent_id | INT DEFAULT 0 | Parent category ID (0 = root) |
published | TINYINT(1) DEFAULT 1 | Visibility flag (0 = hidden from storefront) |
order | INT DEFAULT 0 | Display sort order within siblings |
is_sensitive | TINYINT(1) DEFAULT 0 | Sensitive content flag (e.g., age-gated products) |
block_id | INT NULL | Associated Block Builder content block |
shop_product_category_mui — Category translations
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
category_id | INT | FK to shop_product_category |
lang | VARCHAR(2) | Language code (e.g., el, en) |
name | VARCHAR(255) | Translated category name |
slug | VARCHAR(255) | URL slug (indexed with lang as slug_lang) |
description | TEXT NULL | Category page description (HTML) |
meta_title | VARCHAR(255) NULL | SEO title |
meta_description | TEXT NULL | SEO description |
image | VARCHAR(255) NULL | Category image filename |
shop_product_category_lp — Product-to-category link
| Column | Type | Description |
|---|---|---|
product_id | INT | FK to shop_product |
category_id | INT | FK to shop_product_category |
Other tables involved
| Table | Purpose |
|---|---|
shop_product / shop_product_mui | Product data and translations |
shop_prices_view | Calculated final prices (for price range filtering) |
shop_vendor / shop_vendor_mui | Vendor data |
shop_product_product_tags | Product-to-tag associations |
product_variation_values | Variation option values |
product_code_attributes | Attribute values per SKU |
Known Issues & Security Gaps
5-level hierarchy cap in legacy storefront —
Adv_product_model::getCatProductsBase()(ecommercen/eshop/models/Adv_product_model.php:730) uses a static 5-level self-join. Categories nested deeper than 5 levels are silently excluded from legacy storefront listings. The domain layer (Category\Service::getDescendantIds()) has no depth cap. REST consumers usingcategoryDescendantIdare unaffected. Tracked as a legacy gap inAdvisable-com/ecommercen#198.Dead sixth JOIN in
getCatProductsBase()—ecommercen/eshop/models/Adv_product_model.php:742addsLEFT JOIN shop_product_category AS t6 ON t6.parent_id = t5.idbutt6.idis never included in the SELECT clause and the result loop reads onlylvl1–lvl5. The join fires on every legacy category page request and consumes unnecessary database resources without contributing to the result. Legacy-path only; the domaingetDescendantIds()has no equivalent dead join.
Related Flows
- CF-02 Product Detail — clicking a product from the grid
- CF-03 URL Routing — how category slugs resolve
- CF-04 Search — search results share the product grid component
- CF-12 Promotions — promo pages share product grid component
- CF-18 Product Tags — tag filtering mechanics
- CF-25 Variations — variation filtering
- AD-05 Category Management — admin CRUD for categories
- AD-15 Attributes & Tags — attribute and tag administration
Wiki Guides: Product queries use PSR-6 caching — see Cache System for adapter configuration and TTL settings. Image assets are served through the storage abstraction layer — see Storage Guide.
Shared Patterns
- SY-07 Cache Management — PSCache invalidation and TTL strategy used by product queries
- SY-23 MUI Translation Pattern — how
_muitables power multi-language category names and slugs - SY-28 Storage Abstraction — category and product image storage