The core/MetaData extension point backs three things a member sees on a content item: the coloured staff notice pinned above the first post ("Item Message"), the box of recommended replies shown above the comment list ("Featured Comments"), and the moderator toggle that makes every new reply to one particular topic require approval ("Item Moderation"). All three store their state as rows in a single shared table, core_content_meta, rather than in the content item's own table, and the three extension classes are the code that reads and writes those rows.
It is unlike almost every other extension point in the suite in one important respect, which the rest of this article is largely about: the list is not open. Core does not iterate core/MetaData extensions. It looks up three fixed string keys in core's own application, and nothing else is ever consulted.
The contract
There is no abstract class and no interface. Nothing under system/Extensions/ is named MetaData — the only file in the suite named for it is system/Content/MetaData.php, and that is a trait mixed into content item classes, not a base class for extensions. The three shipped extensions extend nothing:
/* applications/core/extensions/core/MetaData/ItemModeration.php:35 */
class ItemModeration
{
...
}
The Developer Center template confirms this. applications/core/data/defaults/extensions/MetaData.txt generates a class with no parent and no methods at all — just the namespace, the SUITE_UNIQUE_KEY guard and an empty class body.
Because there is no abstract, the "contract" is simply the set of methods core calls by name on each of the three fixed keys. Signatures below are taken character for character from the source.
ContentMessages, in applications/core/extensions/core/MetaData/ContentMessages.php:
public function canOnMessage( string $action, Item $item, ?Member $member = NULL ) : bool /* :40 */ public function addMessage( string $message, ?string $color, Item $item, ?Member $member = NULL, bool $isPublic = TRUE ) : int /* :86 */ public function editMessage( int $id, string $message, ?string $color, Item $item, ?Member $member = NULL, bool $isPublic = TRUE ) : void /* :110 */ public function deleteMessage( int $id, Item $item, ?Member $member = NULL ) : void /* :130 */ public function getMessages( Item $item, ?Member $member = NULL ) : array /* :144 */
canOnMessage() returns whether the member may perform $action — core passes 'add', 'edit', 'delete' and the special 'viewHidden'. addMessage() returns the new core_content_meta.meta_id, which the controller then uses as the attachment claim ID. getMessages() returns the decoded meta array keyed by meta_id, already filtered so that non-moderators only see entries whose is_public is truthy or absent.
The trait's editMessage() wrapper does not match this signature. It is declared editMessage( int $id, string $message, ?string $color = NULL, ?Member $member = NULL, bool $onlyStaff = FALSE ) (system/Content/MetaData.php:259) and passes $onlyStaff straight into the extension's $isPublic (:261). The name is inverted and the default is inverted — FALSE on the trait, TRUE on the extension. Always pass the fifth argument explicitly, and read it as "is public". Content\Controller::messageForm() does exactly that, passing (bool) $values['message_is_public'].
FeaturedComments, in applications/core/extensions/core/MetaData/FeaturedComments.php:
public function featureComment( Item $item, Comment $comment, ?string $note = NULL, ?Member $member = NULL ) : void /* :47 */ public function unfeatureComment( Item $item, Comment $comment, ?Member $member = NULL ) : void /* :74 */ public function featuredComments( Item $item ) : array /* :101 */ public function isCommentShownAtTheTop( Comment $item ): bool /* :178 */
featuredComments() returns an array keyed by comment ID, each value an array with note, comment (a constructed comment object) and featured_by (a constructed IPS\Member). Soft-deleted comments are dropped from it. That array is what the featuredComments template renders.
ItemModeration, in applications/core/extensions/core/MetaData/ItemModeration.php:
public function enabled( Item $item, Member|Group|null $memberOrGroup=NULL ): bool /* :44 */ public function canToggle( Item $item, ?Member $member = NULL ): bool /* :92 */ public function enable( Item $item, ?Member $member = NULL ) : void /* :118 */ public function disable( Item $item ) : void /* :151 */
enabled() returns TRUE when the item has a core_ItemModeration meta row whose enabled flag is set, unless a member or group was passed and that member or group has g_avoid_q. Note the asymmetry in the last two: enable() takes a member, disable() does not, even though the trait calls both the same way (see below).
None of the three declares a constructor. They are constructed by Application::constructExtensionClass() (system/Application/Application.php:469) and always receive exactly one argument. Because Application::extensions() defaults $checkAccess to FALSE (Application.php:911), that argument is always NULL here.
Who calls it
Every call in the suite has the same shape, with the key hard-coded:
Application::load('core')->extensions( 'core', 'MetaData' )['ContentMessages']->getMessages( $this );
Grepping 'core', 'MetaData' across the whole tree returns 23 hits, and all 23 are inside four files:
system/Content/MetaData.php— 14 of them. This trait is the main consumer. Its six storage methods (hasMetaData(),getMeta(),addMeta(),editMeta(),deleteMeta(),deleteAllMeta()) talk tocore_content_metadirectly and use no extension at all; the other public methods are thin wrappers around one extension method each, and their docblocks say so: "This is a wrapper for the extension so content items can extend and apply their own logic". Three of those wrappers name a method that does not exist — see below.system/Content/Controller.php— lines 3285 and 3863 unfeature a comment when it is moved to another item, 4780 features a comment as part of the Promote dialogue, 4876 and 4878 undo that.system/Content/Comment.php— line 568 decides whether a new comment needs approval because item moderation is on, 1126 unfeatures a comment being deleted, 1753 implementsComment::isFeaturedComment().system/Content/Review.php— line 226, the same approval check for reviews.
The wrapper methods on the trait are in turn called from the front end: Content\Controller::messageForm() and messageDelete() (system/Content/Controller.php:2305 and :2372), Content\Controller::toggleItemModeration() (:2587), the item context menu in system/Content/Item.php:7397-7410, Hideable::moderateNewComments() and moderateNewReviews() (declared at system/Content/Hideable.php:1666 and :1693; they call itemModerationEnabled() at :1680 and :1707), and the contentItemMessages and featuredComments templates used by forums, calendar, gallery, blog, downloads and cms.
Three further consumers read the meta types without touching the extensions at all: system/Content/ModeratorPermissions.php:75-94 and applications/core/extensions/core/ModeratorPermissions/ContentGenerator.php:113-132 generate the moderator permission fields, and applications/forums/modules/admin/stats/posts.php queries core_content_meta directly for the recommended-posts statistic.
Your own core/MetaData extension will never be called
This is the failure mode, and it is completely silent.
The ACP Developer Center offers to create one. applications/core/modules/admin/developer/extensions.php builds its "create" buttons by listing every .txt file in applications/<app>/data/defaults/extensions/, and MetaData.txt is one of them. The label it shows comes from applications/core/dev/lang.php:1315 and reads "Add content meta data types". Clicking it writes a real class file into your application and a real entry into your data/extensions.json.
Nothing then reads it. Every call site uses Application::load('core')->extensions(...), and Application::extensions() reads only applications/<that app>/data/extensions.json — here, core's. Core's file declares exactly three MetaData entries:
/* applications/core/data/extensions.json — note the outer app key */
{
"core": {
...
"MetaData": {
"ContentMessages": "IPS\\core\\extensions\\core\\MetaData\\ContentMessages",
"FeaturedComments": "IPS\\core\\extensions\\core\\MetaData\\FeaturedComments",
"ItemModeration": "IPS\\core\\extensions\\core\\MetaData\\ItemModeration"
}
},
"nexus": { ... }
}
The outer key matters: extensions() looks up $json[ $app ][ $extension ] (Application.php:922-924, on the file resolved at :917), where $app is the first argument — the app that owns the extension type — and the file it opens is the one belonging to the object you called it on. Application::load('core')->extensions( 'core', 'MetaData' ) therefore only ever reads $json['core']['MetaData'] out of core's own file.
Core never calls Application::allExtensions( 'core', 'MetaData' ), and it never iterates the returned array — it always subscripts one of those three literal keys. So a fourth key in core's own file would also be dead, and a key of the same name in your application's file is not merged in.
You will notice this as: no error, no log entry, no PHP notice, and no behaviour. The extension appears correctly in the Developer Center tree, which makes it look installed.
There is no supported way around it in IPS 5. There is no code-hook or monkey-patch system in 5.0.19 — the only override mechanism is the event listener system under system/Events/ListenerType/, and none of the eight listener types published there expose messages, featured comments or item moderation. Replacing core's three classes would mean editing applications/core/data/extensions.json, which an upgrade will overwrite.
What you actually implement instead
Two things are genuinely extensible here, and both live on your content item class rather than in an extension file.
First, opting your content item into the three features. Use the IPS\Content\MetaData trait and declare its one abstract member:
/* system/Content/MetaData.php:51 */ abstract static function supportedMetaDataTypes(): array;
Returning 'core_ContentMessages', 'core_FeaturedComments' and/or 'core_ItemModeration' from it is what switches each feature on. That string list is read in eighteen places across core; fifteen of those are in_array() tests against one of the three type names, including the two that generate moderator permissions.
Second, overriding the wrapper methods. Because canOnMessage(), getMessages(), featuredComments(), itemModerationEnabled() and the rest are ordinary public methods on a trait, your item class can override any of them and either add logic before delegating or bypass the extension entirely. That is what the docblocks mean by "so content items can extend and apply their own logic", and it is the only extension route the design actually supports.
Fatal error: class contains 1 abstract method
Fatal error: Class IPS\acme\Ticket contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (IPS\Content\MetaData::supportedMetaDataTypes)
Most content traits you use require nothing of you, so it is easy to add use MetaData; and stop there. MetaData is one of a handful under system/Content/ that declare an abstract member — the others are Assignable, ItemTopic, Reactable and Solvable — and it declares exactly one, which is why the error message says "1 abstract method". If you are using several of these traits the count in the message will be higher and will name each missing method. Declare public static function supportedMetaDataTypes(): array even if it returns array() — IPS\nexus\Package\Item does exactly that at applications/nexus/sources/Package/Item.php:377. This is a compile-time fatal error, raised by PHP the moment it compiles your class file — which is the moment the autoloader includes it. It is not a Throwable, so no try/catch and no error handler will contain it, and it takes down every page that loads your class, not just your own screens.
BadMethodCallException: "Class using must define meta_data column in column map"
Thrown from MetaData::hasMetaData() (system/Content/MetaData.php:59-68, the throw itself at :67), which getMeta() calls unconditionally at line 83. The trait needs a boolean column on your own table to short-circuit the core_content_meta lookup, and it finds it through $databaseColumnMap['meta_data']. All eight core implementations declare it, for example:
/* applications/forums/sources/Topic/Topic.php:219 */ 'meta_data' => 'topic_meta_data',
Omit it and the exception is not caught anywhere on the path that matters. ContentMessages::getMessages() calls $item->getMeta() with no try/catch, and the templates call getMessages() at the top of the item view, so the whole page dies. (FeaturedComments::isCommentShownAtTheTop() does catch BadMethodCallException, at lines 178-200, which is why a missing column map can produce a working comment list and a broken item header at the same time.)
The column itself must exist in your schema and be writable — addMeta() sets it to 1 and calls $this->save() (MetaData.php:128-130), and deleteMeta() sets it back to FALSE when the last row goes.
addMeta() throws an exception with no message
/* system/Content/MetaData.php:112-115 */
if ( !static::supportedMetaDataTypes() OR !in_array( $type, static::supportedMetaDataTypes() ) )
{
throw new BadMethodCallException;
}
The exception carries no message and no code. If you call $item->addMessage(...) on a class whose supportedMetaDataTypes() does not include 'core_ContentMessages', this is what you get, and the stack trace points at MetaData.php, not at your class.
Note the asymmetry: ContentMessages::canOnMessage() and ItemModeration::canToggle() both check supportedMetaDataTypes() and return FALSE cleanly (ContentMessages.php:42, ItemModeration.php:94), but addMessage(), editMessage(), featureComment() and ItemModeration::enable() do not — they go straight to addMeta(). So a wrong supportedMetaDataTypes() hides the buttons but still fatals if anything calls the write path directly.
Moderators cannot use the features even though the permission is granted
Permissions are resolved in two steps. ContentMessages::canOnMessage() first tries the container-aware check:
/* applications/core/extensions/core/MetaData/ContentMessages.php:66-73 */
try
{
return $item::modPermission( "{$action}_item_message", $member, $item->container() );
}
catch( BadMethodCallException $e )
{
return $member->modPermission( "can_{$action}_item_message" );
}
Content::modPermission() (system/Content/Content.php:574) tests can_{$type}_content globally first (:604), then delegates to the container (:609-611), which is where the per-class key generated by ContentGenerator — can_add_item_message_{$class::$title} — is checked.
The fallback branch is the trap. It is taken when $item->container() throws, i.e. for content classes with no node container. It asks for can_add_item_message with no suffix, and no code anywhere in the suite generates that permission key — every generator emits either the _content form or the _{$title} form. A language string for the bare key does survive at applications/core/dev/lang.php:3783, an IPS 4 leftover, which makes the key look real when you go looking for it. Member::modPermission() ends with return $permissions[$key] ?? false; (system/Member/Member.php:4288), so the answer is always FALSE except for members whose moderator permissions are '*'. On a container-less content item, item messages are effectively restricted to full moderators. The same pattern applies to ItemModeration::canToggle(), whose fallback key can_toggle_item_moderation_content does exist (applications/core/extensions/core/ModeratorPermissions/Content.php:74). The 'viewHidden' action is fine too: it is handled by a separate branch at ContentMessages.php:54-64 whose fallback is $item::modPermission( 'view_hidden', $member ) — still Content::modPermission(), so it resolves to the real can_view_hidden_content key. Only the add/edit/delete fallback drops to Member::modPermission() with a key nothing generates.
Separately, note that the moderator permission fields only appear in the ACP for classes that both use the trait and list the type. ContentGenerator.php:113 gates the whole block on IPS::classUsesTrait( $class, 'IPS\Content\MetaData' ), and ModeratorPermissions.php:75 additionally requires supportedMetaDataTypes() to be non-empty.
A core/Permissions extension cannot distinguish add from delete
Each wrapper consults the core/Permissions extension chain before delegating, but it passes a single flat action name:
/* system/Content/MetaData.php:220-223 */
if( $permCheck = Permissions::can( 'onMessage', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
The $action argument of canOnMessage() is not forwarded. A Permissions extension that returns PERM_ALLOW for onMessage grants add, edit, delete and viewHidden together, and one that returns PERM_DENY denies all four. The comparable keys for the other two features are featureComment, unfeatureComment and toggleItemModeration. Also remember that Permissions::can() returns PERM_DEFAULT immediately outside the front and api dispatchers (system/Content/Permissions.php:137-140), so these overrides do nothing in the ACP or under CLI tasks.
Three trait wrappers call a method that does not exist
FeaturedComments declares exactly four public methods — featureComment(), unfeatureComment(), featuredComments() and isCommentShownAtTheTop() — and has no __call(). But the trait wraps three more:
/* system/Content/MetaData.php */ :297 ...['FeaturedComments']->isFeaturedComment( $comment ); /* no such method */ :320 ...['FeaturedComments']->canFeatureComment( $this, $member ); /* no such method */ :343 ...['FeaturedComments']->canUnfeatureComment( $this, $member ); /* no such method */
Calling $item->isFeaturedComment(), $item->canFeatureComment() or $item->canUnfeatureComment() on any class using the trait is therefore an uncaught Error: Call to undefined method IPS\core\extensions\core\MetaData\FeaturedComments::…. Nothing in core reaches them: grepping the suite for those three names returns only the trait's own definitions and call lines. The check core actually uses for "is this comment featured" is Comment::isFeaturedComment() (system/Content/Comment.php:1749, no arguments, delegating to isCommentShownAtTheTop()) — a different method on a different class that happens to share a name. Featuring is gated by the Promote dialogue instead: Content\Controller.php:4722 only offers the option when the item lists core_FeaturedComments, and :4780 then calls the extension directly. So the trait's three methods are dead code in 5.0.19 — but they are public on a trait you are told to use, so they read as API. Do not call them, and do not model your own wrappers on them.
featuredComments() fatals on an item with no comment class
FeaturedComments::featuredComments() reads $item::$commentClass with no guard at line 121, then immediately reads statics off it at line 122. IPS\Content\Item declares public static ?string $commentClass = NULL;, so on an item type that lists core_FeaturedComments but has no comments this is a fatal Error, not a caught exception. It only fires once a featured-comment meta row exists, which makes it look intermittent.
A second unguarded read in the same method is worth knowing about. $col is assigned inside an if/else if pair with no else (lines 124-131) and then used as $row->$col in the loop at line 137. A comment class whose $databaseColumnMap has neither a hidden nor an approved entry therefore reaches the loop with $col undefined. The sort clause at line 135 likewise reads $databaseColumnMap['date'] with no isset().
Un-featuring a comment that is not featured deletes nothing, quietly
/* applications/core/extensions/core/MetaData/FeaturedComments.php:82-91 */
$idToRemove = FALSE;
foreach( $metaData['core_FeaturedComments'] AS $key => $data )
{
if ( $data['comment'] == $comment->$commentIdField )
{
$idToRemove = $key;
break;
}
}
$item->deleteMeta( $idToRemove );
If no row matches, $idToRemove is still FALSE and deleteMeta( FALSE ) runs. deleteMeta() is typed int $id, and IPS does not use declare(strict_types=1), so FALSE coerces to 0, the DELETE matches nothing, and the method still re-counts the remaining rows and may clear your meta_data flag. No exception is raised. Callers such as Comment::delete() (system/Content/Comment.php:1124) guard this with isFeaturedComment() first; Content\Controller.php:3285 guards with $comment->isFeatured(), which is a different check entirely.
The "featured post" badge never appears next to a comment
The badge in applications/core/dev/html/front/global/comment.phtml:49 is gated on the comment using the MetaData trait:
{{if \IPS\IPS::classUsesTrait( $comment, 'IPS\Content\MetaData' ) and $comment->isFeatured() }}
No comment class in the suite uses that trait. Only the eight item classes do — forums\Topic, calendar\Event, cms\Records, downloads\File, blog\Entry, gallery\Image, gallery\Album\Item and nexus\Package\Item. forums\Topic\Post uses Featurable (which supplies isFeatured()) but not MetaData, so the condition is false for every core comment class. The working check is Comment::isFeaturedComment() at system/Content/Comment.php:1749, which delegates to isCommentShownAtTheTop(). Adding the trait to a comment class to satisfy the template is not a fix — the trait's own methods assume $this is an item.
Two smaller things worth knowing
toggleItemModeration() dispatches with a variable method name and always passes two arguments:
/* system/Content/MetaData.php:459 */
Application::load('core')->extensions( 'core', 'MetaData' )['ItemModeration']->$action( $this, $member );
but ItemModeration::disable() is declared with one parameter. PHP tolerates extra arguments to user-defined functions, so this works; it is only a problem if you subclass and add a strict signature.
And every one of those 23 call sites constructs all three extension objects. Application::extensions() caches the resolved class names in a static (guard at Application.php:913, assignment at :957), but when $construct is true it loops the whole list and calls constructExtensionClass() for each before you subscript one (Application.php:960-971). Rendering a topic page therefore instantiates these three classes many times over. They are stateless, so this is cheap, but it means you cannot hold state on the extension instance between calls.
A complete example
The realistic task is opting a content item into all three features. This is IPS\forums\Topic reduced to the parts that matter, with the storage that has to exist alongside it.
The column map entry and the trait, from applications/forums/sources/Topic/Topic.php (lines 109-120 and 219):
class Topic extends Item implements Embeddable, Filter, SplObserver
{
use LiveTopic,
Reactable,
/* ... */
MetaData,
Polls;
public static array $databaseColumnMap = array(
/* ... */
'meta_data' => 'topic_meta_data',
);
/* applications/forums/sources/Topic/Topic.php:2012 */
public static function supportedMetaDataTypes(): array
{
return array( 'core_FeaturedComments', 'core_ContentMessages', 'core_ItemModeration' );
}
}
For your own application the equivalent would be:
<?php
/* applications/acme/sources/Ticket/Ticket.php */
namespace IPS\acme\Ticket;
use IPS\Content\Item;
use IPS\Content\MetaData;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Ticket extends Item
{
use MetaData;
public static string $databaseTable = 'acme_tickets';
public static string $databasePrefix = 'ticket_';
public static string $databaseColumnId = 'id';
public static ?string $commentClass = 'IPS\acme\Ticket\Reply';
public static array $databaseColumnMap = array(
'author' => 'author',
'date' => 'date',
'title' => 'title',
/* Required by the MetaData trait. Must be a real, writable column. */
'meta_data' => 'meta_data',
);
/**
* Required - MetaData declares this abstract.
* Omitting core_FeaturedComments here is what you want if $commentClass is NULL.
*/
public static function supportedMetaDataTypes(): array
{
return array( 'core_ContentMessages', 'core_ItemModeration' );
}
}
Add ticket_meta_data to applications/acme/data/schema.json as an unsigned TINYINT that is NOT NULL DEFAULT 0 — that is what forums_topics.topic_meta_data is. Nothing creates it for you; core_content_meta is shared and already exists, but the per-item flag column is yours.
Then render the messages in your item view template, exactly as forums does at applications/forums/dev/html/front/topics/topic.phtml:15:
{template="contentItemMessages" group="global" app="core" params="$ticket->getMessages(), $ticket"}
and the featured comments box, if you listed that type:
{template="featuredComments" group="global" app="core" params="$ticket->featuredComments(), $ticket->url()->setQueryString( 'recommended', 'comments' )"}
The "Add message" and "Enable moderation" links appear in the item's context menu automatically — system/Content/Item.php:7397 adds them for any class using the trait — and the moderator permission checkboxes appear automatically in ACP → Staff, generated from your supportedMetaDataTypes() return value. You will need the corresponding language strings can_add_item_message_<title>, can_edit_item_message_<title>, can_delete_item_message_<title> and can_toggle_item_moderation_<title> in your admin pack, where <title> is your class's $title; forums declares its set at applications/forums/dev/lang.php:95-98 (with the two can_(un)feature_comments_topic strings just above at :93-94).
You do not create a core/MetaData extension for any of this.
Not verified
Two claims in this article are read from source but were not confirmed against a running site. First, the exact runtime behaviour of the undefined $col in FeaturedComments::featuredComments() — the source clearly has no else branch, but whether PHP 8.1-8.3 produces a warning plus an empty property read, or a fatal, was not tested. Second, whether a third-party core/MetaData extension might be reachable through some path outside the 23 grep hits, for instance through Cloud-only code not present in this tree; the self-hosted 5.0.19 source contains no such path.
Two further claims are source-read only. The statement that no core/Permissions-style event listener can reach these features is based on reading the eight files under system/Events/ListenerType/ and finding no message, featured-comment or item-moderation event; it was not confirmed by writing a listener and watching it not fire. And the can_feature_comments_* / can_unfeature_comments_* moderator permissions are generated into the ACP by ContentGenerator.php:117-118, but grepping this tree finds no runtime modPermission() call that reads them; whether they are genuinely inert or read through a path this grep missed was not established.
Verified against
Read from Invision Community 5.0.19 source. Key files: system/Content/MetaData.php (the trait), all three of applications/core/extensions/core/MetaData/{ContentMessages,FeaturedComments,ItemModeration}.php, applications/core/data/defaults/extensions/MetaData.txt, applications/core/data/extensions.json, system/Application/Application.php (extensions(), constructExtensionClass()), system/Content/Controller.php, system/Content/Comment.php, system/Content/Review.php, system/Content/Item.php, system/Content/Hideable.php, system/Content/Content.php (modPermission(), actionEnabled()), system/Content/Permissions.php, system/Content/ModeratorPermissions.php, system/Member/Member.php (modPermission()), applications/core/extensions/core/ModeratorPermissions/{Content,ContentGenerator}.php, applications/core/modules/admin/developer/extensions.php, the contentItemMessage, contentItemMessages, featuredComments and comment templates under applications/core/dev/html/front/global/, and the eight supportedMetaDataTypes() implementations in forums, calendar, gallery (two), blog, nexus, cms and downloads. Schema for core_content_meta comes from applications/core/data/schema.json.
Recommended Comments