Product Category Management Guide
Overview
Product categories provide the organisational backbone for your product catalogue. Categories form a tree structure that can nest to any depth: a top-level Electronics category can contain a Phones subcategory, which in turn contains Smartphones and Feature Phones. This hierarchy lets your team browse and filter products naturally, and it supports breadcrumb navigation in customer-facing interfaces.
Every category carries a slug generated automatically from its name, a sort order for controlling display sequence among siblings, and two flags - is_active and is_featured - that let you control visibility and highlight key categories without modifying the tree structure. The domain model exposes recursive helpers (getAllDescendants, getAllAncestors, getFullPath) that power the tree and breadcrumb API responses.
Category Tree Structure
Hierarchy Concepts
Categories relate to one another through a simple parent/child model:
| Concept | Description |
|---|---|
| Root category | A category with parent_id: null, appearing at the top level of the tree |
| Subcategory | A category with a parent_id pointing to its parent |
| Depth level | Computed distance from the root: 0 = root, 1 = first child, 2 = grandchild, and so on |
| Siblings | Categories sharing the same parent_id, ordered by sort_order |
| Ancestors | The chain of categories from root down to (but not including) the current category |
| Descendants | All categories recursively nested below a given category |
Example Tree
Electronics (depth_level: 0)
├── Phones (depth_level: 1)
│ ├── Smartphones (depth_level: 2)
│ └── Feature Phones (depth_level: 2)
├── Computers (depth_level: 1)
│ ├── Laptops (depth_level: 2)
│ └── Desktops (depth_level: 2)
└── Audio (depth_level: 1)
├── Headphones (depth_level: 2)
└── Speakers (depth_level: 2)The getFullPath() method on the domain model returns a human-readable path string such as Electronics > Phones > Smartphones - this is what the breadcrumbs endpoint surfaces via the API.
Deletion Constraints
A category can only be deleted if it has no child categories and no products assigned to it. Attempting to delete a parent or a category in use returns a 422 error. Move products to a different category and move or delete children first.
Featured Categories
The is_featured flag is a toggle independent of is_active. A featured category can be surfaced in promotional placements (homepage banners, catalogue highlights) without altering its position in the tree. Use POST /product-categories/{id}/toggle-featured to flip the flag.
Fields Reference
| Field | Type | Description |
|---|---|---|
id | ULID string | Unique identifier (e.g. 01hwxyz123abc456def789ghi0) |
name | string | Display name of the category |
slug | string | URL-safe identifier auto-generated from name (e.g. smartphones) |
description | string | null | Optional descriptive text for the category |
parent_id | ULID | null | Parent category ID; null for root-level categories |
sort_order | integer | Position among sibling categories (lower = first) |
is_active | boolean | Whether the category is visible and in use |
is_featured | boolean | Whether the category is highlighted in featured placements |
depth_level | integer | Computed hierarchy depth (0 = root) |
created_at | ISO 8601 | Record creation timestamp |
updated_at | ISO 8601 | Last modification timestamp |
API Endpoints
All product-category endpoints are under the base path /api/v1/operations/product-categories.
Authentication: All requests require a valid Bearer token in the Authorization header.
List Categories
Endpoint: GET /api/v1/operations/product-categories
Returns a paginated, flat list of categories with optional filtering. Use the tree endpoint for a nested view.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search | string | Search by category name |
is_active | boolean | Filter by active status |
is_featured | boolean | Filter to featured categories only |
parent_id | ULID | null | Filter by parent; pass null to list root categories only |
sort_by | string | Sort field: name, sort_order, created_at, depth_level |
sort_direction | string | asc or desc (default: asc) |
page | integer | Page number (default: 1) |
per_page | integer | Results per page (default: 25, max: 100) |
Example Request:
GET /api/v1/operations/product-categories?is_active=true&is_featured=true&sort_by=sort_orderResponse (200 OK):
{
"success": true,
"message": "Product categories retrieved successfully",
"data": [
{
"id": "01hwxyz123abc456def789ghi0",
"name": "Electronics",
"slug": "electronics",
"description": "Consumer and professional electronics",
"parent_id": null,
"sort_order": 1,
"is_active": true,
"is_featured": true,
"depth_level": 0,
"created_at": "2025-01-15T08:00:00Z",
"updated_at": "2025-11-01T10:30:00Z"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 42,
"last_page": 2
},
"links": {
"first": "/api/v1/operations/product-categories?page=1",
"last": "/api/v1/operations/product-categories?page=2",
"prev": null,
"next": "/api/v1/operations/product-categories?page=2"
}
}Create Category
Endpoint: POST /api/v1/operations/product-categories
Creates a new category. The slug is generated automatically from the name and must be unique within the tenant. To create a subcategory, provide a parent_id.
Required Fields:
name- Display name
Request Body:
{
"name": "Smartphones",
"description": "Android and iOS smartphones from all major brands",
"parent_id": "01hwxyz456def789abc123ghi0",
"sort_order": 1,
"is_active": true,
"is_featured": false
}Response (201 Created):
{
"success": true,
"message": "Product category created successfully",
"data": {
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones",
"slug": "smartphones",
"description": "Android and iOS smartphones from all major brands",
"parent_id": "01hwxyz456def789abc123ghi0",
"sort_order": 1,
"is_active": true,
"is_featured": false,
"depth_level": 2,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
},
"meta": {}
}Error Response (422 Unprocessable Entity):
{
"success": false,
"message": "The given data was invalid.",
"errors": {
"name": ["The name field is required."],
"parent_id": ["The selected parent category does not exist."]
}
}Get Category Details
Endpoint: GET /api/v1/operations/product-categories/{id}
Returns the full record for a single category.
Response (200 OK):
{
"success": true,
"message": "Product category retrieved successfully",
"data": {
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones",
"slug": "smartphones",
"description": "Android and iOS smartphones from all major brands",
"parent_id": "01hwxyz456def789abc123ghi0",
"sort_order": 1,
"is_active": true,
"is_featured": false,
"depth_level": 2,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
},
"meta": {}
}Update Category
Endpoint: PUT /api/v1/operations/product-categories/{id}
Updates a category's details. To change its position in the tree, use the move endpoint instead of updating parent_id here. To change sibling ordering, use the reorder endpoint.
Request Body (partial update allowed):
{
"name": "Smartphones & Mobile Devices",
"description": "Android, iOS, and other mobile operating systems",
"is_featured": true
}Response (200 OK):
{
"success": true,
"message": "Product category updated successfully",
"data": {
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones & Mobile Devices",
"slug": "smartphones-mobile-devices",
"description": "Android, iOS, and other mobile operating systems",
"is_featured": true,
"updated_at": "2026-03-11T11:00:00Z"
},
"meta": {}
}Slug Regeneration
If you update the name, the slug is regenerated automatically from the new name to maintain URL consistency. Ensure any external links or integrations referencing the old slug are updated accordingly.
Delete Category
Endpoint: DELETE /api/v1/operations/product-categories/{id}
Deletes a category. The request will fail with 422 if the category has child categories or products assigned to it.
Response (200 OK):
{
"success": true,
"message": "Product category deleted successfully",
"data": null,
"meta": {}
}Error Response (422 - category has children or products):
{
"success": false,
"message": "Cannot delete a category that has child categories or assigned products.",
"errors": {}
}Get Category Tree
Endpoint: GET /api/v1/operations/product-categories/tree
Returns the complete category hierarchy as a nested tree. Each node in the response includes its children recursively. Use this endpoint to render tree-navigation UI components or export the full taxonomy.
Response (200 OK):
{
"success": true,
"message": "Category tree retrieved successfully",
"data": [
{
"id": "01hwxyz123abc456def789ghi0",
"name": "Electronics",
"slug": "electronics",
"sort_order": 1,
"is_active": true,
"is_featured": true,
"depth_level": 0,
"children": [
{
"id": "01hwxyz456def789abc123ghi0",
"name": "Phones",
"slug": "phones",
"sort_order": 1,
"is_active": true,
"is_featured": false,
"depth_level": 1,
"children": [
{
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones",
"slug": "smartphones",
"sort_order": 1,
"is_active": true,
"is_featured": false,
"depth_level": 2,
"children": []
}
]
}
]
}
],
"meta": {}
}Get Category Breadcrumbs
Endpoint: GET /api/v1/operations/product-categories/{id}/breadcrumbs
Returns the ordered ancestor chain from root down to the requested category. Use this to render breadcrumb navigation on product pages.
Response (200 OK):
{
"success": true,
"message": "Breadcrumbs retrieved successfully",
"data": [
{
"id": "01hwxyz123abc456def789ghi0",
"name": "Electronics",
"slug": "electronics",
"depth_level": 0
},
{
"id": "01hwxyz456def789abc123ghi0",
"name": "Phones",
"slug": "phones",
"depth_level": 1
},
{
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones",
"slug": "smartphones",
"depth_level": 2
}
],
"meta": {}
}The first element is always the root ancestor and the last element is always the requested category itself.
Reorder Categories
Endpoint: POST /api/v1/operations/product-categories/reorder
Updates the sort_order for multiple sibling categories in a single operation. All category IDs in the payload must share the same parent. The new sort_order values replace the existing ones atomically.
Request Body:
{
"categories": [
{ "id": "01hwxyz456def789abc123ghi0", "sort_order": 1 },
{ "id": "01hwxyzAAABBBCCC111222333", "sort_order": 2 },
{ "id": "01hwxyzDDDEEEFFF444555666", "sort_order": 3 }
]
}Response (200 OK):
{
"success": true,
"message": "Categories reordered successfully",
"data": {
"reordered": 3
},
"meta": {}
}Move Category
Endpoint: POST /api/v1/operations/product-categories/{id}/move
Moves a category to a new parent (or to the root level). The domain model validates that moving the category would not create a circular reference (i.e. you cannot move a category into one of its own descendants).
Request Body:
{
"parent_id": "01hwxyzNEWPARENT123456789"
}To move a category to the root level, pass null:
{
"parent_id": null
}Response (200 OK):
{
"success": true,
"message": "Category moved successfully",
"data": {
"id": "01hwxyz999zzz111aaa222bbb3",
"parent_id": "01hwxyzNEWPARENT123456789",
"depth_level": 1,
"updated_at": "2026-03-11T13:00:00Z"
},
"meta": {}
}Error Response (422 - circular reference):
{
"success": false,
"message": "Cannot move a category into one of its own descendants.",
"errors": {}
}Bulk Update Status
Endpoint: POST /api/v1/operations/product-categories/bulk-status
Updates the is_active flag for multiple categories in one request. Useful for activating or deactivating an entire branch before a catalogue launch or seasonal change.
Request Body:
{
"category_ids": [
"01hwxyz123abc456def789ghi0",
"01hwxyz456def789abc123ghi0",
"01hwxyz999zzz111aaa222bbb3"
],
"is_active": true
}Response (200 OK):
{
"success": true,
"message": "Category statuses updated successfully",
"data": {
"updated": 3
},
"meta": {}
}Get Statistics
Endpoint: GET /api/v1/operations/product-categories/statistics
Returns an overview of the category taxonomy including product distribution counts and popular categories ranked by product assignment.
Response (200 OK):
{
"success": true,
"message": "Category statistics retrieved successfully",
"data": {
"overview": {
"total_categories": 42,
"active_categories": 39,
"inactive_categories": 3,
"featured_categories": 8,
"root_categories": 6,
"max_depth_level": 3
},
"product_distribution": {
"total_products_categorised": 1240,
"categories_with_products": 31,
"empty_categories": 11,
"average_products_per_category": 40.0
},
"popular_categories": [
{
"id": "01hwxyz999zzz111aaa222bbb3",
"name": "Smartphones",
"slug": "smartphones",
"product_count": 187
},
{
"id": "01hwxyz456def789abc123ghi0",
"name": "Laptops",
"slug": "laptops",
"product_count": 142
}
]
},
"meta": {}
}Toggle Featured
Endpoint: POST /api/v1/operations/product-categories/{id}/toggle-featured
Flips the is_featured flag for a single category. No request body required.
Response (200 OK):
{
"success": true,
"message": "Category featured status toggled successfully",
"data": {
"id": "01hwxyz999zzz111aaa222bbb3",
"is_featured": true,
"updated_at": "2026-03-11T14:00:00Z"
},
"meta": {}
}Business Scenarios
Scenario 1: Building a New Category Taxonomy from Scratch
Context: You are setting up the product catalogue for the first time and need to build a three-level hierarchy for a hardware retail business.
Workflow:
- Create all root categories
- Create second-level subcategories for each root
- Create third-level categories under each subcategory
- Set sort orders so the most important categories appear first
- Activate all categories and feature the top-level ones
API Calls:
# Step 1 - Create root categories
POST /api/v1/operations/product-categories
{ "name": "Power Tools", "sort_order": 1, "is_active": true, "is_featured": true }
POST /api/v1/operations/product-categories
{ "name": "Hand Tools", "sort_order": 2, "is_active": true, "is_featured": true }
# Step 2 - Create subcategories (parent_id = Power Tools ID)
POST /api/v1/operations/product-categories
{
"name": "Drills",
"parent_id": "01hwxyzPOWERTOOLS000000000",
"sort_order": 1,
"is_active": true
}
POST /api/v1/operations/product-categories
{
"name": "Saws",
"parent_id": "01hwxyzPOWERTOOLS000000000",
"sort_order": 2,
"is_active": true
}
# Step 3 - Create third-level categories (parent_id = Drills ID)
POST /api/v1/operations/product-categories
{
"name": "Cordless Drills",
"parent_id": "01hwxyzDRILLS00000000000000",
"sort_order": 1,
"is_active": true
}
# Step 4 - Verify the full tree
GET /api/v1/operations/product-categories/treeScenario 2: Reorganising the Taxonomy After a Product Range Expansion
Context: Your business has grown and the existing "Audio" subcategory under "Electronics" needs to be promoted to a root-level category with its own subcategories.
Workflow:
- Move "Audio" to the root level
- Review depth levels of existing Audio children (they automatically update)
- Create new subcategories under Audio for new product lines
- Reorder root categories so Audio appears in the correct position
API Calls:
# Step 1 - Move Audio to root level
POST /api/v1/operations/product-categories/01hwxyzAUDIO000000000000000/move
{
"parent_id": null
}
# Step 2 - Create new subcategory under the now-root Audio
POST /api/v1/operations/product-categories
{
"name": "Bluetooth Speakers",
"parent_id": "01hwxyzAUDIO000000000000000",
"sort_order": 3,
"is_active": true
}
# Step 3 - Reorder root categories to place Audio at position 2
POST /api/v1/operations/product-categories/reorder
{
"categories": [
{ "id": "01hwxyzELECTRONICS000000000", "sort_order": 1 },
{ "id": "01hwxyzAUDIO000000000000000", "sort_order": 2 },
{ "id": "01hwxyzPOWERTOOLS000000000", "sort_order": 3 }
]
}Scenario 3: Seasonal Catalogue Activation
Context: Your business runs seasonal product lines. You need to deactivate winter product categories at the end of the season and activate spring categories.
Workflow:
- Collect the IDs of all winter subcategories
- Bulk-deactivate them in one request
- Collect the IDs of spring subcategories
- Bulk-activate them
- Toggle featured status on the new season's hero categories
API Calls:
# Step 1 - Deactivate all winter categories
POST /api/v1/operations/product-categories/bulk-status
{
"category_ids": [
"01hwxyzWINTER_JACKETS000000",
"01hwxyzWINTER_BOOTS0000000",
"01hwxyzWINTER_GLOVES000000"
],
"is_active": false
}
# Step 2 - Activate spring categories
POST /api/v1/operations/product-categories/bulk-status
{
"category_ids": [
"01hwxyzSPRING_JACKETS00000",
"01hwxyzSPRING_FOOTWEAR0000",
"01hwxyzSPRING_ACCESSORIES0"
],
"is_active": true
}
# Step 3 - Feature the new season's hero category
POST /api/v1/operations/product-categories/01hwxyzSPRING_JACKETS00000/toggle-featured
# Step 4 - Confirm statistics show the correct active count
GET /api/v1/operations/product-categories/statisticsBest Practices
1. Design the Tree Before You Build It
Before making API calls, sketch the full hierarchy on paper or in a spreadsheet. Decide on maximum depth (three levels is a practical limit for most businesses) and agree on naming conventions. Reorganising a deep tree after products have been assigned is possible via the move endpoint, but it is disruptive to any external integrations that use slugs as stable keys.
2. Treat Slugs as Stable External Identifiers
The slug is auto-generated from the category name and is your stable, URL-safe key for linking categories in external systems (storefronts, sitemaps, marketing tools). Avoid renaming categories once they are in use in production - every name change regenerates the slug, which breaks external links. If a rename is necessary, update any dependent URLs immediately and use the statistics endpoint to identify which categories have the most products (and therefore the most at-stake external references).
3. Keep Sort Orders Sparse
Assign sort_order values in increments of 10 (e.g. 10, 20, 30) rather than 1, 2, 3. This gives you room to insert categories between existing ones without reordering the entire sibling list. Use the reorder endpoint as a batch operation only when you genuinely need to reorganise a large number of siblings at once.
4. Use Bulk Status for Seasonal Workflows
Rather than toggling is_active on dozens of categories individually, collect their IDs and call bulk-status once. This is faster, produces a single audit-log event per request, and reduces the risk of leaving a category in the wrong state due to a partial update.
5. Monitor Empty Categories
The statistics endpoint surfaces empty_categories - categories with no products assigned. Review this count regularly and either assign products or deactivate empty categories to keep the taxonomy clean. Empty active categories create a confusing browsing experience for end users.
Integration Points
With Products
Every product in the Operations module is assigned to a category via category_id. The category's is_active state affects product discoverability in catalogue queries. When retrieving products by category, the system uses the category ID - so products are not automatically reassigned if a category is moved. Moving a category in the tree changes its depth_level and parent_id but does not change its id, so product associations are unaffected.
With Master Products and Variants
The Operations module's MasterProduct aggregate root uses product categories to group variant families. Filtering by category in the master-products list uses the same category IDs returned by this module. Refer to the Master Products Guide for how category assignment interacts with the three-strategy product creation flow.
With Storefronts and External Catalogue Systems
If you publish your product catalogue externally (an e-commerce storefront, a product information management system, or a B2B portal), the category tree endpoint (GET /product-categories/tree) is the canonical source for building navigation menus. The breadcrumbs endpoint (GET /product-categories/{id}/breadcrumbs) provides the ancestor path needed for structured data markup (e.g. BreadcrumbList schema). Always fetch fresh tree data after any reorder, move, or bulk-status operation to keep your external cache in sync.
Troubleshooting
Cannot Delete a Category
Error: "Cannot delete a category that has child categories or assigned products."
Cause: The system prevents deletion of any category that is still in use - either as a parent to other categories, or as the assigned category of one or more products.
Solution:
- Retrieve the category tree and check for children:
GET /api/v1/operations/product-categories/tree - If children exist, move them to another parent (
POST /product-categories/{childId}/move) or delete them first - If products are assigned, reassign them to a different category before attempting deletion
- Once all children and products are cleared, retry the delete
Circular Reference Error When Moving
Error: "Cannot move a category into one of its own descendants."
Cause: You are attempting to set a category's parent_id to a category that is already a descendant of it. For example, trying to move Electronics into Smartphones (a grandchild of Electronics) would create a loop.
Solution:
- Retrieve the target category's breadcrumbs:
GET /api/v1/operations/product-categories/{targetId}/breadcrumbs - Confirm the category you are trying to move does not appear in the breadcrumb chain
- Choose a valid parent that is not a descendant of the category being moved
Slug Conflicts After Rename
Issue: Updating a category name generates a new slug that conflicts with another existing category.
Cause: Two categories with similar names (e.g. Phones and Phones & Accessories) may produce the same slug after normalisation.
Solution:
- Choose a more distinctive name that will produce a unique slug
- If the naming constraint is business-driven, append a distinguishing suffix (e.g.
Phones - Consumer) to force a unique slug - After the rename, verify the new slug appears correctly in the category detail response:
GET /api/v1/operations/product-categories/{id}