Building a Kanban Board with Laravel Backpack

I’m used to building out different custom pieces of functionality at Sprechen that extend Backpack to meet a client’s needs but occasionally there comes a more complex need such as, recently, a Kanban board.

This interface has become popular with applications such as Trello, Asana, and GitHub projects as they allow a user to quickly move items between different columns and manage state in a natural and interactive way.

To incorporate this into a Backpack for Laravel admin panel, I needed to write a custom operation to handle the different actions that stem from the user interacting with the front end. This is a Backpack feature that not many people know about, which is a shame, as it can be very powerful. They allow you to segment features of your application, so that functionality can be applied to different controllers without the need to repeat yourself.

Cool right? Here I’ll go through how I used custom operations and how others can do the same to really get the most out of Backpack.

First of all, here’s how the front end that will use my operation looks...

Kanban Front end

All the backend functionality it uses comes from a single operation and is applied to two different CRUD controllers and I expect more soon.

To start off, I made a new directory for operations to sit in. I tend to put it within App\Http\Operations however, you can put an operation class wherever you like, it's only a PHP trait at the end of the day.

Operation folder structure

Next up is creating the operation file. Operations are PHP traits, so I start by creating a simple trait with a function called setupKanbanRoutes. If your operation is called KanbanOperation then there’s no need to change the name but if you had NamingOperation, you’d need to call it setupNamingRoutes for it to work.

<?php

namespace App\Http\Controllers\Operations;

trait KanbanOperation
{
    protected function setupKanbanRoutes(string $segment, string $routeName, string $controller)
    {
    
    //Route Config goes here.
    
    }
}

This function is incredibly useful as it means that routes can be defined for a specific purpose and don’t need to be repeated for each different model. It saves having to write out routes repeatedly for each use of the operation. I’ll come back to this function, first I’ll write out the functions which make the operation work.

Operations can hold any functions and code you need them to and keep CRUD controllers free of anything that isn’t CRUD related, in my case I wrote functions that would:

  • Show the view with the Kanban board.
  • Save a change in the Kanban board.
  • Handle a call as this client needed a way to log calls to a customer. Within these functions you have access to all the data you would inside a CRUD controller such as the associated model, its resource route, as well as the request.
   //Show the Kanban Board
    
    public function kanban(): View
    {
        $this->data['crud'] = $this->crud;
        $this->data['statuses'] = $this->kanban->status_model;
        $this->data['saveAction'] = url($this->crud->route . '/kanban/store');
        $this->data['use_source'] = $this->kanban->use_source;

        return view('vendor.backpack.crud.kanban', $this->data);
    }

    //Save changes to the board
    
    public function storeKanban(Request $request): RedirectResponse
    {
        $data = json_decode($request->get('data'));
        foreach ($data as $status_id => $status_group) {
            foreach ($status_group as $item) {
                $current = $this->crud->model::find($item->id);
                $calls = $current->calls;
                foreach($calls as $call)
                {
                    $call->delete();
                }
                $current[$this->kanban->status_field] = $status_id;
                $current->save();
            }
        }

        Alert::success('The ' . $this->crud->entity_name . ' board has been updated.')->flash();

        return redirect($this->crud->route);
    }

    
    //Create a call
    
    public function createCall(Request $request): RedirectResponse
    {
        $request->validate([
            'details' => 'required',
            'item_id' => 'required',
            'user_id' => 'required|exists:users,id'
        ]);

        $call = new Call([
            'causer_id' => $request->get('user_id'),
            'follow_up_user_id' => $request->get('user_id'),
            strtolower($this->crud->entity_name).'_id' => $request->get('item_id'),
            'notes' => $request->get('details'),
            'archive' => 0,
            'completed' => 0,
            'follow_up_date' => date('Y-m-d', strtotime(date('Y-m-d H:i:s') . ' +1 Weekday')),
        ]);
        $call->save();

        $user = User::find($request->get('user_id'));

        $item = $this->crud->model::find($request->get('item_id'));
        $current_comments = json_decode($item->comments);
        $current_comments[] = [
            'comment' => 'Call created ' . Carbon::now()->format('d/m/Y @ H:i:s') . ' by ' . $user->name. ' for '.$this->crud->entity_name.' id: '.$item->uuid->id.'. Details: '.$request->get('details').'. View full call details here: '.url('/app/call/'.$call->id.'/show'),
            'type' => 'call',
            'user' => $user->name . ' on ' . Carbon::now()->format('d/m/Y @ H:i:s'),
        ];
        $item->comments = json_encode($current_comments);

        $item->save();


        Alert::success('A new call has been added')->flash();
        return redirect($this->crud->route . '/kanban');
    }

Now that the functionality has been written, I can define routes so that they can be called.

To do this, I go back to the first function setupKanbanRoutes and define routes in the same way as I would within a routes file. This function is given the controller name, segment name, and route name, which can be used for defining the routes.

Using the segment name in the URI means that it will use the entity name as the prefix for the route and so can be applied to any model. For example, if I added this to a CRUD controller for a model called Lead, it would prefix the routes with ‘lead’. You can add any middleware or prefixing to these routes as you would in a standard routes file.

 protected function setupKanbanRoutes(string $segment, string $routeName, string $controller){
    Route::group(['middleware' => 'permission:crm.manage-' . $segment."s"], function () use ($segment, $routeName, $controller) {
        Route::get($segment . '/kanban', [
            'as' => $routeName . 'kanban',
            'uses' => $controller . '@kanban',
            'operation' => 'kanban'
        ]);

        Route::post($segment . '/kanban/store', [
            'as' => $routeName . 'kanban',
            'uses' => $controller . '@storeKanban',
            'operation' => 'kanban'
        ]);

        Route::post($segment . '/kanban/call/create', [
            'as' => $routeName . 'call',
            'uses' => $controller.'@createCall',
            'operation' => 'kanban'
        ]);
    });
}

With that, the operation can now be added to the desired controller:

use App\Http\Operations\KanbanOperation;

class LeadCrudController extends CrudController
{
    use ListOperation;
    use CreateOperation;
    use UpdateOperation;
    use DeleteOperation;
    use ShowOperation;
    use KanbanOperation;

When you list out the application’s routes using php artisan route:list, you’ll see that routes have been generated for that CRUD controller through the operation!

Artisan route list command output

If the operation was a ‘one size fits all’ set of functions, that would finish it off. However, most functionality needs some sort of configuration. To set this up, I use a global variable that is defined within the operation.

public ?object $kanban

This variable should be public so it can be accessed by the CRUD controller.

Data can be added to this variable as needed within the CRUD controller’s setup function. Simply assign the data you need to the variable and it will be available within the operation functions. In my Kanban example, I add in the model that will be used to define each column of the board.

That’s all there is to it! For this same client I’ve ended up building an inbound email parser, data imports, and a global search all through extending Backpack.

Post Published On: 01/08/2021