Skip to content

Menu Categories

Overview

Categories organize menu items into logical groups. They determine how items are displayed and navigated in Online Ordering and Kiosk.

Key Purpose: Organize menu items for easy customer navigation.

Purpose

This page lets you create, edit, and organize menu categories that group items into sections customers browse on online ordering and kiosk channels.

Key Concepts

  • Category: A named grouping of menu items with an optional description, an optional parent category, and an optional image. Items reference a category through their own category_id.
  • Parent Category: A category can optionally be nested under a parent category via parent_id, giving one level of nesting (subcategories). Only root (top-level) categories can be chosen as parents.
  • Multi-language Details: Category name and description are stored in a details map keyed by language, with default as the primary language entry.
  • Advance-Order Rule: A category carries two optional fields, lead_time_days (how many days ahead a customer must order) and available_pickup_days (which weekdays pickup is allowed). Items inherit them; an item that sets its own value overrides the inherited one. Both default to "no restriction" — 0 days and an empty weekday list. (Verified: app/RawModels/Category.php lines 64-65 and 75-84; Item::applyCategoryPickupRule app/RawModels/Item.php lines 338-349.)

Actions

Create Category

Add a new category with a name, optional description, optional parent category, and an optional image. The category is saved and synced to all active third-party integrations (Square, MplusKassa, etc.).

Edit Category

Update a category's name, description, parent category, or image. After saving, the cache for every item in the category is cleared so changes are reflected immediately, and the change is synced to active integrations.

Delete Category

Remove a category permanently. Deletion is blocked if the category still contains items -- all items must be reassigned or removed first.

Location

  • Backoffice Route: /menus/categories
  • Backend Controller: app/Http/Controllers/Api/BackOffice/CategoryController.php
  • Page: src/pages/menus/categories/index.vueView: src/views/reporting-categories/ReportingCategories.vue
  • Form: src/components/forms/items/CategoryForm.vue

Fields

Category Name

PropertyValue
Field IDdetails.name
LabelCategory Name
TypeText
RequiredYes
ValidationRequired string (no max length enforced)

Description: Name of the category shown to customers as a menu section header or navigation tab.

Best Practices:

  • Keep names short (1-3 words)
  • Use familiar terms customers expect
  • Be consistent with industry standards

Customer Impact:

  • Online Ordering: Tab/section header in menu
  • Kiosk: Large category buttons

Examples:

  • "Starters"
  • "Main Courses"
  • "Pizzas"
  • "Burgers"
  • "Drinks"
  • "Desserts"
  • "Sides"
  • "Kids Menu"

Category Description

PropertyValue
Field IDdetails.description
LabelDescription
TypeTextarea
RequiredNo
ValidationOptional string (no max length enforced)

Description: Optional description shown below category name.

Customer Impact:

  • Online Ordering: Shown as subtitle under category name
  • Kiosk: May be displayed on category selection screen

Examples:

  • "Start your meal with our delicious appetizers"
  • "Hand-stretched, wood-fired pizzas"
  • "Freshly made desserts"

Category Image

PropertyValue
Field IDimage
LabelCategory Image
TypeImage Upload
RequiredNo
ValidationJPG, PNG, SVG, WebP; max 10MB

Description: Image representing the category. Used in visual menu layouts.

Customer Impact:

  • Online Ordering: Category thumbnail (if visual menu enabled)
  • Kiosk: Large category image on selection screen

Best Practices:

  • Use appetizing food photos
  • Show representative items from category
  • Consistent style across categories

Parent Category

PropertyValue
Field IDparent_id
LabelParent Category
TypeSelect
RequiredNo

Description: Optional parent category to nest this category underneath, creating a subcategory. The dropdown lists only root (top-level) categories and excludes the category being edited, so the hierarchy is limited to one level of nesting and a category can never be its own parent.

Business Logic:

  • No parent selected → Root (top-level) category
  • Parent selected → Subcategory nested under that parent

Order Days in Advance (lead time)

PropertyValue
Field IDlead_time_days
LabelOrder days in advance
TypeNumber (suffix "days")
RequiredNo
Default0
Validationnullable|integer|min:0|max:365

