Updating Plugins for Craft 3

Craft 3 is a complete rewrite of the CMS, built on Yii 2. Due to the scope of changes in Yii 2, there was no feasible way to port Craft to it without breaking every plugin in the process. So we took it as an opportunity (opens new window) to refactor several major areas of the system.

The primary goals of the refactoring were:

  • Establish new coding guidelines and best practices, optimizing for performance, clarity, and maintainability.
  • Identify areas where Craft was needlessly reinventing the wheel, and stop doing that.
  • Support modern development toolkits (Composer, PostgreSQL, etc.).

The end result is a faster, leaner, and much more elegant codebase for core development and plugin development alike. We hope you enjoy it.

If you think something is missing, please create an issue (opens new window).

# High Level Notes

  • Craft is now built on Yii 2.
  • The main application instance is available via Craft::$app now, rather than craft().
  • Plugins must now have a composer.json file that defines some basic info about the plugin.
  • Plugins now get their own root namespace, rather than sharing a Craft\ namespace with all of Craft and other plugins, and all Craft and plugin code must follow the PSR-4 (opens new window) specification.
  • Plugins are now an extension of Yii modules (opens new window).

# Changelogs

Craft 3 plugins should include a changelog named CHANGELOG.md, rather than a releases.json file (see Changelogs and Updates).

If you have an existing releases.json file, you can quickly convert it to a changelog using the following command in your terminal:

# go to the plugin directory
cd /path/to/my-plugin

# create a CHANGELOG.md from its releases.json
curl https://api.craftcms.com/v1/utils/releases-2-changelog --data-binary @releases.json > CHANGELOG.md

# Yii 2

Yii, the framework Craft is built on, was completely rewritten for 2.0. See its comprehensive upgrade guide (opens new window) to learn about how things have changed under the hood.

Relevant sections:

# Service Names

The following core service names have changed:

Old New
assetSources volumes
email mailer
templateCache templateCaches
templates view
userSession user

# Components

Component classes (element types, field types, widget types, etc.) follow a new design pattern in Craft 3.

In Craft 2, each component was represented by two classes: a Model (e.g. FieldModel) and a Type (e.g. PlainTextFieldType). The Model was the main representation of the component, and defined the common properties that were always going to be there, regardless of the component’s type (e.g. id, name, and handle); whereas the Type was responsible for defining the things that made the particular component type unique (e.g. its input UI).

In Craft 3, component types no longer act as separate, peripheral classes to the Model; they now are one and the same class as the model.

Here’s how it works:

Properties are consistently returned with the correct type in Craft 3.5.0 onward.
This may not have been the case in earlier releases when using MySQL, where ID columns could be returned as strings rather than ints and have a potential impact on strict comparisons.

# Translations

Craft::t() (opens new window) requires a $category argument now, which should be set to one of these translation categories:

  • yii for Yii translation messages
  • app for Craft translation messages
  • site for front-end translation messages
  • a plugin handle for plugin-specific translation messages
\Craft::t('app', 'Entries')

In addition to front-end translation messages, the site category should be used for admin-defined labels in the control panel:

\Craft::t('app', 'Post a new {section} entry', [
    'section' => \Craft::t('site', $section->name)
])

To keep front-end Twig code looking clean, the |t and |translate filters don’t require that you specify the category, and will default to site. So these two tags will give you the same output:

{{ "News"|t }}
{{ "News"|t('site') }}

# DB Queries

# Table Names

Craft no longer auto-prepends the DB table prefix to table names, so you must write table names in Yii’s {{%mytablename}} syntax.

# Select Queries

Select queries are defined by craft\db\Query (opens new window) classes now.

use craft\db\Query;

$results = (new Query())
    ->select(['column1', 'column2'])
    ->from(['{{%mytablename}}'])
    ->where(['foo' => 'bar'])
    ->all();

# Operational Queries

Operational queries can be built from the helper methods on craft\db\Command (opens new window) (accessed via Craft::$app->db->createCommand()), much like the DbCommand (opens new window) class in Craft 2.

One notable difference is that the helper methods no longer automatically execute the query, so you must chain a call to execute().

$result = \Craft::$app->db->createCommand()
    ->insert('{{%mytablename}}', $rowData)
    ->execute();

# Element Queries

ElementCriteriaModel has been replaced with Element Queries in Craft 3:

// Old:
$criteria = craft()->elements->getCriteria(ElementType::Entry);
$criteria->section = 'news';
$entries = $criteria->find();

// New:
use craft\elements\Entry;

$entries = Entry::find()
    ->section('news')
    ->all();

# Craft Config Settings

All of Craft’s config settings have been moved to actual properties on a few config classes, located in vendor/craftcms/cms/src/config/. The new Config service (craft\services\Config (opens new window)) provides getter methods/properties that will return those classes:

