Skip to content

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:

ConceptDescription
Root categoryA category with parent_id: null, appearing at the top level of the tree
SubcategoryA category with a parent_id pointing to its parent
Depth levelComputed distance from the root: 0 = root, 1 = first child, 2 = grandchild, and so on
SiblingsCategories sharing the same parent_id, ordered by sort_order
AncestorsThe chain of categories from root down to (but not including) the current category
DescendantsAll 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.

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

FieldTypeDescription
idULID stringUnique identifier (e.g. 01hwxyz123abc456def789ghi0)
namestringDisplay name of the category
slugstringURL-safe identifier auto-generated from name (e.g. smartphones)
descriptionstring | nullOptional descriptive text for the category
parent_idULID | nullParent category ID; null for root-level categories
sort_orderintegerPosition among sibling categories (lower = first)
is_activebooleanWhether the category is visible and in use
is_featuredbooleanWhether the category is highlighted in featured placements
depth_levelintegerComputed hierarchy depth (0 = root)
created_atISO 8601Record creation timestamp
updated_atISO 8601Last 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:

ParameterTypeDescription
searchstringSearch by category name
is_activebooleanFilter by active status
is_featuredbooleanFilter to featured categories only
parent_idULID | nullFilter by parent; pass null to list root categories only
sort_bystringSort field: name, sort_order, created_at, depth_level
sort_directionstringasc or desc (default: asc)
pageintegerPage number (default: 1)
per_pageintegerResults per page (default: 25, max: 100)

Example Request:

bash
GET /api/v1/operations/product-categories?is_active=true&is_featured=true&sort_by=sort_order

Response (200 OK):

json
{
  "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:

json
{
  "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):

json
{
  "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):

json
{
  "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):

json
{
  "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):

json
{
  "name": "Smartphones & Mobile Devices",
  "description": "Android, iOS, and other mobile operating systems",
  "is_featured": true
}

Response (200 OK):

json
{
  "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):

json
{
  "success": true,
  "message": "Product category deleted successfully",
  "data": null,
  "meta": {}
}

Error Response (422 - category has children or products):

json
{
  "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):

json
{
  "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):

json
{
  "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:

json
{
  "categories": [
    { "id": "01hwxyz456def789abc123ghi0", "sort_order": 1 },
    { "id": "01hwxyzAAABBBCCC111222333", "sort_order": 2 },
    { "id": "01hwxyzDDDEEEFFF444555666", "sort_order": 3 }
  ]
}

Response (200 OK):

json
{
  "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:

json
{
  "parent_id": "01hwxyzNEWPARENT123456789"
}

To move a category to the root level, pass null:

json
{
  "parent_id": null
}

Response (200 OK):

json
{
  "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):

json
{
  "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:

json
{
  "category_ids": [
    "01hwxyz123abc456def789ghi0",
    "01hwxyz456def789abc123ghi0",
    "01hwxyz999zzz111aaa222bbb3"
  ],
  "is_active": true
}

Response (200 OK):

json
{
  "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):

json
{
  "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": {}
}

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):

json
{
  "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:

  1. Create all root categories
  2. Create second-level subcategories for each root
  3. Create third-level categories under each subcategory
  4. Set sort orders so the most important categories appear first
  5. Activate all categories and feature the top-level ones

API Calls:

bash
# 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/tree

Scenario 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:

  1. Move "Audio" to the root level
  2. Review depth levels of existing Audio children (they automatically update)
  3. Create new subcategories under Audio for new product lines
  4. Reorder root categories so Audio appears in the correct position

API Calls:

bash
# 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:

  1. Collect the IDs of all winter subcategories
  2. Bulk-deactivate them in one request
  3. Collect the IDs of spring subcategories
  4. Bulk-activate them
  5. Toggle featured status on the new season's hero categories

API Calls:

bash
# 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/statistics

Best 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:

  1. Retrieve the category tree and check for children: GET /api/v1/operations/product-categories/tree
  2. If children exist, move them to another parent (POST /product-categories/{childId}/move) or delete them first
  3. If products are assigned, reassign them to a different category before attempting deletion
  4. 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:

  1. Retrieve the target category's breadcrumbs: GET /api/v1/operations/product-categories/{targetId}/breadcrumbs
  2. Confirm the category you are trying to move does not appear in the breadcrumb chain
  3. 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:

  1. Choose a more distinctive name that will produce a unique slug
  2. If the naming constraint is business-driven, append a distinguishing suffix (e.g. Phones - Consumer) to force a unique slug
  3. After the rename, verify the new slug appears correctly in the category detail response: GET /api/v1/operations/product-categories/{id}

Documentation for SynthesQ CRM/ERP Platform