Description: How many days ahead a customer must order the items in this category. 0 means same-day ordering is allowed — i.e. no restriction. Items in this category inherit this value; an item that sets its own lead time overrides it.

(Verified: form field upvendo-backoffice src/components/forms/items/CategoryForm.vue lines 132-147; store default src/store/modules/category.ts line 14, coerced on load line 90; validation upvendo-backend app/Http/Requests/BackOffice/Category/StoreCategoryRequest.php line 50.)


Available Pickup Days

PropertyValue
Field IDavailable_pickup_days
LabelAvailable pickup days
TypeMulti-select (weekday names, chips)
RequiredNo
Default[] (empty = every day)
Validationnullable|array; each entry must be one of Carbon::getDays()Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday

Description: The weekdays on which items in this category may be picked up. Leaving it empty allows every day. The values are full English weekday names, not codes or indices — the back-office option list is built from pickupWeekdayOptions() specifically to stay aligned with the backend's Carbon::getDays(). Items in this category inherit this list; an item that sets its own pickup days overrides it.

(Verified: form field CategoryForm.vue lines 148-161, options via weekdayOptions line 27 → pickupWeekdayOptions() in src/utils/dateUtils.ts lines 76-79; store default src/store/modules/category.ts line 15, coerced on load line 91; validation StoreCategoryRequest.php lines 51-52.)


Advance-Order Inheritance

