Skip to main content
Categories provide a hierarchical organization structure for your products. Shopper uses nested categories with parent-child relationships, allowing unlimited depth. You can model complex product taxonomies like Electronics > Computers > Laptops without artificial limits.

Model

The model used is Shopper\Models\Category, which extends Shopper\Core\Models\Category. It implements the Shopper\Core\Models\Contracts\Category contract and Spatie\MediaLibrary\HasMedia for media support. The core model provides the business logic (relationships, scopes, slug generation, hierarchical structure via laravel-adjacency-list), while the admin model adds media collections and conversions through HasMedia and RegistersMediaCollections traits. This separation means the core model has no dependency on Spatie MediaLibrary. Media support is added at the admin layer.

Extending the Model

To add custom behavior, extend the admin model and update your configuration:
namespace App\Models;

use Shopper\Models\Category as ShopperCategory;

class Category extends ShopperCategory
{
}
Update config/shopper/models.php:
return [
    'category' => \App\Models\Category::class,
];

Database Schema

ColumnTypeNullableDefaultDescription
idbigintnoautoPrimary key
namestringno-Category name
slugstringyesautoURL-friendly identifier (unique, auto-generated)
descriptionlongtextyesnullCategory description
positionsmallintno0Display order position
is_enabledbooleannofalseCategory visibility status
seo_titlestring(60)yesnullSEO meta title
seo_descriptionstring(160)yesnullSEO meta description
metadatajsonbyesnullAdditional custom data
parent_idbigintyesnullForeign key to parent category
created_attimestampyesnullCreation timestamp
updated_attimestampyesnullLast update timestamp
The slug column is nullable at the database level, but is always auto-generated from the name when you set it. You never need to set it manually.

Hierarchical Structure

Categories support unlimited nesting levels, so you can organize products into deeply nested taxonomies:
Electronics (parent_id: null)
├── Computers (parent_id: 1)
│   ├── Laptops (parent_id: 2)
│   └── Desktops (parent_id: 2)
├── Phones (parent_id: 1)
│   ├── Smartphones (parent_id: 5)
│   └── Accessories (parent_id: 5)
└── Audio (parent_id: 1)

Media

Categories support two media collections through Spatie MediaLibrary, using the same config-driven collection names as products.
CollectionConfig keyBehaviorDescription
Default galleryshopper.media.storage.collection_nameMultiple filesCategory images
Thumbnailshopper.media.storage.thumbnail_collectionSingle filePrimary image for listings and navigation
Both collections accept JPEG, PNG, WebP, AVIF, and SVG files by default (configurable in config/shopper/media.php). To display a category’s thumbnail on your storefront: The collection names are defined in config/shopper/media.php under the storage key. The getFirstMediaUrl method returns the URL for the first media in a collection. If no media has been uploaded, a fallback URL is returned instead. You can also request a specific conversion size like medium (500x500) or large (800x800).
use Shopper\Models\Category;

$category = Category::query()->find($id);
$collection = config('shopper.media.storage.thumbnail_collection');

$thumbnailUrl = $category->getFirstMediaUrl($collection);
$mediumUrl = $category->getFirstMediaUrl($collection, 'medium');
To add a thumbnail to a category programmatically:
$collection = config('shopper.media.storage.thumbnail_collection');

$category->addMedia($pathToImage)
    ->toMediaCollection($collection);

Relationships

Parent Category

Every category can optionally belong to a parent category. Use this to build breadcrumbs or display the parent context:
$category->parent;

if ($category->parent) {
    $breadcrumb = $category->ancestors;
}

Child Categories

Using the laravel-adjacency-list package, categories expose a full set of hierarchical relationships: The children relationship returns direct children only. Use descendants for all recursive children (children of children, etc.) and ancestors for all recursive parents up to the root.
$category->children;
$category->descendants;
$category->ancestors;
$category->descendantsAndSelf;

Descendant Categories

The descendantCategories() relationship returns a HasManyOfDescendants Eloquent relation, which means you can use it in whereHas(), withCount(), and other query builder contexts. This differs from descendants() which is a recursive scope and cannot be used the same way.
$categoriesWithDescendants = Category::query()
    ->withCount('descendantCategories')
    ->get();

Products

Categories have a polymorphic many-to-many relationship with products through the product_has_relations table:
$category->products;
$category->products()->count();

$category->products()->publish()->get();

Available Tree Methods

The laravel-adjacency-list package provides these methods for traversing the hierarchy:
MethodDescription
ancestors()The model’s recursive parents
ancestorsAndSelf()The model’s recursive parents and itself
bloodline()The model’s ancestors, descendants and itself
children()The model’s direct children
childrenAndSelf()The model’s direct children and itself
descendants()The model’s recursive children
descendantsAndSelf()The model’s recursive children and itself
parent()The model’s direct parent
parentAndSelf()The model’s direct parent and itself
rootAncestor()The model’s topmost parent
siblings()The parent’s other children
siblingsAndSelf()All the parent’s children
$ancestors = Category::query()->find($id)->ancestors;
$categories = Category::with('descendants')->get();
$total = Category::query()->find($id)->descendants()->count();
Category::query()->find($id)->descendants()->update(['is_enabled' => false]);

Query Scopes

Enabled Categories

Filter categories that are visible to customers:
Category::query()->enabled()->get();

Root Categories

