Controllers in CMF Cotonti

The article is an introduction to understanding the principles of using controllers and objects of object-oriented programming classes in Cotonti CMF. Examples and explanations.

Filed under: Cotonti Siena CMF

Controllers in Cotonti

Controllers are a vital part of the MVC (Model–View–Controller) architecture, which has been increasingly adopted in Cotonti since the Siena version and especially in Verona. They are objects of classes inherited from cot\controllers\BaseController. The main task of a controller is to accept an incoming HTTP request, process it — interacting with models (database) and views (templates) as necessary — and form an outgoing response. In other words, a controller is the connecting link, the «dispatcher» of your application.

Before controllers were introduced, Cotonti developers used the module and standalone hooks. These were procedural entry points in which request processing logic and response generation were mixed in a single file, often leading to code that was difficult to maintain. Controllers, on the other hand, offer an object-oriented, structured approach that allows clear separation of responsibilities and facilitates code reuse.

In this guide, we will take a detailed look at how to create and use controllers in Cotonti, examining all aspects of their operation — from routing to the lifecycle.

1. Actions — the heart of the controller

A controller consists of one or more actions. An action is the smallest functional unit available to the end user for execution. For example, viewing an article, creating a comment, deleting a product — all of these can be implemented as controller actions.

An action is a public method of a controller class whose name begins with the action prefix. The return value of the method determines the body of the response that will be sent to the user. This can be HTML code, a JSON string, a redirect result, and so on.

1.1. A simple example of a controller with two actions

Let's consider a controller for working with blog posts. Below is the code for the PostController controller, containing two actions: view and create.

namespace cot\modules\blog\controllers;
use cot\controllers\BaseController;
use cot\exceptions\NotFoundHttpException;
use cot\modules\blog\models\PostRepository;

class PostController extends BaseController
{
    public function actionView(): string
    {
        $postId = cot_import('id', 'G', 'INT');
        $post = PostRepository::getById($postId);
        if ($post === null) {
            throw new NotFoundHttpException("Post not found");
        }

        $template = cot_tplfile('blog', 'post');
        $t = new XTemplate($template);
        $t->assign([
            'ID' => $post['id'],
            'TITLE' => htmlspecialchars($post['title']),
            'TEXT' => $post['text'],
        ]);
        $t->parse('MAIN');
        return $t->text('MAIN');
    }

    public function actionCreate()
    {
        $title = cot_import('title', 'P', 'TXT');
        $text  = cot_import('text', 'P', 'TXT');
        Cot::$db->insert(Cot::$db->posts, [
            'title' => $title,
            'text'  => $text,
            'created' => Cot::$sys['now']
        ]);
        $id = Cot::$db->lastInsertId();
        cot_redirect(cot_url('post', ['n' => 'post', 'a' => 'view', 'id' => $id], '', true));
    }
}

In the view action (the actionView() method), the post ID is obtained from the id GET parameter. The data is then loaded through the PostRepository repository. If the post is not found, a NotFoundHttpException is thrown, which will result in a 404 page. If the data exists, it is passed to the template, which is parsed and returned as a string.

The create action (the actionCreate() method) accepts the title and text POST parameters, saves them to the posts table, retrieves the new record's ID, and redirects the browser to view the newly created post using cot_redirect(). Note: after the redirect, script execution stops, so no return value is required — the method is declared without the : string type.

1.2. Analogy: a controller as an ATM

Imagine an ATM. The controller is the ATM itself. It has several actions: «withdraw cash», «check balance», «pay bills». Each action is launched by pressing a specific button. The action performs the necessary operations and outputs a result (a receipt, money, an on-screen message). The ATM can also redirect you to another action, for example, after withdrawing cash, showing the balance. So it is with a controller: after executing an action, you can return HTML or redirect the user to another action.

2. Routes — how to find the right action

To call a controller action, the user (or other code) forms a URL that corresponds to a specific route. A route is a way of identifying the extension, controller, and action using GET parameters.

The standard Cotonti route looks as follows:

https://your-site.tld?e=extension&n=controller&a=action

where:

  • e (extension) — the code of the extension. For example, blog, shop, forum. This parameter determines which extension the controller should be searched for in.
  • n (name) — the identifier of the controller. A unique name within the extension. For example, post, product, order.
  • a (action) — the identifier of the action. A unique name within the controller. For example, view, update, list.

