Categories System
This document explains the Categories system - a flexible tagging system that allows users and admins to categorize pets with breed, type, or other pet-specific characteristics.
Overview
Categories enable pet owners and administrators to organize and tag pets with meaningful labels such as breed information, size, training status, age group, and other distinguishing characteristics. Each category is specific to a pet type (cat, dog, bird, rabbit, etc.).
Key Features:
- Pet-type-specific categories (cats have different categories than dogs)
- Dual-mode creation: admins create via admin panel, users create on-demand during pet creation/editing
- Approval workflow: user-created categories require admin approval before appearing in general lists
- Usage tracking: automatic count of pets using each category
- Max limit enforcement: up to 10 categories per pet
- Search and autocomplete: real-time category filtering
- Full-text search support for category discovery
Key Components:
- Backend Model:
backend/app/Models/Category.php - Database: Two tables -
categoriesandpet_categories(pivot) - API Endpoints:
GET /api/categories,POST /api/categories - Admin Panel: Full CRUD via Filament Resource at Admin → System → Categories
- Frontend Component:
CategorySelectmulti-select with autocomplete and create capability
Backend Architecture
Database Schema
categories Table
id bigint (primary key)
name jsonb - translatable category name (e.g., {"en": "Siamese", "vi": "Mèo Siamese"})
slug varchar(60) - URL-friendly name (auto-generated, unique per pet type)
pet_type_id bigint (foreign key) - references pet_types.id on delete cascade
description text (nullable) - category description/guidelines
created_by bigint (nullable, foreign key) - user who created it (null if seed/admin-created)
approved_at timestamp (nullable) - approval timestamp (null = pending user review)
created_at timestamp
updated_at timestamp
-- Unique constraints
UNIQUE(name, pet_type_id) -- Name must be unique per pet type
UNIQUE(slug, pet_type_id) -- Slug must be unique per pet type
-- Indices
INDEX(pet_type_id, name) -- For filtering by pet type + searchpet_categories Pivot Table
id bigint (primary key)
pet_id bigint (foreign key) - references pets.id on delete cascade
category_id bigint (foreign key) - references categories.id on delete cascade
created_at timestamp
updated_at timestamp
-- Unique constraint
UNIQUE(pet_id, category_id) -- Prevent duplicate pet-category assignmentsModel: Category
Located at backend/app/Models/Category.php
Relationships:
petType(): BelongsToPetType- the pet type this category applies topets(): BelongsToManyPetviapet_categoriespivotcreator(): BelongsToUser(created_by) - user who created the category
Attributes:
name: Category display name (Translatable JSONB)slug: Auto-generated from name, unique per pet typedescription: Optional markdown/text descriptioncreated_by: User ID of creator (null if admin-created)approved_at: Approval timestamp (determines visibility)usage_count: Virtual attribute - count of pets using this category (read-only, computed)
Scopes:
forPetType($petTypeId): Filter by pet typeapproved(): Only show admin-approved categoriesvisibleTo(User $user): Show approved categories + categories created by the userpending(): Only show unapproved categoriesbyUsage(): Order by usage count descending
Methods:
isApproved(): Check if category is approvedapprove(): Set approval timestamp to nowdisapprove(): Clear approval timestampgetUsageCountAttribute(): Compute count of pets with this categoryboot(): Generates slug automatically on create/update
Boot Method Auto-Slug Generation:
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (!$model->slug) {
$model->slug = self::generateUniqueSlug($model->name, $model->pet_type_id);
}
});
static::updating(function ($model) {
if ($model->isDirty('name')) {
$model->slug = self::generateUniqueSlug($model->name, $model->pet_type_id);
}
});
}
// Generates slug with numeric suffix if needed (e.g., "siamese-1", "siamese-2")
private static function generateUniqueSlug($name, $petTypeId) { ... }Migrations
2025_12_04_000001_create_categories_table.php
- Creates
categoriestable with all fields and constraints - Adds indices for performance
- Foreign key references to
pet_typesandusers
2025_12_04_000002_create_pet_categories_table.php
- Creates
pet_categoriespivot table - Unique constraint on [pet_id, category_id] prevents duplicates
- Cascade delete on both directions
Factory & Seeding
CategoryFactory (backend/database/factories/CategoryFactory.php)
- State options:
approved(),pending(),forPetType($petTypeId) - Random description generation
- Example usage:
Category::factory()->approved()->forPetType($catTypeId)->create(['name' => 'Siamese']);
Category::factory()->pending()->forPetType($dogTypeId)->count(5)->create();CategorySeeder (backend/database/seeders/CategorySeeder.php)
- Pre-seeds 30+ categories for cats and dogs
- Cats: Siamese, Persian, Maine Coon, British Shorthair, Ragdoll, Bengal, Sphynx, Scottish Fold, Abyssinian, Mixed Breed, Long-haired, Short-haired, Indoor, Outdoor, Senior, Kitten
- Dogs: Labrador, Golden Retriever, German Shepherd, Beagle, Bulldog, Poodle, Rottweiler, Yorkshire Terrier, Boxer, Dachshund, Shiba Inu, Husky, Mixed Breed, Small/Medium/Large Breed, Puppy, Senior, Trained
- All seeded as
approved_at = now()(trusted seed data) - Uses
firstOrCreateto prevent duplicate insertion on re-run - Registered in
DatabaseSeeder
API Controllers
ListCategoriesController
Endpoint: GET /api/categories
Query Parameters:
pet_type_id(required): ID of the pet type to filter bysearch(optional): Search term for category name/description
Response: JSON array of Category objects
[
{
"id": 1,
"name": "Siamese",
"slug": "siamese",
"pet_type_id": 1,
"description": "Siamese breed",
"created_by": null,
"approved_at": "2025-12-04T00:00:00Z",
"usage_count": 5,
"created_at": "2025-12-04T00:00:00Z",
"updated_at": "2025-12-04T00:00:00Z"
}
]Logic:
- Validates
pet_type_idis required and exists - Filters categories by pet type
- If user is authenticated: shows approved categories + categories they created
- If user is not authenticated: shows only approved categories
- If search term provided: filters name and description using case-insensitive ilike match
- Returns empty array if no matches
Example Requests:
# Get all approved cat categories
GET /api/categories?pet_type_id=1
# Search for cat categories containing "persian"
GET /api/categories?pet_type_id=1&search=persian
# Includes user-created pending categories if authenticated
GET /api/categories?pet_type_id=1&search=tabby \
-H "Authorization: Bearer {token}"StoreCategoryController
Endpoint: POST /api/categories (requires authentication)
Request Body:
{
"name": "Calico",
"pet_type_id": 1,
"description": "Multi-colored cat pattern"
}Validation:
name: required, string, max 50 charspet_type_id: required, integer, must exist in pet_typesdescription: optional, string, max 500 chars- Unique constraint: name + pet_type_id combination must not already exist
Response: Created Category object (201 status)
{
"id": 42,
"name": "Calico",
"slug": "calico",
"pet_type_id": 1,
"description": "Multi-colored cat pattern",
"created_by": 5,
"approved_at": null,
"usage_count": 0,
"created_at": "2025-12-04T14:30:00Z",
"updated_at": "2025-12-04T14:30:00Z"
}Logic:
- Validates request data
- Checks name doesn't already exist for this pet type
- Auto-generates unique slug from name
- Sets
created_byto current user - Sets
approved_atto null (requires admin approval) - Returns created category
Example Request:
POST /api/categories \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \
-d '{
"name": "Calico",
"pet_type_id": 1,
"description": "Multi-colored cat pattern"
}'Pet Controller Integration
StorePetController - Added category support:
- Accepts
category_idsarray in request:[1, 2, 3] - Validates all IDs exist in categories table
- After pet creation, syncs categories via pivot table:
$pet->categories()->sync($validated['category_ids'] ?? []);UpdatePetController - Added category support:
- Same
category_idsvalidation and sync - Allows user to change pet categories without recreating pet
ShowPetController - Category loading:
- Eager loads categories relationship:
Pet::with('categories.petType')->find($id)Filament Admin Resource
CategoryResource (backend/app/Filament/Resources/CategoryResource.php)
Navigation:
- Section: "System" (not Pets)
- Sort order: 2 (after Pet Types with sort 1)
- Icon: Tag icon
- Breadcrumb support
List View (ListCategories Page):
- Columns: name, slug, pet_type.name, description, is_approved badge, created_by.name, usage_count
- Sortable: name, usage_count, created_at
- Searchable: name, slug
- Filterable: by pet_type_id, by is_approved status
- Bulk actions: toggle approval (check/uncheck), delete
- Empty state: "No categories yet"
Create/Edit Form:
- Name (required): TextInput, max 50 chars
- Pet Type (required): Select from pet_types, searchable
- Slug (read-only): TextInput, displays auto-generated value
- Description (optional): Textarea, max 500 chars
- Is Approved: Toggle, unlocked only if user has "manage_categories" permission (or is superadmin)
- Created By (read-only): Display creator name
View Page:
- Display-only read of all fields
- Usage count: shows count of pets using this category
- Breadcrumb navigation
Permissions Check:
- Create: requires
manage_categoriespermission (or Filament super admin) - Update: requires
manage_categoriespermission - Delete: requires
manage_categoriespermission - Approve: requires
manage_categoriespermission
Custom Actions:
- Toggle Approval (on List): Bulk toggle
approved_atbetween null and now() - Preview: Shows how category will appear in frontend
Frontend Architecture
Types: Category
Located in frontend/src/types/pet.ts
export interface Category {
id: number;
name: string;
slug: string;
pet_type_id: number;
description?: string;
created_by?: number;
approved_at?: string | null;
usage_count: number;
created_at: string;
updated_at: string;
pet_type?: PetType;
}
// Pet interface extended with:
export interface Pet {
// ... existing fields ...
categories?: Category[];
}
// Create payload includes:
export interface CreatePetPayload {
// ... existing fields ...
category_ids?: number[];
}API Functions
Located in frontend/src/api/categories.ts
// Fetch categories for a pet type, with optional search
export async function getCategories(params: {
pet_type_id: number;
search?: string;
}): Promise<Category[]>;
// Create a new category (user-initiated, requires auth)
export async function createCategory(data: {
name: string;
pet_type_id: number;
description?: string;
}): Promise<Category>;Example Usage:
// Fetch approved + user's pending categories for dogs
const categories = await getCategories({
pet_type_id: 2,
search: "labrador",
});
// Create new category pending approval
const newCategory = await createCategory({
name: "Service Dog",
pet_type_id: 2,
description: "Trained service dog",
});Component: CategorySelect
Located at frontend/src/components/pets/CategorySelect.tsx
Purpose: Multi-select autocomplete component for assigning categories to pets during create/edit.
Props:
interface CategorySelectProps {
petTypeId: number;
value?: Category[];
onChange?: (categories: Category[]) => void;
disabled?: boolean;
maxCategories?: number; // default: 10
}Features:
- Dynamic Loading: Fetches categories from API filtered by pet type
- Search/Filter: Real-time input filtering of category names and descriptions
- Create New: Inline category creation (sets
created_byto current user,approved_atto null) - Approval Badges: Shows "Pending" badge next to unapproved categories
- Usage Indicators: Shows category usage count
- Max Limit Enforcement: Prevents adding more than N categories (default 10)
- Type Safety: Removes categories that don't match new pet type when switching pet types
- Loading States: Shows skeleton while fetching categories
- Disabled State: Prevents interaction when disabled prop true
UI Implementation:
- Uses official shadcn/ui Tags component for consistent styling
- Tags display selected categories with removable badges
- TagsContent dropdown shows available categories with search
- Search input allows filtering and entering new category names
- Pending categories show visual indicator (e.g., ⏳ badge)
- Selected categories show checkmark in dropdown
- Create button appears when search matches no results and user has permission
Example Usage in Form:
<CategorySelect
petTypeId={petTypeId}
value={selectedCategories}
onChange={updateCategories}
disabled={isLoading}
/>Behavior:
- User clicks Tags trigger to open dropdown
- Categories list appears (filtered by pet type)
- User types to search categories
- Search results filter in real-time
- User clicks category to select/deselect
- If search text doesn't match any category:
- "Create" button appears
- Click creates new category with search text as name
- New category appears in selection with "Pending" badge
- Selected categories show in Tags above dropdown
- Click X on tag to remove category
Form Integration
useCreatePetForm Hook - Enhanced with category support:
interface CreatePetFormData {
// ... existing fields ...
categories: Category[]
category_ids: number[]
}
// Add category update function
updateCategories(categories: Category[]): void
// Form payload includes category_ids arrayCreatePetPage - Category section:
- CategorySelect appears after Pet Type selection
- In create mode: users can search existing + create new categories
- In edit mode: loads pet's current categories, allows modification
- Validates categories match selected pet type
- On save: sends category_ids to backend
User Workflows
As a Pet Owner
Creating a Pet with Categories:
- Open "Create Pet" form
- Select pet type (e.g., Dog)
- Available categories auto-filter to dogs only
- Search for "labrador" → see all matching categories
- Select "Labrador" from results
- Select additional categories (e.g., "Large Breed", "Trained")
- Can create new category on-the-fly if "Shepherd Mix" not in list
- Submit form - categories assigned to pet
- View pet profile - categories display with badges
Adding Categories to Existing Pet:
- Edit existing pet
- CategorySelect shows current categories
- Remove categories by clicking X
- Add new categories via search
- Save changes
As an Administrator
Managing Categories:
- Navigate to Admin → System → Categories
- View all categories across all pet types
- Filter by pet type or approval status
- Create new category manually (pre-approved)
- Search for categories
- Edit category details or approve user-created categories
- View category usage (how many pets tagged)
- Bulk approve/disapprove pending categories
Approval Workflow:
- User creates category "Scottish Fold" on frontend
- Category appears in dropdown with "Pending" badge
- Category visible only to creator + admins
- Admin sees in Categories list
- Admin clicks "Approve" bulk action or toggles approval in edit form
approved_atset to current timestamp- Category now visible to all users for assignment
- Badge removed from UI
Testing
Test Coverage: backend/tests/Feature/CategoryTest.php
18 comprehensive feature tests covering:
- List categories with search and filtering
- Create category with validation
- Category relationships (pet type, pets)
- Approval workflow and visibility
- Usage count accuracy
- Unique constraints (name + pet_type_id)
- Scope functionality
- Pet controller integration
Run Tests:
# Run all category tests
php artisan test --parallel --filter CategoryTest
# Run with verbose output
php artisan test --parallel --filter CategoryTest -v
# Run specific test
php artisan test --parallel --filter test_can_list_categories_by_pet_typeAll 18 tests passing (100% pass rate):
- 49 total assertions verified
- Database state properly isolated via RefreshDatabase
- Seeded data validated
Permissions & Authorization
Required Permission for Admin Actions:
manage_categories- required for admin CRUD- Assigned via Filament policy or role permission
Frontend Authorization:
- Create category: User must be authenticated
- View categories: Public (approved only) + authenticated user (+ pending they created)
- Edit/Delete: Admin only (enforced by Filament)
Notes & Future Enhancements
Internationalization (i18n)
- Add
internal_namefield for cross-language consistency - Example: English "Siamese" maps to Vietnamese "Nước Xiêm"
- Implement translation table for multi-language support
Category Hierarchy
- Add
parent_idfield to support subcategories - Example: "Large Breed" → parent "Breed"
- Implement recursive loading for category trees
Advanced Permissions
- Role-based category creation controls
- Only certain roles can create categories
- Approval permissions by pet type
Analytics & Reporting
- Track category popularity across platform
- Most-used categories dashboard widget
- Usage trends over time
- Admin recommendations for missing categories
Bulk Operations
- Import categories from CSV
- Bulk re-approval of categories
- Export categories by pet type
Troubleshooting
Issue: "Category not found" error when assigning to pet
- Verify category ID exists and is approved
- Verify category belongs to selected pet type
- Check if user-created category needs admin approval
Issue: Duplicate category names appearing
- Check for unique constraint violations in database
- Verify slug generation is working
- Review CategorySeeder for duplicates
Issue: Categories not showing in CategorySelect dropdown
- Verify pet_type_id is correctly passed
- Check user authentication (pending categories need auth)
- Search API endpoint for errors in logs
- Verify categories are approved or created by current user
Issue: Usage count is incorrect
- Rebuild attribute cache:
Cache::forget('category_usage_*') - Check pet_categories pivot table for orphaned records
- Run database integrity check