How to Build a Custom JSON API Endpoint in Joomla 4: Step-by-Step Guide

Introduction

Joomla 4 brings powerful API capabilities out of the box, allowing developers to build robust web services. By default, Joomla responds with JSON when the request includes the Accept: application/json header. While the core only supports JSON, Joomla’s architecture lets you extend and customize API responses to fit your needs.

In this guide, we’ll walk through creating a custom JSON API endpoint in Joomla 4. We’ll cover building a webservices plugin, setting up the API part of a component, and using module parameters to shape the API response. This tutorial assumes you already know how to create Joomla extensions.


What You’ll Learn

  • How to get a JSON response from Joomla’s API
  • How to create a webservices plugin and API section for a component
  • How to use module parameters to customize API output

Note: This guide does not cover the basics of extension development. It assumes you can create plugins and components in Joomla 4.


1. Setting Up the Component’s Admin Panel

Even if your component doesn’t require configuration, Joomla expects certain files (like config.xml and access.xml) in the backend directory. This ensures your component appears in the admin menu. You can provide a minimal view with a simple message stating that no settings are required.

How to Build a Custom JSON API Endpoint in Joomla 4: Step-by-Step Guide


2. Key Points Before You Start

  • API Authentication:
    • Token-based authentication is recommended.
    • Password-based authentication is not recommended.
  • Public Endpoints:
    • For public content (like a blog), set the public flag to true when registering the endpoint in your webservices plugin.

3. Creating the Webservices Plugin

The webservices plugin registers your API routes (endpoints) and specifies which controller should handle requests.

Steps:

  1. Create the Plugin Folder:
    • Name it plg_webservices_vapi (or your preferred name).
  2. Create the Main PHP File:
    • Inside the folder, add vapi.php with the following code:
 '(d+)'],
            [
                'component'  => 'com_vapi',
                'public' => true,
                'format' => [
                    'application/json'

                ]
            ]
        );
        $router->addRoute($route);
    }
}

Explanation:

  • onBeforeApiRoute: Required in every webservices plugin. Defines your endpoints.
  • ['GET']: Supported HTTP methods (must be uppercase).
  • 'v1/vapi/modules/:id': Endpoint pattern. v1 is the API version, vapi is your component name (without com_), and :id is a dynamic parameter.
  • 'module.displayModule': Controller and task to execute.
  • ['id' => '(d+)']: Regular expression for the id parameter (must be a number).
  • 'public' => true: Makes the endpoint public.
  • 'format' => ['application/json']: Ensures the response is JSON.

Don’t forget to create the plugin’s XML manifest file.


4. Adding the API Section to Your Component

Joomla 4 allows components to have a dedicated API section, similar to the site and administrator sections.

Steps:

  1. Update the XML Manifest:
    Add the following to your component’s manifest:

    
       
           src
       
    
  2. Create the API Directory Structure:
    • In your component’s root, create api/src/.
    • Inside src/, create Controller/, Model/, and View/Modules/ folders as needed.

5. Building the API Controller

The controller handles incoming API requests and prepares data for the response.

Example: ModuleController.php

namespace YourNamespaceComponentVapiApiController;

defined('_JEXEC') || die;

use JoomlaCMSMVCFactoryApiMVCFactory;
use JoomlaCMSApplicationApiApplication;
use JoomlaInputInput;
use JoomlaCMSLanguageText;
use JoomlaComponentContentAdministratorExtensionContentComponent;
use JoomlaCMSComponentComponentHelper;

class ModuleController extends JoomlaCMSMVCControllerBaseController
{
    protected $default_view = 'modules';
    protected $moduleParams;

    public function __construct($config = array(), ApiMVCFactory $factory = null, ?ApiApplication $app = null, ?Input $input = null)
    {
        if (array_key_exists('moduleParams', $config)) {

            $this->moduleParams = new JoomlaRegistryRegistry($config['moduleParams']);
        }
        parent::__construct($config, $factory, $app, $input);
    }