Thus, the URL https://example.com?e=blog&n=post&a=view&id=5 will call the view action of the post controller in the blog extension, passing the additional parameter id=5.

Routes are like a postal address: the extension is the city, the controller is the street, the action is the house number. Additional parameters (id, page, etc.) are like the apartment number.

2.1. Friendly URLs and controllers

Cotonti can transform GET routes into clean URLs using the URL manager settings. You can define rules so that, for example, blog/post/view/5 is translated into ?e=blog&n=post&a=view&id=5. This makes links more user- and search-engine-friendly without changing the controllers' operating logic.

3. Creating a controller

A controller is a class that must inherit from cot\controllers\BaseController (or its descendants). By default, the base class provides access to essential Cotonti services such as Cot::$db, Cot::$cfg, Cot::$extrafields, etc. It also contains the beforeAction() and afterAction() methods, which we will discuss later.

3.1. File location

All controllers of an extension are placed in the controllers subdirectory of the extension's root folder. For example, for the blog module, the full path would be: modules/blog/controllers/. The controllers of the administrative part (control panel) are located in controllers/admin/ of the same extension.

3.2. Controller identifier and its class name

The controller identifier (what is written in the n parameter) is usually chosen meaningfully, as a noun indicating the type of resource the controller manages. For example, article, user, comment.

For Cotonti to automatically find and load the controller class, a naming convention must be followed. It is simple:

  1. Take the identifier, for example post-comment.
  2. Capitalize the first letter of each word: Post-Comment.
  3. Remove hyphens: PostComment.
  4. Append the Controller suffix: PostCommentController.
  5. Prepend the extension namespace, which by default is formed as cot\modules\extension_name\controllers. For the blog, this would be cot\modules\blog\controllers\PostCommentController.

The file with such a class should be located at the path modules/blog/controllers/PostCommentController.php. If the identifier is simple, for example post, the class will be PostController in the file PostController.php.

3.3. Default controller

If a route does not contain the n parameter (i.e., no controller is specified), Cotonti will attempt to call the controller with the index identifier — that is, IndexController. Therefore, to create the main page of an extension, it is enough to create an IndexController class with a default action (usually actionIndex()).

4. Creating actions in more detail

Actions are methods of a controller, and we can create them in two ways: inline actions and standalone actions. Let's look at both.

4.1. Inline actions

The simplest way is to declare a public method with the action prefix. The method name is derived from the action identifier according to rules similar to controller naming:

  1. Action ID: hello-world → method actionHelloWorld()
  2. Action ID: list → method actionList()

Methods must be public; private and protected methods will not be recognized as actions. Method names are case-sensitive (actionindex is not equal to actionIndex). If a method is not found according to the rules, an exception will be thrown.

Inline actions are convenient when the action logic is specific to the given controller and will not be reused. This is the most common case.

4.2. Standalone Actions

If you plan to use the same action in different controllers (for example, a «delete» action for different entities), it is better to extract it into a separate class inheriting from cot\controllers\BaseAction. Such an action is called a standalone action.

To attach a standalone action to a controller, you need to override the actions() method in the controller class and return an array of «action identifier → configuration» mappings. Example:

public static function actions(): array
{
    return [
        'error' => cot\modules\blog\controllers\actions\ErrorAction::class,
        'view' => [
            'class' => cot\modules\blog\controllers\actions\ViewAction::class,
            'viewPrefix' => 'blog.post',
        ],
    ];
}

The standalone action class must implement a public run() method, which will contain the logic. For example:

namespace cot\modules\blog\controllers\actions;

use cot\controllers\BaseAction;

class HelloWorldAction extends BaseAction
{
    public function run(): string
    {
        return "Hello, world!";
    }
}

Advantages: such actions can be tested separately from the controller, and they can easily be reused in other parts of the system. The drawback is that a little more code is required for declaration.

4.3. Default action in a controller

Each controller has a $defaultAction property, which determines which action is called if the a parameter is not passed in the route. By default, this is 'index'. It is easy to change:

class PostController extends BaseController
{
    public static $defaultAction = 'list';

