Example Joomla Component without MVC

Project Edith PF

Often in a CMS project there is a part of it which is just an application which would perhaps would be more suitable for a framework or standalone application. If you run Joomla next to another PHP application that's not necessarily bad, but it means you have to deal with authentication and sessions working together somehow. This project is to try out another way.

This is not a framework - it's merely an example application build as a component that uses Joomla services with an opt in approach.

The bits and the rules

  • A single menu item points to a single view manifest, the view path is not used, and in fact the internal non-SEF url is not used at all.
  • Instead we'll use a router to send the url segments to our custom front-controller/router/commander/request object which will map the first part of the snake-cased uri segment to the camelcased controller
  • We test the http method and other segments to determine the method of the controller, we use the 7 methods like Rails/Laravel resource controllers.
  • Every post request will always redirect. Only post requests with a jform including a csrf token and a base64 redirect are accepted (at least for Joomla requests).
  • The lifecycle has two big objects and some other small business:
    1. An entry file, eg. the Joomla component entry file first calls the bootstrap file which sets up our custom autoloader and will load the joomla framework if it's not there already, this allows cronjobs or a webapp api to use the component.
    2. The entry file will then pass in some objects (eg. Joomla db, user, jinput) into our Request object (ie. the front-controller, router, input filterer). This uses an array (which we call the schema - see below) to filter all post and get values with Joomla's jInput. Every value from the request has thus gone through basic filtering before it is sent to the controller.
    3. The Request calls the method on the controller, ie. either index, show, create, edit, store, update, destroy.
    4. Post methods (edit, store and destroy) all redirect, the other methods set the values of a doc array and pass it to a single template. Additional objects for models and views are optional.

Other details

The schema. This bit will probably be unfamiliar because I totally made it up. The schema object provides all the info which can handled generically. You can put into it what ever you like, eg.

  • the sql columns
  • post values with input filters
  • get values (for admin table dropdown filters or preselected form options)
  • role access mapped to methods
  • menu options

OOP and MVC. Yeah, nah. There's no single structure for applications and MVC is often misapplied in PHP just because it's the default pattern. I find that usually you're just managing queries and access controls in the controller methods and deferring anything to helpers including any complicated data processing and even html components.

Wrappers. Wrapping Joomla services is often handy and sometimes mandatory. Wrap rather than extend cause we're generally dealing with singletons and the application is small enough to either new them up at the start and pass them along in the request or new them up in the controllers when that's appropriate.

Generally I wrap:

  • jform
  • database
  • user
  • mail

All the Files

The Manifest

/components/com_nomvc/nomvc.xml

<?xml version="1.0" encoding="utf-8"?>
<extension type="component" version="3.8.0" method="upgrade">

    <name>Example No MVC</name>
    <version>1.0.0</version>
    <description>A basic crud front end only component</description>
    <author>Me</author>
    <creationDate>When ever</creationDate>

    <files folder="site">
        <filename>index.html</filename>
        <filename>nomvc.php</filename>
        <filename>bootstrap.php</filename>
        <filename>router.php</filename>
        <folder>controllers</folder>
        <folder>helpers</folder>
        <folder>requests</folder>
        <folder>templates</folder>
        <folder>views</folder>
        <folder>forms</folder>
    </files>

    <administration>
    </administration>

</extension>

The Entry File

/components/com_nomvc/nomvc.php

<?php
defined('_JEXEC') or die;

define('_NOMVC',1);
require_once 'bootstrap.php';

$jinput = JFactory::getApplication()->input;
$juser  = JFactory::getUser();
$request = new PfJoomlaRequest($jinput,$juser);

Router

/components/com_nomvc/router.php

<?php

class NomvcRouter extends JComponentRouterBase
{
    private $url_scheme = ['segment1','segment2','segment3']; 

    public function build(&$query)
    {
        return;
    }

    public function parse(&$segments)
    {
        $vars = [];
        foreach($segments as $index => $s){
            if(!empty($this->url_scheme[$index])){   
                $vars[$this->url_scheme[$index]] = $s;
            }
        }
        return $vars;
    }
}

Bootstrap

/components/com_nomvc/bootstrap.php