// Old:
$devMode = craft()->config->get('devMode');
$tablePrefix = craft()->config->get('tablePrefix', ConfigFile::Db);

// New:
$devMode = Craft::$app->config->general->devMode;
$tablePrefix = Craft::$app->config->db->tablePrefix;

# Files

# Events

The traditional way of registering event handlers in Craft 2/Yii 1 was:

$component->onEventName = $callback;

This would directly register the event listener on the component.

In Craft 3/Yii 2, use yii\base\Component::on() (opens new window) instead:

$component->on('eventName', $callback);

Craft 2 also provided a craft()->on() method, which could be used to register event handlers on a service:

craft()->on('elements.beforeSaveElement', $callback);

There is no direct equivalent in Craft 3, but generally event handlers that used craft()->on() in Craft 2 should use class-level event handlers (opens new window) in Craft 3.

use craft\services\Elements;
use yii\base\Event;

Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, $callback);

In addition to services, you can use class-level event handlers for components that may not be initialized yet, or where tracking down a reference to them is not straightforward.

For example, if you want to be notified every time a Matrix field is saved, you could do this:

use craft\events\ModelEvent;
use craft\fields\Matrix;
use yii\base\Event;

Event::on(Matrix::class, Matrix::EVENT_AFTER_SAVE, function(ModelEvent $event) {
    // ...
});

# Plugin Hooks

The concept of “plugin hooks” has been removed in Craft 3. Here’s a list of the previously-supported hooks and how you should accomplish the same things in Craft 3:

# General Hooks

# addRichTextLinkOptions

public function addRichTextLinkOptions()
{
    return [
        [
            'optionTitle' => Craft::t('Link to a product'),
            'elementType' => 'Commerce_Product',
        ],
    ];
}

# addTwigExtension

public function addTwigExtension()
{
    Craft::import('plugins.cocktailrecipes.twigextensions.MyExtension');
    return new MyExtension();
}

# addUserAdministrationOptions

public function addUserAdministrationOptions(UserModel $user)
{
    if (!$user->isCurrent()) {
        return [
            [
                'label'  => Craft::t('Send Bacon'),
                'action' => 'baconater/sendBacon'
            ],
        ];
    }
}

# getResourcePath

// Craft 2:
public function getResourcePath($path)
{
    if (strpos($path, 'myplugin/') === 0) {
        return craft()->path->getStoragePath().'myplugin/'.substr($path, 9);
    }
}

There is no direct Craft 3 equivalent for this hook, which allowed plugins to handle resource requests, because the concept of resource requests has been removed in Craft 3. See Asset Bundles to learn how plugins can serve resources in Craft 3.

# modifyCpNav

public function modifyCpNav(&$nav)
{
    if (craft()->userSession->isAdmin()) {
        $nav['foo'] = [
            'label' => Craft::t('Foo'),
            'url' => 'foo'
        ];
    }
}

# registerCachePaths

public function registerCachePaths()
{
    return [
        craft()->path->getStoragePath().'drinks/' => Craft::t('Drink images'),
    ];
}

# registerEmailMessages

public function registerEmailMessages()
{
    return ['my_message_key'];
}

Rather than defining the full message heading/subject/body right within the Craft::t() (opens new window) call, you can pass placeholder strings (e.g. 'email_heading') and define the actual string in your plugin’s translation file.

# registerUserPermissions

public function registerUserPermissions()
{
    return [
        'drinkAlcohol' => ['label' => Craft::t('Drink alcohol')],
        'stayUpLate' => ['label' => Craft::t('Stay up late')],
    ];
}

# getCpAlerts

public function getCpAlerts($path, $fetch)
{
    if (craft()->config->devMode) {
        return [Craft::t('Dev Mode is enabled!')];
    }
}

# modifyAssetFilename

public function modifyAssetFilename($filename)
{
    return 'KittensRule-'.$filename;
}

# Routing Hooks

# registerCpRoutes

public function registerCpRoutes()
{
    return [
        'cocktails/new' => 'cocktails/_edit',
        'cocktails/(?P<widgetId>\d+)' => ['action' => 'cocktails/editCocktail'],
    ];
}

# registerSiteRoutes

public function registerSiteRoutes()
{
    return [
        'cocktails/new' => 'cocktails/_edit',
        'cocktails/(?P<widgetId>\d+)' => ['action' => 'cocktails/editCocktail'],
    ];
}

# getElementRoute

public function getElementRoute(BaseElementModel $element)
{
    if (
        $element->getElementType() === ElementType::Entry &&
        $element->getSection()->handle === 'products'
    ) {
        return ['action' => 'products/viewEntry'];
    }
}

