diff --git a/include/class.faq.php b/include/class.faq.php
index 83b4f8053072f1ae30e599ef317e6dae4f15fc9e..a9d0a5349371a05fa70fe1d94d40c6bfc84f7ace 100644
--- a/include/class.faq.php
+++ b/include/class.faq.php
@@ -63,27 +63,22 @@ class FAQ extends VerySimpleModel {
     function getCategory() { return $this->category; }
 
     function getHelpTopicsIds() {
-        if ($topics=$this->getHelpTopics()) {
-            return array_keys($topics);
-        }
-        return array();
+        $ids = array();
+        foreach ($this->getHelpTopics() as $topic)
+            $ids[] = $topic->getId();
+        return $ids;
     }
 
     function getHelpTopics() {
         //XXX: change it to obj (when needed)!
 
         if (!isset($this->topics)) {
-            $this->topics = array();
-            $sql='SELECT t.topic_id, CONCAT_WS(" / ", pt.topic, t.topic) as name  FROM '.TOPIC_TABLE.' t '
-                .' INNER JOIN '.FAQ_TOPIC_TABLE.' ft ON(ft.topic_id=t.topic_id AND ft.faq_id='.db_input($this->getId()).') '
-                .' LEFT JOIN '.TOPIC_TABLE.' pt ON(pt.topic_id=t.topic_pid) '
-                .' ORDER BY t.topic';
-            if (($res=db_query($sql)) && db_num_rows($res)) {
-                while(list($id,$name) = db_fetch_row($res))
-                    $this->topics[$id]=$name;
-            }
+            $this->topics = Topic::objects()->filter(array(
+                'topic_id__in' => FaqTopic::objects()->filter(array(
+                        'faq_id' => $this->getId(),
+                    ))->values('topic_id'),
+            ));
         }
-
         return $this->topics;
     }
 
@@ -384,4 +379,24 @@ class FAQ extends VerySimpleModel {
     }
 }
 FAQ::_inspect();
+
+class FaqTopic extends VerySimpleModel {
+
+    static $meta = array(
+        'table' => FAQ_TOPIC_TABLE,
+        'pk' => array('faq_id', 'topic_id'),
+        'joins' => array(
+            'faq' => array(
+                'constraint' => array(
+                    'faq_id' => 'FAQ.faq_id',
+                ),
+            ),
+            'topic' => array(
+                'constraint' => array(
+                    'topic_id' => 'Topic.topic_id',
+                ),
+            ),
+        ),
+    );
+}
 ?>
