Skip to content

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)

MethodEndpointActionAuth
GET/rest/product/categoryList categories (paginated, filterable)JWT
GET/rest/product/category/{id}Get single category by IDJWT
GET/rest/product/category/itemGet single category by filterJWT
POST/rest/product/categoryCreate categoryJWT
POST/rest/product/category/{id}Update categoryJWT
DELETE/rest/product/category/{id}Delete categoryJWT

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)

RouteController MethodDescription
product_categories_adminindex($parentId)List child categories of a parent (default root)
product_categories_admin/searchsearch()Keyword search with breadcrumb path resolution
product_categories_admin/addadd()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/updateorderupdateorder()AJAX drag-and-drop reorder
product_categories_admin/getchiledegoriesgetchiledegories()AJAX child category dropdown
product_categories_admin/getchiledegories_2getchiledegories_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())

  1. Authorisation: Checked in constructor -- allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS]).
  2. Validation: validation() sets MUI rules per language (name required), plus optional SEO/URL rules for ADVISABLE role.
  3. File upload: Uses advuploader->uploadFilesSameFolder() for product_category_image and product_category_smallImage to files/product_category/.
  4. 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.
  5. MUI data assembly: Per language -- name, fulltext, extra_description, slug (auto-generated), builder_block_id. SEO fields (meta_title, meta_keywords, meta_description) only if canAccess('metatags'). URL fields (url, redirect_url) only if canAccess('urlFields').
  6. Slug generation: product_category_model->create_slug($parentId, $name, $lang) builds a hierarchical slug by prepending the parent slug with /.
  7. Insert: product_category_model->add_record($data, $dataMui, $relativeCatIds) -- sets order to max+1, inserts master, then MUI rows, then relative categories.
  8. Product lists: If category_product_lists config is enabled, associates selected product lists with priority ordering via shop_product_category_lists.
  9. Tag groups: manageCategoryTags($id) associates tag category groups via updateCategoryTagGroup().
  10. Blog articles: manageBlogArticles($id) saves blog article associations if ESHOP.BLOG_PRODUCT_CATEGORY_ENABLED registry is '1'.
  11. Post-insert hook: afterAdd($id) -- empty in base class, overridable by client repos.
  12. 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_smallImage post flags clear image via update_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))

  1. Validates numeric ID.
  2. Calls canDeleteRecord($id) -- returns false if category has children or has products assigned via shop_product_category_lp.
  3. On success: deletes MUI rows first, then master row, calls afterDelete($id), clears cache.
  1. Requires POST with keyword (minimum 3 characters).
  2. Searches shop_product_category_mui.name via LIKE.
  3. For each result, resolves full breadcrumb path by recursively fetching parent names up to depth 3.
  4. 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): Sets discount_percent on all products in the category.
  • set_special_discount($catId): Sets special_from, special_to, and special_discount_percent on products not flagged with disable_special_discounts.
  • disable_special_discount($catId): Sets disable_special_discounts = true and clears special discount fields.
  • enable_special_discount($catId): Resets disable_special_discounts = false.
  • set_negative_stock($catId): Toggles negative_stock flag 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/)

ClassResponsibility
Repository\EntityEntity mapping for shop_product_category. Implements FilterTranslation for locale-aware filtering.
Repository\MuiEntityEntity mapping for shop_product_category_mui (translations).
Repository\RepositoryRead repository. Table: shop_product_category.
Repository\MuiRepositoryRead repository for MUI. Table: shop_product_category_mui.
Repository\RepositoryConfiguratorDefines 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\WriteRepositoryWrite repository for master table.
Repository\MuiWriteRepositoryWrite repository for MUI table. Provides insertForEntity(), replaceForEntity(), deleteForEntity().
ServiceRead service. Builds specifications from ListRequest (filters, sorts, pagination, relations).
WriteServiceCRUD service. create() / update() / delete() with transactional MUI handling.
WriteDataValue object for master write fields (camelCase input accepted, maps to snake_case DB columns).
MuiWriteDataValue object for translation write fields.
ValidatorValidates create/update data and translations (currently checks lang required).
ListRequestDefines 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:

MethodDescription
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): voidCentral 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/) -- manages product_category_blog junction table for blog article associations.
  • Adv_product_tag_categories_model (ecommercen/eshop/models/) -- provides getTagGroups() and getCategoryGroupTags() 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

ClassSchema Name
ResourceProductCategoryResource -- full entity with conditional backend-only fields (asProductsDetails, order, menuColor, published, isSensitive, slider IDs)
CollectionProductCategoryCollection
MuiResourceProductCategoryMuiResource -- translation fields
MuiCollectionProductCategoryMuiCollection