# Element Hooks

The following sets of hooks have been combined into single events that are shared across all element types.

For each of these, you could either pass craft\base\Element::class (opens new window) to the first argument of yii\base\Event::on() (registering the event listener for all element types), or a specific element type class (registering the event listener for just that one element type).

# addEntryActions, addCategoryActions, addAssetActions, & addUserActions

public function addEntryActions($source)
{
    return [new MyElementAction()];
}

# modifyEntrySortableAttributes, modifyCategorySortableAttributes, modifyAssetSortableAttributes, & modifyUserSortableAttributes

public function modifyEntrySortableAttributes(&$attributes)
{
    $attributes['id'] = Craft::t('ID');
}

# modifyEntrySources, modifyCategorySources, modifyAssetSources, & modifyUserSources

public function modifyEntrySources(&$sources, $context)
{
    if ($context == 'index') {
        $sources[] = [
            'heading' => Craft::t('Statuses'),
        ];

        $statuses = craft()->elements->getElementType(ElementType::Entry)
            ->getStatuses();
        foreach ($statuses as $status => $label) {
            $sources['status:'.$status] = [
                'label' => $label,
                'criteria' => ['status' => $status]
            ];
        }
    }
}

# defineAdditionalEntryTableAttributes, defineAdditionalCategoryTableAttributes, defineAdditionalAssetTableAttributes, & defineAdditionalUserTableAttributes

public function defineAdditionalEntryTableAttributes()
{
    return [
        'foo' => Craft::t('Foo'),
        'bar' => Craft::t('Bar'),
    ];
}

# getEntryTableAttributeHtml, getCategoryTableAttributeHtml, getAssetTableAttributeHtml, & getUserTableAttributeHtml

public function getEntryTableAttributeHtml(EntryModel $entry, $attribute)
{
    if ($attribute === 'price') {
        return '$'.$entry->price;
    }
}

# getTableAttributesForSource

// Craft 2:
public function getTableAttributesForSource($elementType, $sourceKey)
{
    if ($sourceKey == 'foo') {
        return craft()->elementIndexes->getTableAttributes($elementType, 'bar');
    }
}

There is no direct Craft 3 equivalent for this hook, which allowed plugins to completely change the table attributes for an element type right before the element index view was rendered. The closest thing in Craft 3 is the craft\base\Element::EVENT_REGISTER_TABLE_ATTRIBUTES (opens new window) event, which can be used to change the available table attributes for an element type when an admin is customizing the element index sources.

# Template Variables

Template variables are no longer a thing in Craft 3, however plugins can still register custom services on the global craft variable by listening to its init event:

// Craft 3:
use craft\web\twig\variables\CraftVariable;
use yii\base\Event;

Event::on(
    CraftVariable::class,
    CraftVariable::EVENT_INIT,
        function(Event $event) {
        /** @var CraftVariable $variable */
        $variable = $event->sender;
        $variable->set('componentName', YourVariableClass::class);
    }
);

(Replace componentName with whatever you want your variable’s name to be off of the craft object. For backwards-compatibility, you might want to go with your old camelCased plugin handle.)

# Rendering Templates

The TemplatesService has been replaced with a View component.

craft()->templates->render('pluginHandle/path/to/template', $variables);

# Controller Action Templates

Controllers’ renderTemplate() method hasn’t changed much. The only difference is that it used to output the template and end the request for you, whereas now it returns the rendered template, which your controller action should return.

$this->renderTemplate('pluginHandle/path/to/template', $variables);

# Rendering Plugin Templates on Front End Requests

If you want to render a plugin-supplied template on a front-end request, you need to set the View component to the CP’s template mode:

$oldPath = craft()->templates->getTemplatesPath();
$newPath = craft()->path->getPluginsPath().'pluginhandle/templates/';
craft()->templates->setTemplatesPath($newPath);
$html = craft()->templates->render('path/to/template');
craft()->templates->setTemplatesPath($oldPath);

# Control Panel Templates

If your plugin has any templates that extend Craft’s _layouts/cp.html control panel layout template, there are a few things that might need to be updated.

# extraPageHeaderHtml

Support for the extraPageHeaderHtml variable has been removed. To create a primary action button in the page header, use the new actionButton block.

{% set extraPageHeaderHtml %}
  <a href="{{ url('recipes/new') }}" class="btn submit">{{ 'New recipe'|t('app') }}</a>
{% endset %}

# Full-Page Grids

If you had a template that overrode the main block, and defined a full-page grid inside it, you should divide the grid items’ contents into the new content and details blocks.

Additionally, any <div class="pane">s you had should generally lose their pane classes.

