OctoberCMS CRUD – Building a Team/Project Management Plugin
So far, we covered different aspects of OctoberCMS. This is a follow up article to discover how to use OctoberCMS for CRUD applications and take a detailed view at how to work with models, relations and controllers. Let’s get started.
Requirements
I will assume you already know how to set up a working installation of OctoberCMS. If not, you can check out the introduction article, or read the installation section in the documentation.
What We’re Building
We are going to build a project management plugin where you can add different users to teams and assign them to projects. You can check the final result on GitHub, and feel free to suggest edits or additions to the plugins.
Setting up the Plugin
Let’s start by using the create:plugin
scaffolding command to create the initial plugin structure, and define the Plugin::pluginDetails
method with our plugin details.
php artisan create:plugin rafie.sitepointDemo
// Plugin.php
public function pluginDetails()
{
return [
'name' => 'Project management',
'description' => 'Manage your teams and projects.',
'author' => 'RAFIE Younes',
'icon' => 'icon-leaf'
];
}
Creating Database Tables
Every team has a name, a list of users and projects.
php artisan create:model rafie.sitepointdemo Team
// models/team.php
class Team extends Model
{
// ...
public $table = 'rafie_sitepointDemo_teams';
public $hasMany = [
'projects' => '\Rafie\SitepointDemo\Projects',
'users' => '\Backend\Models\User'
];
// ...
}
// updates/create_teams_table.php
class CreateTeamsTable extends Migration
{
public function up()
{
Schema::create('rafie_sitepointDemo_teams', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id');
$table->string('name', 100);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('rafie_sitepointDemo_teams');
}
}
Every project belongs to a team and has a name, a description and an end date.
php artisan create:model rafie.sitepointdemo Project
// models/project.php
class Project extends Model
{
// ...
public $table = 'rafie_sitepointDemo_projects';
public $belongsTo = [
'team' => '\Rafie\SitepointDemo\Models\Team'
];
// ...
}
// updates/create_projects_table.php
class CreateProjectsTable extends Migration
{
public function up()
{
Schema::create('rafie_sitepointDemo_projects', function($table)
{
$table->engine = 'InnoDB';
$table->increments('id');
$table->string('name', 100);
$table->text('description');
$table->datetime('ends_at');
$table->integer('team_id')->unsigned();
$table->timestamps();
});
}
// ...
}
Because OctoberCMS already has a users table for the backend, we should add the team_id
column. We will create a migration to add our team index.
// updates/add_team_to_users.php
class AddTeamToUsers extends Migration
{
public function up()
{
if(!Schema::hasColumn('backend_users', 'team_id'))
{
Schema::table('backend_users', function($table)
{
$table->integer('team_id')->unsigned()->index()->nullable();
});
}
}
public function down()
{
if(Schema::hasColumn('backend_users', 'team_id'))
{
Schema::table('backend_users', function($table)
{
$table->dropColumn('team_id');
});
}
}
}
Then, we need to link to it with a new relationship definition.
// Plugin.php
class Plugin extends PluginBase
{
// ...
public function boot()
{
User::extend(function($model){
$model->belongsTo['team'] = ['Rafie\SitepointDemo\Models\Team'];
});
}
}
Our plugin version file looks like this:
// updates/version.yaml
1.0.1:
- First version of Sitepoint demo
- add_team_to_users.php
- create_teams_table.php
- create_projects_table.php
Managing Teams
Inside your models folder, you can see that every model class has a configuration folder which contains two files:
columns.yaml
: Holds the table columns that you want to use when listing table records.fields.yaml
: The same thing as columns but it’s used to configure forms for creating and updating records.
To manage teams we need to create a controller to take action on certain events. We use the following scaffolding command.
php artisan create:controller rafie.sitepointDemo Teams
If you follow the naming conventions, the controller will automatically map to the model. If you check in your browser at the backend/rafie/sitepointDemo/teams/create
URL, you’ll see the new record form.
Inside config_form.yaml
, you’ll see that the form
and modelClass
properties are mapped to our team model. The new team form only shows a disabled input for the ID. We can add other inputs using the fields.yaml
file inside our model.
// models/team/fields.yaml
fields:
name:
label: Name
type: text
required: true
users:
label: Users
type: checkboxlist
Every team has a name and a list of users. The name is a simple text value, while the users are listed using the checkboxlist
component. You can check the list of field types in the documentation.
You have also the ability to use form widgets in the field types. Widgets are rich components like a WYSWYG editor, Color picker, Media Finder, etc. The only part left here is to create a Team::getUsersOptions
method to fill the users checkbox list.
// models/team.php
class Team extends Model
{
// ...
public function getUsersOptions()
{
return \Backend\Models\User::lists('login', 'id');
}
}
You can see from the screenshot above that the fields marked as required
have an asterisk after the input name. However, this does not mean that the validation is handled for you. You still need to add validation inside your models.
// models/Team.php
class Team extends Model
{
use \October\Rain\Database\Traits\Validation;
public $rules = [
'name' => 'required'
];
// ...
}
The fields in the configuration file are automatically mapped to the model if found. If not, we need to use the create_onSave
and update_onSave
methods to alter the saving strategy.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function create_onSave()
{
$inputs = post('Team');
// save team
$teamModel = new \Rafie\SitepointDemo\Models\Team;
$teamModel->name = $inputs['name'];
$teamModel->save();
// update users team_id
\Backend\Models\User::whereIn('id', $inputs['users'])
->update(['team_id' => $teamModel->id]);
\Flash::success("Team saved successfully");
return $this->makeRedirect('update', $teamModel);
}
}
The post
method is a helper function to avoid resolving the request object from the container. After saving the team model and updating the user relation we show a success message and create a redirect response using the FormController::makeRedirect
method.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function update_onSave($recordId)
{
$inputs = post('Team');
// update team
$teamModel = \Rafie\SitepointDemo\Models\Team::findOrFail($recordId);
$teamModel->name = $inputs['name'];
$teamModel->save();
\Backend\Models\User::where('team_id', $teamModel->id)
->update(['team_id' => 0]);
// update users team_id
\Backend\Models\User::whereIn('id', $inputs['users'])
->update(['team_id' => $teamModel->id]);
\Flash::success("Team updated successfully");
}
}
The update_onSave
method has one parameter containing the updated record ID. We update the team and the attached users accordingly. Another way to accomplish this is to make use of the FormController::update_onSave
method. It takes care of mapping form fields to the model and saving it.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function update_onSave($recordId)
{
$inputs = post('Team');
\Backend\Models\User::where('team_id', $recordId)
->update(['team_id' => 0]);
// update users team_id
\Backend\Models\User::whereIn('id', $inputs['users'])
->update(['team_id' => $recordId]);
$this->asExtension('FormController')->update_onSave($recordId, $context);
}
}
The only part left is deleting the records. You may use the update_onDelete
method to reset the team_id
on the users table and then delete the team.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function update_onDelete($recordId)
{
$teamModel = \Rafie\SitepointDemo\Models\Team::findOrFail($recordId);
\Backend\Models\User::where('team_id', $teamModel->id)
->update(['team_id' => 0]);
$teamModel->delete();
\Flash::success("Team deleted successfully");
return $this->makeRedirect('delete', $teamModel);
}
}
Or you could just use formAfterDelete
to reset the team_id
.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function formAfterDelete($model)
{
\Backend\Models\User::where('team_id', $model->id)
->update(['team_id' => 0]);
}
}
If you noticed, the update form doesn’t automatically select the users attached to the team. We may have to do it manually using the formExtendFields
method inside the Teams
controller. The getContext
method returns whether the user is creating or updating the model.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function formExtendFields($form)
{
if( $form->getContext() === 'update')
{
$team = $form->model;
$userField = $form->getField('users');
$userField->value = $team->users->lists('id');
}
}
}
Managing Projects
We follow the same steps for managing projects: we start by defining the form fields.
models/project/fields.yaml
fields:
name:
label: Name
type: text
required: true
description:
label: Description
type: textarea
required: true
ends_at:
label: Ends At
type: datepicker
required: true
team_id:
label: Team
type: dropdown
We define the list of teams to be displayed on the team dropdown.
// models/project.php
class Project extends Model
{
// ...
public function getTeamIdOptions()
{
$teams = \Rafie\SitepointDemo\Models\Team::all(['id', 'name']);
$teamsOptions = [];
$teams->each(function($team) use (&$teamsOptions) {
$teamsOptions[$team->id] = $team->name;
});
return $teamsOptions;
}
}
Because all form fields are mapped to the model, we won’t have to hook into the saving process to update some relations. The create
, update
and delete
actions are handled automatically in this case.
Listing
OctoberCMS makes listing records very simple and extendable. You can show and hide columns, search, sort, filter, format column values, etc. Check the documentation for the full list of options.
Listing Teams
The controllers/teams/config_list.yaml
file contains our listing options. Every property has a comment describing its usage.
// controllers/teams/config_list.yaml
# Model List Column configuration
list: $/rafie/sitepointdemo/models/team/columns.yaml
# Model Class name
modelClass: Rafie\SitepointDemo\Models\Team
# List Title
title: Manage Teams
# Link URL for each record
recordUrl: rafie/sitepointdemo/teams/update/:id
# Message to display if the list is empty
noRecordsMessage: backend::lang.list.no_records
# Records to display per page
recordsPerPage: 20
# Displays the list column set up button
showSetup: true
# Displays the sorting link on each column
showSorting: true
//...
You can see that the list
property points to the columns.yaml
file, which defines the columns that should be displayed. The showSetup
option lets the user select what columns to show in the list.
// models/team/columns.yaml
columns:
id:
label: ID
searchable: true
name:
label: Name
users:
label: Users
relation: users
select: login
searchable: false
The id
and name
properties are self-explanatory. The searchable
property is set to true
by default, that’s why we didn’t specify it on the name
property.
OctoberCMS has a relation
property which can be used to show values from related models. In this case, we select the login
attribute from the users
relation model. You can check the documentation for the full list of available column options.
Listing Projects
We follow the same steps for listing projects: we have a name, a description, an end date and a team.
// models/project/columns.yaml
columns:
id:
label: ID
searchable: true
name:
label: Name
description:
label: Description
type: text
ends_at:
label: End At
type: datetime
team:
label: Team
relation: team
select: name
To help format the end date properly inside our list, we specify the column type as datetime
. This may throw an exception for you because you need to add the ends_at
attribute in your model inside the $dates
array. Check the documentation for the full list of available column types.
// models/project.php
class Project extends Model
{
// ...
protected $dates = ['ends_at'];
// ...
}
As for the team column, we specify the relation type and select the name attribute from the model. The final result looks like this.
Extending Lists
If you want to alter the list behavior, you may override the index
method inside your controller and the index.htm
view. What we want to do now is truncate the description column. You can check the documentation for more details about extending the list behavior.
// controllers/projects.php
class Projects extends Controller
{
// ...
public function listOverrideColumnValue($record, $columnName)
{
if( $columnName == "description" && strlen($record->description) > 20 )
{
$description = substr($record->description, 0, 20);
return "<span title='{$record->description}'>{$description}...</span>";
}
}
}
You may be thinking about extending the users listing to show their current team. We use the boot
method inside our plugin definition file to extend other plugins.
// Plugin.php
class Plugin extends PluginBase
{
// ...
public function boot()
{
// ...
\Backend\Controllers\Users::extendListColumns(function ($list) {
$list->addColumns([
'team' => [
'label' => 'Team',
'relation' => 'team',
'select' => 'name'
]
]);
});
}
}
Filters
Filtering lists in OctoberCMS is easy. First, you reference your filter configuration file inside your config_list.yaml
file.
// controllers/projects/config_list.yaml
// ...
filter: config_filter.yaml
// ...
Inside your config_filter.yaml
file, you define a list of scopes that you want to use.
// controllers/projects/config_filter.yaml
scopes:
team:
label: Team
modelClass: \Rafie\SitepointDemo\Models\Team
nameFrom: name
conditions: team_id = :filtered
Our scope is named team
and will list our available teams using the specified modelClass
and nameFrom
properties. The conditions will filter projects where the team_id
is equal to the selected teams; you may think of it as a raw SQL where statement. The screenshot below shows the list of projects taken by the Backend
team.
OctoberCMS has two scope types. The first one is the group type, and it’s the one we used previously. The second is the checkbox type, which is used for boolean situations. We’ll use the latter to hide the past due projects from the list.
// controllers/projects/config_filter.yaml
scopes:
// ...
hide_past_due:
label: Hide past due
type: checkbox
conditions: ends_at > now()
The only past due project in our list is the one with an id of 2
.
Permissions
We can’t talk about CRUD operations without covering permissions and how to guard your controllers. Combined with the simplicity of Laravel, OctoberCMS lets you define a list of permissions for your plugin and group them under a specific tab, then use them to guard your controllers.
// Plugin.php
class Plugin extends PluginBase
{
// ...
public function registerPermissions()
{
return [
'rafie.sitepointDemo.manage_teams' => [
'label' => 'Manage Teams',
'tab' => 'SitepointDemo'
],
'rafie.sitepointDemo.manage_projects' => [
'label' => 'Manage Projects',
'tab' => 'SitepointDemo'
]
];
}
}
If you visit the update or create user page and select the permissions tab, you’ll see the SitepointDemo
tab which holds the plugin permissions.
Now, inside the projects and teams controllers you add the $requiredPermission
attribute.
// controllers/teams.php
class Teams extends Controller
{
// ...
public $requiredPermissions = ['rafie.sitepointDemo.manage_teams'];
// ...
}
// controllers/projects.php
class Projects extends Controller
{
// ...
public $requiredPermissions = ['rafie.sitepointDemo.manage_projects'];
// ...
}
If you want to restrict access to a certain action inside a controller, you may use the User::hasAccess
and User::hasPermissions
methods. Check the documentation for more details about permissions.
// controllers/teams.php
class Teams extends Controller
{
// ...
public function update()
{
if( !$this->user->hasPermissions(['rafie.sitepointDemo.update_teams']) )
{
// redirect Unauthorized 401
}
}
}
Conclusion
Every CMS tries to make CRUD operations easier and more straightforward for newcomers, and I think that OctoberCMS achieved that goal successfully by making every aspect of it clear and extendable.
You can check the final result on GitHub and if you have any questions or opinions let me know in the comments!