Custom Database Tables

Note: This tutorial is only for MODX Revolution. MODX Evolution does not have xPDO methods so it would be useless in that version. Please update your MODX install to at least Version 2.0.4-pl or newer before running the script.

This tutorial shows how to create the files necessary to use the MODX xPDO methods with your custom database tables. It is for people who want a simple method that allows them to use xPDO with their tables, but will never create a Transport Package to share their classes with the world. If you may want to distribute a package containing your classes, see one of the existing tutorials on creating a package with custom tables (e.g., the Doodles or Storefinder tutorials). The Doodles tutorial also includes information on creating a Custom Manager Page (CMP) to manage your database tables. It's recommended that you work through this tutorial first, though, to get a feel for how the process works.

MODX 3 Note

Due to changes in MODX Revolution 3, the script below runs but does not produce the .map files on a MODX 3 site. The .map files are not needed in MODX 3, so as long as the schema is correct, the process should still work, though I haven't had time to test it. Thanks to MODX Forum user eromefr for bringing this to my attention.

Best Practices

This tutorial assumes that you've imported your custom tables into the MODX database. It's possible to access tables in another database and you'd still follow the steps in this tutorial, but in snippets that use the classes, you'd create a new instance of xPDO. That would look something like this:

$xpdo = new xPDO('mysql:host=localhost;dbname=mydatabase',

For the purposes of the tutorial, we'll create a simple database table that stores quotations with four fields: id, quote, topic, and author. We'll call the table bobs_quotation (note the table prefix: bobs_ — always end your prefix with an underscore).

Make sure your custom database table has a primary key called id that is an autoincrement field.

It's a good practice to use a different table prefix than the one in your MODX database (in fact, it's required with the current version of writeSchema(), though that may change) and to use a singular noun for the table name because the table name will be the name of your class. It's not a bad idea to create a separate localhost install of MODX for the tutorial since we'll be modifying the MODX DB, although the only modification will be the addition of the custom table. The CreateXpdoClasses snippet does not modify the database in any way — it simply reads it and writes files under the core/components/ directory.


We're going to create a "package" called "quotes" with the classes necessary to use xPDO with our bobs_quotation table. In order to use xPDO with a custom table, you need a set of class and map files that will be used to create the necessary classes. MODX itself has these in the core/model/modx/ directory. We'll put ours under the core/components/ directory. That's where add-on components usually put theirs. In any snippet that uses the classes, you need to tell xPDO where the files are in a call to addPackage(). To create the files, we'll use two methods of the xpdogenerator class: writeSchema() and parseSchema().

The first one, writeSchema(), writes the schema XML file based on the table(s) in the database that we specify. That file contains a formal description of the database table(s) the xPDO methods will use to access each table.

The MODX schema is in the core/model/schema/modx.mysql.schema.xml file if you'd like to take a look at it.

For our schema, the CreateXpdoClasses snippet will calculate a path for the schema file based on the package name. Our package name is "quotes" so the path will be core/components/quotes/schema/. Our schema file will be called quotes.mysql.schema.xml and will end up in that directory. We'll also include an argument with the prefix bobs_ so that writeSchema() will only process tables with that prefix. We only have one table, but if we had more with that prefix, writeSchema() would process all of them and put descriptions of them all in the schema file. The location of the schema file is arbitrary, but we're putting it in a fairly standard location.

The second method, parseSchema(), also doesn't care where the schema file is. It takes two arguments, the first tells it where the schema file is, the second tells it where to put the class and map files (also an arbitrary location). The CreateXpdoClasses snippet automatically uses the path we gave to writeSchema() to find the schema file and calculates the location for the class a map files based on the package name. That second path will be core/components/quotes/model/quotes/. That's the path you use in snippets that make use of the class and map files to tell $modx->addPackage() where they are. The addPackage() method also doesn't care where the files are as long as you give it the correct path to them.

Once the CreateXpdoClasses snippet has run, you should have the following structure under your core/components/quotes/ directory:


In snippets or plugins that use the class files you need to use the correct class name in any calls to xPDO methods (e.g., getObject(), getCollection(), etc.). If you're not sure what it is, look in the schema file. It will have a line for each object with something like this:

<object class="className" . . . .

Creating the Table