{% block main %}
  <div class="grid first" data-max-cols="3">
    <div class="item" data-position="left" data-colspan="2">
      <div id="recipe-fields" class="pane">
        <!-- Primary Content -->
      </div>
    </div>
    <div class="item" data-position="right">
      <div class="pane meta">
        <!-- Secondary Content -->
      </div>
    </div>
  </div>
{% endblock %}

# Control Panel Template Hooks

The following control panel template hooks have been renamed:

Old New
cp.categories.edit.right-pane cp.categories.edit.details
cp.entries.edit.right-pane cp.entries.edit.details
cp.users.edit.right-pane cp.users.edit.details

# Resource Requests

Craft 3 doesn’t have the concept of resource requests. See Asset Bundles for information about working with front end resources.

# Registering Arbitrary HTML

If you need to include arbitrary HTML somewhere on the page, use the beginBody or endBody events on the View component:

craft()->templates->includeFootHtml($html);

# Background Tasks

Craft’s Tasks service has been replaced with a job queue, powered by the Yii 2 Queue Extension (opens new window).

If your plugin provides any custom task types, they will need to be converted to jobs:

class MyTask extends BaseTask
{
    public function getDescription()
    {
        return 'Default description';
    }

    public function getTotalSteps()
    {
        return 5;
    }

    public function runStep($step)
    {
        // do something...
        return true;
    }
}

Adding jobs to the queue is a little different as well:

craft()->tasks->createTask('MyTask', 'Custom description', array(
    'mySetting' => 'value',
));

# Writing an Upgrade Migration

You may need to give your plugin a migration path for Craft 2 installations, so they don’t get stranded.

First you must determine whether Craft is going to consider your plugin to be an update or a new installation. If your plugin handle hasn’t changed (besides going from UpperCamelCase to kebab-case), Craft will see your new version as an update. But if your handle did change in a more significant way, Craft isn’t going to recognize it, and will consider it a completely new plugin.

If the handle (basically) stayed the same, create a new migration named something like “craft3_upgrade”. Your upgrade code will go in its safeUp() method just like any other migration.

If the handle has changed, you’ll need to put your upgrade code in your Install migration instead. Use this as a starting point:

<?php
namespace mynamespace\migrations;

use craft\db\Migration;

class Install extends Migration
{
    public function safeUp()
    {
        if ($this->_upgradeFromCraft2()) {
            return;
        }

        // Fresh install code goes here...
    }

    private function _upgradeFromCraft2(): bool
    {
        // Fetch the old plugin row, if it was installed
        $row = (new \craft\db\Query())
            ->select(['id', 'handle', 'settings'])
            ->from(['{{%plugins}}'])
            ->where(['in', 'handle', ['<old-handle>', '<oldhandle>']])
            ->one();

        if (!$row) {
            return false;
        }

        // Update this one's settings to old values
        $projectConfig = \Craft::$app->projectConfig;
        $oldKey = "plugins.{$row['handle']}";
        $newKey = 'plugins.<new-handle>';
        $projectConfig->set($newKey, $projectConfig->get($oldKey));

        // Delete the old plugin row and project config data
        $this->delete('{{%plugins}}', ['id' => $row['id']]);
        $projectConfig->remove($oldKey);

        // Any additional upgrade code goes here...

        return true;
    }

    public function safeDown()
    {
        // ...
    }
}

Replace <old-handle> and <oldhandle> with your plugin’s previous handle (in kebab-case and onewordalllowercase), and <new-handle> with your new plugin handle.

If there’s any additional upgrade logic you need to add, put it at the end of the _upgradeFromCraft2() method (before the return statement). Your normal install migration code (for fresh installations of your plugin) should go at the end of safeUp().

# Component Class Names

If your plugin provides any custom element types, field types, or widget types, you will need to update the type column in the appropriate tables to match their new class names.

# Elements

$this->update('{{%elements}}', [
    'type' => MyElement::class
], ['type' => 'OldPlugin_ElementType']);

# Fields

$this->update('{{%fields}}', [
    'type' => MyField::class
], ['type' => 'OldPlugin_FieldType']);

# Widgets

$this->update('{{%widgets}}', [
    'type' => MyWidget::class
], ['type' => 'OldPlugin_WidgetType']);

# Locale FKs

If your plugin created any custom foreign keys to the locales table in Craft 2, the Craft 3 upgrade will have automatically added new columns alongside them, with foreign keys to the sites table instead, as the locales table is no longer with us.

The data should be good to go, but you will probably want to drop the old column, and rename the new one Craft created for you.

// Drop the old locale FK column
$this->dropColumn('{{%mytablename}}', 'oldName');

// Rename the new siteId FK column
MigrationHelper::renameColumn('{{%mytablename}}', 'oldName__siteId', 'newName', $this);