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 thancraft()
. - 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:
- Namespace (opens new window)
- Component and Object (opens new window)
- Object Configuration (opens new window)
- Events (opens new window)
- Path Aliases (opens new window)
- Models (opens new window)
- Controllers (opens new window)
- Console Applications (opens new window)
- I18N (opens new window)
- Assets (opens new window)
- Helpers (opens new window)
- Query Builder (opens new window)
- Active Record (opens new window)
- Active Record Behaviors (opens new window)
- User and IdentityInterface (opens new window)
# 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:
- Any required component methods such as
getInputHtml()
are defined by an interface (e.g. craft\base\FieldInterface (opens new window)). - Common properties such as
$handle
are defined by a trait (e.g. craft\base\FieldTrait (opens new window)). - A base implementation of the component type is provided by an abstract base class (e.g. craft\base\Field (opens new window)).
- The base class is extended by the various component classes (e.g. craft\fields\PlainText (opens new window)).
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 messagesapp
for Craft translation messagessite
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
IOHelper
has been replaced with craft\helpers\FileHelper (opens new window), which extends Yii’s yii\helpers\BaseFileHelper (opens new window).- Directory paths returned by craft\helpers\FileHelper (opens new window) and craft\services\Path (opens new window) methods no longer include a trailing slash.
- File system paths in Craft now use the
DIRECTORY_SEPARATOR
PHP constant (which is set to either/
or\
depending on the environment) rather than hard-coded forward slashes (/
).
# 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
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
# 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
# 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.
# 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.
# 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:
# 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);