Skip to content

Collections

View markdown

Reference for the KDIGI CMS content model: every collection, its key fields, versioning and trash behaviour, localization, and the hooks that matter.

Related: multi-site.md, roles-and-permissions.md (access per collection), content-markdown.md, seo.md, media-storage.md, rest-api.md, architecture.md.

Terminology. The Sites collection (sites) is the tenant boundary: the docs say "site", but the site-scoped collections carry a relationship field literally named tenant, and users carry a tenants[] array. Both are injected by @payloadcms/plugin-multi-tenant (tenantsSlug: 'sites' in src/payload.config.ts). There are no Payload globals; all per-site configuration lives in the Sites document.

Collections are registered in src/payload.config.ts and implemented in src/collections/*.ts.

CollectionSlugPurposeSite-scoped (tenant)Localized fieldsVersions / trash
UsersusersAdmin accounts and rolesvia tenants[]nono
SitessitesOne document per site (tenant root)n/a (is the tenant)yes (branding text, footer, KG, manifest, page slugs)no
MediamediaUploads, folders, image editoryes (own tenant field)nono
TaxonomiestaxonomiesCategories, product categories, tags, customyesname, description, content, SEOversions
AuthorsauthorsPublic bylinesyesbiono
MenusmenusNavigation treesyesitem labelsno
FAQsfaqsGroups of Q&A pairsyesquestion, answerversions + trash
PagespagesStatic pagesyestitle, content, SEOversions + trash
PostspostsBlog posts, news, guidesyestitle, content, excerpt, SEOversions + trash
ProductsproductsProduct / service pagesyestitle, content, excerpt, notice, SEOversions + trash
LandingslandingsBlock-based marketing pagesyestitle, excerpt, block text, SEOversions + trash
ShortcodesshortcodesReusable Markdown snippetsyestitle, contentversions + trash
Redirectsredirects301/302 rulesyesnono
CommentscommentsReader comments (moderated)yesnono
Rating eventsrating-eventsIP dedup log for ratings (hidden)nonono

Payload also creates its own system collections: payload-folders (media folder tree, site-scoped through src/collections/payload-folders-tenant-scope.ts), payload-jobs, payload-locked-documents, payload-preferences, payload-migrations.

src/payload.config.ts: localization: { locales: ['vi', 'en'], defaultLocale: 'vi', fallback: true }. A missing en value falls back to vi. Admin UI language is configured separately (i18n, fallback vi). Field localized: true is used on titles, bodies, excerpts, SEO text, FAQ items, menu labels, shortcode content and the public-facing Sites strings. Admin labels are bilingual objects ({ vi, en }).

REST: pass ?locale=vi|en (and optionally fallback-locale); locale=all returns every locale as an object.

  • No Payload drafts. Publishing is a plain status select (draft | published; landings add archived). Only status = 'published' documents are public, and publishedAt in the future schedules publishing (see below).
  • Versions (versions: { maxPerDoc: 30 }, no drafts): pages, posts, products, landings, faqs, shortcodes, taxonomies. The admin shows History, diff and Restore. readVersions uses tenantScopedVersionsRead; there is no anonymous history. Restore goes through normal update access and beforeChange hooks, so an author cannot restore a published version.
  • Trash (trash: true): pages, posts, products, landings, faqs, shortcodes. Deleting in the admin moves the document to the trash (sets deletedAt); permanent delete is admin-level (see roles-and-permissions.md). Slug (shortcode name) uniqueness per site applies only to non-trashed rows, so a replacement may reuse a trashed slug; restoring into a taken slug is refused with a bilingual validation error by guard-restore-slug-conflict.
  • Scheduling. Setting publishedAt in the future keeps the date, and the anonymous read predicate (publishedAndLive) hides the document until then. When a document is saved as published with an empty publishedAt, it is stamped with the current time. Pages, posts and products use sidebarPublishedAtField() (src/fields/sidebar-meta-fields.ts), which also keeps the original date when an already-published document is edited; landings use a simpler inline hook that re-stamps any empty or past publishedAt on every save while published. The document appears on the first request after its time, but the frontend cache is only revalidated by saves, not by the clock.

Slugs are unique per site for pages, posts, products and landings ((tenant, slug)), and shortcode names per site ((tenant, name)); these five are partial unique indexes (WHERE deleted_at IS NULL), so trashed rows do not block reuse. Taxonomies are unique per (tenant, type, slug) (a category and a tag may share a slug) and redirects per (tenant, from). All of them are created in the baseline migration src/migrations/20261103_042515_initial.ts; sites.siteKey and users.email are unique columns. Authors have no database index: slug uniqueness per site is checked in a collection beforeValidate hook.

Body fields use markdownHtmlField(name) (src/fields/markdown-html-field.ts), which generates two fields: <name>Md (the Markdown source, edited with the Milkdown editor) and a hidden Lexical <name> field. Over REST, an afterRead hook converts <name>Md to sanitized HTML (expanding [sc name="…"] shortcodes first) and returns it as <name>; Local API and GraphQL see the stored values. So contentMd is the source of truth and content is the rendered HTML. Details in content-markdown.md.

Pages, posts, products, landings and taxonomies share seoFieldGroup() (src/fields/seo-field-group.ts): group seo with title, description, focusKeywords, Open Graph/Twitter, canonicalOverride, robots, plus hidden derived values (jsonLd.raw, articleMeta). Defaults come from the site's seoTitles templates. See seo.md.

HookWhereWhat it does
enforceAuthorPermissionsbeforeChange on pages, posts, products, landingsBlocks author from publishing; sets createdBy.
makeGuardRestoreSlugConflictbeforeChange on pages, posts, products, landings (slug), shortcodes (name)Friendly error when restoring from the trash into a taken slug.
makeApplySeoTemplatesHookbeforeChange on pages, posts, products, landings, taxonomiesFills a blank seo.title/seo.description from the site's seoTitles templates; never overwrites existing values.
autoRedirectOnSlugChangebeforeChange on pages, posts, products, landingsWhen a slug changes on update, creates a 301 in redirects (same site). Never blocks the save.
autoFillExcerptbeforeChange on posts, productsFills a blank excerpt from the first 160 characters of contentMd.
autoFillAuthorbeforeChange on postsOn create, sets author to the authors row of the same site with the logged-in user's email.
postsWordCountbeforeChange on postsComputes wordCount from contentMd in the same write (one version per save).
makeGenerateJsonLdHookafterChange on pages, posts, products, landings, taxonomiesBuilds the JSON-LD graph and stores it in seo.jsonLd.raw with a single scoped SQL update (no extra version).
makeResolveOgImageFallbackHookafterReadVirtual og:image fallback: featuredImage then the site's seoDefaults.ogImage. Never persisted.
triggerRevalidate / triggerRevalidateAfterDeleteafterChange / afterDeleteQueues a revalidateFrontend job that POSTs https://<Sites.domain>/api/cms/revalidate (see architecture.md). Saves never wait on the frontend.
syncCommentCountsafterChange/afterDelete on commentsRecomputes commentCount on the parent post/product.
normalizeTelephoneE164beforeChange on sitesNormalizes knowledgeGraph.telephone to E.164 (default region VN) into telephoneE164.
triggerSiteRevalidateafterChange on sitesRevalidates the frontend when site settings change.

The tenant root: one document per site. Identity, domain, branding, homepage, contact/footer, SEO defaults, integrations and storage credentials. Full field reference and how frontends use it: multi-site.md. Access: anonymous read; create/delete super-admin; update super-admin/admin (narrowed to assigned sites by the plugin); the r2 group is super-admin only.

Auth collection with API keys (useAPIKey: true) and a 180-day token. Own field: role (super-admin | admin | editor | author, default editor). A beforeChange hook promotes the very first user of a fresh install to super-admin. The tenants[] array (rows with a tenant relationship to Sites) is injected by the plugin and must not be redeclared. See roles-and-permissions.md.

FieldTypeNotes
typeselect, required, default blogblog | news | guide
titletext, required, localized
statusselect, required, default draftdraft | published
publishedAtdateScheduling, see above
modifieddate, read-onlySet on every save
slugtext, requiredAuto-generated from the title (diacritics and đ folded) when blank
author→ authorsAuto-filled on create when possible
featuredImageupload → media
categories→ taxonomies, hasMany
tier, clusterselect 1/2/3, → taxonomiesOptional internal topic-cluster metadata for editorial use and frontends; not rendered by the CMS and not used by the built-in link suggester
wordCountnumber, read-onlypostsWordCount hook
sitemapPrioritynumber 0–1, default 0.7
isNotifiedcheckboxLets a frontend surface the post in a notification list
contentMd / contentMarkdown source / rendered HTMLLocalized
excerpttextarea, localized
faqs→ faqs, hasManyFeeds the FAQPage JSON-LD only
seogroupSee seo.md
kkStarRatinggroup { avg, count, ratings }Aggregate rating, written by POST /api/ratings/submit
commentCountnumber, update: () => falseMaintained by comment hooks
createdBy→ users, hidden, update: () => falseOwnership for the author role

Hooks: beforeChange [enforceAuthorPermissions, guardRestoreSlugConflict, autoFillAuthor, postsWordCount, autoFillExcerpt, applySeoTemplates, autoRedirectOnSlugChange]; afterChange [generateJsonLd, triggerRevalidate]; afterRead [resolveOgImageFallback]. Versions (30) + trash.

title, status, publishedAt, slug, pageType (default | blog | landing; blog maps to the BLOG key in Sites.pageGroup), isPillarPage, featuredImage, sitemapPriority (default 0.6), contentMd/content, faqs, seo, createdBy. Versions (30) + trash. Hooks as posts minus the author, excerpt and word-count hooks.

Generic product / service pages. Fields:

FieldNotes
title, status, publishedAt, slug, featuredImage, categories, author, sitemapPriority (default 0.8)As posts
contentMd/content, excerpt, faqs, seo
hero.slider[].image, hero.slideVideoImage carousel and an embed URL (YouTube/TikTok)
noticeMd/noticeSecond Markdown field for a notice block, localized
prices[]Exactly one row (minRows: 1, maxRows: 1): originalPrice, currentPrice, currency (VND default; USD, EUR, GBP, JPY), validUntil. Kept as an array to keep a stable REST shape. currency feeds the JSON-LD Offer.
kkStarRatingGroup { avg, count, ratings }, update: () => false
aggregateRatingOverrideCheckbox: ignore the site's knowledgeGraph.minReviewsForAggregate threshold
commentCount, createdByAs posts

Versions (30) + trash.

Block-based marketing pages.

FieldNotes
title, slug, excerpt, featuredImage
statusdraft | published | archived (only published is public)
publishedAt, isDisabled, sitemapPriority (default 0.5), template (default | wide | fullscreen)
layoutBlocks: hero, content (Markdown body), imageGallery, testimonials, cta, faq, stats, pricingTable, blogFeed, offerCards, embed (raw HTML/iframe), divider
campaignname, utmSource, utmMedium, utmCampaign, conversionGoal, abTestId
schemaTypeWebPage (default) | FAQPage | Article | Product | Event
faq[]Inline Q&A for FAQPage JSON-LD
seo, createdBy

Versions (30) + trash.

Taxonomies — src/collections/taxonomies.ts

Section titled “Taxonomies — src/collections/taxonomies.ts”

type (required): category | product-category | tag | custom (with a free-text customType when custom, for example a taxonomy name preserved from a WordPress import). Keeping category (blog) separate from product-category (WooCommerce product_cat) is deliberate. Other fields: name (required, localized), slug (required, auto-slugified from the name), description (localized), parent (self relationship → tree), contentWordCount (auto, read-only; below THIN_CONTENT_THRESHOLD = 500 words is flagged thin), contentMd/content, seo. Public read; versions (30), no trash. Hooks: SEO templates, JSON-LD, revalidate (also after delete).

location (PRIMARY | SECONDARY_MENU | MOBILE_MENU | FOOTER_MENU | LOGGEDIN_ACCOUNT_MENU). tree (JSON) is the canonical structure, edited with the drag-and-drop MenuTreeField; the legacy items array is hidden and kept only for compatibility. Over REST an afterRead hook removes tree and projects it to items for the requested locale; GraphQL and the Local API see the raw tree (labels as { vi, en }).

One document is a group of questions: group (required label) and items[] of { question, answer } (required, localized). answer is Markdown; over REST an afterRead hook expands shortcodes and renders sanitized HTML. Versions (30) + trash. Anonymous reads exclude trashed rows.

name, slug (auto), avatar, bio (localized), sameAs[].url (validated http(s) URLs, used for JSON-LD), email (never returned by REST). A collection-level beforeValidate enforces slug uniqueness per site.

Upload collection with native folders (folders: true), a custom grid list view and endpoints POST /api/media/from-url (SSRF-guarded import), POST /api/media/:id/edit-image and POST /api/media/fetch-from-r2. Fields: alt (required), tenant (site; auto-assigned on upload, only a super-admin may change it), externalUrl (http(s) only, becomes the public url), version (hidden cache-bust counter, appended as ?v= when greater than 1), plus the upload fields. Per-site WebP compression and storage backend are described in media-storage.md.

sourceMediaId (indexed text, read-only in the admin sidebar, not localized; Media has no versions) holds the WordPress attachment id. It is written only by the WordPress importer so that re-running an import reuses existing media instead of uploading duplicates; WordPress ids are only unique per install, so every lookup is scoped by tenant. It is added by the migration 20261103_052647_media_source_media_id (idempotent and reversible).

Shortcodes — src/collections/shortcodes.ts

Section titled “Shortcodes — src/collections/shortcodes.ts”

Reusable Markdown snippets: title (localized), name (required, ^[a-z0-9-]+$, auto-derived from the title), contentMd/content (required, localized). Shortcodes are per site: a tag only resolves against shortcodes of the document's own site. Syntax [sc name="my-shortcode" /] or [sc name="my-shortcode"][/sc]; nesting depth is capped at 3, and unknown names, cycles or over-deep nesting expand to an empty string. Versions (30) + trash; saving revalidates the frontend. Details in content-markdown.md and multi-site.md.

Redirects — src/collections/redirects.ts

Section titled “Redirects — src/collections/redirects.ts”

from, to, statusCode (301 default | 302), isEnabled. from is unique per site (database index), so a duplicate source path is rejected. A beforeValidate hook normalizes from to a leading slash, rejects from = to, rejects external to URLs (internal paths only) and logs (does not block) a potential reverse chain. Public read, because the frontend fetches the enabled list; frontends should cap redirect hops themselves.

post or product (exactly one, enforced in beforeValidate), authorName (required, ≤100 chars, tags stripped), authorEmail (never returned), content (required, sanitized), status (pending default | approved | spam), createdAt. Public submission goes through POST /api/comments/create (body: postId or productId, authorName, authorEmail, content, recaptchaToken, tenantSiteKey); the endpoint checks that the parent belongs to the site named by tenantSiteKey, verifies reCAPTCHA when RECAPTCHA_SECRET is set (a failed score is logged, not blocking) and always stores pending. Hooks: syncCommentCounts (including the previous parent when a comment is re-linked), an optional webhook to MODERATION_WEBHOOK_URL for new pending comments, and revalidateParentPostOnCommentApproval.

Rating events — src/collections/rating-events.ts

Section titled “Rating events — src/collections/rating-events.ts”

Hidden, internal. collection (posts | products), contentId, ipHash (SHA-256 of the client IP; the raw IP is never stored). All four access rules are () => false; only POST /api/ratings/submit (body: collection, id, rating 1–5, tenantSiteKey) writes it, using overrideAccess: true, to allow one vote per IP per document.

sites 1──* { pages, posts, products, landings, taxonomies, menus, faqs, authors,
comments, shortcodes, redirects, media, payload-folders }
users *──* sites (users.tenants[])
posts *──* taxonomies (categories) *──1 taxonomies (cluster)
posts *──1 authors *──1 media (featuredImage) *──* faqs *──1 users (createdBy)
products *──* taxonomies *──1 authors *──1 media *──* faqs hero.slider[] *──1 media
taxonomies *──1 taxonomies (parent)
comments *──1 posts | products (exactly one)
rating-events (collection, contentId, ipHash) no foreign key