The file allows other entry points to the component, such as a cron script, custom api endpoint etc.

<?php
defined('_NOMVC') or die;

// ----------------------------------------------- OPTIONAL JOOMLA FRAMEWORK ------------
if(!defined('_JEXEC')){
    define('_JEXEC', 1);
    $base = rtrim(__DIR__,'/components/com_nomvc');
    define('JPATH_BASE', $base);
    require_once JPATH_BASE . '/includes/defines.php';
    require_once JPATH_BASE . '/includes/framework.php';
}

// ----------------------------------------------- CONSTANTS ----------------------------
if(!defined('JPATH_COMPONENT')){
    define('JPATH_COMPONENT',__DIR__);
}

// ------------------------------------------------ AUTOLOAD -----------------------------

/*
 For all folders with classes, just make a naming convention and add them here
 The helper folder is the fallback so any classes in there only need the prefix to work
 You don’t have to use PF but you will need to update any code as appropriate.
*/

function autoload_class($class) {
    if(strpos($class,'Pf') === false){
        return;
    } elseif(strpos($class,'User') !== false){
        include $class . '.php';
    } elseif(strpos($class,'Request') !== false){
        include 'requests/' . $class . '.php';
    } elseif (strpos($class,'Controller') !== false){
        include 'controllers/' . $class . '.php';
    } else {
        include 'helpers/' . $class . '.php';
    }
}

spl_autoload_register('autoload_class');

View Manifest

/components/com_nomvc/views/minimal/default.xml

This simply allows a menu type to appear in the menu manager.

<?xml version="1.0" encoding="utf-8"?>
<metadata></metadata>

Example Form

/components/com_nomvc/forms/example.xml

Note that the field group is the name attribute value of the fields tag. The wrapper uses it to bind the form data so this must remain consistant. Note we don’t add the submit button, csrf token, or redirect value in the form manifest - these are all add by the wrappper which is called by the controller.

<?xml version="1.0" encoding="UTF-8"?>
<form>
    <fields name="pfform">
        <fieldset name="firstset">

            <field
            type="text"
            name="title"
            id="title"
            label="Title"
            class="form-control"
            size="80"
            maxLength="255"
            required="true" />

            <field name="id" type="hidden" default="0" />

        </fieldset>
    </fields>
</form>

JForm Wrapper

/components/com_nomvc/helpers/PfForm.php

Allows us to bind data and call the form html without too much hassle.

<?php

Class PfForm
{
    protected $xml_path_or_string;
    protected $data;
    protected $form;
    protected $redirect;
    protected $attr = ['method' => 'POST', 'action' => ''];

    public function __construct($xml_path_or_string = "",$data = [],$attr = [],$redirect = "")
    {
        if(count($attr)){
            $this->attr = array_merge($this->attr,$attr);
        }
        $this->redirect = ($redirect) ? $redirect : "/";
        if(!empty($xml_path_or_string)){
            $this->setPath($xml_path_or_string);
        }
        if(!empty($data)){
            $this->bind($data);
        }
    }

    public function wrapFields($fields_html)
    {
        $attr = "";
        foreach($this->attr as $k => $v){
            $attr .= $k . ' ="' . $v . '" ';
        }
        $html  = '<form '.$attr.'>';
        $html .= $fields_html;
        $html .= '<button type="submit">Submit</button>';
        $html .= JHtml::_( 'form.token' );
        $html .= '<input type="hidden" name="redirect" value="' . base64_encode($this->redirect) . '" >';        
        $html .= "</form>";
        return $html;
    }

    public function setPath($xml_path_or_string)
    {
        $this->form = JForm::getInstance('pf', $xml_path_or_string);
    }

    public function setRedirect($redirect_path)
    {
        $this->redirect = $redirect_path;
    }

    public function renderForm($with_fieldset_titles = 1)
    {   
        $html = "";
        foreach ($this->form->getFieldsets() as $fieldsets => $fieldset){
            $html .= $this->renderFieldSet($fieldset,$with_fieldset_titles);
        }
        return $this->wrapFields($html);
    }

    public function renderFieldSet($fieldset,$with_fieldset_titles = 1)
    {
        $html = "";
        if($with_fieldset_titles){
            $html .= "<h3>" . $fieldset->name . "</h3>";
        }
        foreach($this->form->getFieldset($fieldset->name) as $field){
            if ($field->hidden){
                $html .= $field->input;
            } else {
                $html .= '<div class="form-group">';
                $html .= $field->label;
                $html .= $field->input;
                $html .= "</div>";
            }
        }
        return $html;
    }

    public function bind($data = []){
        $this->form->bind($data);
    }
}