    public function actionList()
    {
        // display list of posts
    }
}

4.4. Action results and response types

An action can return a string that will become the body of the HTTP response. However, this is not the only option. The controller can manage the response more flexibly:

  • String — returns HTML, JSON, XML, or anything else. By default, Cotonti will send the Content-Type: text/html header.
  • Redirect — calling cot_redirect() inside an action will abort execution and send a Location header. Returning a string after a redirect is pointless.
  • Exception — if the action throws an exception (for example, NotFoundHttpException), Cotonti will catch it and display the corresponding error page.
  • Direct HTTP manipulation — you can use Cot::$out['headers'] to set arbitrary headers and Cot::$out['body'] for the response body if needed.

An example of an action returning JSON:

public function actionApiGetPost(): string
{
    $post = PostRepository::getById(5);
    Cot::$out['headers']['Content-Type'] = 'application/json; charset=utf-8';
    return json_encode($post);
}

5. Controller lifecycle

Understanding the lifecycle helps to properly organize code and avoid errors. When Cotonti receives a request addressed to a controller, the following steps are performed:

  1. Creation of the controller instance. The application determines which controller and action have been requested, loads the corresponding class, and creates an object.
  2. Call beforeAction(). This method is called before any action is executed. It can be overridden in the controller to check access rights, load common data, etc. If the method returns false, the execution of the action is canceled, and the user receives a denial (for example, 403 Forbidden). Usage example:

    public function beforeAction($action): bool
    {
        if (!Cot::$usr['isadmin'] && $action == 'delete') {
            cot_die_message(403, "Access denied");
            return false;
        }
        return parent::beforeAction($action);
    }
  3. Execution of the action. The actionXxx() method or the run() method of a standalone action is called. The result is saved.
  4. Call afterAction(). This method receives the result of the action and can modify it or perform final operations (for example, record statistics). The return value of afterAction() will replace the final result.
  5. Sending the response. The application packages the result (string) into an HTTP response and sends it to the client.

This scheme allows for the elegant introduction of cross-cutting functionality: logging, CSRF token verification, adding an HTML wrapper.

6. Practical examples of using controllers

6.1. Controller for pages with pagination

Let's consider a controller responsible for a list of pages in a category with pagination. It demonstrates retrieving parameters, working with a model, and passing data to a template.

namespace cot\modules\pages\controllers;

use cot\controllers\BaseController;
use cot\modules\pages\models\PageRepository;

class PageController extends BaseController
{
    public function actionList(): string
    {
        $cat = cot_import('cat', 'G', 'ALP');
        $page = cot_import('d', 'G', 'INT', 0);
        $perPage = Cot::$cfg['maxrowsperpage'] ?? 20;

        $total = PageRepository::countByCategory($cat);
        $items = PageRepository::findByCategory($cat, $page * $perPage, $perPage);

        $pagination = cot_generatePaginationTags(
            cot_pagenav('pages', ['cat' => $cat], $page, $total, $perPage)
        );

        $t = new XTemplate(cot_tplfile('pages.list'));
        $t->assign($pagination);
        foreach ($items as $i => $item) {
            $t->assign([
                'LIST_ROW_NUM' => $i + 1,
                'LIST_ROW_ID' => $item['id'],
                'LIST_ROW_TITLE' => htmlspecialchars($item['title']),
                'LIST_ROW_URL' => cot_url('page', ['id' => $item['id']]),
            ]);
            $t->parse('MAIN.PAGE_ROW');
        }
        $t->parse('MAIN');
        return $t->text('MAIN');
    }
}

6.2. Shopping cart controller

The CartController illustrates working with session data, adding a product to the cart, changing quantity, removing items, and displaying the cart.

namespace cot\modules\shop\controllers;

use cot\controllers\BaseController;

class CartController extends BaseController
{
    public function actionAdd()
    {
        $productId = cot_import('pid', 'P', 'INT');
        $qty = cot_import('qty', 'P', 'INT', 1);
        $cart = $_SESSION['cart'] ?? [];
        $cart[$productId] = ($cart[$productId] ?? 0) + $qty;
        $_SESSION['cart'] = $cart;
        cot_redirect(cot_url('shop', ['n' => 'cart', 'a' => 'view']));
    }

