Content model
CPalius deliberately rejects the "everything is content" approach. A blog post and a vehicle sales record have different needs: the first gets a slug, is translated, versioned and searched; the second needs strictly typed columns, reporting and aggregate queries. That is why there are two classes:
┌──────────────────────────┐
│ CPalius │
└─────────────┬────────────┘
┌──────────────┴──────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Node (content) │ │ Resource (record) │
├────────────────────┤ ├────────────────────┤
│ slug / language │ │ no slug / language │
│ multilingual │ │ state machine │
│ SEO / revisions │ │ immutable logs │
│ fields: JSON data │ │ fields: SQL columns│
└────────────────────┘ └────────────────────┘
Decide which class to choose with this question: Is this content to be read, like a page or post, or a business record to be reported on?
Node: content
The Node entity is the common carrier of content such as pages, posts, news, articles and events. The type (type) field tells them apart: post, page, portal_news and so on. Structural data is in real SQL columns:
| Column | Meaning |
|---|---|
title |
Title |
slug |
Address segment (unique within a language) |
type |
Content type (bundle) |
status |
draft, published, scheduled |
moderation_state |
Workflow state (optional) |
locale |
Language code |
translation_group_id |
UUID that links translations |
data |
Custom fields (JSON) |
Hybrid model
Each content type has different custom fields (source in news, date in events, price on a product page). Opening a column for each field means constantly changing the schema. CPalius packs these fields into a single JSON column (data) and keeps structural metadata (id, slug, status, language) in real columns.
$node = new Node('Hello', 'hello', 'post', 'en');
$node->setDataValue('summary', 'Short summary');
$node->getDataValue('summary'); // 'Short summary'
Flat field index
Searching inside JSON is slow. For this there is the node_field_index helper table (node_id, field_name, typed value columns: text, integer, decimal, date). If a field is marked queryable, it is written to this table on save and filters join against this table; so no JSON scan is done.
You define queryable fields in two places: the queryable_fields section of the module's contributions.yaml file, or by marking the field definition "queryable" in AACP.
Slugs and addresses
SlugGenerator produces a unique slug from the title with language-aware character conversion (ç→c, ş→s…). Uniqueness is over slug + locale; on a collision -2, -3 is appended.
The shape of the address is determined by a path pattern: cp-content/config/sync/path_pattern.<type>.yaml.
pattern: '/blog/[node:created:Y]/[node:title]'
enabled: true
Expressions in square brackets are tokens (below). They are managed from the AACP → Structure → Path patterns and URL aliases screens.
Translations
A translation of a piece of content is a separate Node row and shares the same translation_group_id. Database constraints prevent address collisions on multilingual sites:
UNIQUE(slug, locale): the same slug does not appear twice in the same languageUNIQUE(translation_group_id, locale): one translation per language in a group
Details: Multilingual support.
Resource: business record
Entities marked with #[CpResource] are business records with real SQL columns (vehicle, invoice, booking, staff). There is no slug or language; there are typed columns, aggregate queries and strict validation. The schema does not change at runtime.
#[ORM\Entity]
#[CpResource(
name: 'vehicle',
module: 'auto-gallery',
capabilities: ['create', 'edit', 'delete', 'view'],
auditable: true,
multiTenant: true,
workflow: 'vehicle_lifecycle',
)]
class Vehicle
{
#[CpField(label: 'Plate', searchable: true, sortable: true, priority: 10)]
#[ORM\Column(length: 16)]
private string $plate = '';
}
This single attribute gives you the following from the platform:
- Capabilities:
vehicle.create,vehicle.edit,vehicle.delete,vehicle.vieware registered automatically. - Audit log: changes are recorded if
auditable: true. - Tenant isolation: if
multiTenant: true, all queries are scoped to the active tenant. - Workflow: if
workflowis given, the state machine is attached. - Automatic admin screen: list and form are generated from Doctrine metadata and
#[CpField]hints. - API endpoints and trash support.
#[CpField] is per-field presentation hints: label, show in list, show in form, read-only, sortable, searchable, priority and a required widget (text, textarea, email, url, number, checkbox, date, datetime, choice, reference).
The generated form is sufficient for standard records. For a very specialized admin screen (sectioned form, custom widgets), the module writes its own form factory; the Portal module does this.
Fields
Fields can be added to content types by the administrator or by a module; details are on the Field system page.
Taxonomy (vocabularies)
Lists such as categories and tags are vocabulary (Vocabulary) and term (Term) entities. Terms can be hierarchical and are bound to a language. The default vocabularies for Blog are blog_category and blog_tag. Each vocabulary registers its own capabilities automatically. Management: AACP → Structure → Vocabularies.
Revisions
Every time a Node is saved, its previous state is stored as a revision; it can be restored.
| Setting | Default |
|---|---|
content.revisions.enabled |
on |
content.revisions.max_per_node |
25 |
Old revisions beyond the limit are pruned by a scheduled task. The controller can supply a log message and author information before saving (RevisionManager::stageContext).
Workflow (editorial workflow)
Content moderation is optional: content.moderation.enabled_types says which types go through the flow (e.g. post,page). The default flow is editorial and is defined in cp-content/config/sync/workflow/editorial.yaml:
draft ──submit──▶ review ──approve──▶ published ──archive──▶ archived
▲ │ │
└────reject───────┘ unpublish ─────┘ restore: archived ▶ draft
Each transition has its own capability (content.moderate for submit/reject/archive, content.publish for approve and unpublish). States in the publish_places list count the content as "live", and Node::status is kept in sync with it. Every transition is written to the audit log and triggers a hook that modules can listen to: workflow.<name>.transitioned.
To define your own workflow, put a new YAML in the same folder (the file name is the flow id): places, initial, property, publish_places, labels, transitions.
Trash
Deleted records are moved to the trash instead of being permanently deleted (TrashManager): the row is stored as it is and written back with a plain SQL INSERT on restore. This way restore works even if the entity class later changes or is deleted. When a record is restored, if its primary key has been taken by another record or its parent does not exist, it fails with the database's own error message; it does not silently merge into another record.
- It can be switched on and off per resource type, and the retention period is set per type (default 30 days).
- Expired ones are cleaned by a scheduled task.
- An entity is excluded from the trash with
#[NotTrashable].
Management: AACP → Maintenance → Trash (system.trash.manage, system.trash.purge for permanent deletion).
Publication status, soft delete, scheduled publishing
For Resources, the #[Publishable] and #[SoftDeletable] attributes and PublishableTrait / SoftDeletableTrait can be used. For Nodes, the scheduled status is scheduled publishing that goes live when its date arrives (Blog processes this with cron).
Tokens
Placeholders of the form [type:property] are used in text and path patterns:
| Token | Meaning |
|---|---|
[node:title] |
Content title |
[node:created:Y] |
Creation year (the date format is a parameter) |
[user:mail] |
User email |
[site:name] |
Site name |
A token that cannot be resolved does not turn into an empty string; it stays in the output as it is ([node:titel]), so a broken pattern becomes visible instead of silently losing content. Modules can add their own token providers.
Rich text and sanitization
Rich text fields pass through the HTML sanitizer at save time. On display the text format (restricted, basic_html, full_html, markdown) is applied, and a format is made of filters: HTML restriction, auto-link, newline→<br>, Markdown, media embedding, token replacement. Which user can choose which format is determined by capabilities.