Base Request/Commander

/components/com_nomvc/requests/PfBaseRequest.php

The base request loads the appropriate controller - so this is essential a front controller. It also doubles as a service holder cause the user and database can be passed onto the controller. Hence you'll see in the controller's constructor that it grabs a handle to the request and sets a handle to the user and db just for convenience.

<?php

Class PfBaseRequest
{
    // All Non-Static Global Application Objects
    public $user;
    public $db;

    // Vars required for the execute method
    protected $origin;
    public $type;
    public $method;
    protected $params = [];

    protected $errors = [];

    protected function setOrigin($origin)
    {
        $this->origin = $origin;
    }

    protected function setType($type)
    {
        $this->type = $type;
    }

    protected function setMethod($method)
    {
        $this->method = $method;
    }

    protected function setParams($params)
    {
        $this->params = $params;
    }

    // run a single controller method 
    protected function execute()
    {
        if(!empty($this->errors)){
            $this->type = "error";
            $this->method = "index";
        }
        $class = 'Pf' . ucfirst($this->origin) . PfData::underscoresToPascalCase($this->type) . 'Controller';
        $controller = new $class($this);
        if(!method_exists($controller, $this->method)){
            // No method so replace the controller with the origin's error controller and switch the method to 'index'.
            $this->errors[] = 'No such method *' . $this->method . '* in class ' . $class;
            $this->type = "error";
            $this->method = "index";
            $class = 'Pf' . ucfirst($this->origin) . PfData::underscoresToPascalCase($this->type) . 'Controller';
            $controller = new $class($this);
        }
        $controller->{$this->method}($this->params);
    }
}

Joomla Request/Commander

/components/com_nomvc/requests/PfJoomlaRequest.php

Extending the base request lets us make different requests for entities that aren't a Joomla request.

<?php 

