Appearance
Category Management (Admin)
Flow ID: AD-05 Module(s): eshop Complexity: Medium Last Updated: 2026-06-04
Business Overview
Category management handles the product category tree used to organise the e-commerce catalogue. Categories form a parent-child hierarchy up to four levels deep, with drag-and-drop ordering, multi-language names/descriptions/SEO metadata, images, banners, slider assignments, and optional integrations with tag groups, related categories, product lists, and blog articles.
Categories are central to product browsing (CF-01), product detail breadcrumbs (CF-02), menu navigation, and feed generation (IN-01). Each category has a master record controlling hierarchy, visibility, and display options, plus per-language MUI records holding the name, slug, descriptions, and SEO fields.
API Reference
REST API (Modern Layer)
| Method | Endpoint | Action | Auth |
|---|---|---|---|
GET | /rest/product/category | List categories (paginated, filterable) | JWT |
GET | /rest/product/category/{id} | Get single category by ID | JWT |
GET | /rest/product/category/item | Get single category by filter | JWT |
POST | /rest/product/category | Create category | JWT |
POST | /rest/product/category/{id} | Update category | JWT |
DELETE | /rest/product/category/{id} | Delete category | JWT |
All REST routes support locale-prefixed variants (/{locale}/rest/product/category).
Filters: id, parentId, order, published, isSensitive, sliderId, menuSliderId, extraSliderId, name.{locale} (partial), slug.{locale} (exact), metaTitle.{locale} (partial), url.{locale} (partial).
Sorts: id, parentId, order, published, isSensitive, name.{locale}, slug.{locale}.
Relations (via ?with=): translations, parent, children, tagGroups, relativeCategories, articles.
File uploads: image and small_image fields accepted as multipart/form-data on store/update, uploaded to files/product_category/.
Routes defined in application/config/rest_routes.php (lines 303-305, 378-380, 1429-1434).
Admin Routes (Legacy Layer)
| Route | Controller Method | Description |
|---|---|---|
product_categories_admin | index($parentId) | List child categories of a parent (default root) |
product_categories_admin/search | search() | Keyword search with breadcrumb path resolution |
product_categories_admin/add | add() | Create category form and handler |
product_categories_admin/edit/{id} | edit($id) | Edit category form and handler |
product_categories_admin/delete/{id} | delete($id) | Delete category (with guard) |
product_categories_admin/set_discount/{catId} | set_discount($catId) | Batch set discount % on category products |
product_categories_admin/set_special_discount/{catId} | set_special_discount($catId) | Batch set special discount with date range |
product_categories_admin/disable_special_discount/{catId} | disable_special_discount($catId) | Batch disable special discounts |
product_categories_admin/enable_special_discount/{catId} | enable_special_discount($catId) | Batch re-enable special discounts |
product_categories_admin/set_negative_stock/{catId} | set_negative_stock($catId) | Batch toggle negative stock on products |
product_categories_admin/updateorder | updateorder() | AJAX drag-and-drop reorder |
product_categories_admin/getchiledegories | getchiledegories() | AJAX child category dropdown |
product_categories_admin/getchiledegories_2 | getchiledegories_2() | AJAX second-level child dropdown |
Admin menu entry in application/config/admin_menu.php under the PRODUCTS group. Requires roles: AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, or AUTH_ROLE_PRODUCTS.
Code Flow
Create Category (add())
- Authorisation: Checked in constructor --
allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS]). - Validation:
validation()sets MUI rules per language (name required), plus optional SEO/URL rules for ADVISABLE role. - File upload: Uses
advuploader->uploadFilesSameFolder()forproduct_category_imageandproduct_category_smallImagetofiles/product_category/. - Master data assembly:
parent_id,image,small_image,banner_link,menu_color,as_products_details,is_sensitive,menu_chileds,published,slider_id,extra_slider_id,menu_slider_id. - MUI data assembly: Per language --
name,fulltext,extra_description,slug(auto-generated),builder_block_id. SEO fields (meta_title,meta_keywords,meta_description) only ifcanAccess('metatags'). URL fields (url,redirect_url) only ifcanAccess('urlFields'). - Slug generation:
product_category_model->create_slug($parentId, $name, $lang)builds a hierarchical slug by prepending the parent slug with/. - Insert:
product_category_model->add_record($data, $dataMui, $relativeCatIds)-- setsorderto max+1, inserts master, then MUI rows, then relative categories. - Product lists: If
category_product_listsconfig is enabled, associates selected product lists with priority ordering viashop_product_category_lists. - Tag groups:
manageCategoryTags($id)associates tag category groups viaupdateCategoryTagGroup(). - Blog articles:
manageBlogArticles($id)saves blog article associations ifESHOP.BLOG_PRODUCT_CATEGORY_ENABLEDregistry is'1'. - Post-insert hook:
afterAdd($id)-- empty in base class, overridable by client repos. - Cache clear:
clearCache('product_categories_vendors').
Update Category (edit($id))
Follows the same structure as create with these differences:
- Image deletion handled separately (
del_image/del_smallImagepost flags clear image viaupdate_master_record()). - Slug preserved unless empty (auto-generated only when no slug exists for a language); ADVISABLE role users can manually set slugs.
- Relative categories updated via
update_relative_categories()(delete + re-insert). - Product lists updated via
update_product_category_lists()(delete + re-insert). afterEdit($id)hook called post-update.
Delete Category (delete($id))
- Validates numeric ID.
- Calls
canDeleteRecord($id)-- returnsfalseif category has children or has products assigned viashop_product_category_lp. - On success: deletes MUI rows first, then master row, calls
afterDelete($id), clears cache.
Search (search())
- Requires POST with
keyword(minimum 3 characters). - Searches
shop_product_category_mui.nameviaLIKE. - For each result, resolves full breadcrumb path by recursively fetching parent names up to depth 3.
- Each result shows the full path and whether the category has children (for drill-down link).
Drag-and-Drop Reorder (updateorder())
AJAX-only endpoint. Receives listItem[] array mapping order position to category ID. Calls update_order() which iterates and updates order column for each category. Calls afterUpdateOrder() hook.
Batch Discount Operations
Several category-level batch operations apply changes to all products within a category:
set_discount($catId): Setsdiscount_percenton all products in the category.set_special_discount($catId): Setsspecial_from,special_to, andspecial_discount_percenton products not flagged withdisable_special_discounts.disable_special_discount($catId): Setsdisable_special_discounts = trueand clears special discount fields.enable_special_discount($catId): Resetsdisable_special_discounts = false.set_negative_stock($catId): Togglesnegative_stockflag on all products.
All batch operations use product_model->batchMasterUpdate() and call afterBatchMasterUpdate() hook. Cache is cleared on discount operations.
Domain Layer
Modern Domain (src/Domains/Product/Category/)
| Class | Responsibility |
|---|---|
Repository\Entity | Entity mapping for shop_product_category. Implements FilterTranslation for locale-aware filtering. |
Repository\MuiEntity | Entity mapping for shop_product_category_mui (translations). |
Repository\Repository | Read repository. Table: shop_product_category. |
Repository\MuiRepository | Read repository for MUI. Table: shop_product_category_mui. |
Repository\RepositoryConfigurator | Defines relations: translations (one-to-many), parent (belongs-to self via parent_id, recursive), children (one-to-many self via parent_id, recursive), tagGroups (many-to-many via shop_product_category_group_tags_lp), relativeCategories (many-to-many via shop_product_category_relative_prod), articles (many-to-many via product_category_blog). |
Repository\WriteRepository | Write repository for master table. |
Repository\MuiWriteRepository | Write repository for MUI table. Provides insertForEntity(), replaceForEntity(), deleteForEntity(). |
Service | Read service. Builds specifications from ListRequest (filters, sorts, pagination, relations). |
WriteService | CRUD service. create() / update() / delete() with transactional MUI handling. |
WriteData | Value object for master write fields (camelCase input accepted, maps to snake_case DB columns). |
MuiWriteData | Value object for translation write fields. |
Validator | Validates create/update data and translations (currently checks lang required). |
ListRequest | Defines allowed filters, sorts, and relation sorts. Locale-aware. |
Legacy Model (ecommercen/eshop/models/Adv_product_category_model.php)
Extends Adv_base_model. Tables: shop_product_category, shop_product_category_mui, shop_product_category_lp (product junction), shop_product_category_relative_prod, shop_product_category_group_tags_lp, shop_product_category_lists.
Key methods:
| Method | Description |
|---|---|
get_mui_records() | Multi-purpose query with conditions, pagination, language filter, optional subcategory count |
add_record() | Insert master (auto-order) + MUI rows + relative categories |
update_record() | Update master + upsert MUI rows via updateOrInsertMui() |
delete_record() | Delete MUI first, then master |
create_slug() | Build hierarchical slug from parent slug + greek_lower(name) |
hasChildren() / hasProducts() / canDeleteRecord() | Deletion guard checks |
getDepth() / get_depth() | Calculate depth by recursive parent traversal |
getpath() | Build breadcrumb path string by recursive parent name lookup |
getRecordsTree() / getRecordsTreeMemory() | Build complete category tree for menu rendering |
searchCategories() | Name-based LIKE search joined with current language |
update_order() | Batch update order column |
updateCategoryTagGroup() | Diff-based tag group sync (insert new, remove old) |
update_relative_categories() | Delete + re-insert relative category associations |
update_product_category_lists() | Delete + re-insert product list associations |
linkProductCategory($product_id, $category_id): void | Central idempotent insert via INSERT ... ON DUPLICATE KEY UPDATE. All write paths route through this helper. (Adv_product_category_model.php:1032-1042) |
insert_categories_lp($product_id, $catids) | Loops calling linkProductCategory() for each category ID. (Adv_product_category_model.php:1048-1053) |
update_categories_lp_batch($products, $catids) | Delegates to insert_categories_lp(); per-pair COUNT existence check removed. (Adv_product_category_model.php:1621-1628) |
changeProductsCategoryBatch($productIds, $source, $destination) | Input validation (empty IDs / source≤0 / destination≤0 / source===destination → early return) + UPDATE IGNORE move + cleanup DELETE + transaction. (Adv_product_category_model.php:1822-1856) |
removeAllProductCategoriesAndAssignToOne($productIds, $destination) | Same validation + transaction + linkProductCategory() re-insert. (Adv_product_category_model.php:1864-1888) |
Supporting Models
Adv_product_category_blog_model(ecommercen/eshop/models/) -- managesproduct_category_blogjunction table for blog article associations.Adv_product_tag_categories_model(ecommercen/eshop/models/) -- providesgetTagGroups()andgetCategoryGroupTags()for tag group assignment UI.
Architecture
REST Controller (src/Rest/Product/Controllers/Category.php)
Extends HandlesRestfulActions with HandlesUploadActions trait. Constructor receives ReadService, resource/collection classes, WriteService, and UploadService.
Upload config: image and small_image both stored under files/product_category/.
Supports application/json (no files) and multipart/form-data (with files) on store/update.
REST Resources
| Class | Schema Name |
|---|---|
Resource | ProductCategoryResource -- full entity with conditional backend-only fields (asProductsDetails, order, menuColor, published, isSensitive, slider IDs) |
Collection | ProductCategoryCollection |
MuiResource | ProductCategoryMuiResource -- translation fields |
MuiCollection | ProductCategoryMuiCollection |
DI Container
Domain registration (src/Domains/Product/container.php):
Category\Repository\Repository,Category\Repository\RepositoryConfiguratorCategory\Repository\MuiRepositoryCategory\ServiceCategory\Repository\WriteRepository,Category\Repository\MuiWriteRepositoryCategory\Validator,Category\WriteService
REST registration (src/Rest/Product/container.php):
- Controller wired with Service, Resource, Collection, ListRequest, WriteService, and UploadService references.
Legacy Controller (ecommercen/eshop/controllers/Adv_product_categories_admin.php)
Extends Admin_c. Loads models: eshop/product_category_model, eshop/product_model, sliders/sliders_model, eshop/product_tag_categories_model. Uses BlockBuilder for builder block integration.
Provides afterAdd(), afterEdit(), afterDelete(), afterUpdateOrder(), afterBatchMasterUpdate(), and afterUpdateProductsDiscountPercent() hooks -- all empty in the base class, designed for client repo overrides.
Data Model
shop_product_category (Master)
| Column | Type | Description |
|---|---|---|
id | int PK AI | Category ID |
parent_id | int | Parent category ID (0 = root) |
as_products_details | tinyint | Display products in detail view mode |
order | int | Sort order within parent |
image | varchar | Main category image filename |
banner_link | varchar | Banner click-through URL |
small_image | varchar | Thumbnail/small image filename |
menu_color | varchar | Hex colour for menu highlight |
menu_chileds | int | Number of child items displayed in mega menu |
published | tinyint | Visibility flag (1 = visible on storefront) |
is_sensitive | tinyint | Sensitive content flag (hidden from certain contexts) |
slider_id | int FK nullable | Associated main slider |
menu_slider_id | int FK nullable | Associated menu slider |
extra_slider_id | int FK nullable | Associated extra slider |
shop_product_category_mui (Translations)
| Column | Type | Description |
|---|---|---|
id | int PK AI | MUI record ID |
category_id | int FK | Parent category ID |
lang | varchar | Language code (el, en, etc.) |
name | varchar | Category name |
slug | varchar | URL slug (hierarchical, auto-generated) |
fulltext | text | Rich text description |
extra_description | text | Additional description |
meta_title | varchar | SEO title |
meta_keywords | varchar | SEO keywords |
meta_description | text | SEO description |
url | varchar | Custom URL override |
redirect_url | varchar | Redirect URL |
builder_block_id | int FK nullable | Builder block ID for rich content |
Junction Tables
| Table | Columns | Purpose |
|---|---|---|
shop_product_category_lp | product_id, category_id | Product-to-category assignment (many-to-many). Has UNIQUE INDEX product_category (product_id, category_id) per migration 20260529120000_dedup_product_category_lp_unique.php. |
shop_product_category_relative_prod | category_id, relative_category_id | Related categories (shown as "see also") |
shop_product_category_group_tags_lp | category_id, group_tag_id | Tag group associations for filtering |
shop_product_category_lists | id, category_id, product_list_id, priority | Product list assignments with priority |
product_category_blog | blog_id, product_category_id, priority | Blog article associations |
Migrations
| Migration | Description |
|---|---|
20241031161307_add_product_category_block_id | Adds builder_block_id column to shop_product_category_mui |
20241127121816_shop_product_category_lists | Creates shop_product_category_lists junction table |
20260529120000_dedup_product_category_lp_unique | Deduplicates existing (product_id, category_id) pairs (keeps lowest id), then replaces the old non-unique product_category KEY with UNIQUE INDEX product_category (product_id, category_id). down() is empty (forward-only). Requires php migrator.php migrate. |
Configuration
| Config | Location | Default | Description |
|---|---|---|---|
category_product_lists | application/config/main.php | false | Enables product list assignment on categories |
ESHOP.BLOG_PRODUCT_CATEGORY_ENABLED | Registry | '0' | Enables blog article association on categories |
Images stored under files/product_category/ via the storage abstraction layer (local/S3/SFTP).
Cache key product_categories_vendors is cleared on any create/update/delete/reorder operation.
Client Extension Points
The admin controller provides six empty hook methods designed for client repo overrides:
| Hook | Trigger |
|---|---|
afterAdd($id) | After successful category creation |
afterEdit($id) | After successful category update |
afterDelete($id) | After successful category deletion |
afterUpdateOrder($data) | After drag-and-drop reorder |
afterBatchMasterUpdate($productIds, $updateData) | After batch discount/stock operations |
afterUpdateProductsDiscountPercent($productIds, $discount) | After batch discount percent set |
Client repos override the controller in application/controllers/ and implement these hooks for custom logic (e.g., ERP sync, Solr reindex, external API calls).
The REST layer can be extended via Custom\Domains\Product\Category\ and Custom\Rest\Product\ with DI alias overrides in custom/Domains/container.php and custom/Rest/container.php.
Business Rules
| Rule | Description |
|---|---|
| Hierarchy depth limit | Up to 4 levels (root + 3 child levels). Enforced by breadcrumb/path logic in controller. |
| Auto-slug generation | Slug built hierarchically: parent-slug/greek_lower(name). Generated on create; on edit, only when slug is empty. |
| Slug manual override | Only users with AUTH_ROLE_ADVISABLE can manually set slugs via the urlFields permission. |
| SEO field access control | meta_title, meta_keywords, meta_description fields restricted to users with metatags permission. |
| URL field access control | url, redirect_url fields restricted to users with urlFields permission. |
| Deletion guard | Cannot delete a category that has child categories or assigned products (canDeleteRecord() check). |
| Published flag | Controls storefront visibility. Unpublished categories hidden from navigation and product browsing. |
| Sensitive flag | is_sensitive hides category from certain display contexts (e.g., guest-visible menus). |
| Relative categories | Available only for depth-2 (leaf) categories. Shows "related categories" on storefront. |
| Order auto-increment | New categories receive order = max(order) + 1. Reorder via drag-and-drop AJAX. |
| Builder block integration | Per-language builder_block_id for rich content rendering via the builder system. |
| Cache invalidation | clearCache('product_categories_vendors') called on every mutation. PSCache for frontend reads. |
| Batch operations | Discount/stock batch operations apply to all active products within the category. Special discount batch respects per-product disable_special_discounts flag. |
| Product-category link uniqueness | (product_id, category_id) is enforced by UNIQUE INDEX product_category on shop_product_category_lp. All write paths route through the idempotent linkProductCategory() (Adv_product_category_model.php:1032-1042); ON DUPLICATE KEY UPDATE was chosen over INSERT IGNORE so genuine errors (FK violations, truncation) still surface. |
Known Issues & Security Gaps
initial.sqlnot updated for UNIQUE constraint:database/initial/initial.sql:1715still declares the oldKEY product_category (product_id, category_id)(non-unique). The UNIQUE constraint is applied by the Phinx migration on existing DBs. A fresh install using onlyinitial.sql+ no migration will have a non-unique index.Stale
UPDATE IGNOREcomment inupdate_categories_lp_batch():ecommercen/eshop/models/Adv_product_category_model.php:1623-1624says "INSERT IGNORE inlinkProductCategory()" but the implementation usesON DUPLICATE KEY UPDATE(intentional — see thelinkProductCategory()docblock at:1025-1031).No automated test coverage for #279 logic:
linkProductCategory(),changeProductsCategoryBatch(),removeAllProductCategoriesAndAssignToOne(), and the UNIQUE constraint are not covered by unit or integration tests.
Related Flows
- CF-01 Product Browsing -- categories drive product listing, filtering, and navigation menus
- CF-02 Product Detail -- breadcrumb generation from category hierarchy
- AD-02 Product Management -- products assigned to categories on create/edit
- AD-14 SEO Management -- per-category SEO fields (meta title, keywords, description)
- AD-15 Attributes & Tags Management -- tag groups associated with categories for filtering
- AD-21 Slider Management -- slider assignments (
slider_id,menu_slider_id,extra_slider_id) - AD-27 Product Lists -- product list assignments on categories (when
category_product_listsconfig enabled) - AD-29 Builder Admin -- builder block per-language assignment via
builder_block_id(see Builder guide) - AD-12 Blog Admin -- blog article associations (when
BLOG_PRODUCT_CATEGORY_ENABLED) - IN-01 Feed Generation -- categories used in product feed mapping
- SY-23 MUI Translation Pattern --
shop_product_category_muistores per-language names, slugs, descriptions, and SEO fields - SY-25 File Upload & Storage --
advuploader->uploadFilesSameFolder()handlesimageandsmall_imageuploads tofiles/product_category/