Versions:

Versioning

Database content in SilverStripe can be "staged" before its publication, as well as track all changes through the lifetime of a database record.

It is most commonly applied to pages in the CMS (the SiteTree class). Draft content edited in the CMS can be different from published content shown to your website visitors.

Versioning in SilverStripe is handled through the Versioned class. As a DataExtension it is possible to be applied to any DataObject subclass. The extension class will automatically update read and write operations done via the ORM via the augmentSQL database hook.

Adding Versioned to your DataObject subclass works the same as any other extension. It accepts two or more arguments denoting the different "stages", which map to different database tables.

mysite/_config/app.yml

MyRecord:
  extensions:
    - Versioned("Stage","Live")

The extension is automatically applied to SiteTree class. For more information on extensions see Extending and the Configuration documentation.

Database Structure

Depending on how many stages you configured, two or more new tables will be created for your records. In the above, this will create a new MyRecord_Live table once you've rebuilt the database.

Note that the "Stage" naming has a special meaning here, it will leave the original table name unchanged, rather than adding a suffix.

  • MyRecord table: Contains staged data
  • MyRecord_Live table: Contains live data
  • MyRecord_versions table: Contains a version history (new record created on each save)

Similarly, any subclass you create on top of a versioned base will trigger the creation of additional tables, which are automatically joined as required:

  • MyRecordSubclass table: Contains only staged data for subclass columns
  • MyRecordSubclass_Live table: Contains only live data for subclass columns
  • MyRecordSubclass_versions table: Contains only version history for subclass columns

Usage

Reading Versions

By default, all records are retrieved from the "Draft" stage (so the MyRecord table in our example). You can explicitly request a certain stage through various getters on the Versioned class.

// Fetching multiple records
$stageRecords = Versioned::get_by_stage('MyRecord', 'Stage');
$liveRecords = Versioned::get_by_stage('MyRecord', 'Live');

// Fetching a single record
$stageRecord = Versioned::get_by_stage('MyRecord', 'Stage')->byID(99);
$liveRecord = Versioned::get_by_stage('MyRecord', 'Live')->byID(99);

Historical Versions

The above commands will just retrieve the latest version of its respective stage for you, but not older versions stored in the <class>_versions tables.

$historicalRecord = Versioned::get_version('MyRecord', <record-id>, <version-id>);

The record is retrieved as a DataObject, but saving back modifications via write() will create a new version, rather than modifying the existing one.

In order to get a list of all versions for a specific record, we need to generate specialized Versioned_Version objects, which expose the same database information as a DataObject, but also include information about when and how a record was published.

$record = MyRecord::get()->byID(99); // stage doesn't matter here
$versions = $record->allVersions();
echo $versions->First()->Version; // instance of Versioned_Version

Writing Versions and Changing Stages

The usual call to DataObject->write() will write to whatever stage is currently active, as defined by the Versioned::current_stage() global setting. Each call will automatically create a new version in the <class>_versions table. To avoid this, use writeWithoutVersion())` instead.

To move a saved version from one stage to another, call writeToStage())` on the object. The process of moving a version to a different stage is also called "publishing", so we've created a shortcut for this: publish(<from-stage>, <to-stage>).

$record = Versioned::get_by_stage('MyRecord', 'Stage')->byID(99);
$record->MyField = 'changed';
// will update `MyRecord` table (assuming Versioned::current_stage() == 'Stage'),
// and write a row to `MyRecord_versions`.
$record->write(); 
// will copy the saved record information to the `MyRecord_Live` table
$record->publish('Stage', 'Live');

Similarly, an "unpublish" operation does the reverse, and removes a record from a specific stage.

$record = MyRecord::get()->byID(99); // stage doesn't matter here
// will remove the row from the `MyRecord_Live` table
$record->deleteFromStage('Live');

Forcing the Current Stage

The current stage is stored as global state on the object. It is usually modified by controllers, e.g. when a preview is initialized. But it can also be set and reset temporarily to force a specific operation to run on a certain stage.

$origMode = Versioned::get_reading_mode(); // save current mode
$obj = MyRecord::getComplexObjectRetrieval(); // returns 'Live' records
Versioned::set_reading_mode('Stage'); // temporarily overwrite mode
$obj = MyRecord::getComplexObjectRetrieval(); // returns 'Stage' records
Versioned::set_reading_mode($origMode); // reset current mode

Custom SQL

We generally discourage writing Versioned queries from scratch, due to the complexities involved through joining multiple tables across an inherited table scheme (see Versioned->augmentSQL()&version=3.1&module=framework)). If possible, try to stick to smaller modifications of the generated DataList objects.

Example: Get the first 10 live records, filtered by creation date:

$records = Versioned::get_by_stage('MyRecord', 'Live')->limit(10)->sort('Created', 'ASC');

Permissions

The Versioned extension doesn't provide any permissions on its own, but you can have a look at the SiteTree class for implementation samples, specifically canPublish() and canDeleteFromStage().

Page Specific Operations

Since the Versioned extension is primarily used for page objects, the underlying SiteTree class has some additional helpers.

Templates Variables

In templates, you don't need to worry about this distinction. The $Content variable contain the published content by default, and only preview draft content if explicitly requested (e.g. by the "preview" feature in the CMS, or by adding ?stage=Stage to the URL). If you want to force a specific stage, we recommend the Controller->init() method for this purpose, for example:

mysite/code/MyController.php

public function init() {
    parent::init();
    Versioned::set_reading_mode('Stage.Stage');
}

Controllers

The current stage for each request is determined by VersionedRequestFilter before any controllers initialize, through Versioned::choose_site_stage(). It checks for a Stage GET parameter, so you can force a draft stage by appending ?stage=Stage to your request. The setting is "sticky" in the PHP session, so any subsequent requests will also be in draft stage.

The choose_site_stage() call only deals with setting the default stage, and doesn't check if the user is authenticated to view it. As with any other controller logic, please use DataObject->canView() to determine permissions, and avoid exposing unpublished content to your users.

API Documentation