Get only top-level categories (those without a parent):
Category::query()->whereNull('parent_id')->get();
Using the tree structure to get the full hierarchy:
Category::tree()->get();

Custom Paths & Slug Behavior

Categories automatically generate slug paths for nested URLs. For a category “Laptops” under “Computers” under “Electronics”, $category->slug_path returns electronics/computers/laptops:
public function getCustomPaths(): array
{
    return [
        [
            'name' => 'slug_path',
            'column' => 'slug',
            'separator' => '/',
        ],
    ];
}
When a category has a parent, the CategoryObserver automatically combines the parent’s slug with the category name on creation and update. For example, creating a “Laptops” category under a parent with slug computers produces the slug computers-laptops. This ensures slug uniqueness across the hierarchy.

Methods & Helpers

Find by Slug

The findBySlug static method looks up a category by its slug and throws a ModelNotFoundException if not found:
use Shopper\Models\Category;

$category = Category::findBySlug('electronics');

Label with Path

The getLabelOptionName() method returns the full hierarchical path as a formatted string, useful for select dropdowns and admin interfaces: For a category “Laptops” nested under “Computers” under “Electronics”, the method returns Electronics / Computers / Laptops.
$category->getLabelOptionName();

Status Management

Toggle a category’s visibility with updateStatus(): Pass true to enable a category or false to disable it.
$category->updateStatus(true);
$category->updateStatus(false);

Creating Categories

Create a root category by omitting the parent_id:
$electronics = Category::query()->create([
    'name' => 'Electronics',
    'is_enabled' => true,
    'position' => 1,
]);
Create a subcategory by setting parent_id:
$computers = Category::query()->create([
    'name' => 'Computers',
    'parent_id' => $electronics->id,
    'is_enabled' => true,
]);

Retrieving Categories

Root categories with their children:
$categories = Category::query()
    ->whereNull('parent_id')
    ->enabled()
    ->with('children')
    ->orderBy('position')
    ->get();
Full category tree:
$tree = Category::query()
    ->enabled()
    ->tree()
    ->get()
    ->toTree();
A category with all its descendants:
$category = Category::query()
    ->where('slug', 'electronics')
    ->with('descendants')
    ->first();

$breadcrumb = $category->ancestors;
For navigation menus with nested enabled children:
$navCategories = Category::query()
    ->whereNull('parent_id')
    ->enabled()
    ->with(['children' => fn ($q) => $q->enabled()])
    ->orderBy('position')
    ->get();

Working with Products

Products in this category only:
$category = Category::query()->find($id);
$products = $category->products;
Products in category and all descendants:
$categoryIds = $category->descendants->pluck('id')->push($category->id);

$products = Product::query()
    ->publish()
    ->whereHas('categories', fn ($q) => $q->whereIn('id', $categoryIds))
    ->get();

Metadata

The metadata JSON column lets you store arbitrary key-value data on a category without modifying the schema. This is useful for storing custom attributes like banner colors, display preferences, or integration identifiers:
$category = Category::query()->create([
    'name' => 'Summer Sale',
    'is_enabled' => true,
    'metadata' => [
        'banner_color' => '#FF6B35',
        'featured_on_homepage' => true,
        'external_id' => 'cat_abc123',
    ],
]);

$color = $category->metadata['banner_color'];

Disabling Category Feature

If your store doesn’t use categories, you can disable the feature entirely. This removes the category section from the admin panel: In your config/shopper/features.php file, set the category feature to disabled:
use Shopper\Enum\FeatureState;

return [
    'category' => FeatureState::Disabled,
];

Permissions

The admin panel generates four permissions for category management:
PermissionDescription
browse_categoriesView the categories list
read_categoriesView a single category
add_categoriesCreate new categories
edit_categoriesEdit existing categories
delete_categoriesDelete categories

Components

You can publish the Livewire components to customize the admin UI for categories:
php artisan shopper:component:publish category
Creates config/shopper/components/category.php:
use Shopper\Livewire;

return [
    'pages' => [
        'category-index' => Livewire\Pages\Category\Index::class,
    ],
    'components' => [
        'slide-overs.category-form' => Livewire\SlideOvers\CategoryForm::class,
        'slide-overs.re-order-categories' => Livewire\SlideOvers\ReOrderCategories::class,
    ],
];

Storefront Example

Here is a complete controller for displaying categories and their products on your storefront. The show method collects products from the category and all its descendants, so browsing “Electronics” also returns products from “Computers”, “Laptops”, etc.
namespace App\Http\Controllers;

use Shopper\Models\Category;
use Shopper\Models\Product;

class CategoryController extends Controller
{
    public function index()
    {
        $categories = Category::query()
            ->whereNull('parent_id')
            ->enabled()
            ->with(['children' => fn ($q) => $q->enabled()])
            ->withCount('products')
            ->orderBy('position')
            ->get();

        return view('categories.index', compact('categories'));
    }

    public function show(string $slug)
    {
        $category = Category::findBySlug($slug);

        abort_unless($category->is_enabled, 404);

        $category->load('ancestors');

        $categoryIds = $category->descendants
            ->pluck('id')
            ->push($category->id);

        $products = Product::query()
            ->publish()
            ->whereHas('categories', fn ($q) => $q->whereIn('id', $categoryIds))
            ->paginate(12);

        return view('categories.show', compact('category', 'products'));
    }
}