# CPalius CMF 2.0.0

**Released:** 2026-09-16 · **Channel:** stable · **Critical:** no

Every table in the database is renamed, the three collations it carried become
one, and four forum tables that were each written twice become two. This is a
major release because it renames tables and removes classes: a third-party
module that names either will stop working until it is updated.

Turkish: [2.0.0-tr.md](2.0.0-tr.md)

## Read this before upgrading

**Back up the database first.** The schema migration is not reversible. It
renames 31 tables, folds four into two, and converts every table's collation;
undoing the merges would mean inventing votes that nobody cast. Rolling back
means restoring the backup, not running a down migration.

The upgrade runs the migration for you. It took about 1.5 seconds against a
2,100-row database and does no table rewrites beyond the collation conversion,
so the window is short — but it is a window, and a site with a large forum
should expect the collation pass to take proportionally longer.

## One prefix, everywhere

Nine core tables predated the `cp_` convention and never got it: `users`,
`nodes`, `assets`, `menus`, `menu_items`, `node_field_index`, `url_aliases`,
`node_category` and `node_tag`. Modules used whatever prefix their author
picked — `forum_`, `blog_`, or nothing at all. Nothing in a table's name said
who owned it.

From this release the rule is one sentence:

- Core tables are `cp_<noun>`
- Module tables are `cp_<module>_<noun>`

So `users` is now `cp_users`, `forum_posts` is `cp_forum_posts`, and a module
you write puts its tables under `cp_<yourmodule>_`. `doctrine_migration_versions`
and `messenger_messages` keep their names: they belong to Doctrine and Symfony
Messenger, which hardcode them.

## One collation

The database carried three: `utf8mb4_0900_ai_ci` on the nine oldest tables,
`utf8mb4_unicode_ci` on the other 56, and `utf8mb4_general_ci` on Doctrine's
migration table. Joining a `varchar` across that boundary raises *Illegal mix of
collations* — a query that worked in development and failed in production, or
the other way round, depending on which tables it touched.

Everything is now `utf8mb4_unicode_ci`, which is what 56 of the 66 tables
already used and what both MySQL 8 and MariaDB 11 accept.

## Likes and dislikes are one table now

`forum_post_likes` and `forum_post_dislikes` held the same four columns and had
repository classes that were identical line for line apart from the entity name.
They are now `cp_forum_post_votes`, with the verdict stored as `+1` or `-1`.

This closes a real hole. Whether a member could hold both a like and a dislike
on the same post was decided by `ForumTopicService`, which deleted the opposing
row before inserting its own. Nothing in the database enforced it, so two
requests arriving together could leave both rows standing. `UNIQUE(post_id,
user_id)` now makes that state impossible to represent.

Existing votes are carried over. A member who somehow held both keeps whichever
they cast last.

## Thread read state and watches are one row

`forum_topic_views` and `forum_topic_watches` were both *this member, this
thread, this timestamp*, both keyed on the same pair. They become
`cp_forum_topic_user_state`, with `last_seen_at` and `watching_since` as two
nullable columns of one row. Unwatching a thread now clears a column instead of
deleting a row, so it no longer throws away where you had read up to.

## Removed classes

A module that references any of these needs updating:

- `Modules\Forum\Entity\ForumPostLike` and `ForumPostDislike` — use
  `ForumPostVote` with `ForumPostVote::LIKE` / `::DISLIKE`
- `Modules\Forum\Entity\ForumTopicView` and `ForumTopicWatch` — use
  `ForumTopicUserState`
- `ForumPostLikeRepository`, `ForumPostDislikeRepository`,
  `ForumTopicViewRepository`, `ForumTopicWatchRepository` — use
  `ForumPostVoteRepository` and `ForumTopicUserStateRepository`

The domain events are unchanged: `ForumPostLikedEvent` and
`ForumPostDislikedEvent` still fire with the same payload.

## Fixes that came with it

**Doctrine's query cache is now cleared after an update.** It stores the SQL it
generated for each DQL query, not the DQL, and it lives in Redis in production —
so `var/cache` being wiped never reached it. Before this release, an update that
changed a table name would have left Redis handing out statements selecting from
tables that no longer existed. Nothing shipped in that state; it was found while
preparing this one.

**Module schema files are in the repository again.** The `*.sql` rule in
`.gitignore` was written to keep database dumps out, and it was also excluding
the schema files under `cp-content/modules/*/Resources/migrations/`. Three of
four had never been committed. The worst of them was Whitepaper's: nothing else
creates `cp_whitepaper_documents` or `cp_whitepaper_sections`, so a site
installed from a clean clone could not run that module at all.

**The forum presence table no longer breaks a fresh module install.** Two places
create it — a core migration and the module's own SQL — and the module's copy had
no `IF NOT EXISTS`, so on a new site, where core migrations run first, installing
the Forum module failed on a table that was already there.

**A post-auth SSRF in the performance probe is closed.** The Varnish/PageSpeed
check URL is operator-supplied and its scheme was never checked, so `gopher://`
turned a header probe into *send arbitrary bytes to any reachable internal port*.
The scheme is now pinned to HTTP and HTTPS in two places. Probing loopback still
works — that is what the check is for.

**A path sanitiser that manufactured the traversal it removed.** The page cache
ran `str_replace(['..', "\0"], '', $path)`, which makes two passes in order, so
`.\0./.\0.` survived the first and became `../..` in the second. Not reachable
over HTTP, because Symfony never URL-decodes the path — closed anyway.

## Upgrade notes

1. **Take a database backup.** Not optional for this one.
2. Apply the update from AACP → Updates as usual. Migrations run first, then
   update hooks, then the cache rebuild.
3. If you host on shared hosting and apply by FTP, remember to empty
   `cp-core/var/cache/prod/` afterwards, and clear the Doctrine cache pools —
   this release makes the updater do that itself, but only from the update that
   *lands* it onwards.
4. Check any module you did not get from us for the removed classes above.

## Known limits

`doctrine:schema:validate` still reports drift on an upgraded database: 81 lines
of it, none introduced here. They are MariaDB representing JSON columns as
`longtext` with a `json_valid` check where MySQL uses a native `JSON` type,
indexes that migrations named by hand where Doctrine would have generated a
name, and three tables driven by raw SQL that have no entity for Doctrine to
compare against. Fixing those is a separate piece of work.