    public function actionRemove()
    {
        $productId = cot_import('pid', 'G', 'INT');
        unset($_SESSION['cart'][$productId]);
        cot_redirect(cot_url('shop', ['n' => 'cart', 'a' => 'view']));
    }

    public function actionView(): string
    {
        $cart = $_SESSION['cart'] ?? [];
        $productIds = array_keys($cart);
        $products = [];
        if ($productIds) {
            $ids = implode(',', array_map('intval', $productIds));
            $res = Cot::$db->query("SELECT * FROM {$Cot::$db->products} WHERE id IN ($ids)");
            while ($row = $res->fetch()) {
                $row['qty'] = $cart[$row['id']];
                $products[] = $row;
            }
        }
        $t = new XTemplate(cot_tplfile('shop.cart'));
        foreach ($products as $item) {
            $t->assign([
                'CART_ROW_ID'    => $item['id'],
                'CART_ROW_TITLE' => htmlspecialchars($item['title']),
                'CART_ROW_PRICE' => $item['price'],
                'CART_ROW_QTY'   => $item['qty'],
                'CART_ROW_SUM'   => $item['price'] * $item['qty'],
            ]);
            $t->parse('MAIN.CART_ROW');
        }
        $t->parse('MAIN');
        return $t->text('MAIN');
    }
}

7. Routing and modularity

Controllers allow a large extension to be divided into several small, logically connected classes. For example, instead of one huge blog.standalone.php file, you can create PostController, CommentController, CategoryController controllers. Each will contain only its own actions, which greatly simplifies maintenance and testing.

Additionally, you can create nested namespaces for actions, for example controllers\admin\DashboardController for the admin panel, controllers\api\PostController for a REST API. This makes the project structure more understandable.

8. Error handling and security

Controllers allow centralized error management. Instead of scattered cot_die_message() calls throughout the code, you can throw exceptions that will be caught by the Cotonti core. This makes the code cleaner and facilitates modular testing.

  • NotFoundHttpException — for 404 errors.
  • ForbiddenHttpException — for 403 errors.
  • Custom exceptions inherited from \Exception.

It is also strongly recommended to check access rights in the beforeAction() method so as not to duplicate code in every action. For example, for all actions of an admin panel controller, a general administrator check can be made.

9. Working together with models and views

Although Cotonti does not enforce a strict separation into models, it is good practice to move data access logic into separate repository classes (as in the examples above). This avoids duplication of SQL queries and simplifies testing. The controller, meanwhile, remains thin and focused on the flow of control.

For views, use XTemplate templates with a call to cot_tplfile(). Try not to mix HTML inside the controller; the exception is simple debugging.

10. Migration from hooks to controllers

If you have an old extension built on the module or standalone hook, you can gradually transition it to controllers. It is enough to create a controller and, in the hook, call a redirect to the desired route. For example, the old blog.standalone.php file can be replaced with a controller and the following written in the extension's system file:

Cot::$out['redirect'] = cot_url('blog', ['n' => 'post', 'a' => 'view', 'id' => $id]);

This will keep old links working and allow a gradual move away from outdated code.

11. Testing controllers

Thanks to the fact that a controller is a regular class with injected dependencies (through Cot's static properties), it can be tested in isolation. You can substitute the database, configuration, and the $_GET, $_POST global arrays using PHPUnit. This is a huge step forward compared to the procedural code of hooks, which is virtually impossible to test automatically.

12. Conclusion

Controllers in Cotonti are a powerful tool that allows you to write better structured, more testable, and easily extensible code. They replace outdated hooks and bring development to a modern level. We have examined the creation of controllers, actions, routing, the lifecycle, and practical examples for various tasks. By mastering these principles, you will be able to build complex extensions with minimal maintenance costs.

Use controllers in new projects, as well as for modernizing existing ones — it is an investment in the future of your site.

13 minutes read Sodium Carbonate

Comments (0)

No comments yet
Only registered users can post new comments

Similar pages

XTemplate и Controllers в Cotonti: Руководство для разработчиков
1 XTemplate и хуки в Cotonti: руководство разработчикаШаблонизатор XTemplate и система хуков образуют фундамент
User Blog • 2026-04-29 17:28 webitproff