The advance-order rule is defined on a category and inherited by its items, so you can set it once per category rather than on every item.

  • The item wins where it sets a value; the category fills in only what the item leaves unset. The two fields are independent — an item may define its own pickup days and still inherit the category's lead time. (Verified: Item::applyCategoryPickupRule, upvendo-backend app/RawModels/Item.php lines 338-349.)
  • A stored 0 / empty list reads as "inherit", not as "no restriction". Because an item's 0 lead time and empty pickup-day list cannot be told apart from "never set", an item cannot currently opt out of its category's rule back down to same-day ordering. (Verified: the same method; the limitation is called out in its docblock.)
  • Inheritance walks the whole ancestor chain, nearest first. A subcategory that sets nothing inherits from its parent, and so on to the root. The first ancestor that defines a non-zero lead time supplies it; likewise the first that defines a non-empty pickup-day list. (Verified: CategoryRepository::getEffectivePickupRuleMap lines 500-552.)
  • The fold is in-memory and never persisted. Nothing is written onto the item document; the rule is resolved at menu-build and order-validation time. (Verified: applyCategoryPickupRule docblock; call sites app/RawModels/Menu/MenuLoadContext.php lines 162, 252 and 330-356, and app/Repositories/ItemRepository.php lines 147-188.)
  • Editing a category refreshes its whole subtree. Because the rule cascades, saving a category busts the item caches and reloads the menus for that category and every descendant — nothing on the items themselves changes, which is exactly why the push has to be explicit. (Verified: CategoryService::refreshSubtreeMenus, app/Services/BackOffice/CategoryService.php lines 144-147 and 165+.)
  • The API enforces it at order submission. When an order is submitted, the backend checks every item against its effective rule (its own, or its category's where unset) and rejects the order with a 422 and ":item is not available for the selected pickup date." naming the first offending item. (Verified: ItemRepository::firstUnavailableForPickup lines 147-188 and Item::isAvailableForPickup lines 362-372, called from OnlineOrderingOrchestrator::assertItemsAvailableForPickup line 1642 and KioskOrchestrator line 1057.)

NOTE

How the storefront and kiosk display an item that fails the rule is a channel concern and is not verified here. This section covers the back-office fields, the inheritance resolution, and the server-side enforcement only.


Customer Impact

Online Ordering

  1. Navigation: Categories as tabs or sidebar sections
  2. Scrolling: Scroll to category section
  3. Filtering: Click category to filter items
  4. Visual: Category images (if enabled)

Kiosk

  1. Home Screen: Large category buttons
  2. Selection: Tap category to see items
  3. Navigation: Back to categories button
  4. Visual: Category images prominent

Receipt

  • Items grouped by category (optional)
  • Category name may appear as section header

Relations

Depends On

  • Parent Category: A subcategory references a parent category via parent_id (one level of nesting).

Affects

  • Menu Items: Items reference a category through their own category_id.
  • Order Capacity: Category-specific limits reference categories.
  • Reports: Sales by category.

Business Rules

  • A category cannot be deleted if it still contains items; the system returns a 400 error with "Delete failed since the category is currently being used."
  • Category images are processed through media upload and stored as content entities; the resulting content_id is saved on the category record.
  • When a category is updated, the item cache for every item in that category is cleared to ensure ordering channels display the latest data. Because the advance-order rule cascades, the refresh covers the category's whole subtree, not just its direct items. (Verified: CategoryService::refreshSubtreeMenus.)
  • A category's advance-order rule (lead_time_days, available_pickup_days) is inherited by its items and by its descendant categories. An item that sets its own value overrides the inherited one, per field. A stored 0 / empty list on an item means "inherit", not "no restriction". (Verified: Item::applyCategoryPickupRule, CategoryRepository::getEffectivePickupRuleMap.)
  • lead_time_days is validated as nullable|integer|min:0|max:365; available_pickup_days must be an array of full English weekday names from Carbon::getDays(). The same two rules apply on the item validator. (Verified: StoreCategoryRequest.php lines 50-52, StoreItemRequest.php lines 146-148.)
  • Category creation, update, and deletion are synced to all active third-party integrations (Square, MplusKassa, etc.) using the category model type and the corresponding event constant.
  • Categories synced one-way from a POS are protected: Kassanet-synced categories cannot be modified or deleted locally, and read-only inbound POS systems (e.g. ShopCaisse) cannot create or delete categories from the back office.
  • The details field merges existing language translations with the new update, preserving translations for languages not included in the current request.

FAQs

  • How do I create a subcategory? When creating or editing a category, choose a Parent Category to nest it underneath. Only root (top-level) categories can be selected as parents, so the hierarchy is limited to one level of nesting.

  • Is there a limit to how many categories I can create? There is no hard limit in the system, but keeping the number manageable is recommended for a good customer browsing experience.

  • What happens if I try to delete a category that still has items? Deletion is blocked. You must reassign or remove every item first, otherwise the system returns "Delete failed since the category is currently being used."

  • "What fields can I actually set when creating a category?" A category has six editable fields on the create/edit form: Category Name (required), Description (optional), Parent Category (optional), Order days in advance (lead_time_days, optional), Available pickup days (available_pickup_days, optional), and an image. There's no menu picker, availability schedule, or tax-rate field on the form itself. Visibility is handled separately — the category list has per-row "Mark Unavailable" and "Hide" toggles (a status of Active / Unavailable / Hidden that cascades to subcategories), not a field on the form. (Verified: src/components/forms/items/CategoryForm.vue; StoreCategoryRequest::rules().)

  • "How do I make a whole category order-ahead only (e.g. cakes need 2 days' notice)?" Set Order days in advance on the category. Every item in it inherits that lead time, so you don't have to edit each item. If some items in the category need a different lead time, set it on those items — an item's own value overrides the category's.

  • "How do I restrict a category to certain pickup days?" Use Available pickup days on the category and pick the weekdays. Leaving it empty means every day. Items inherit the list; an item with its own pickup days overrides it.

  • "I set a lead time on a category but one item should still be same-day — how?" This is currently not possible. An item's stored 0 lead time is indistinguishable from "not set", so it reads as "inherit" rather than as an opt-out. The workaround is to move that item to a category without a lead time. (Verified: the applyCategoryPickupRule docblock records this as a known limitation on app/RawModels/Item.php lines 338-349.)

  • "Does a subcategory inherit its parent's advance-order rule?" Yes. Resolution walks up the ancestor chain nearest-first, so a subcategory that sets nothing takes the first ancestor that does. Each of the two fields resolves independently. (Verified: CategoryRepository::getEffectivePickupRuleMap.)

  • "How do I create a subcategory / nest categories?" When creating or editing a category, choose a Parent Category to nest it underneath. The list then shows the hierarchy as an expandable tree, and only root (top-level) categories can be selected as parents.

  • "Why can't I pick a parent category that is itself a subcategory?" The parent dropdown only lists root categories and excludes the category you're editing, so the hierarchy is limited to one level of nesting and a category can never be its own parent.

  • "What image formats and size are allowed for a category image?" Category images accept JPG, PNG, SVG, and WebP up to 10 MB.

  • "Why does a category show a higher item count than the items directly inside it?" Each category in the tree shows both its direct item count and an aggregate count that adds up the items in all of its subcategories, so a parent reflects everything beneath it.

  • "Why can't I delete a category?" Deletion is blocked while the category still has items assigned. Reassign or remove every item first, otherwise you get "Delete failed since the category is currently being used."

  • "I added a translation but my edit didn't wipe the other languages — is that expected?" Yes. Category name and description are stored per language, and saving merges your update with existing translations, so languages you didn't touch are preserved.

  • "Why is the Name (or Parent) field greyed out on some categories?" Categories synced from a POS are locked: Kassanet (Hendrickx / Vanhoutte) categories lock Name and Parent, and MplusKassa categories lock Name and Description, because those fields are owned by the POS and would be overwritten on the next sync. (Lightspeed K-Series has no category field-locking.)

  • "I edited a category but nothing changed for customers — why?" After saving, the system clears the cache for every item in that category so ordering channels pick up the change. If you still don't see it, confirm the save succeeded and that the items belong to that category.

  • "Do my categories get pushed to my connected POS / Square?" Yes. Creating, updating, or deleting a category triggers a sync to all active integrations (Square, MplusKassa, etc.); read-only inbound POS systems like Kassanet and ShopCaisse are protected and can't be created/edited/deleted from the back office.

  • "I can't find the Create Category button — why?" It only appears if you have the create-category permission and the location's POS allows catalog creation; a read-only inbound POS hides it.


Troubleshooting

Problem: Category not showing in menu

Causes:

  1. Category has no items
  2. All items in category unavailable

Solutions:

  1. Add items to category
  2. Enable item availability

Problem: Items in wrong category

Causes:

  1. Item assigned to wrong category
  2. Category names confusing

Solutions:

  1. Edit item → Change category
  2. Rename categories for clarity

Problem: Edited category but nothing changed for customers

Causes:

  1. Item cache not yet reflected
  2. Items not actually assigned to this category

Solutions:

  1. Confirm the save succeeded (the system clears the cache for every item in the category on save)
  2. Verify the items belong to that category

Examples

Single Category (create payload)

The store/update endpoint accepts a details object (with name and optional description), an optional parent_id, an optional image, and the optional advance-order pair lead_time_days / available_pickup_days:

json
{
  "details": {
    "name": "Starters",
    "description": "Start your meal with our delicious appetizers"
  },
  "parent_id": null
}

Category with an advance-order rule (create payload)

Items in this category must be ordered at least 2 days ahead and can only be picked up on Friday or Saturday, unless the item overrides one of the two:

json
{
  "details": {
    "name": "Celebration Cakes",
    "description": "Made to order"
  },
  "parent_id": null,
  "lead_time_days": 2,
  "available_pickup_days": ["Friday", "Saturday"]
}

Standard Restaurant Categories

json
{
  "categories": [
    { "details": { "name": "Starters" } },
    { "details": { "name": "Salads" } },
    { "details": { "name": "Main Courses" } },
    { "details": { "name": "Sides" } },
    { "details": { "name": "Desserts" } },
    { "details": { "name": "Drinks" } }
  ]
}

Pizza Restaurant

json
{
  "categories": [
    { "details": { "name": "Classic Pizzas", "description": "Our traditional favorites" } },
    { "details": { "name": "Specialty Pizzas", "description": "Chef's special creations" } },
    { "details": { "name": "Build Your Own", "description": "Create your perfect pizza" } },
    { "details": { "name": "Pasta" } },
    { "details": { "name": "Salads" } },
    { "details": { "name": "Sides & Extras" } },
    { "details": { "name": "Desserts" } },
    { "details": { "name": "Drinks" } }
  ]
}

Subcategories (one level of nesting)

A child category references its parent via parent_id:

json
{
  "categories": [
    { "details": { "name": "Drinks" } },
    { "details": { "name": "Hot Drinks" }, "parent_id": "<drinks_category_id>" },
    { "details": { "name": "Cold Drinks" }, "parent_id": "<drinks_category_id>" }
  ]
}