PHP
Article

OctoberCMS CRUD – Building a Team/Project Management Plugin

By Younes Rafie

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.

OctoberCMS logo

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');
    }
}

Create team

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;
    }
}

Create Project

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.

List Setup

// 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.

Teams List

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.

Projects listing

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'
                ]
            ]);
        });
    }
}

Listing Users

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.

Team filtered projects

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.

Past due filtered projects

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.

User 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!

  • Sinisa Perovic

    Amazing post. Thank you Younes.

    • younesrafie

      Thanks Sinisa, Glad you like it :)

  • Lait Man

    Awesome tut! Maybe it’d be better if you add registerNavigation function at the begin of tut to the Plugin.php, because of simple access to specific plugin page

  • RichLove

    Fantastic article. I’m new to October and about to use it for a new company project. The OctoberCMS documentation is good at explaining basics but your articles are awesome to see how October is used in real world situations. This will help me get up and running with aspects of my new project. Please keep them coming :)

    • younesrafie

      Thanks for the great comment :)

  • Swapnil Balak

    How to store multiple value in database.
    I have dropdown value is user1 user2 user3 .
    I selected user1 and user2
    but save in database only 1 value user2
    how to save both?

    • younesrafie

      I don’t see why it shouldn’t if the specified type is Dropdown on model config. I can take a look if you have the project on GitHub

  • Sasha Pinchuk

    I have executed the command: php artisan plugin:refresh rafie.sitepointDemo, but had got this error: Plugin “rafie.sitepointDemo” not found. Please, help!
    Or how i should execute db migration? (sorry for my english)

    • younesrafie

      – Try `rafie.sitepointdemo`.
      – Migrations are automatically triggered.

      • Sasha Pinchuk

        i had tried, not help.
        maybe it is a octobercms bug

        • younesrafie

          Maybe something related to the latest updates! I didn’t check it for a while :/

          • Sasha Pinchuk

            Well, thanks for trying to help me!
            You write good tutorials, continue in the same spirit ! =)

  • Sam

    Hi, sorry I am new to this but trying to follow your tut.

    Can you please explain how do we create a migration? What is the command you use for octobercms?

    ” **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.** “

    • younesrafie

      In this case we don’t need to create a new migration because we only want to extend the existing users table. However, the documentation covers how to create migrations (https://octobercms.com/docs/database/structure#migration-structure)

      • Sam

        Thank you for your help Younes, I actually realised that I was missing the “use Backend;” after that namespace line. How do we know what is required for use in each controller we build?

        • younesrafie

          I didn’t get your question, but I guess that it depends on your application needs!

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in PHP, once a week, for free.