Class PfJoomlaRequest extends PfBaseRequest
{
    public $server_method;
    public $id;
    public $other_get_values = [];
    public $data = [];
    public $schema;
    public $errors = [];
    public $redirect;

    /*
     * Each Request must populate 4 variables in the parent class (origin, type, method, params) and call execute
    */
    public function __construct($jinput,$juser)
    {
        $this->origin = "Joomla";
        $this->db = new PfDb;
        $this->user = new PfUser($this->db,$this->origin,$juser);
        $this->processInput($jinput);
        $this->checkAccess();
        $this->execute();
    }

    /*
     * The jinput object goes no further than this method
     * The properties of interest are:
     * 1. The request method
     * 2. The route segments (first maps to the controllers, second is an optional id, third is optional edit flag)
     * 3. The generic index methods which become part of the index query array by default, and are also retrievable from the 'other_get_variables' optionally by the show methods
     * 4. Any other get or post values that are specified in the schema along with their filters
     */

    private function processInput($jinput)
    {
        // =========================================================== GET THE HTTP METHOD  =================
        $this->server_method = $jinput->getMethod();

        // =========================================================== GET THE ROUTE SEGMENTS ===============
        $type  = PfData::dashesToUnderscores($jinput->get('segment1','home','CMD'));
        $this->id = $jinput->get('segment2',0,'INT');
        $extra = $jinput->get('segment3','','CMD');

        // =========================================================== ASSIGN EDIT FLAG ====================
        $edit = ($extra == "edit") ? true : false;

        // =========================================================== ASSIGN TYPE/CONTROLLER ==============
        $this->type = $type ? $type : 'home';
        $this->schema = PfSchema::getType($this->type);
        if($this->schema === false){
            $this->error[] = "No Value Type";
        }

        // =========================================================== ASSIGN GET METHODS ==================
        switch ($this->server_method) {
            case 'GET':
                if($edit){
                    $this->method = $this->id ? 'edit' : 'create';
                    $this->setParams($this->id);
                } else {
                    $index_params = [];
                    $index_params['page']   = $jinput->get('page',1,'int');
                    $index_params['limit']  = $jinput->get('num',100,'int');
                    $index_params['search'] = $jinput->get('search','','string');
                    $index_params['order']  = $jinput->get('order','','string');
                    $index_params['rev']    = $jinput->get('rev',0,'int');
                    $index_params['offset'] = $index_params['limit'] * ($index_params['page'] - 1);

                    // sometimes a show method includes a list of nested data so we provide the index vals just in case
                    if($this->id){
                        $this->method = "show";
                        $this->other_get_values = $index_params;
                        $this->setParams($this->id);
                    } else {
                        $this->method   = "index";
                        $this->setParams($index_params);
                    }                   
                }

                // =========================================================== GET GET VALUES IF REQUIRED ==========

                $get_filters = PfSchema::getGetFilters($this->type);
                $defaults = [
                    'int'    => 0,
                    'string' => '',
                    'cmd'    => '',
                ];
                foreach($get_filters as $field => $filter){
                    $this->other_get_values[$field] = $jinput->get($field,$defaults[$filter],$filter);  
                }
                break;
            case 'POST':
                // =========================================================== HARD FAIL IF NO TOKEN IN FORM ======
                JSession::checkToken() or die('Invalid Token');
                // =========================================================== ASSIGN POST METHODS ================
                if($this->id){
                    $delete = $jinput->post->get('delete',0,'int');
                    if($delete){
                        $this->method = "destroy";
                        $this->setParams($this->id);
                    } else {
                        $this->method = "update";
                    }
                } else {
                    $this->method = "store";
                }

                // =========================================================== SET REDIRECT ========================
                $redirect_base64 = $jinput->post->get('redirect','','BASE64');
                $unsantized_redirect = base64_decode($redirect_base64);

                if(filter_var($unsantized_redirect, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)){
                    $this->redirect = $unsantized_redirect;
                } else {
                    $this->errors[] = "Invalid redirect URI provided";
                }

                // ======================================================= PROECESS POST DATA OR FILES IF REQUIRED ==
                if(in_array($this->method,['update','store'])){
                    $data = [];
                    $post_filters = PfSchema::getPostFilters($this->type);
                    $raw_form_data = new JInput($jinput->get('pfform', [], 'array')); 
                    $defaults = [
                        'int' => 0,
                        'string' => '',
                        'array' => 'array',
                    ];
                    foreach($post_filters as $field => $filter){
                        $data[$field] = $raw_form_data->get($field,$defaults[$filter],$filter); 
                    }

                $this->setParams($data);
                break;
            }
        }
    }

    /*
        Run the Schema based Role checks and User based Id access checks
    */
    private function checkAccess()
    {
        // Block access based on the schema's rule: type [ method => [roles] ] 
        if(!$this->user->userHasAtLeastOneOfTheseRoles(PfSchema::getRolesThatCanAccess($this->type,$this->method))){
            $this->errors[] = "Insufficient permissions to access " . ucfirst($this->type);
        }

        // All Id specific blocks are in specific methods in the user object
        if($this->id){
            $access_check_method = "canUserAccess" . PfData::underscoresToCamelCase($this->type) . "Id";
            if(method_exists($this->user, $access_check_method) && !$this->user->$access_check_method($this->id)){
                $this->errors[] = "User cannot access that " . ucfirst($this->type) . " ID.";
                return false;
            }
        }
    }
}

Base Controller

to do

Joomla Controller

to do

Example Controller

/components/com_nomvc/controllers/PfExampleController.php

todo

<?php

Class PfJoomlaExampleController extends PfJoomlaController
{

    public function index($query = [])
    {

    }

    public function show($id)
    {

    }

    public function create()
    {

    }

    public function edit($id)
    {

    }

    public function store($data)
    {

    }

    public function update($data)
    {

    }

    public function destroy($id)
    {

    }

}

Html Helper

Data Helper

Application Schema

User Wrapper

DB Helper