Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
25 KiB
SkinbaseNova — Multi-Category Artwork Classification
Milestone: SN-CAT-001
Status: Implemented
Scope: Categories, artwork classification, upload flow, Nova AI suggestions, API, SEO, migration, tests
Date: 2026-09-20
1. Objective
Upgrade SkinbaseNova so that an artwork can belong to more than one category while preserving one unambiguous primary category.
The final model must support:
- exactly one content type per artwork;
- one primary category where a category is required;
- zero to five secondary categories by default;
- all selected categories belonging to the same content type as the artwork;
- existing canonical artwork URLs;
- deterministic breadcrumbs and SEO;
- category browsing through all memberships;
- Nova AI category suggestions;
- backward-compatible migration from the current single-category application behavior.
This feature must not turn categories into tags. Categories remain a curated taxonomy with strong semantic meaning. Tags continue to provide fine-grained descriptors.
2. Current State
The existing database is already partly prepared for multi-category artwork classification.
Relevant tables
categories
The category taxonomy contains, among other fields:
idcontent_type_idparent_idnameslug
The taxonomy is scoped by content type.
A category slug can therefore exist under different content types, for example:
Wallpapers / LandscapePhotography / Landscape
These are distinct category records.
artwork_category
The existing pivot table already models an artwork-to-category many-to-many relationship.
Conceptually:
artworks
↕
artwork_category
↕
categories
The composite primary key prevents duplicate memberships:
PRIMARY KEY (artwork_id, category_id)
The current schema contains a foreign key for category_id -> categories.id, but the implementation should verify whether a matching FK exists for:
artwork_category.artwork_id -> artworks.id
If missing, it must be added with ON DELETE CASCADE.
uploads
The current upload staging model contains a single:
uploads.category_id
This reflects the current application's single-category behavior and must be adapted.
artwork_ai_assists
The existing AI-assist structure contains:
category_suggestions_json
This is already suitable for multiple ranked category suggestions and should be reused rather than replaced unless the current code proves otherwise.
3. Terminology
Do not call categories "groups" in this implementation.
Skinbase has separate concepts and terminology.
Use:
Content Type
Category
Primary Category
Secondary Category
Tag
Example:
Artwork: Morning Coffee in Venice
Content Type:
Wallpapers
Primary Category:
Cityscape
Secondary Categories:
Architecture
Travel
Tags:
Venice
Italy
Grand Canal
coffee
sunrise
golden hour
4. Product Rules
4.1 Content type
An artwork belongs to one content type.
Examples:
- Wallpapers
- Photography
- Digital Art
- Skins
Multi-category classification must not implicitly make one artwork belong to multiple content types.
4.2 Primary category
An artwork has at most one primary category.
The primary category is used for:
- breadcrumb generation;
- primary classification in artwork UI;
- default SEO/category context;
- legacy single-category compatibility;
- admin/editor display;
- any place that requires one deterministic category.
4.3 Secondary categories
An artwork may have multiple secondary categories.
Default application rule:
1 primary category
0–5 secondary categories
The exact maximum should be configurable rather than hard-coded throughout the application.
Suggested config:
'max_secondary_categories' => 5,
Per-content-type overrides may be introduced if the existing architecture already has an appropriate configuration mechanism.
For example, the application may eventually choose stricter rules for skin formats than for wallpapers or photography.
4.4 Same-content-type invariant
Every selected category must belong to the same content type as the artwork.
Valid:
Content Type: Wallpapers
Primary:
Wallpapers / Cityscape
Secondary:
Wallpapers / Architecture
Wallpapers / Sci-Fi
Invalid:
Content Type: Wallpapers
Primary:
Wallpapers / Cityscape
Secondary:
Photography / Landscape
Digital Art / 3D
This invariant must be enforced server-side.
Never rely only on frontend filtering.
4.5 Categories are not tags
Do not permit arbitrary category spam.
Categories are broad curated browse/classification signals.
Tags remain the detailed semantic descriptors.
A category must not be automatically created merely because an AI suggestion contains an unknown label.
5. Target Data Model
5.1 artworks.primary_category_id
Add a nullable primary category reference:
ALTER TABLE artworks
ADD COLUMN primary_category_id BIGINT UNSIGNED NULL;
Add an indexed FK to categories.id.
Expected behavior:
artworks.primary_category_id
-> categories.id
Recommended deletion behavior:
- prefer
ON DELETE SET NULLforartworks.primary_category_id; - category deletion logic should separately remove the pivot membership through the existing pivot FK/cascade behavior.
The implementation must follow existing project migration conventions.
Why store the primary category on artworks?
The pivot remains the canonical set of memberships, while primary_category_id gives the application a deterministic and efficient answer to:
Which category represents this artwork in a single-category context?
This avoids ordering-dependent behavior such as:
$artwork->categories->first()
which is not a valid definition of "primary".
5.2 artwork_category
Keep the existing many-to-many table.
Add or verify:
FK artwork_id -> artworks.id ON DELETE CASCADE
FK category_id -> categories.id ON DELETE CASCADE
Retain the composite uniqueness:
PRIMARY KEY (artwork_id, category_id)
Consider adding metadata if it is consistent with existing project conventions:
source
confidence
created_at
updated_at
Suggested semantics:
source:
user
ai
moderator
system
migration
confidence should be nullable and should only have meaning for machine-generated or machine-assisted classification.
Do not add unnecessary metadata if the current application already stores equivalent provenance elsewhere.
5.3 Membership invariant
If:
artworks.primary_category_id = X
then the row:
(artwork_id, X)
must exist in artwork_category.
All writes must preserve this invariant.
5.4 Legacy category_source
If artworks.category_source currently describes the single selected category, do not remove it in the first deployment unless all usages have been audited.
Prefer a staged migration:
- support the new model;
- migrate provenance to the new relation if appropriate;
- update all readers/writers;
- deprecate the legacy field;
- remove it only in a later cleanup migration.
6. Central Domain Service
Do not distribute category synchronization logic across controllers, jobs and model observers.
Create or extend one domain/service-layer entry point, for example:
ArtworkCategoryService
Suggested operation:
sync(
Artwork $artwork,
int $primaryCategoryId,
array $secondaryCategoryIds = [],
?string $source = null
): void
The exact class/signature should follow current SkinbaseNova architecture.
The service must
- load all requested categories;
- reject nonexistent IDs;
- remove duplicates;
- verify the primary category is included exactly once;
- validate the category content type against the artwork;
- enforce the configured secondary-category limit;
- update the pivot and
primary_category_idin one DB transaction; - preserve or update provenance metadata correctly;
- leave the artwork in a valid state if any operation fails;
- emit existing domain events/cache invalidation/search-index updates if required by the codebase.
Controllers, upload finalization, admin editing, imports and AI acceptance should call this service rather than implementing their own sync behavior.
7. Eloquent Model Changes
Audit the existing models before changing them.
The final Artwork model should conceptually support:
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class);
}
public function primaryCategory(): BelongsTo
{
return $this->belongsTo(Category::class, 'primary_category_id');
}
The exact pivot configuration must match the existing schema.
Audit for problematic patterns such as:
$artwork->categories()->first();
$artwork->categories->first();
sync([$categoryId]);
firstOrFail();
where the code assumes there can only be one category.
Replace such assumptions only where they are category-related; do not make unrelated refactors.
8. Upload Flow
8.1 UX
The upload/edit UI should present:
Primary category
[ single select ]
Additional categories
[ multi-select, max 3 ]
Requirements:
- secondary selection excludes the primary category;
- only categories from the selected content type are available;
- changing content type must revalidate/clear incompatible selections;
- duplicate selections are impossible;
- the UI displays the configured maximum;
- server validation remains authoritative.
Suggested copy:
Primary category
Choose the category that best represents the artwork.
Additional categories
Optional. Add up to 5 other relevant categories.
8.2 Upload staging schema
Audit how the current uploads.category_id is used before changing it.
Preferred target model:
uploads.primary_category_id
plus either:
upload_category
---------------
upload_id
category_id
or another existing staging mechanism that cleanly supports multiple category IDs.
Do not introduce a second competing staging architecture if the project already has reusable draft metadata storage.
8.3 Safe migration strategy
A staged approach is acceptable:
Phase A
- existing
uploads.category_idcontinues to mean primary category; - add secondary-category staging support;
- update finalization to use the central category service.
Phase B
- rename/deprecate
uploads.category_idtoprimary_category_idif appropriate; - remove compatibility code after all writers/readers are migrated.
Prefer deployability and rollback safety over a large destructive migration.
9. Nova AI Category Suggestions
Reuse the existing AI-assist category suggestion facility where possible.
Suggested normalized suggestion payload:
[
{
"category_id": 123,
"name": "Cityscape",
"confidence": 0.96
},
{
"category_id": 456,
"name": "Architecture",
"confidence": 0.89
}
]
The implementation should prefer IDs from the known taxonomy rather than free-form names.
AI rules
Nova may:
- rank existing categories;
- suggest a likely primary category;
- suggest secondary categories;
- show confidence where available.
Nova must not:
- create new categories automatically;
- silently replace a user's confirmed primary category;
- attach categories from another content type;
- exceed the configured maximum.
Initial rollout should require explicit user/editor confirmation for AI category changes.
Future auto-accept behavior can be added separately after measuring accuracy.
10. API Contract
Audit current endpoints and serializers/resources.
The API should expose both the deterministic primary category and all category memberships.
Conceptual response:
{
"primary_category": {
"id": 10,
"name": "Cityscape",
"slug": "cityscape"
},
"categories": [
{
"id": 10,
"name": "Cityscape",
"slug": "cityscape",
"is_primary": true
},
{
"id": 12,
"name": "Architecture",
"slug": "architecture",
"is_primary": false
}
]
}
If the current public API has a singular legacy category property:
- keep it backward-compatible;
- map it to the primary category;
- add the plural structure additively;
- document deprecation only if the project intends to remove the singular field later.
For create/update operations, prefer an explicit contract such as:
{
"primary_category_id": 10,
"secondary_category_ids": [12, 18]
}
This is clearer than relying on array order.
11. Category Pages and Browsing
A category page such as:
/wallpapers/cityscape
must include every artwork attached to that category, regardless of whether it is the artwork's primary or secondary category.
Conceptually query through:
artwork_category
not only:
artworks.primary_category_id
This is the main discovery benefit of the feature.
Review:
- pagination;
- category counts;
- sorting;
- cache keys;
- search indexing;
- sitemap generation;
- related-artwork queries;
- category statistics.
Any category count that currently assumes one category per artwork must be audited.
12. Artwork Page, Breadcrumbs and SEO
12.1 Canonical artwork URL
Do not change the canonical artwork URL.
Keep the current model:
/art/{id}/{slug}
An artwork must not gain a different canonical URL for every category.
12.2 Breadcrumb
Use the primary category only.
Example:
Home
→ Wallpapers
→ Cityscape
→ Morning Coffee in Venice
Do not select the breadcrumb category by pivot ordering.
12.3 Category display
Artwork UI may display:
Category
Cityscape
Also in
Architecture
Travel
or a combined component where the primary category is visibly distinguished.
12.4 Structured data
If the project emits BreadcrumbList or other structured data:
- use the primary category path;
- preserve one stable breadcrumb path;
- avoid multiple competing breadcrumb definitions for the same artwork.
12.5 Internal links
Secondary category links are beneficial discovery links, but they must all point to their normal category pages.
All category pages must link back to the same canonical artwork URL.
13. Parent Categories
The schema supports categories.parent_id.
Do not automatically duplicate membership into ancestor categories unless the existing taxonomy explicitly requires this.
Example:
Nature
└── Flowers
If the artwork is directly classified as:
Flowers
store the direct membership once.
If the Nature category page is intended to include descendants, compute or query descendant membership rather than writing redundant pivot rows.
Audit current category hierarchy behavior before changing it.
14. Existing Data Migration
The migration must not make arbitrary semantic choices silently.
14.1 Preflight report
Before backfilling primary_category_id, inspect current production data.
Required report:
SELECT
artwork_id,
COUNT(*) AS category_count
FROM artwork_category
GROUP BY artwork_id
HAVING COUNT(*) > 1
ORDER BY category_count DESC;
Distribution:
SELECT
category_count,
COUNT(*) AS artworks
FROM (
SELECT
artwork_id,
COUNT(*) AS category_count
FROM artwork_category
GROUP BY artwork_id
) x
GROUP BY category_count
ORDER BY category_count;
Also report artworks with no category if category membership is expected for their content type.
14.2 Backfill rules
For artwork with exactly one category:
primary_category_id = that category
For artwork with zero categories:
primary_category_id = NULL
and include it in an audit report if the content type normally requires a category.
For artwork with more than one category:
- do not silently use
MIN(category_id),first()or database row order as semantic truth; - first inspect whether existing application data provides a deterministic primary source;
- if no reliable source exists, produce an actionable report for explicit resolution.
An Artisan command is preferred for migration/preflight because it can support dry-run and reporting.
Suggested capabilities:
--dry-run
--report
--apply
Follow the project's command naming conventions.
14.3 Idempotency
The migration/backfill process must be safe to run more than once.
Never overwrite an already-confirmed primary category unless explicitly requested.
15. Database Integrity
Verify/add appropriate indexes.
At minimum:
artworks.primary_category_id
artwork_category (artwork_id, category_id) PK/unique
artwork_category category lookup index if not already covered
Verify both pivot FKs.
Application-level validation must enforce:
- primary is a member of the pivot;
- same content type;
- category-count limit.
If project conventions use database constraints or triggers for these invariants, evaluate them, but avoid introducing database-specific complexity without a strong reason.
16. Search and Recommendation Systems
Audit all consumers of artwork categories.
Potential consumers include:
- full-text/search indexing;
- Meilisearch/Elasticsearch/OpenSearch if present;
- recommendation scoring;
- related artworks;
- Nova embeddings/classification;
- feeds;
- filters;
- analytics;
- category statistics.
Suggested representation in a search document:
{
"primary_category_id": 10,
"category_ids": [10, 12, 18]
}
Recommendation systems may weight primary and secondary membership differently, for example:
primary category: strong signal
secondary category: medium signal
tags: fine-grained signal
embeddings: semantic similarity signal
Do not introduce arbitrary scoring changes in this milestone unless the existing recommendation architecture clearly needs them. Make the data available first.
17. Admin / Moderation
Admin/editor UI should clearly show:
Primary
Secondary
Source
AI confidence
where those metadata exist.
Editors must be able to:
- replace the primary category;
- add/remove secondary categories;
- promote a secondary category to primary;
- see incompatible selections rejected;
- save everything transactionally.
When promoting a secondary category to primary, the previous primary may remain as a secondary category unless the editor removes it.
18. Caching and Invalidation
Audit cached data related to:
- artwork detail;
- category pages;
- category artwork counts;
- breadcrumbs;
- related artwork;
- search documents.
Changing category membership must invalidate/rebuild all affected representations.
A change from:
Cityscape
to:
Primary: Architecture
Secondary: Cityscape
must invalidate both relevant category views as well as the artwork detail/breadcrumb cache.
Use existing cache/event patterns rather than creating a parallel mechanism.
19. Authorization and Security
The new fields must use the same authorization rules as the current category edit action.
Do not allow users to assign:
- inaccessible/private taxonomy entries;
- disabled categories;
- categories belonging to another content type;
- more than the allowed maximum.
Validate IDs server-side.
Avoid mass-assignment of unvalidated category arrays.
20. Tests
Add tests at the level appropriate to the current project.
Database / model
- artwork can have multiple categories;
- duplicate pivot membership is rejected/prevented;
- deleting an artwork removes pivot rows;
- deleting a category handles pivot rows and primary reference safely;
- primary category relationship works.
Domain service
- one primary only;
- primary is always in the pivot;
- secondary categories sync correctly;
- duplicate IDs are normalized/rejected consistently;
- category from another content type is rejected;
- more than configured secondary categories is rejected;
- transaction rolls back on failure;
- source/confidence metadata is preserved if implemented.
API
- legacy singular category still maps to primary where required;
- plural categories are returned;
- create/update accepts primary + secondaries;
- invalid cross-content-type categories return validation errors;
- maximum is enforced.
Upload
- primary category selection works;
- multiple secondary categories persist through staging/finalization;
- changing content type invalidates incompatible categories;
- upload finalization uses central domain service.
Category pages
- artwork appears on its primary category page;
- artwork also appears on secondary category pages;
- pagination/counts do not duplicate the same artwork;
- category URL behavior remains unchanged.
Artwork page / SEO
- canonical artwork URL remains unchanged;
- breadcrumb uses the primary category;
- changing secondary categories does not change breadcrumb;
- promoting a secondary category updates breadcrumb;
- structured data uses the primary path.
AI assist
- suggestions are limited to known categories;
- suggestions are filtered to matching content type;
- AI cannot exceed the maximum;
- confirmed user selection is not silently overwritten.
Migration
- zero-category artwork is handled;
- one-category artwork is backfilled deterministically;
- multi-category legacy artwork is reported instead of arbitrarily resolved;
- rerunning backfill is idempotent.
21. Backward Compatibility
This implementation must not break existing public artwork/category URLs.
Do not change:
/art/{id}/{slug}
Do not change existing category route structure merely for this feature.
If legacy API/UI code expects a singular category, map that concept to:
primary_category
during the compatibility period.
Prefer additive schema/API changes first, cleanup later.
22. Observability / Audit
During rollout, log or report:
- artwork with no primary category where one is required;
- primary category missing from pivot;
- cross-content-type memberships;
- artwork exceeding the configured category limit;
- migration conflicts;
- failed AI suggestion mappings.
If the project already has health/audit commands, integrate with them rather than creating unnecessary infrastructure.
23. Rollout Plan
Phase 1 — Audit
- inspect current code paths;
- inspect current data distribution;
- identify all single-category assumptions;
- confirm missing/existing foreign keys;
- confirm API compatibility requirements.
Phase 2 — Additive database changes
- add
artworks.primary_category_id; - add/verify pivot integrity;
- add pivot metadata only if needed;
- add upload staging support.
Phase 3 — Domain logic
- centralize category sync;
- update models;
- update validation;
- update events/cache/search integration.
Phase 4 — Backfill
- run dry-run report;
- automatically backfill artworks with exactly one category;
- explicitly resolve any legacy multi-category conflicts;
- verify invariants.
Phase 5 — UI/API
- upload editor;
- artwork editor/admin;
- API resources;
- artwork display;
- category pages;
- breadcrumb/SEO.
Phase 6 — Nova integration
- reuse ranked category suggestions;
- support primary + secondary acceptance;
- preserve explicit user control.
Phase 7 — Cleanup
Only after production verification:
- remove obsolete single-category assumptions;
- deprecate/remove legacy fields where safe;
- update documentation.
24. Acceptance Criteria
The milestone is complete only when all of the following are true:
- One artwork can belong to multiple categories.
- Exactly one selected category can be marked as primary.
- Primary category is always present in
artwork_category. - Secondary category limit is enforced server-side.
- Cross-content-type category assignment is impossible.
- Existing artwork URLs remain unchanged.
- Breadcrumb uses only the primary category.
- Artwork is discoverable through every assigned category page.
- Category pages do not duplicate an artwork.
- Existing single-category API behavior remains compatible where required.
- Upload/edit flow supports primary + secondary categories.
- Nova category suggestions work with the new model.
- Existing data can be migrated without arbitrary silent primary selection.
- Missing
artwork_category.artwork_idFK is fixed if confirmed absent. - Cache/search/index consumers are updated.
- Automated tests cover the new invariants.
- No unrelated refactors are included.
- Documentation reflects the final implementation.
25. Definition of Done
Before declaring the milestone finished:
- run the complete relevant automated test suite;
- run lint/static analysis/type checks used by the repository;
- run the migration/backfill in dry-run mode against representative data;
- verify at least one artwork with one primary and two secondary categories;
- verify that the artwork appears on all three category pages;
- verify its canonical URL is unchanged;
- verify breadcrumb follows only the primary category;
- verify a cross-content-type assignment is rejected;
- verify category deletion/artwork deletion preserves DB integrity;
- document migration evidence, test results and any compatibility decisions.
Do not mark the feature complete if the database supports multiple categories but any main write path still collapses them back to one.