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
| Column | Type | Nullable | Default | Description |
|---|
id | bigint | no | auto | Primary key |
name | string | no | - | Category name |
slug | string | yes | auto | URL-friendly identifier (unique, auto-generated) |
description | longtext | yes | null | Category description |
position | smallint | no | 0 | Display order position |
is_enabled | boolean | no | false | Category visibility status |
seo_title | string(60) | yes | null | SEO meta title |
seo_description | string(160) | yes | null | SEO meta description |
metadata | jsonb | yes | null | Additional custom data |
parent_id | bigint | yes | null | Foreign key to parent category |
created_at | timestamp | yes | null | Creation timestamp |
updated_at | timestamp | yes | null | Last 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)
Categories support two media collections through Spatie MediaLibrary, using the same config-driven collection names as products.
| Collection | Config key | Behavior | Description |
|---|
| Default gallery | shopper.media.storage.collection_name | Multiple files | Category images |
| Thumbnail | shopper.media.storage.thumbnail_collection | Single file | Primary 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:
| Method | Description |
|---|
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:
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();
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:
| Permission | Description |
|---|
browse_categories | View the categories list |
read_categories | View a single category |
add_categories | Create new categories |
edit_categories | Edit existing categories |
delete_categories | Delete 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'));
}
}