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.
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 totrue
when registering the endpoint in your webservices plugin.
- For public content (like a blog), set the
3. Creating the Webservices Plugin
The webservices plugin registers your API routes (endpoints) and specifies which controller should handle requests.
Steps:
- Create the Plugin Folder:
- Name it
plg_webservices_vapi
(or your preferred name).
- Name it
- Create the Main PHP File:
- Inside the folder, add
vapi.php
with the following code:
- Inside the folder, add
'(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 (withoutcom_
), and:id
is a dynamic parameter.'module.displayModule'
: Controller and task to execute.['id' => '(d+)']
: Regular expression for theid
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:
- Update the XML Manifest:
Add the following to your component’s manifest:src
- Create the API Directory Structure:
- In your component’s root, create
api/src/
. - Inside
src/
, createController/
,Model/
, andView/Modules/
folders as needed.
- In your component’s root, create
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!