diff --git a/include/class.i18n.php b/include/class.i18n.php
index bfdfdfbc3cbbcfca5eaeb6bb6236f3b377223cc9..317c851cf5480c42789ebac1aeb0228316309fc5 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -55,7 +55,7 @@ class Internationalization {
             'list.yaml' =>          'DynamicList::create',
             // Note that department, sla, and forms are required for
             // help_topic
-            'help_topic.yaml' =>    'Topic::create',
+            'help_topic.yaml' =>    'Topic::__create',
             'filter.yaml' =>        'Filter::create',
             'team.yaml' =>          'Team::create',
             // Organization
diff --git a/include/class.orm.php b/include/class.orm.php
index 3ae84a2b2cf43582f6de7a983736fa54414d7ddd..07f6131041c0f69f715c9b95e24fd6fefc4b8184 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -132,6 +132,7 @@ class VerySimpleModel {
     }
 
     function __onload() {}
+    static function __oninspect() {}
 
     static function _inspect() {
         if (!static::$meta['table'])
@@ -163,6 +164,9 @@ class VerySimpleModel {
             $j['fkey'] = explode('.', $foreign);
             $j['local'] = $keys[0];
         }
+
+        // Let the model participate
+        static::__oninspect();
     }
 
     static function objects() {
diff --git a/include/class.topic.php b/include/class.topic.php
index 6cac47112efeb2dd41d1ac8a979c6e445fce98a6..ae84b36ffad113186d14427040260a046c93f1ad 100644
--- a/include/class.topic.php
+++ b/include/class.topic.php
@@ -16,12 +16,22 @@
 
 require_once INCLUDE_DIR . 'class.sequence.php';
 
-class Topic {
-    var $id;
+class Topic extends VerySimpleModel {
+
+    static $meta = array(
+        'table' => TOPIC_TABLE,
+        'pk' => array('topic_id'),
+        'ordering' => array('topic'),
+        'joins' => array(
+            'parent' => array(
+                'list' => false,
+                'constraint' => array(
+                    'topic_pid' => 'Topic.topic_id',
+                ),
+            ),
+        ),
+    );
 
-    var $ht;
-
-    var $parent;
     var $page;
     var $form;
 
@@ -31,39 +41,13 @@ class Topic {
 
     const FLAG_CUSTOM_NUMBERS = 0x0001;
 
-    function Topic($id) {
-        $this->id=0;
-        $this->load($id);
-    }
-
-    function load($id=0) {
+    static function __oninspect() {
         global $cfg;
 
-        if(!$id && !($id=$this->getId()))
-            return false;
-
-        $sql='SELECT ht.* '
-            .' FROM '.TOPIC_TABLE.' ht '
-            .' WHERE ht.topic_id='.db_input($id);
-
-        if(!($res=db_query($sql)) || !db_num_rows($res))
-            return false;
-
-        $this->ht = db_fetch_array($res);
-        $this->id = $this->ht['topic_id'];
-
-        $this->page = $this->form = null;
-
         // Handle upgrade case where sort has not yet been defined
         if (!$this->ht['sort'] && $cfg->getTopicSortMode() == 'a') {
             static::updateSortOrder();
         }
-
-        return true;
-    }
-
-    function reload() {
-        return $this->load();
     }
 
     function asVar() {
@@ -71,22 +55,23 @@ class Topic {
     }
 
     function getId() {
-        return $this->id;
+        return $this->topic_id;
     }
 
     function getPid() {
-        return $this->ht['topic_pid'];
+        return $this->topic_pid;
     }
 
     function getParent() {
-        if(!$this->parent && $this->getPid())
-            $this->parent = self::lookup($this->getPid());
-
         return $this->parent;
     }
 
     function getName() {
-        return $this->ht['topic'];
+        return $this->topic;
+    }
+
+    function getLocalName() {
+        return $this->getLocal('name');
     }
 
     function getFullName() {
@@ -99,31 +84,31 @@ class Topic {
     }
 
     function getDeptId() {
-        return $this->ht['dept_id'];
+        return $this->dept_id;
     }
 
     function getSLAId() {
-        return $this->ht['sla_id'];
+        return $this->sla_id;
     }
 
     function getPriorityId() {
-        return $this->ht['priority_id'];
+        return $this->priority_id;
     }
 
     function getStatusId() {
-        return $this->ht['status_id'];
+        return $this->status_id;
     }
 
     function getStaffId() {
-        return $this->ht['staff_id'];
+        return $this->staff_id;
     }
 
     function getTeamId() {
-        return $this->ht['team_id'];
+        return $this->team_id;
     }
 
     function getPageId() {
-        return $this->ht['page_id'];
+        return $this->page_id;
     }
 
     function getPage() {
@@ -134,7 +119,7 @@ class Topic {
     }
 
     function getFormId() {
-        return $this->ht['form_id'];
+        return $this->form_id;
     }
 
     function getForm() {
@@ -149,7 +134,7 @@ class Topic {
     }
 
     function autoRespond() {
-        return (!$this->ht['noautoresp']);
+        return !$this->noautoresp;
     }
 
     function isEnabled() {
@@ -170,7 +155,7 @@ class Topic {
      *      there is a loop in the ancestry
      */
     function isActive(array $chain=array()) {
-        if (!$this->ht['isactive'])
+        if (!$this->isactive)
             return false;
 
         if (!isset($chain[$this->getId()]) && ($p = $this->getParent())) {
@@ -178,12 +163,12 @@ class Topic {
             return $p->isActive($chain);
         }
         else {
-            return $this->ht['isactive'];
+            return $this->isactive;
         }
     }
 
     function isPublic() {
-        return ($this->ht['ispublic']);
+        return ($this->ispublic);
     }
 
     function getHashtable() {
@@ -197,7 +182,7 @@ class Topic {
     }
 
     function hasFlag($flag) {
-        return $this->ht['flags'] & $flag != 0;
+        return $this->flags & $flag != 0;
     }
 
     function getNewTicketNumber() {
@@ -206,17 +191,17 @@ class Topic {
         if (!$this->hasFlag(self::FLAG_CUSTOM_NUMBERS))
             return $cfg->getNewTicketNumber();
 
-        if ($this->ht['sequence_id'])
-            $sequence = Sequence::lookup($this->ht['sequence_id']);
+        if ($this->sequence_id)
+            $sequence = Sequence::lookup($this->sequence_id);
         if (!$sequence)
             $sequence = new RandomSequence();
 
-        return $sequence->next($this->ht['number_format'] ?: '######',
+        return $sequence->next($this->number_format ?: '######',
             array('Ticket', 'isTicketNumberUnique'));
     }
 
     function getTranslateTag($subtag) {
-        return _H(sprintf('topic.%s.%s', $subtag, $this->id));
+        return _H(sprintf('topic.%s.%s', $subtag, $this->getId()));
     }
     function getLocal($subtag) {
         $tag = $this->getTranslateTag($subtag);
@@ -225,42 +210,47 @@ class Topic {
     }
 
     function setSortOrder($i) {
-        if ($i != $this->ht['sort']) {
-            $sql = 'UPDATE '.TOPIC_TABLE.' SET `sort`='.db_input($i)
-                .' WHERE `topic_id`='.db_input($this->getId());
-            return (db_query($sql) && db_affected_rows() == 1);
+        if ($i != $this->sort) {
+            $this->sort = $i;
+            return $this->save();
         }
         // Noop
         return true;
     }
 
-    function update($vars, &$errors) {
-
-        if(!$this->save($this->getId(), $vars, $errors))
-            return false;
-
-        $this->reload();
-        return true;
-    }
-
     function delete() {
         global $cfg;
 
         if ($this->getId() == $cfg->getDefaultTopicId())
             return false;
 
-        $sql='DELETE FROM '.TOPIC_TABLE.' WHERE topic_id='.db_input($this->getId()).' LIMIT 1';
-        if(db_query($sql) && ($num=db_affected_rows())) {
-            db_query('UPDATE '.TOPIC_TABLE.' SET topic_pid=0 WHERE topic_pid='.db_input($this->getId()));
+        if (parent::delete()) {
+            self::objects()->filter(array(
+                'topic_pid' => $this->getId()
+            ))->update(array(
+                'topic_pid' => 0
+            ));
+            FaqTopic::objects()->filter(array(
+                'topic_id' => $this->getId()
+            ))->delete();
             db_query('UPDATE '.TICKET_TABLE.' SET topic_id=0 WHERE topic_id='.db_input($this->getId()));
-            db_query('DELETE FROM '.FAQ_TOPIC_TABLE.' WHERE topic_id='.db_input($this->getId()));
         }
 
-        return $num;
+        return true;
     }
+
     /*** Static functions ***/
-    function create($vars, &$errors) {
-        return self::save(0, $vars, $errors);
+
+    static function create($vars=array()) {
+        $topic = parent::create($vars);
+        $topic->created = SqlFunction::NOW();
+        return $topic;
+    }
+
+    static function __create($vars, &$errors) {
+        $topic = self::create();
+        $topic->save($vars, $errors);
+        return $topic;
     }
 
     static function getHelpTopics($publicOnly=false, $disabled=false, $localize=true) {
@@ -269,15 +259,18 @@ class Topic {
 
         // If localization is specifically requested, then rebuild the list.
         if (!$names || $localize) {
-            $sql = 'SELECT topic_id, topic_pid, ispublic, isactive, topic FROM '.TOPIC_TABLE
-                . ' ORDER BY `sort`';
-            $res = db_query($sql);
+            $objects = self::objects()->values_flat(
+                'topic_id', 'topic_pid', 'ispublic', 'isactive', 'topic'
+            )
+            ->order_by('sort');
 
             // Fetch information for all topics, in declared sort order
             $topics = array();
-            while (list($id, $pid, $pub, $act, $topic) = db_fetch_row($res))
+            foreach ($objects as $T) {
+                list($id, $pid, $pub, $act, $topic) = $T;
                 $topics[$id] = array('pid'=>$pid, 'public'=>$pub,
                     'disabled'=>!$act, 'topic'=>$topic);
+            }
 
             $localize_this = function($id, $default) use ($localize) {
                 if (!$localize)
@@ -329,42 +322,38 @@ class Topic {
         return $requested_names;
     }
 
-    function getPublicHelpTopics() {
+    static function getPublicHelpTopics() {
         return self::getHelpTopics(true);
     }
 
-    function getAllHelpTopics($localize=false) {
+    static function getAllHelpTopics($localize=false) {
         return self::getHelpTopics(false, true, $localize);
     }
 
-    function getIdByName($name, $pid=0) {
-
-        $sql='SELECT topic_id FROM '.TOPIC_TABLE
-            .' WHERE topic='.db_input($name)
-            .' AND topic_pid='.db_input($pid);
-        if(($res=db_query($sql)) && db_num_rows($res))
-            list($id) = db_fetch_row($res);
+    static function getIdByName($name, $pid=0) {
+        $list = self::objects()->filter(array(
+            'topic'=>$name,
+            'topic_pid'=>$pid,
+        ))->values_flat('topic_id')->one();
 
-        return $id;
+        if ($list)
+            return $list[0];
     }
 
-    static function lookup($id) {
-        return ($id && is_numeric($id) && ($t= new Topic($id)) && $t->getId()==$id)?$t:null;
-    }
-
-    function save($id, $vars, &$errors) {
+    function update($vars, &$errors) {
         global $cfg;
 
-        $vars['topic']=Format::striptags(trim($vars['topic']));
+        $vars['topic'] = Format::striptags(trim($vars['topic']));
 
-        if($id && $id!=$vars['id'])
+        if (isset($this->topic_id) && $this->getId() != $vars['id'])
             $errors['err']=__('Internal error occurred');
 
-        if(!$vars['topic'])
+        if (!$vars['topic'])
             $errors['topic']=__('Help topic name is required');
-        elseif(strlen($vars['topic'])<5)
+        elseif (strlen($vars['topic'])<5)
             $errors['topic']=__('Topic is too short. Five characters minimum');
-        elseif(($tid=self::getIdByName($vars['topic'], $vars['topic_pid'])) && $tid!=$id)
+        elseif (($tid=self::getIdByName($vars['topic'], $vars['topic_pid']))
+                && $tid!=$this->getId())
             $errors['topic']=__('Topic already exists');
 
         if (!is_numeric($vars['dept_id']))
@@ -374,60 +363,53 @@ class Topic {
             $errors['number_format'] =
                 'Ticket number format requires at least one hash character (#)';
 
-        if($errors) return false;
-
-        foreach (array('sla_id','form_id','page_id','topic_pid') as $f)
-            if (!isset($vars[$f]))
-                $vars[$f] = 0;
-
-        $sql=' updated=NOW() '
-            .',topic='.db_input($vars['topic'])
-            .',topic_pid='.db_input($vars['topic_pid'])
-            .',dept_id='.db_input($vars['dept_id'])
-            .',priority_id='.db_input($vars['priority_id'])
-            .',status_id='.db_input($vars['status_id'])
-            .',sla_id='.db_input($vars['sla_id'])
-            .',form_id='.db_input($vars['form_id'])
-            .',page_id='.db_input($vars['page_id'])
-            .',isactive='.db_input($vars['isactive'])
-            .',ispublic='.db_input($vars['ispublic'])
-            .',sequence_id='.db_input($vars['custom-numbers'] ? $vars['sequence_id'] : 0)
-            .',number_format='.db_input($vars['custom-numbers'] ? $vars['number_format'] : '')
-            .',flags='.db_input($vars['custom-numbers'] ? self::FLAG_CUSTOM_NUMBERS : 0)
-            .',noautoresp='.db_input(isset($vars['noautoresp']) && $vars['noautoresp']?1:0)
-            .',notes='.db_input(Format::sanitize($vars['notes']));
+        if ($errors)
+            return false;
+
+        $this->topic = $vars['topic'];
+        $this->topic_pid = $vars['topic_pid'] ?: 0;
+        $this->dept_id = $vars['dept_id'];
+        $this->priority_id = $vars['priority_id'] ?: 0;
+        $this->status_id = $vars['status_id'] ?: 0;
+        $this->sla_id = $vars['sla_id'] ?: 0;
+        $this->form_id = $vars['form_id'] ?: 0;
+        $this->page_id = $vars['page_id'] ?: 0;
+        $this->isactive = !!$vars['isactive'];
+        $this->ispublic = !!$vars['ispublic'];
+        $this->sequence_id = $vars['custom-numbers'] ? $vars['sequence_id'] : 0;
+        $this->number_format = $vars['custom-numbers'] ? $vars['number_format'] : '';
+        $this->flags = $vars['custom-numbers'] ? self::FLAG_CUSTOM_NUMBERS : 0;
+        $this->noautoresp = !!$vars['noautoresp'];
+        $this->notes = Format::sanitize($vars['notes']);
 
         //Auto assign ID is overloaded...
-        if($vars['assign'] && $vars['assign'][0]=='s')
-             $sql.=',team_id=0, staff_id='.db_input(preg_replace("/[^0-9]/", "", $vars['assign']));
-        elseif($vars['assign'] && $vars['assign'][0]=='t')
-            $sql.=',staff_id=0, team_id='.db_input(preg_replace("/[^0-9]/", "", $vars['assign']));
-        else
-            $sql.=',staff_id=0, team_id=0 '; //no auto-assignment!
+        if ($vars['assign'] && $vars['assign'][0] == 's') {
+            $this->team_id = 0;
+            $this->staff_id = preg_replace("/[^0-9]/", "", $vars['assign']);
+        }
+        elseif ($vars['assign'] && $vars['assign'][0] == 't') {
+            $this->staff_id = 0;
+            $this->team_id = preg_replace("/[^0-9]/", "", $vars['assign']);
+        }
+        else {
+            $this->staff_id = 0;
+            $this->team_id = 0;
+        }
 
         $rv = false;
-        if ($id) {
-            $sql='UPDATE '.TOPIC_TABLE.' SET '.$sql.' WHERE topic_id='.db_input($id);
-            if (!($rv = db_query($sql)))
-                $errors['err']=sprintf(__('Unable to update %s.'), __('this help topic'))
-                .' '.__('Internal error occurred');
-        } else {
-            if (isset($vars['topic_id']))
-                $sql .= ', topic_id='.db_input($vars['topic_id']);
-            // If in manual sort mode, place the new item directly below the
-            // parent item
-            if ($vars['topic_pid'] && $cfg && $cfg->getTopicSortMode() != 'a') {
-                $sql .= ', `sort`='.db_input(
-                    db_result(db_query('SELECT COALESCE(`sort`,0)+1 FROM '.TOPIC_TABLE
-                        .' WHERE `topic_id`='.db_input($vars['topic_pid']))));
+        if ($this->__new__) {
+            if (isset($this->topic_pid)
+                    && ($parent = Topic::lookup($this->topic_pid))) {
+                $this->sort = ($parent->sort ?: 0) + 1;
             }
-
-            $sql='INSERT INTO '.TOPIC_TABLE.' SET '.$sql.',created=NOW()';
-            if (db_query($sql) && ($id = db_insert_id()))
-                $rv = $id;
-            else
+            if (!($rv = $this->save())) {
                 $errors['err']=sprintf(__('Unable to create %s.'), __('this help topic'))
                .' '.__('Internal error occurred');
+            }
+        }
+        elseif (!($rv = $this->save())) {
+            $errors['err']=sprintf(__('Unable to update %s.'), __('this help topic'))
+            .' '.__('Internal error occurred');
         }
         if (!$cfg || $cfg->getTopicSortMode() == 'a') {
             static::updateSortOrder();
@@ -435,6 +417,12 @@ class Topic {
         return $rv;
     }
 
+    function save($refetch=false) {
+        if ($this->dirty)
+            $this->updated = SqlFunction::NOW();
+        return parent::save($refetch || $this->dirty);
+    }
+
     static function updateSortOrder() {
         global $cfg;
 
diff --git a/scp/helptopics.php b/scp/helptopics.php
index 44af9f9c9bb9fa233e6259837b845d8166ad814d..c447675fda8d796d8fe4528c8e2b77f9a09c3154 100644
--- a/scp/helptopics.php
+++ b/scp/helptopics.php
@@ -35,7 +35,8 @@ if($_POST){
             }
             break;
         case 'create':
-            if(($id=Topic::create($_POST,$errors))){
+            $topic = Topic::create();
+            if ($topic->update($_POST, $errors)) {
                 $msg=sprintf(__('Successfully added %s'), Format::htmlchars($_POST['topic']));
                 $_REQUEST['a']=null;
             }elseif(!$errors['err']){
@@ -58,10 +59,13 @@ if($_POST){
 
                 switch(strtolower($_POST['a'])) {
                     case 'enable':
-                        $sql='UPDATE '.TOPIC_TABLE.' SET isactive=1 '
-                            .' WHERE topic_id IN ('.implode(',', db_input($_POST['ids'])).')';
+                        $num = Topic::objects()->filter(array(
+                            'topic_id__in' => $_POST['ids'],
+                        ))->update(array(
+                            'isactive' => true,
+                        ));
 
-                        if(db_query($sql) && ($num=db_affected_rows())) {
+                        if ($num > 0) {
                             if($num==$count)
                                 $msg = sprintf(__('Successfully enabled %s'),
                                     _N('selected help topic', 'selected help topics', $count));
@@ -74,10 +78,14 @@ if($_POST){
                         }
                         break;
                     case 'disable':
-                        $sql='UPDATE '.TOPIC_TABLE.' SET isactive=0 '
-                            .' WHERE topic_id IN ('.implode(',', db_input($_POST['ids'])).')'
-                            .' AND topic_id <> '.db_input($cfg->getDefaultTopicId());
-                        if(db_query($sql) && ($num=db_affected_rows())) {
+                        $num = Topic::objects()->filter(array(
+                            'topic_id__in'=>$_POST['ids'],
+                        ))->exclude(array(
+                            'topic_id'=>$cfg->getDefaultTopicId(),
+                        ))->update(array(
+                            'isactive' => false,
+                        ));
+                        if ($num > 0) {
                             if($num==$count)
                                 $msg = sprintf(__('Successfully diabled %s'),
                                     _N('selected help topic', 'selected help topics', $count));
@@ -90,11 +98,9 @@ if($_POST){
                         }
                         break;
                     case 'delete':
-                        $i=0;
-                        foreach($_POST['ids'] as $k=>$v) {
-                            if(($t=Topic::lookup($v)) && $t->delete())
-                                $i++;
-                        }
+                        $i = Topic::objects()->filter(array(
+                            'topic_id__in'=>$_POST['ids']
+                        ))->delete();
 
                         if($i && $i==$count)
                             $msg = sprintf(__('Successfully deleted %s'),