Getting Technical: Building Complex Craft Plugins
Introduction
At the time of writing this, we currently have 5 plugins released on the Craft Plugin Store. Some of these are pretty simple whereas others are somewhat more complicated. This post aims to help anyone who is about to embark on building a more complex plugin.
In addition to the publicly available plugins, we also have a number of more project specific plugins that aren't publicly available. It was while building one of these that I found some gaps in my knowledge. After searching through the official documentation, looking at other peoples plugin code and reading a few blog posts, I managed to piece it all together to get where I needed to be. This wasn’t easy to find or centralised into one location, so this article is an attempt at doing exactly that. Hopefully it will be helpful for other developers, as well as a reference for me next time I need to write a mammoth plugin for Craft.
Notice
Please be aware that this is a long read article and we hadn't really expected any blog posts to be quite so long! We mindful that the formatting could be improved, so there will be some improvements made to our site over the coming months.
Background
This plugin is based around some custom functionality we've recently built into a fee calculator for property conveyancing services. In short, a visitor can provide various bits of information which are fed into a calculation that outputs a price based on several criteria.
Where to get started
When I need to make a plugin, the first thing I do after planning out the requirements is to go straight to pluginfactory.io. This allows me to generate the scaffolding without having to worry about if I’ve followed the correct naming conventions for my classes, or missed out a crucial function in the plugins main php file.
Just pick what you need and add helpful names to your services, controllers, etc and download the scaffolding it provides you with. Once this is done we can drop the whole folder into a “plugins” folder which I create within Craft 3. Now we just need to tweak our sites composer.json file to add the local repository and run composer update. Details of all of this can be found in a previous article titled Building our first plugin for Craft 3.
We have our scaffolding in place, so what now?
As stated at the beginning of this article, we are assuming you are building a more complex plugin, so as we go we will keep thinking of new features we can add. This was certainly the case for myself whilst building a recent requirement for a client project.
You will easily start seeing potential where you can add a new CP section to manage records, or a new widget that could sit on the dashboard, or an extra model/record here to better manage the data, etc. I would urge you however to make sure that you have a plan and stick to it as best you can! Setting a goal to get the core functionality in place first means the deliverables won’t be sacrificed for snazzy features that can be added in at the end or as a separate version release.
Now that we have our scaffolding in place and a plan to stick to, we can finally move onto a few areas of plugin development which I’d like to share.
CP Sections
I want to add a CP section but I’ve already built my scaffolding without one
Not to worry, we can enable a plugin to have a CP section at any stage in its lifecycle. To do this we just need to change a few settings. In your plugins composer.json file you will see a setting called “hasCpSection” under the “extra” object. Update this from false to true and save the file.
If you have included the plugin locally then you will need to use composer to remove your plugin on the command line and then use composer to require it again as the composer update command won’t detect the change.
I have a CP section but how do I use subsections within this
To set up some subsections within the CP, we need to focus on our plugins main php file. Inside this file we will find that our scaffolding contains some events to register site url rules and cp url rules. The CP url rules are the one that we are interested in here, so what we are going to do is to build up an array of routes.
We can enter any number of segments into these urls in the same way that we would with standard craft routes in the control panel. The difference here though is that instead of defining a template to point to, we are going to use a controller. For ease of use I’ve named this controller “cp” so that all of our routes point to {plugin-name}/cp/{function-name}.
$event->rules['cpActionTrigger1'] = 'conveyancing-calculator/default/do-something';
becomes:
$event->rules = array_merge(
$event->rules,
[
'conveyancing-calculator' => 'conveyancing-calculator/cp',
'conveyancing-calculator/dashboard' => 'conveyancing-calculator/cp',
'conveyancing-calculator/quotes' => 'conveyancing-calculator/cp/quotes',
'conveyancing-calculator/quotes/new' => 'conveyancing-calculator/cp/quotes-new'
]
);
This will direct our request to load the relevant template onto the url we have specified in our route. Our next step is to then create a new function in our plugins core php file which will return our sub navigation items to craft to be rendered within the control panels sidebar. This function looks like so:
public function getCpNavItem()
{
$subNavs = [];
$navItem = parent::getCpNavItem();
$subNavs['dashboard'] = [
'label' => 'Dashboard',
'url' => 'conveyancing-calculator/dashboard',
];
$subNavs['quotes'] = [
'label' => 'Quotes',
'url' => 'conveyancing-calculator/quotes',
];
$navItem = array_merge($navItem, [
'subnav' => $subNavs,
]);
return $navItem;
}
Within the cp controller functions for now we are just going to add the following line of code:
return $this->renderTemplate('conveyancing-calculator/index', $variables);
We can create additional settings within this controller to effect the view but this will be covered later in the services section of this article. We are also ready to set up our templates at this point which we will cover later in the templates section of this article.
I want to use one of my subsections as the default location for my plugin settings
Whilst this may seem simple enough after you’ve got the hang of creating subsections, you can easily end up in a position where the settings aren’t saving correctly, or you have the settings page working correctly but there is also the default setting page still active. The way we handle this is to set up our cp route like so:
'conveyancing-calculator/plugin' => 'conveyancing-calculator/cp/plugin'
Within our plugin function in the CpController.php file we now add the following code:
$variables = [];
$variables['settings'] = ConveyancingCalculator::$plugin->getSettings();
return $this->renderTemplate('conveyancing-calculator/settings', $variables);
This fetches our plugins settings and passes them through to our specified template which is then rendered and returned to the user. Our next step is to copy the code for all of our inputs in the core plugin settings file into our new settings/index.twig template. This template needs to be set up in the same way as any of our other cp section templates but with one important difference. We need to set a twig parameter called fullPageForm to be true and then add in some form fields.
<input type="hidden" name="action" value="conveyancing-calculator/cp/save-plugin-settings">
{{ redirectInput("conveyancing-calculator/plugin") }}
{% namespace "settings" %}
This tells the form to post to our controller and we are using the namespace tag to wrap around all of our other inputs. We don’t need to open or close the form because the twig variable of fullpageForm handles this for us. Similarly we don’t need a submit button either as this is handled by Craft too.
Finally back in our controller we need to set up our actionSavePluginSettings function. Here we are going to use the following code:
$plugin = Craft::$app->getPlugins()->getPlugin('conveyancing-calculator');
$settings = Craft::$app->getRequest()->getBodyParam('settings', []);
Craft::$app->getPlugins()->savePluginSettings($plugin, $settings);
return $this->redirectToPostedUrl();
This will save our posted values into our plugin settings and then redirect us back to the url we set in the redirectInput within our twig template. Now that we have our plugin settings loading and saving correctly from within a subsection, the last thing to do is to redirect our standard plugin settings page to our new template. In your original plugin settings file just remove all of the code and add the following line:
{% redirect url("conveyancing-calculator/plugin") %}
The url you enter here will need to match the url you entered into your cp route in the core plugin file at the start of this process. Once this redirect is in place we are finished and you can test your set up.
Great, I have subsections but I need a sidebar on one of my pages for different types of content
There are a few different ways you could set this up but our favoured approach is to have a model/record set up for each of our sub sections. Within these subsections we then have additional settings which can either compliment our plugins settings, or override them completely on a per section basis.
Setting the model/record up and fetching their data is covered later in this article, but once we have that data returned to our template we need to use it to build up our sidebar. To do this we simply use a for loop on our returned data and as we loop through each item we build up an array. We save this array to a variable called navItems which we then loop through to set crafts sidebar block. Here is an example of our code:
{% set navItems = {
'heading': { heading: "Authorities"|t },
} %}
{% for location in authorities %}
{% set friendlyUrl = location[0]|replace({" ": "", "-": ""})|lower %}
{% set navItems = navItems |merge({
(friendlyUrl): {
title: location[0]|t("conveyancing-calculator"),
},
}) %}
{% endfor %}
{% block sidebar %}
<nav>
<ul>
{% for id, item in navItems %}
{% if item.heading is defined %}
<li class="heading"><span>{{ item.heading }}</span></li>
{% else %}
<li><a href="{{ url("conveyancing-calculator/authorities/#{id}") }}"{% if item.title == selectedItem %} class="sel"{% endif %}>{{ item.title }}</a></li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endblock %}
To show which item is currently selected, we do this within our controller function before we render the template.
$variables['selectedSubnavItem'] = 'authorities';
$variables['currentSubSection'] = $subSection;
The $subSection variable is set when we initially call our function like so:
public function actionAuthorities(string $subSection = 'overview') {
It has a default value but this can be taken from the url also. We have told it to do this in our cp routing using the following syntax:
'conveyancing-calculator/authorities/<subSection:{handle}>' => 'conveyancing-calculator/cp/authorities',
The changes we have made above should allow your templates to populate and display a sidebar and also show which item is currently selected.
I want to create some user permissions to restrict access to the subsections
Understandably as plugins grow in size, you might not want the users to be able to do everything. An example of this is that someone may need to see some data, but they don’t necessarily need to be able to create and remove discount codes or modify any of the settings.
To create our user permissions we need to add an event to our plugin. This event will accept an array of permissions where we set a key and a label. This event looks like so:
Event::on(
UserPermissions::class,
UserPermissions::EVENT_REGISTER_PERMISSIONS,
function (RegisterUserPermissionsEvent $event) {
Craft::debug(
'UserPermissions::EVENT_REGISTER_PERMISSIONS',
__METHOD__
);
$event->permissions[Craft::t('conveyancing-calculator', 'Conveyancing Calculator')] = [
'conveyancingCalculator:dashboard' => [
'label' => Craft::t('conveyancing-calculator', 'Dashboard'),
],
'conveyancingCalculator:quotes' => [
'label' => Craft::t('conveyancing-calculator', 'Manage Quotes'),
]
];
}
);
To modify our sidebar based on our user permissions, we need to go into our plugins core php file and modify the getCpNavItem function we created previously. Within here we need to get our current user and check their permissions against each item. We do this by setting our current user like so:
$currentUser = Craft::$app->getUser()->getIdentity();
Now we have our current user selected we can run a conditional around each $subNav checking the permission key whilst we are building up our array, this will look like so:
if ($currentUser->can('conveyancingCalculator:dashboard)) {
$subNavs['quotes'] = [
'label' => Dashboard,
'url' => 'conveyancing-calculator/dashboard,
];
}
if ($currentUser->can('conveyancingCalculator:quotes')) {
$subNavs['quotes'] = [
'label' => 'Quotes',
'url' => 'conveyancing-calculator/quotes',
];
}
Now our permissions should be fully up and running. These are based on cp sections but you could further expand this to sub sections too and then specific actions if required as these conditionals can also be used in your controller or twig template.
Plugin Settings
I want to add some settings to a simple plugin which I’m extending
Great, you’ve ditched the hardcoded variables and you’re getting ready to unleash your users onto a plugin settings page. To do this though we first need to create it. Open up your plugins core php file and add the following:
protected function createSettingsModel() {
return new Settings();
}
protected function settingsHtml(): string
{
return Craft::$app->view->renderTemplate(
'conveyancing-calculator/settings',
[
'settings' => $this->getSettings()
]
);
}
These two functions provide us with access to a settings model, and a rule to render a specific template when requested. Next we will create the settings model which we are now referencing. The settings model doesn’t require a record to be created alongside it as it won’t be posting into its own database table, instead it saves itself into the settings column within crafts plugins table.
The model needs to be named Settings.php and placed inside of a folder called models. If you have a model in your plugin already then it will be set up in exactly the same way. We add our namespace to the top and use the plugin, craft, and craft\base\Model. Then we need to follow this with:
class Settings extends Model {
public $settingFieldName = '';
public function rules() {
['settingFieldName', 'string'],
['settingFieldName', 'default', 'value' => '']
}
}
You can add as many fields as you want to this model. The example I’ve included above shows you how to create a string with a blank default value. If you ever need to modify a field type, add new fields, or remove fields from your settings page, then you will need to update this model as well as your template.
Finally we just need to create our settings template. The settings template needs to sit where you’ve told your settingsHtml function to renderTemplate. In the twig template you don’t need to extend the layout or set any blocks as it is detected as a default settings file. Just add the following code:
{% import "_includes/forms" as forms %}
{{ forms.textField({
label: 'Setting Field Name',
instructions: 'Enter some instructions here.',
id: 'settingFieldName',
name: 'settingFieldName',
value: settings['settingFieldName']
}) }}
This will use crafts built in macro to generate a text input for you using all of the default styling and including a label and instructions. Now that you’ve created your template, you are free to test a submission. From here you could modify your models validation around your inputs to make it as simple or complex as you like. Other field types are available.
How do I add a table field to my settings
When we need more data than an input field would allow, we turn to tables. This could be to set up some sub nav items, or create line items from within a model/record, whatever we are using it for though we need to get it set up first. Our first step is to modify our Settings model. If we want our table to exist without any rows at first then we can use an empty array syntax, but if we want to create a default value for our first row then we can do the following:
public $legalFees = [['100000', '0.00', '0.00']];
We also need to add this to our rules function further down.
['legalFees', 'default', 'value' => [['100000', '0.00', '0.00']]],
Now that this is done we can move onto our twig template. To create the field we will be using another built in craft macro, this time we are targeting the editableTableField. This is done like so:
{{ forms.editableTableField({
label: 'Legal Fees',
instructions: 'Enter some setting here.',
id: 'legalFees',
name: 'legalFees',
cols: [
{
heading: "Banding",
info: "<p>Please enter the maximum Property Value this band will apply to</p>",
type: "singleline"
},
{
heading: "Freehold Fee",
info: "<p>Please enter the Freehold Fee applicable to this banding</p>",
type: "singleline"
},
{
heading: "Leasehold Fee",
info: "<p>Please enter the Leasehold Fee applicable to this banding</p>",
type: "singleline"
}
],
rows: settings['legalFees']
}) }}
Now that you have modified your model and template, it should be up and running. When using the setting in your code you will be presented with a nested array. The first level will be an array of rows, and the second level within this will be an array of values.
How can I populate a sidebar from my plugin settings and have it create database rows on save
When building a plugin for one of our clients, we needed to allow them to create a number of authorities, however each authority would contain its own rates. This meant that the authorities were essentially stored within a record and set up as sidebar items in a cp section. To add remove and rename these though we have used a table field within our plugins settings.
To tie these parts together, we have set up our plugin settings within its own cp section, this has allowed us to control the plugin settings save event with a function in our cp controller. Inside of this function we are allowing the plugin to save its settings, but then immediately after before we return a response to the user, we have looped through our saved settings table field and called one of our services functions on each iteration. This service then has the standard syntax to create a record from the data passed to it if it doesn’t already exist and then to use some other plugin settings as default field values for that record. Once the loop is complete we then return a redirect to the user to go to the posted url.
Whilst this may seem simple at first, it is important to have the correct set up in place so that you can leverage your services during the saving process of your settings, this will allow you to perform any action you can think of to manipulate the submitted data.
Twig Extensions
I want to add a twig extension
To add either a twig filter or a twig function, we first need to register it in our plugins core php file. This is done within the init() function using the following line of code:
Craft::$app->view->registerTwigExtension(new GdprdatacheckerTwigExtension());
Once this is done you will need to create a folder within your plugin called twigextensions. Within this folder you need a file called GdprdatacheckerTwigExtension.php which will contain our code.
At the top of this file you will need to set up your namespace and use cases like so:
namespace adigital\gdprdatachecker\twigextensions;
use adigital\gdprdatachecker\Gdprdatachecker;
use Craft;
class GdprdatacheckerTwigExtension extends \Twig_Extension {
Within the class we just opened we need to create a few functions. The first one is called getName() which just returns a string e.g. 'Gdprdatachecker'. The next two functions are getFilters() and getFunctions(). These will register your twig extension for usage in your templates. A filter is used like so in twig: {{ variable | camelToSpace }} and a function is used like this: {% set var = camelToSpace('variable') %}. Both of these functions return an array like so:
// Filter:
return [
new \Twig_SimpleFilter('camelToSpace', [$this, 'camelToSpaceFunction'])
];
// Function:
return [
new \Twig_SimpleFunction('camelToSpace', [$this, 'camelToSpaceFunction'])
];
You may spot that both of the return values above specify something called camelToSpaceFunction. This is the name of our next function. In the return arrays above we can register as many twig filters and/or functions as we like, but they each need to point to a function which contains the code they shall be executing. Our camelToSpace function looks like so:
public function camelToSpaceFunction($text = null)
{
$pattern = '/(([A-Z]{1}))/';
return preg_replace_callback(
$pattern,
function ($matches) {return " " .$matches[0];},
$text
);
}
Now that this is done, we can call out twig extension in any template using either the function or filter that we have registered.
Variables
How much code should I have in a variable
I’m going to keep this short and sweet because that’s exactly how your variable should be. This is just an access point to your service for returning the data to the template, so try not to include any logic here.
Controllers
I need to allow users to submit data into the plugin
Sounds like you need a controller. Create a front end form and make sure you include the following:
<input type="hidden" name="action" value="conveyancing-calculator/default/instruct-quote">
{{ csrfInput() }}
{{ redirectInput('online-tools/conveyancing-complete) }}
What this does is to send any information which you submit into your plugins DefaultController actionInstructQuote() function. At first only logged in users will be able to do this but if you open up your controller file and add ‘instruct-quote’ to the protected $allowAnonymous array at the top of the class then it will allow non logged in users to also submit data into the controller.
To handle this data we need to call `$request = Craft::$app->getRequest();` to store our request to a php variable so we aren’t calling the Craft app for each input. Then to access the values we just use `$request->getParam("inputName")`. From here the decision is up to you, personally I like to pass the entire $request variable through to a service and handle the data from there to save it to a record or send an email.
The controller needs to handle control panel routing for my subsections
This has been discussed above when talking about subsections, but controllers can also set twig variables when rendering our templates. By using the $variables array we can set things such as `$variables['fullPageForm'] = true;` as a basic example. This removed the need to do it in our twig template. This means that we could in essence have 2 controller functions rendering the same template, but with different outcomes. We can set all of our breadcrumbs using this method also like so:
$variables['crumbs'] = [
[
'label' => $pluginName,
'url' => UrlHelper::cpUrl('conveyancing-calculator'),
],
[
'label' => $templateTitle,
'url' => UrlHelper::cpUrl('conveyancing-calculator/authorities'),
]
];
Services
I want to generate a pdf
Fantastic, there are a few ways of doing this but our preferred method is to use dompdf. To add dompdf to your plugin, open your plugins composer.json file and add "dompdf/dompdf": "^0.8.2" to the require object underneath craftcms/craft. For this to take effect on a local plugin you may need to composer remove and then composer require as composer update may not work in detecting the change.
Now that we have our pdf library added to our plugin, we can set about using it. At the top of our service we have added the following:
use Dompdf\Dompdf;
use Dompdf\Options;
use craft\helpers\FileHelper;
Further down in our function where we want to generate our pdf we have used the following code:
$io = new FileHelper();
$html = Craft::$app->getView()->renderTemplate($template, $variables);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$size = "A4";
$orientation = "portrait";
$dompdf->set_paper($size, $orientation);
$dompdf->render();
$pdf_gen = $dompdf->output();
$io->writeToFile($destination.$pdfName.".pdf", $pdf_gen);
This code will generate your pdf from a twig template within your plugin which we have passed variables through to. Although we are using renderTemplate we aren’t returning it to the user, instead we are storing the html in a variable and then passing it through to dompdf. Dompdf then generates the pdf output and passes it through to crafts built in FileHelper which we use to save to a destination of our choosing. Once this is done you can return the pdf to the user along with some headers to tell the browser to download it.
How do I create an email
Once again we need to open our service file, this time though we are going to add `use craft\mail\Message;` to the top of it. Now that we have included crafts helper we can go down to our function that we wish to email from and add the following code:
$settings = Craft::$app->systemSettings->getSettings('email');
$message = new Message();
$message->setFrom([$settings['fromEmail'] => $settings['fromName']]);
$message->setTo($emailAddress);
$message->setSubject($subject);
$html = Craft::$app->getView()->renderTemplate($template, $variables);
$message->setHtmlBody($html);
$message->setTextBody($textBody);
if (file_exists($filePath)) {
$message->attach($filePath, [
'fileName' => $fileName
]);
}
Craft::$app->mailer->send($message);
Using the code above will allow you to create a twig template to point to which will be rendered and passed into the setHtmlBody part of the above code. This means that you can keep your email template out of php and use twig inside it with any variables you may wish to pass in.
We are loading in crafts email settings also so it will send in the same way as other craft system emails are sent. This means that if for example you have configured an smtp service, then the email will be sent using that service.
How do I create a new row within a record
Assuming you have your record set up correctly (see records below if not) we can now create a row within our record and have it saved to the database. First of all you will need to make sure that you have added the record to the top of your service file with a use statement like so:
use adigital\conveyancingcalculator\records\Quote as QuoteRecord;
Now all we need to do is tell Craft that we want to create a new row within our record. We can also assign values to all of our fields, or one at a time. Once we are happy with our row we can save it to the database. The following code shows you exactly how to do all of this:
$model = new QuoteRecord();
$model->setAttributes([
"conveyance", $submission["conveyance"],
"buying", $submission["buying"],
"selling", $submission["selling"]
], false);
$model->setAttribute("conveyance", $submission["conveyance"]);
$model->save();
Once this is done, we can still access the row using our $model variable but it will have been updated with an id, dateCreated, dateUpdated and uid. Getting values out of the model is covered in the next section.
I want to find rows within the record based on some criteria
Without this functionality there is no point to storing the data in the first place. Accessing it is relatively easy though thankfully. We can select multiple rows using the following syntax:
$models = DiscountRecord::find()->orderBy("dateUpdated desc")->all();
Or we can select a single row using this syntax:
$model = DiscountRecord::findOne(["id" => $request->getParam("discount")]);
There are multiple syntaxes that can be used for these queries so I’ve used a mixture in the examples above to highlight this. Further information for find(), findOne() and findAll() can be found on the yii documentation: https://www.yiiframework.com/doc/api/2.0/yii-db-activerecordinterface#find()-detail
A row needs updating in the record
First of all you will need to make sure that you are adding your use case at the top of your service file like so:
use adigital\conveyancingcalculator\records\Quote as QuoteRecord;
Now you will need to query your record for a single row, we will save this is a variable called $model from which we can update the attributes before saving it back to our record.
$model = QuoteRecord::findOne(["id" => $submission["id"]]);
if ($model === null) {
$model = new QuoteRecord();
}
The above code looks for a row within our record based on it’s id. Because we are using findOne we are telling it that we expect to only receive 1 row as a result. The conditional is in place so that if a record isn’t found, we create a new one. We can then start to update our attributes like so:
$model->setAttribute("conveyance", $submission["conveyance"]);
Using the above method would update a single attribute on the model. However we can also update multiple attributes at once if needed using the following syntax.
$modelAuthority = [
"legalFees" => $settings["legalFees"],
"landRegistryFee" => $settings["landRegistryFee"],
"localSearch" => $settings["localSearch"]
];
$model->setAttributes($modelAuthority, false);
Now that we have set our attributes, we need to save them to our record. To do this we simply use the save function like so:
$model->save();
All of the above has been covered in creating a new record and finding rows within a record, all we have done is to put the pieces together so that we can update our records.
I want to delete a row from the record
Deleting a row from a record is a very simple process, much like adding and updating records. The main difference is that we do not use the save function when deleting. Here is an example of how to delete a record based on its id.
$model = QuoteRecord::findOne(["id" => $request->getParam("id")]);
$model->delete();
First we use the findOne function on our record to save the result to our $model variable, then we just call the delete function, and we are done. The row in that record should no longer exist. A word of warning though is to use this with caution and be careful which parameters are sent through, you wouldn’t want a user to be able to edit the id before submitting it to the delete function for example. The above code is just an example of its most simple implementation.
Templates
Email templates
When you wish to send an email from within a plugin, instead of putting all of your html directly into the service file we can instead create a separate template within our plugins templates folder. I usually prefer to create an _email folder here but ultimately it is up to you to choose how you manage your plugins template files here. Within our template we can use all of our twig tags but we don’t need to call our plugins variable to access these. Instead, back in our service file, we can use craft to render the template with a set of variables which we pass through. This will look something like this:
$template = 'conveyancing-calculator/_email/instruct';
$oldMode = Craft::$app->view->getTemplateMode();
Craft::$app->view->setTemplateMode(View::TEMPLATE_MODE_CP);
$html = Craft::$app->view->renderTemplate($template, $variables);
Craft::$app->view->setTemplateMode($oldMode);
We set our template mode so that craft knows where to look before rendering it, and we store the old value in a variable so that we can reset it back to this once we are done. Now that we have rendered out email template and stored it in a $html variable, we can pass through to our email function like so:
$settings = Craft::$app->systemSettings->getSettings('email');
$message = new Message();
$message->setFrom([$settings['fromEmail'] => $settings['fromName']]);
$message->setTo($emailAddress);
$message->setSubject($subject);
$message->setHtmlBody($html);
Craft::$app->mailer->send($message);
This will send an email using the default cp email settings that you have configured within craft.
Widget templates
When using widgets in our plugins, we need to create some templates for these. If you added widgets to your scaffolding then you should have some files already created for you within the templates/_components/widgets folder. This should be an empty twig template to begin with, one for the body and one for the settings. You should also have a widgets folder with a single php file inside. If you open this then you should find the getSettingsHtml and getBodyHtml functions.
The getSettingsHtml function should just have a renderTemplate command pointing to our settings template. All we need to do at this point is to add our fields into the template and then add the field names to our widget php file within the rules function.
Next we need to configure our widgets body. Go to the getBodyHtml function and do any processing you need to around your submitted widget options. You can also add additional service calls to return data if needed and all of this data can be packaged into an array which is added to the renderTemplate command.
Finally you can code up your body twig template using the tags we’ve passed through or even calling a variable to grab more if needed. Your widget should be fully set up at this point and ready to go.
CP templates
These are just twig templates which are very similar to your regular front end templates. However there are a few tricks we can use to inherit styles from the craft control panel. As discussed earlier we can have subsections and sidebars but it doesn’t stop here. For starters we will use some form fields like so:
{% extends "_layouts/cp" %}
{% import "_includes/forms" as forms %}
{% set fullPageForm = true %}
{% set content %}
<input type="hidden" name="action" value="conveyancing-calculator/cp/save-authorities">
{{ redirectInput('/cms/conveyancing-calculator/authorities) }}
{{ forms.textField({
label: 'Local Search CON29R',
instructions: 'Enter some setting here.',
id: 'localSearchCon29r',
name: 'localSearchCon29r',
value: settings['localSearchCon29r']
}) }}
{% endset %}
The above code is using an import tag to set a forms variable, from this we can target functions such as textField and pass through some parameters. Essentially this handles all of the html for us around our form inputs. Also we have used the fullPageForm variable and set it to a value of true, this means that our entire page will be nested inside of a form. To get the form working all we need to do is set out action and redirect inputs which we have also done above.
If you are ever unsure of the structure of a craft control panel layout, there is an extremely handy ascii image in code comment which can be found in the layout file. This can be found under vendor/craftcms/cms/src/templates/_layouts/cp.html. This will give you a good idea of the elements available on a page and looking through these files will allow you to find twig variables such as fullPageForm, or the actionButton block which can also be overridden.
PDF templates
There are many ways to generate a pdf template within Craft but my personal favourite is by using the DOMPDF library. This allows us to set a number of config options before passing through a html file to be rendered as a pdf. So let’s run through the necessary steps to get this working.
First of all we need to make sure that we are including dompdf within our plugins composer file. We can do this by adding it to the require part of our json file like so.
"require": {
"craftcms/cms": "^3.0.0-RC1",
"dompdf/dompdf": "^0.8.2"
},
Now that this is done we will need to remove our plugin from composer and then re-add it as there hasn’t been a version number change so it won’t detect the change. Once you’ve installed the plugin again via composer the dompdf library will appear within your sites vendor folder.
At the top of your service file we now need to add in our use case to start sing the dompdf library, this can be done with the following lines of code.
use Dompdf\Dompdf;
use Dompdf\Options;
Now that we have included the library and defined that we want to use it, it is time to start configuring it in our code. First of all we want to set some default options and assign a new instance of dompdf to a variable, we can do this like so:
$options = new Options([
'tempDir' => $dompdfTempDir,
'fontCache' => $dompdfFontCache,
'logOutputFile' => $dompdfLogFile,
'enableRemote' => true
]);
$dompdf = new Dompdf($options);
Now that we have this variable created, we can render our pdf template which can be found at templates/_pdf.twig in this example. I will assume that we have already created this, defined our template variables, and saved it to a $html variable using the renderTemplate function as discussed above in the email templates section of this page. So the next step is to pass this html through into our pdf generation, we can do this like so:
$dompdf->loadHtml($html);
$size = "A4";
$orientation = "portrait";
$dompdf->set_paper($size, $orientation);
$dompdf->render();
$pdf_gen = $dompdf->output();
I’ve used a few additional config options in the above code to achieve this but the result is then saved to a $pdf_gen variable. This variable is then used to write our output into a file using crafts FileHelper like so.
$destination = realpath(dirname(dirname(__FILE__)))."/templates/reports/";
if (FileHelper::isWritable($destination)) {
FileHelper::removeDirectory($destination);
}
FileHelper::createDirectory($destination);
FileHelper::writeToFile($destination.$pdfName.".pdf", $pdf_gen);
Once this is done we will be able to add our generated pdf to an email using the $pdfName variable and appending .pdf to the end of it. Or we could return it to a template to be displayed or downloaded. But essentially we have now been able to generate a pdf, render it with options, and save it to a location of our choosing.
Widgets
Submitting a form within a widget
Ajax is your friend. And no I don’t mean the football team. The reason I say that ajax is our friend in this scenario is because we don’t want to be reloading our page if possible. When submitting a form within the dashboard, we want to mimic how the native contact us widget works. This means that we should replace our widgets form content with our response. To do this we need to use ajax to handle our submission data and return our response to our widget. We can achieve this by loading in our widgets asset bundle and modifying the js file it contains. In the Zendesk plugin we have used the following code:
$(document).ready(function(){
$("#zendeskWidgetForm").submit(function(e){
var formBody = $(this).parent();
var response;
var submission = new FormData(this);
$(this).find("input[name='zendeskTicketSubmit']").hide();
$(this).find("div[name='loadingSpinner']").show();
$.ajax({
url : "/actions/zendesk/default/support-ticket",
type: "POST",
data : submission,
processData: false,
contentType: false,
success:function(data, textStatus, jqXHR){
response = data;
formBody.html(response);
},
error: function(jqXHR, textStatus, errorThrown){
response = "<p>Error: We are sorry but your ticket was not submitted correctly.</p>";
formBody.html(response);
}
});
e.preventDefault();
});
});
The code above is triggered on submission but it prevents the default browser action of actually submitting the data. Instead it proceeds to scoop up this data and hide the form replacing it with a loading graphic. Our ajax function then kicks in to submit the data to our controller and receive a response which we use to replace our forms containing elements html with.
By using javascript to ajax submit our data, we are improving ours users experience. This results in a clean and slick process where the form is replaced by its response and the user can immediately see if it has succeeded or failed, in the case of our Zendesk plugin they are provided with a link to view their submitted ticket within the Zendesk platform.
Migrations
Do I need to update my Install.php migration
In a word… YES! This is a very important part of plugin development when working with migrations that has caused many github issues for various developers. If you’ve ever had an issue where updating a plugin works fine but installing it on a fresh install causes issue, you have most likely come across a situation where the migrations were written for the update, but the install file was forgotten about.
To prevent this from happening with your plugins, any time you change a model, record, or write a migration, make sure that you are accounting for this within your install file also. If you aren’t sure then just set up a fresh install on a dev domain and see if everything works or not. The database tables which your plugin creates are all taken from the install file when it is added to a site, once this initial install is done, any future updates will come from a migration. It is easy to forget about the install file when writing updates for a plugin but it is good to get into the habit of checking it.
I’ve made changes to the models and records, how do I do a migration
To create a migration, you have 2 options. The first option is to manually create the file yourself, and the second is to run a command on the server to generate this file for you. Once you have your migration file created you can proceed by adding some code into a safeUp function. This function will be run when the migration is run. This is essentially installing the update, whereas the safeDown function uninstalls / reverts it.
Migrations should be used to update your database, but please also repeat the changes in your Install.php file as mentioned above. Once you’ve written your migration you just need to go into your plugins main php file and update the scheme version. This is stored in a static variable called $schemaVersion and uses semantic versioning. More information on semantic versioning can be found here: https://semver.org/
Models / Records
I use records, so how do the models come into play
All plugins that have control panel settings will have a model included as part of the scaffolding. We also usually use models alongside records as a direct match. For example, we will have a model file called Quote.php and a record file also called Quote.php. These files both relate to the same database table.
A model will indicate what variables / columns are allowed on the model / table row. It will also define any rules and validation which need to be run against these values. Essentially a record is just the table name, but the model handles all of the columns and validation.
I need to create extra columns in my record
To create extra columns in a record, you actually need to edit the model, not the record. We also need to set some default values and some validation whilst doing this. Once this is done we can write a migration to update the plugins database tables with these new columns. Don’t forget to update your Install.php file though as discussed above.
New columns can be added like so within your models class:
public $authority = '';
public $legalFees = [['100000', '0.00']];
public function rules()
{
return [
['authority', 'string'],
['authority', 'default', 'value' => ''],
['legalFees', 'default', 'value' => [['100000', '0.00']]]
]
}
The initial public variables are defining your columns and some default values to be used by php, the rules function defines the default value on the database and any additional validation which needs to take place before the corresponding record is saved.
Matt Shearing
Matt develops custom plugins for Craft CMS to extend the core functionality. He also focusses on pagespeed improvements and server environments.