    public function displayModule(): void
    {
        $moduleID    = $this->input->get('id', 0, 'int');
        $moduleState = new JoomlaRegistryRegistry(['moduleID' => $moduleID]);

        $moduleModel = $this->factory->createModel('Module', 'Api', ['ignore_request' => true, 'state' => $moduleState]);


        if (empty($this->moduleParams)) {
            $this->setModuleParams($moduleModel);
        }

        $mainModel = $this->getMainModelForView($this->moduleParams);

        $document   = $this->app->getDocument();
        $viewType   = $document->getType();
        $viewName   = $this->input->get('view', $this->default_view);
        $viewLayout = $this->input->get('layout', 'default', 'string');


        $view = $this->getView(
            $viewName,
            $viewType,
            '',
            ['moduleParams' => $this->moduleParams, 'base_path' => $this->basePath, 'layout' => $viewLayout]
        );

        $view->setModel($mainModel, true);
        $view->setModel($moduleModel);

        $view->document = $this->app->getDocument();
        $view->display();
    }

    protected function getMainModelForView($params)
    {
        $mvcContentFactory  = $this->app->bootComponent('com_content')->getMVCFactory();
        $articlesModel = $mvcContentFactory->createModel('Articles', 'Site', ['ignore_request' => true]);
        $appParams = ComponentHelper::getComponent('com_content')->getParams();
        $articlesModel->setState('params', $appParams);
        $articlesModel->setState('filter.published', ContentComponent::CONDITION_PUBLISHED);
        $articlesModel->setState('list.start', 0);

        $articlesModel->setState('list.limit', (int) $params->get('count', 0));
        $catids = $params->get('catid');
        $articlesModel->setState('filter.category_id', $catids);
        $ordering = $params->get('article_ordering', 'a.ordering');
        $articlesModel->setState('list.ordering', $ordering);
        $articlesModel->setState('list.direction', $params->get('article_ordering_direction', 'ASC'));
        $articlesModel->setState('filter.featured', $params->get('show_front', 'show'));
        $excluded_articles = $params->get('excluded_articles', '');
        if ($excluded_articles) {

            $excluded_articles = explode("rn", $excluded_articles);
            $articlesModel->setState('filter.article_id', $excluded_articles);
            $articlesModel->setState('filter.article_id.include', false);
        }
        return $articlesModel;
    }

    protected function setModuleParams($moduleModel)
    {
        $module = $moduleModel->getModule();
        if (is_null($module)) {

            throw new UnexpectedValueException('Module not found');
        }
        return $this->moduleParams = new JoomlaRegistryRegistry($module->params);
    }
}

6. Creating the Model

The model fetches the module data from the database based on the provided ID.

Example: ModuleModel.php

namespace YourNamespaceComponentVapiApiModel;

defined('_JEXEC') || die;

use JoomlaCMSMVCModelBaseDatabaseModel;
use JoomlaCMSFactory;
use JoomlaCMSCacheCacheControllerFactoryInterface;
use JoomlaDatabaseParameterType;
use JoomlaCMSLanguageText;

class ModuleModel extends BaseDatabaseModel
{
    public function getModule(): ?object
    {

        $app = Factory::getApplication();
        $mid = $this->state->get('moduleID', 0);
        if ($mid === 0) {
            throw new InvalidArgumentException('A module ID is necessary');
        }
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->select('*')

            ->from($db->quoteName('#__modules'))
            ->where($db->quoteName('id') . ' = :moduleId')
            ->bind(':moduleId', $mid, ParameterType::INTEGER);
        $db->setQuery($query);
        $cacheId = 'com_vapi.moduleId' . $mid;
        try {
            $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
                ->createCacheController('callback', ['defaultgroup' => 'com_modules']);

            $module = $cache->get([$db, 'loadObject'], [], md5($cacheId), false);
        } catch (RuntimeException $e) {
            $app->getLogger()->warning(Text::sprintf('JLIB_APPLICATION_ERROR_MODULE_LOAD', $e->getMessage()), ['category' => 'jerror']);
            return new stdClass();
        }
        return $module;
    }
}

