Skip to content

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 categoryDescendantId filter on /rest/products get unlimited depth via Category\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

MethodPathAuthDescription
GET/rest/product/categoryGuestList categories with filtering, sorting, pagination
GET/rest/product/category/{id}GuestGet category by ID
GET/rest/product/category/itemGuestGet single category by filter
POST/rest/product/categoryBackend (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 PatternControllerMethod
/{category-slug}Adv_eshop.phpAdv_product_categories.phpindex($slug, $page)
/{category-slug}/{page}samesame (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.phpindex() (line 63-440)

  1. Load category (line 68): Cached fetch by slug. Returns 404 if not found or unpublished.
  2. Calculate depth (line 90): calcCategoryDepth() traverses parent chain via getCategoryParentMap(). Depth controls which sidebar filters appear.
  3. Expand to all children: getCatProductsBase() executes a 5-level self-join on shop_product_category to collect ALL descendant category IDs (only published = 1 included).

Step 3: Filter & Sort Application

File: same controller (line 227-296)

Filters loaded from session (persisted across interactions) and URL query params:

FilterConfig GateQuery Mechanism
Price rangeRegistry ENABLE_PRODUCT_PRICE_RANGESshop_prices_view.final_price >= / <=
VendorAlways availableshop_product.vendor_id =
Tagscategory_level_for_tags_in_sidebar vs depthJOIN shop_product_product_tags with AND/OR logic per tag category
Attributescategory_level_for_attributes_in_sidebar vs depthJOIN product_code_attributes by group ID
Variationscategory_level_for_variations_in_sidebar + enableProductVariationsJOIN 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.phpgetCatProductsProducts() (line 825-957)

Complex SQL joining: shop_productshop_product_muishop_product_category_lpshop_product_vatsshop_vendorshop_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

ComponentPathPurpose
Servicesrc/Domains/Product/Category/Service.phpRead service with specification-based queries
Repositorysrc/Domains/Product/Category/Repository/Repository.phpmatch(), matchOne(), get(), count()
Entitysrc/Domains/Product/Category/Repository/Entity.phpMaps shop_product_category columns
MUI Repositorysrc/Domains/Product/Category/Repository/MuiRepository.phpHandles shop_product_category_mui translations
WriteServicesrc/Domains/Product/Category/WriteService.phpCRUD operations via REST
Configuratorsrc/Domains/Product/Category/Repository/RepositoryConfigurator.phpDeclares relationships (parent, children, tags, articles)

Product domain (category-filtered listing)

ComponentPathPurpose
Product Servicesrc/Domains/Product/Product/Service.phpInjects Category\Service; intercepts categoryDescendantId filter via buildCategoryDescendantFilter()
Product ListRequestsrc/Domains/Product/Product/ListRequest.phpExposes categoryId (direct) and categoryDescendantId (BFS recursive) filters
FilterByCategorysrc/Domains/Product/Product/Repository/Specification/FilterByCategory.phpSubquery spec: shop_product.id IN (SELECT product_id FROM shop_product_category_lp WHERE category_id IN (...))

Legacy ↔ Domain Mapping

Legacy Model MethodDomain 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

HookLocationPurpose
catDynExtra()Adv_product_categories.php line 532Sidebar components (menus, vendor list, filters)

Override in: application/modules/eshop/controllers/Adv_product_categories.php

Model Overrides

ModelKey MethodsOverride In
Adv_product_modelgetCatProductsBase() (hierarchy), getCatProductsProducts() (query)application/modules/eshop/models/
Adv_product_category_modelget_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)

KeyPurpose
ENABLE_PRODUCT_PRICE_RANGESEnable price range filter sidebar
enableProductVariationsEnable variation filter sidebar
category_level_for_tags_in_sidebarMin depth to show tag filters
category_level_for_attributes_in_sidebarMin depth for attribute filters
category_level_for_variations_in_sidebarMin depth for variation filters
DEFAULT_FILTER_FRONT_ORDER / DEFAULT_FILTER_FRONTDefault sort direction and field
LOW_STOCK_END_OF_LISTPush out-of-stock products to end
ENABLE_SPECIAL_DISCOUNTSEnable special discount calculation
category_product_listsEnable curated product lists on categories

Data Model

shop_product_category — Category hierarchy

ColumnTypeDescription
idINT AUTO_INCREMENTPrimary key
parent_idINT DEFAULT 0Parent category ID (0 = root)
publishedTINYINT(1) DEFAULT 1Visibility flag (0 = hidden from storefront)
orderINT DEFAULT 0Display sort order within siblings
is_sensitiveTINYINT(1) DEFAULT 0Sensitive content flag (e.g., age-gated products)
block_idINT NULLAssociated Block Builder content block

shop_product_category_mui — Category translations

ColumnTypeDescription
idINT AUTO_INCREMENTPrimary key
category_idINTFK to shop_product_category
langVARCHAR(2)Language code (e.g., el, en)
nameVARCHAR(255)Translated category name
slugVARCHAR(255)URL slug (indexed with lang as slug_lang)
descriptionTEXT NULLCategory page description (HTML)
meta_titleVARCHAR(255) NULLSEO title
meta_descriptionTEXT NULLSEO description
imageVARCHAR(255) NULLCategory image filename
ColumnTypeDescription
product_idINTFK to shop_product
category_idINTFK to shop_product_category

Other tables involved

TablePurpose
shop_product / shop_product_muiProduct data and translations
shop_prices_viewCalculated final prices (for price range filtering)
shop_vendor / shop_vendor_muiVendor data
shop_product_product_tagsProduct-to-tag associations
product_variation_valuesVariation option values
product_code_attributesAttribute values per SKU

Known Issues & Security Gaps

  1. 5-level hierarchy cap in legacy storefrontAdv_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 using categoryDescendantId are unaffected. Tracked as a legacy gap in Advisable-com/ecommercen#198.

  2. Dead sixth JOIN in getCatProductsBase()ecommercen/eshop/models/Adv_product_model.php:742 adds LEFT JOIN shop_product_category AS t6 ON t6.parent_id = t5.id but t6.id is never included in the SELECT clause and the result loop reads only lvl1lvl5. The join fires on every legacy category page request and consumes unnecessary database resources without contributing to the result. Legacy-path only; the domain getDescendantIds() has no equivalent dead join.


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