Using PhpMyAdmin, open up your MODX database and click on the "SQL" tab. Paste in the following code and click on the "Go" button.

CREATE TABLE IF NOT EXISTS `bobs_quotation` (
    `id` int(25) unsigned NOT NULL auto_increment,
    `quote` mediumtext NOT NULL,
    `author` varchar(200) NOT NULL default '',
    `topic` varchar(20) NOT NULL default '',
    PRIMARY KEY (`id`),
    KEY `topic` (`topic`),
    KEY `author` (`author`),
    FULLTEXT KEY `quote` (`quote`,`author`,`topic`)

We'll also need some quotations in the table in order to test our classes. Still in PhpMyAdmin, select the bobs_quotation table and click on the "Insert" tab at the top. Add a couple of quotations with a topic and an author by filling in the Value fields (leave the id field blank and don't mess with any of the other fields) and click on the "Go" button at the bottom. Add a few more if you like. If you click on the "Browse" tab, you should see your quotations in the table. Click on the little pencil next to one if you need to edit it.

Installing the CreateXpdoClasses Snippet

The snippet may be available as a Transport Package you can download and install using Package Manager's "Download Extras" button. If not, you can paste the code at the bottom of this page or from the downloadable ZIP file here into a new snippet called 'CreateXpdoClasses'.

Creating the Files

Create a new resource called "CreateClasses" and put the following snippet tag in the Resource Content field:

[[!CreateXpdoClasses? &myPackage=`quotes` &myPrefix=`bobs_`]]

When you preview your CreateClasses resource, you should see messages telling you that the schema and class files are being created. You only need to do this once if you get things right. Each time you change the schema, however, you need to run this again to generate the class and map files.

If you'd like to look at the files, they will be under the /core/components/quotes/ directory.

Note that there are some other, optional properties that control the snippet. The &createSchema and &createClasses properties control whether writeSchema() and parseSchema() are called. Both default to true so you can usually leave them out. If you want to create your schema file manually, however, you can use &createSchema=`0` in the snippet tag to skip that step. The &useCustomTemplates property defaults to false. It allows you to use the custom templates at the end of the CreateXpdoClasses code for your class and map files. The ones provided add PhpDoc comments to the top of the files, but you could alter them to create your own custom classes. This is somewhat risky and very seldom necessary.

Testing your classes

Create a new snippet called "ShowQuotes" with the following code:

$path = MODX_CORE_PATH . 'components/quotes/';
$result = $modx->addPackage('quotes',$path .

if (! $result) {
    return 'failed to add package';
} else {
    $output = '<p>added Package</p>';

$quotes = $modx->getCollection('Quotation');

$output .= '<p>Total: '. count($quotes) . '</p>';

foreach($quotes as $quote) {
    $output .= '<p>Topic: ' . $quote->get('topic');
    $output .= '<br/>Quote: ' . $quote->get('quote');
    $output .= '<br/>Author: ' . $quote->get('author') . '<br/></p>';

return $output;

Now create a new resource called "ShowQuotes" and put the following snippet tag in the Resource Content field:


When you preview your ShowQuotes resource, you should see your quotations. If that works, all the other xPDO methods will work on your table as well.

Try changing the getCollection() call to this:


Change 'SomeTopic' to one of your actual topics. For more information on using xPDO with your tables (and other MODX objects) look here.

Doing it With a Tpl Chunk

The method above is a quick and dirty way of testing things, but it would be better to put the HTML code in a Tpl Chunk and use a snippet to display it. That would do a better job of separating the form from the content and make maintaining and improving the site easier. Here's an example showing how to do that.

Create a chunk called "ShowQuote" and put the following code in it:

    <p class="Quotation">Topic: [[+topic]]<br/>
    Quotation: [[+quote]]<br/>
    Author: [[+author]]</p>

Change the last section of your ShowQuotes snippet to look like this:

    foreach($quotes as $quote) {
        $fields = $quote->toArray();
        $output .= $modx->getChunk('ShowQuote', $fields);

The code just above could use some explanation. The toArray() method just turns all fields of an xPDO object into a PHP associative array of fieldname/value pairs. It will work on any xPDO object. As you probably know, the getChunk() method returns the content of a MODX chunk. The first argument is the name of the chunk. The second (optional) argument to getChunk() is an associative array. When getting the chunk, MODX uses the associative array to replace any matching placeholders in the chunk. After calling toArray() on the $quote object, the $fields array looks something like this:

    'topic' => 'some topic',
    'quote' => 'some quotation',
    'author' => 'some author

Notice that the fields on the left match the placeholders in the Tpl chunk. Those placeholders will be replaced with the values grabbed by toArray() during the getChunk() call.

Saving Data From a Form

The form you'd use to save a new quotation is a standard HTML form like this one:

<form method="post" action="[[~[[*id]]]]">
    <input type="hidden" name="quoteId" value="[[+id]]"/>
    <label for="topic">topic</label>
    <input type="text" name="topic" id="topic" value="[[+topic]]"/></br>
    <label for="quote">quote</label>
    <input type="text" name="quote" id="quote" value="[[+quote]]"/></br>
    <label for="author">author</label>
    <input type="text" name="author" id="author" value="[[+author]]"/></br>
    <input type="submit" name="submit" id="submit" value="MySubmitVar"/></br>

This form will work both for creating a new quotation and editing an existing one. The placeholder tags are there for editing existing quotes. Since they are not set when creating a new quotation, MODX will simply remove them.

The MySubmitVar value adds a little extra security and makes sure that the data is coming from our form rather than some other form on the page.

Our form has input fields for entering a quote, topic and author plus a submit button. The code to save the information to the DB would be this simple, thanks to xPDO:

$quote = $modx->newObject('Quotation');

/* the next three lines can be in any order */

$quote->set('topic', $_POST['topic']);
$quote->set('quote', $_POST['quote']);
$quote->set('author', $_POST['author']);


You can also send an associative array of fields as the second argument like this:

$fields = array(
    'topic' => $_POST['topic'],
    'quote' => $_POST['quote'],
    'author' => $_POST['author'],
$quote = $modx->newObject('Quotation', $fields);

The form itself can be done in a number of ways in MODX. Usually it's done with a Tpl Chunk and placeholders. Imagine that you have a standard HTML form like the example above in a chunk called quotationTpl. The page itself would just contain a snippet tag. Let's call the snippet CreateQuote.


The snippet would look something like this:

/* CreateQuote Snippet */

if (isset($_POST['submit']) &&
    ($_POST['submit'] == 'MySubmitVar')) {
    /* Form has been submitted */

    /* You might want to check for empty
     * required fields here */

    /* We need to load our class so newObject() will work */
    $path = MODX_CORE_PATH . 'components/quotes/';
    $result = $modx->addPackage('quotes',$path .
    if (! $result) {
        return 'Failed to add package';

    $fields = array(
        'topic' => $_POST['topic'],
        'quote' => $_POST['quote'],
        'author' => $_POST['author'],
    $quote = $modx->newObject('Quotation', $fields);
    if ( $quote->save()) {
        $output = "Quote Created";
    } else {
        $output = "Error";

} else {
    /* Not a repost, just display the form */
    $output = $modx->getChunk('quotationTpl');
return $output;

Editing an Existing Quotation

This is very similar to the code above. The only difference is that you need to get the quote rather than creating a new one and you need to display the current values of its fields. Otherwise, the code is the same. Let's call the snippet EditQuote and assume that you've sent the ID of the quote you want to change as a property called &quoteId in the EditQuote tag. So if you wanted to edit Quote 12, you'd do this:

[[!EditQuote? &quoteId=`12`]]

For each field in the Tpl Chunk, you'd set a placeholder for the value like this:

<p>Author: <input type="text" value="[[+author]]"/></p>

Note that this code will fail unless you have an actual quote in the database and send its ID in the quotId property. Now the code would look something like this:

/* EditQuote Snippet */

/* Load our class */
$path = MODX_CORE_PATH . 'components/quotes/';
$result = $modx->addPackage('quotes',$path .
if (! $result) {
    return 'Failed to add package';

/* Get the existing Quote */
$quoteId = $scriptProperties['quoteId'];
$quote = $modx->getObject('Quotation',

/* Show error message if quote is not found */
if (empty($quote)) {
    return ('Could not find Quote with ID: ' . $quoteId);

if (isset($_POST['submit']) &&
    ($_POST['submit'] == 'MySubmitVar')) {

    /* Form has been submitted */

    $quote->set('topic', $_POST['topic']);
    $quote->set('quote', $_POST['quote']);
    $quote->set('author', $_POST['author']);

    if ( $quote->save()) {
        $output = "Quote Created";
    } else {
        $output = "Error";
} else {
    /* Not a repost, just display the form */

    /* The second argument to getChunk() tells MODX
     * to replace the placeholders with the existing
     * quote's values

    $output = $modx->getChunk('quotationTpl',$quote->toArray() );
return $output;

Code for CreateXpdoClasses

Here's the code (you can download it using the link at the end of the Installing the CreateXpdoClasses Snippet section above). You shouldn't need to modify it at all to use this tutorial.

* @package = CreateXpdoClasses
* Create Xpdo Classes script
* This script creates xPDO-ready classes from existing custom
* database tables. Run it again whenever you change
* your schema.
* It assumes that your custom tables have been imported into
* the MODX DB and use a different table prefix than the MODX
* tables.

* In theory, you can use a separate DB but this has not been
* tested and the process of using the classes would be more
* complicated. To do this, you would need to set the
* following variables after including the config file,
* but before creating the $modx object:
* $database_server = 'yourDbServer';
* $database_type = 'mysql';
* $dbase = 'yourDbName';
* $database_user = 'user';
* $database_password = 'password';
* $table_prefix = 'yourPrefix_';
* In the snippets that use the classes, you'd need to
* instantiate a new $xpdo object, and use $xpdo-> rather
* than $modx-> to call the methods.
* $xpdo = new xPDO('mysql:host=localhost;dbname=yourDbName',
*   $database_user,$database_password,$table_prefix);
* Note: If you are running this outside of MODX and testing
* it in the Manager, it will log you out when it runs, even
* though the Manager screen will still be visible. Actions
* taken in the Manager (e.g., saving a snippet) will not be
* successful. After running the script, reload the Manager
* page in your browser and you will be prompted to
* log back in.
/* assume we're in a snippet */
$outsideModx = false;

 if (!defined('MODX_CORE_PATH')) {
     $outsideModx = true;
    /* put the path to your core in the next line to run
     * outside of MODX */
    define(MODX_CORE_PATH, 'c:/xampp/htdocs/test/core/');
    include_once MODX_CORE_PATH .
    $modx= new modX();

/* set these if running outside of MODX */

if ($outsideModx) {
    $myPackage = 'mypackage';

    /* table prefix; must match the prefix of the tables
     * to process. Using the same prefix as  the MODX
     * tables is not recommended */
    $myPrefix = 'bobs_';

    // $myTables = 'bobs_quotation';

/* These two switches let you write the schema and/or
 * create the classes; useful for debugging and for leaving
 * a manually edited schema file alone*/

$createSchema = $modx->getOption('createSchema',
$createClasses = $modx->getOption('createClasses',

/* Used to include the phpDoc templates below or
 * other custom templates */
$includeCustomTemplates = empty($includeCustomTemplates)?
false : $includeCustomTemplates;

/* $myPackage is the name of your package. Use this in
 * your addPackage() call to load all classes created
 * by this script.
 * The class files will be created under the
 * /core/components/$myPackage/model/
 * directory.
 * Example:
 * $myPackage = 'quotes';
 * $myPrefix = 'bobs_';
 * $path = MODX_CORE_PATH . 'components/' . $myPackage .
 * $result = $modx->addPackage($myPackage, $path .
       'model/', $myPrefix);
 * if (! $result) {
 *   return('Failed to add package');
 * }
$myPackage = empty($myPackage)? 'mypackage' : $myPackage;

/* table prefix; must match the prefix of the
 * tables you want to process */
$myPrefix = empty ($myPrefix)? '' : $myPrefix;

/* Table names to process -- this is only necessary if
 * your table prefix is the same as that of the MODX
 * tables (not recommended). You can send a comma-separated
 * list of full table names. In that case the class name
 * will be the table name minus the prefix with any
 * underscores removed and any letter after an
 * underscore in upper case.
 * You can also send an array of arrays of
 * tableName => className,
 * which allows you to specify the exact class name rather
 * then letting MODX create it from the table name.
 * Each inner array specifies a full table name and the
 * class name to use.
 * NOTE: This feature may not be implemented yet.
 * Examples:

$myTables = 'bobs_quotation';

$myTables = array(

$myTables = empty($myTables)? '' : $myTables;

/* You shouldn't need to modify the code beyond this point
******************************************************* */

$sources = array(
    'config' => MODX_CORE_PATH . 'config/',
    'package' => MODX_CORE_PATH . 'components/' .
        $myPackage . '/',
    'model' => MODX_CORE_PATH. 'components/' . $myPackage .
    'schema' => MODX_CORE_PATH . 'components/' .
        $myPackage . '/schema/',

if (! file_exists($sources['package'])) {

if (! file_exists($sources['model'])) {
if (! file_exists($sources['schema'])) {

$modx->setLogTarget(XPDO_CLI_MODE ? 'ECHO' : 'HTML');

$manager = $modx->getManager();
$generator = $manager->getGenerator();

if ($includeCustomTemplates) {

$file = $sources['schema'] . $myPackage .

// echo '<br/>File: ' . $file;

/* writeSchema() arguments
 * ---------------------------
 * string $schemaFile -- Full path to the schema file
 *     you want to write
 * string $package -- (optional) Name of your component
 * string $baseClass -- (optional) xPDO base class to use;
 *     (send '' if using args below)
 * string $tablePrefix -- Table prefix (of tables to
 *     process)
 * boolean $restrictPrefix) -- (optional) Process only
 * tables with $tablePrefix
 * mixed $tableList -- Array of arrays of
 *    full-table-name=>className or
 *    a string with a comma-separated
 *    list of full table names;
 *    if you send the string the table
 *    name will be used as the class name.
 * returns true on success, false on failure

if ($createSchema) {
    $xml= $generator->writeSchema($file,
        $myPackage, '',$myPrefix,true,$myTables);

    if ($xml) {
            'Schema file written to ' . $file);
    } else {
            'Error writing schema file');

if ($createClasses) {
    if ($generator->parseSchema($file, $sources['model'])){
            'Schema file parsed -- Files written to ' .
    } else {
            'Error parsing schema file');
$modx->log(modX::LOG_LEVEL_INFO, 'FINISHED');

function customTemplates($generator) {
$generator->classTemplate= <<<EOD
 * [+phpdoc-package+]
 * [+phpdoc-subpackage+]
class [[+class]] extends [[+extends]] {

$generator->platformTemplate= <<<EOD
 * [+phpdoc-package+]
 * [+phpdoc-subpackage+]
require_once (dirname(dirname(__FILE__)) .
class [+class+]_[+platform+] extends [+class+] {
$generator->mapHeader = <<<EOD
 * [+phpdoc-package+]
 * [+phpdoc-subpackage+]

Related Objects

While the schema files created by this tutorial will work fine with standard xPDO get(), set(), getObject(), and getCollection() calls, they won't contain information about the relationships between tables containing related objects. In order to deal with related objects (e.g., getOne(), getMany(), getObjectGraph() , etc.) and any joins, you'll have to edit the schema file manually to define any aggregate or composite relationships (and then regenerate the classes and maps! by re-running the CreateXpdoClasses snippet) to tell xPDO how to join the tables. Be sure to back up your schema files first and add &createSchema=`0` when you re-run the snippet so it won't overwrite your manually edited schema file. You need to regenerate the classes and maps every time you make a change to the schema file. MODX actually ignores the schema file completely, it's only there to define the classes for the methods used in the CreateXpdoClasses snippet.

The best way to learn about how related objects are specified in the schema file is to look at the MODX schema file (core/modx/schema/modx.mysql.schema.xml) and at the end of this page: Revolution Objects. The modResource object section provides good examples.

Many-to-one Relationships

A resource can have only one parent, so in the modResource class object definition in the schema, you'll see this line below the field and index specifications:

<aggregate alias="Parent" class="modResource"
        local="parent" foreign="id" cardinality="one"

It is an 'aggregate alias' because it is a one-to-many or many-to-one relationship. The 'alias' is how the other objects will be referred to in a related objects function call (e.g. $resource->getOne('Parent'). The 'class' is the type of object that call will return. In this case, it's modResource because the parent object is also a resource. The 'local' term specifies the name of the local field that points to the related object (in this case, it's the 'parent' field). The 'foreign' term refers to the field in the related object that the local field points to (usually the 'id' field). The 'cardinality' is 'one' because a resource can have only one parent. The owner of the 'id' field is the foreign object (the parent) so 'owner' is set to 'foreign'.

You could retrieve the foreign (parent) related object with this xPDO code:

$parentObject = $modx->resource->getOne('Parent');

One-to-Many Relationships

A resource can have more than one child, so there are a couple of differences in the specification for the Children alias.

<aggregate alias="Children" class="modResource" local="id"
        foreign="parent" cardinality="many" owner="local"/>

It's still an 'aggregate alias' that points to a modResource object'. The 'local' field that connects the two tables, however, is the 'id' field. The field in the related (foreign) object (the child object in this case) that connects the two is the 'parent' field. The 'cardinality' is 'many' because we're in the modResource object and it can have many children. The 'owner' of the 'id' field is the resource so the owner is 'local'.

You could retrieve the foreign (children) related objects with this xPDO code:

$children = $modx->resource->getMany('Children');

Many-to-Many Relationships

A resource can belong to many resource groups and a resource group can have many related resources. That means they must be related by an intermediate (or 'intersect') object. In this case, the intersect object is called modResourceGroupResource. Each modResourceGroupResource object represents a single row in the intersection table. The row contains the ID of a resource group and the ID of a resource (the two actual fields are called 'document_group' and 'document'). In the schema, the relationship is expressed like this:

<composite alias="ResourceGroupResources"
        class="modResourceGroupResource" local="id"
        foreign="document" cardinality="many"

Because it involves a many-to-many relationship, it is a 'composite' rather than an 'aggregate' alias. The object pointed to by the 'alias' is the intersect object, a modResourcGroupResource object, and it is related to the 'id' of the modResource object. In the foreign object the field relating to the object we're in (modResource) is the 'document' field. The 'cardinality' is 'many' because the resource can have many related modResourcGroupResource objects (each representing a resource group the resource belongs to). Like the Children alias, the modResource is the one with the ID so the 'owner' is 'local'.

If you look at the modResourceGroup object, you'll find a similar composite alias that also points to the ResourceGroupResources table. It looks like this:

<composite alias="ResourceGroupResources"
        local="id" foreign="document_group"
        cardinality="many" owner="local"/>

Notice that is it the same except for the name of the 'foreign' field.

As you would expect, in the modResourceGroupResource intersect object specification, you'll find two aggregate aliases pointing to the modResource and the modResourceGroup objects. They are aggregate aliases rather than composite aliases because each modResourceGroupResource object is related to only one resource and only one resource group. Here are the two specifications:

<aggregate alias="ResourceGroup" class="modResourceGroup"
        key="id" local="document_group" foreign="id"
        cardinality="one" owner="foreign"/>

<aggregate alias="Resource" class="modResource" key="id"
        local="document" foreign="id" cardinality="one"

Notice that these also have a 'key' entry specifying the 'id' field as the key.

More Information

Check out the links below for more information about using xPDO with MODX Revolution

Creating a Model With xPDO

Using Your xPDO Model

Using Custom Database Tables in your 3rd Party Components

Reverse Engineer xPDO Classes from Existing Database Table

Developing an Extra in MODX Revolution


My book, MODX: The Official Guide - Digital Edition is now available here. The paper version of the book is available from Amazon.

If you have the book and would like to download the code, you can find it here.

If you have the book and would like to see the updates and corrections page, you can find it here.

MODX: The Official Guide is 772 pages long and goes far beyond this web site in explaining beginning and advanced MODX techniques. It includes detailed information on:

  • Installing MODX
  • How MODX Works
  • Working with MODX resources and Elements
  • Using Git with MODX
  • Using common MODX add-on components like SPForm, Login, getResources, and FormIt
  • MODX security Permissions
  • Customizing the MODX Manager
  • Using Form Customization
  • Creating Transport Packages
  • MODX and xPDO object methods
  • MODX System Events
  • Using PHP with MODX

Go here for more information about the book.

Thank you for visiting

  —  Bob Ray