7. Creating the View for JSON Output

The view formats the data as JSON and controls which fields are exposed.

Example: JsonView.php

namespace YourNamespaceComponentVapiApiViewModules;

defined('_JEXEC') || die;

use JoomlaCMSMVCViewJsonView as BaseJsonView;
use JoomlaCMSMVCViewGenericDataException;
use JoomlaCMSHTMLHTMLHelper;

class JsonView extends BaseJsonView
{
    protected $fieldsToRenderList = [
        'id', 'title', 'alias', 'displayDate', 'metadesc', 'metakey',
        'params', 'displayHits', 'displayCategoryTitle', 'displayAuthorName',
    ];
    protected $display = [];

    public function __construct($config = [])
    {
        if (array_key_exists('moduleParams', $config)) {
            $params = $config['moduleParams'];

            $this->display['show_date'] = $params->get('show_date', 0);
            $this->display['show_date_field'] = $params->get('show_date_field', 'created');
            $this->display['show_date_format'] = $params->get('show_date_format', 'Y-m-d H:i:s');
            $this->display['show_category'] = $params->get('show_category', 0);
            $this->display['show_author'] = $params->get('show_author', 0);
            $this->display['show_hits'] = $params->get('show_hits', 0);
        }

        parent::__construct($config);
    }

    protected function setOutput(array $items = null): void
    {
        $mainModel = $this->getModel();
        $moduleModel = $this->getModel('module');
        if ($items === null) {
            $items = [];
            foreach ($mainModel->getItems() as $item) {
                $_item = $this->prepareItem($item, $moduleModel);

                $items[] = $this->getAllowedPropertiesToRender($_item);
            }
        }
        if (count($errors = $this->get('Errors'))) {
            throw new GenericDataException(implode("n", $errors), 500);
        }
        $this->_output = $items;
    }

    protected function getAllowedPropertiesToRender($item): stdClass

    {
        $allowedFields = new stdClass;
        foreach($item as $key => $value) {
            if (in_array($key, $this->fieldsToRenderList, true)) {
                $allowedFields->$key = $value;
            }
        }
        return $allowedFields;
    }


    protected function prepareItem($item, $moduleModel)
    {
        $item->slug = $item->alias . ':' . $item->id;
        if ($this->display['show_date']) {
            $show_date_field = $this->display['show_date_field'];
            $item->displayDate = HTMLHelper::_('date', $item->$show_date_field, $this->display['show_date_format']);
        }
        $item->displayCategoryTitle = $this->display['show_category'] ? $item->category_title : '';
        $item->displayHits = $this->display['show_hits'] ? $item->hits : '';

        $item->displayAuthorName = $this->display['show_author'] ? $item->author : '';
        return $item;
    }

    public function display($tpl = null)
    {
        ob_clean();
        header_remove();
        $this->setOutput();
        parent::display($tpl);
        echo $this->document->render();
    }
}

Security Tip: Only expose fields that are safe for public consumption. Avoid leaking sensitive data such as user IDs or IP addresses.


8. Testing Your Endpoint

Use an API client like Postman to send a GET request to:

[yourLocalRootSiteURL]/api/index.php/v1/vapi/modules/[idOfYourModule]

You should receive a JSON response similar to:

[
  {
    "id": 11,
    "title": "Typography",
    "alias": "typography",
    "metakey": "",
    "metadesc": "",
    "params": {...},
    "displayDate": "2022-11-20 20:49:17",
    "displayCategoryTitle": "Typography",
    "displayHits": 0,
    "displayAuthorName": "Carlos Rodriguez"
  }
]

Conclusion & Tips

Congratulations! You’ve built a custom JSON API endpoint in Joomla 4. This approach gives you full control over your API’s structure and output, making it easy to integrate Joomla with modern web and mobile applications.

Tips:

  • Always validate and sanitize input parameters.
  • Limit the fields exposed in public endpoints to avoid information leaks.
  • Use token-based authentication for secure endpoints.
  • Explore Joomla’s API documentation for more advanced features.

Happy coding!