DI Container

Domain registration (src/Domains/Product/container.php):

  • Category\Repository\Repository, Category\Repository\RepositoryConfigurator
  • Category\Repository\MuiRepository
  • Category\Service
  • Category\Repository\WriteRepository, Category\Repository\MuiWriteRepository
  • Category\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)

ColumnTypeDescription
idint PK AICategory ID
parent_idintParent category ID (0 = root)
as_products_detailstinyintDisplay products in detail view mode
orderintSort order within parent
imagevarcharMain category image filename
banner_linkvarcharBanner click-through URL
small_imagevarcharThumbnail/small image filename
menu_colorvarcharHex colour for menu highlight
menu_chiledsintNumber of child items displayed in mega menu
publishedtinyintVisibility flag (1 = visible on storefront)
is_sensitivetinyintSensitive content flag (hidden from certain contexts)
slider_idint FK nullableAssociated main slider
menu_slider_idint FK nullableAssociated menu slider
extra_slider_idint FK nullableAssociated extra slider

shop_product_category_mui (Translations)

ColumnTypeDescription
idint PK AIMUI record ID
category_idint FKParent category ID
langvarcharLanguage code (el, en, etc.)
namevarcharCategory name
slugvarcharURL slug (hierarchical, auto-generated)
fulltexttextRich text description
extra_descriptiontextAdditional description
meta_titlevarcharSEO title
meta_keywordsvarcharSEO keywords
meta_descriptiontextSEO description
urlvarcharCustom URL override
redirect_urlvarcharRedirect URL
builder_block_idint FK nullableBuilder block ID for rich content

Junction Tables

TableColumnsPurpose
shop_product_category_lpproduct_id, category_idProduct-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_prodcategory_id, relative_category_idRelated categories (shown as "see also")
shop_product_category_group_tags_lpcategory_id, group_tag_idTag group associations for filtering
shop_product_category_listsid, category_id, product_list_id, priorityProduct list assignments with priority
product_category_blogblog_id, product_category_id, priorityBlog article associations

Migrations

MigrationDescription
20241031161307_add_product_category_block_idAdds builder_block_id column to shop_product_category_mui
20241127121816_shop_product_category_listsCreates shop_product_category_lists junction table
20260529120000_dedup_product_category_lp_uniqueDeduplicates 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

ConfigLocationDefaultDescription
category_product_listsapplication/config/main.phpfalseEnables product list assignment on categories
ESHOP.BLOG_PRODUCT_CATEGORY_ENABLEDRegistry'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:

HookTrigger
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

RuleDescription
Hierarchy depth limitUp to 4 levels (root + 3 child levels). Enforced by breadcrumb/path logic in controller.
Auto-slug generationSlug built hierarchically: parent-slug/greek_lower(name). Generated on create; on edit, only when slug is empty.
Slug manual overrideOnly users with AUTH_ROLE_ADVISABLE can manually set slugs via the urlFields permission.
SEO field access controlmeta_title, meta_keywords, meta_description fields restricted to users with metatags permission.
URL field access controlurl, redirect_url fields restricted to users with urlFields permission.
Deletion guardCannot delete a category that has child categories or assigned products (canDeleteRecord() check).
Published flagControls storefront visibility. Unpublished categories hidden from navigation and product browsing.
Sensitive flagis_sensitive hides category from certain display contexts (e.g., guest-visible menus).
Relative categoriesAvailable only for depth-2 (leaf) categories. Shows "related categories" on storefront.
Order auto-incrementNew categories receive order = max(order) + 1. Reorder via drag-and-drop AJAX.
Builder block integrationPer-language builder_block_id for rich content rendering via the builder system.
Cache invalidationclearCache('product_categories_vendors') called on every mutation. PSCache for frontend reads.
Batch operationsDiscount/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

  1. initial.sql not updated for UNIQUE constraint: database/initial/initial.sql:1715 still declares the old KEY product_category (product_id, category_id) (non-unique). The UNIQUE constraint is applied by the Phinx migration on existing DBs. A fresh install using only initial.sql + no migration will have a non-unique index.

  2. Stale UPDATE IGNORE comment in update_categories_lp_batch(): ecommercen/eshop/models/Adv_product_category_model.php:1623-1624 says "INSERT IGNORE in linkProductCategory()" but the implementation uses ON DUPLICATE KEY UPDATE (intentional — see the linkProductCategory() docblock at :1025-1031).

  3. No automated test coverage for #279 logic: linkProductCategory(), changeProductsCategoryBatch(), removeAllProductCategoriesAndAssignToOne(), and the UNIQUE constraint are not covered by unit or integration tests.