Extending the Joomla Web Services API

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

The Joomla Web Services API lets another program read and change the data of a site over HTTP. The core already exposes articles, categories, contacts, users and more, and a component of your own joins them with two additions: a small webservices plugin that registers URL routes, and an api folder inside the component that holds the controllers and views that answer them. This page shows both halves for the Tallpine Hiking Club component com_tallpine, so that /api/index.php/v1/tallpine/trails behaves like the core endpoints: JSON:API documents, token authentication, pagination, filters, and the same permission rules as the administrator screens.

It was written against Joomla 6.2 (current, PHP 8.3 or newer) and checked against the Joomla Developer Manual and the 6.2 source tree on 2026-10-30. Joomla 5.4 receives security fixes only, and Joomla 4.x and 3.x are end of life; where those releases behave differently the text says so. The pieces are the ones introduced for the club’s site across this sub-section: package pkg_tallpine, component com_tallpine (namespace Tallpine\Component\Tallpine) and plugin plg_webservices_tallpine. Using the API from outside, with its authentication, formats and core endpoints, is covered in The Web Services API. Building a front end on top of it is the subject of Headless Joomla. The administrator half of the same trail entity, whose models and tables the API reuses, is in Component development: the administrator MVC.

The two halves of an API endpoint

An API request is handled by a separate application, the API application, whose entry point is api/index.php. It is not the administrator and not the site, and it has no menu, no template and no session in the usual sense. Two things decide how a request is answered:

  • Routing. The application imports the webservices plugin group, then dispatches the event onBeforeApiRoute. Each plugin adds routes to the API router. A route ties an HTTP method and a URL pattern to a controller name and a task, plus a set of default variables such as component.

  • Execution. The matched route’s component variable becomes the option of the request, the component’s dispatcher runs, and a controller from the component’s Api namespace performs the task and asks a JsonapiView to render the result.

Piece Where it lives (Tallpine example) Role

Webservices plugin

plugins/webservices/tallpine; class Tallpine\Plugin\WebServices\Tallpine\Extension\Tallpine

Registers the routes. Contains no data logic.

API controllers

api/components/com_tallpine/src/Controller/TrailsController.php

Extend Joomla\CMS\MVC\Controller\ApiController; implement displayList(), displayItem(), add(), edit() and delete(), or your own tasks.

API views

api/components/com_tallpine/src/View/Trails/JsonapiView.php

Extend Joomla\CMS\MVC\View\JsonApiView; say which model fields appear in the document.

Models and tables

administrator/components/com_tallpine/src/Model/TrailModel.php, TrailsModel.php and Table/TrailTable.php

Reused from the administrator part. The API ships no models unless it needs different behaviour.

Service provider

administrator/components/com_tallpine/services/provider.php

The same provider as for the other client areas; no API-specific provider is needed.

Source: Joomla Manual: Web Services. That Manual page describes how to call the API; the extension side is documented by the source tree, for example api/components/com_banners in joomla-cms 6.2-dev, which this page follows.

The sequence below follows one request for a list of trails, from the route registration to the document.

sequenceDiagram autonumber participant C as Client (curl or front end) participant A as API application participant P as plg_webservices_tallpine participant R as ApiRouter participant D as ApiDispatcher participant K as TrailsController (Api) participant M as TrailsModel (Administrator) participant V as JsonapiView C->>A: GET /api/index.php/v1/tallpine/trails (X-Joomla-Token) A->>P: onBeforeApiRoute(BeforeApiRouteEvent) P->>R: createCRUDRoutes("v1/tallpine/trails", "trails", component com_tallpine) A->>R: parseApiRoute("GET") R-->>A: controller trails, task displayList, vars component=com_tallpine, public=false A->>A: content negotiation (application/vnd.api+json) A->>A: login with action core.login.api (token plugin) A->>D: dispatch option=com_tallpine D->>K: execute("displayList") K->>M: getModel("trails") with state from filter and page K->>V: displayList() V->>M: getItems(), getPagination() V-->>C: 200 JSON:API document

The order matters for security. Routing happens first, the authentication check (for routes that are not public) happens right after routing, and the component’s own permission checks happen last, inside the controller and the model. Each layer protects something different, and none of them replaces the others.

The webservices plugin

Why a plugin and not a component file

A component cannot add routes by itself, because the API router is shared by every extension and is built before any component is loaded. The plugin is the hook. It also means the routes exist only while the plugin is enabled: disabling plg_webservices_tallpine in System → Manage → Plugins removes every Tallpine endpoint at once, which is a useful switch during an incident. The core webservices plugins (plg_webservices_content, plg_webservices_banners and the rest) work the same way, and a fresh installation enables them.

The plugin class

The plugin extends CMSPlugin and implements SubscriberInterface. It subscribes to onBeforeApiRoute, whose argument is a BeforeApiRouteEvent (added in Joomla 5.0). The event carries one argument, the ApiRouter, and getRouter() returns it.

<?php

/**
 * @package     Tallpine.Plugin
 * @subpackage  Webservices.tallpine
 *
 * @copyright   (C) 2026 Tallpine Hiking Club
 * @license     GNU General Public License version 2 or later
 */

namespace Tallpine\Plugin\WebServices\Tallpine\Extension;

use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Joomla\Router\Route;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Web Services adapter for com_tallpine.
 */
final class Tallpine extends CMSPlugin implements SubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            'onBeforeApiRoute' => 'onBeforeApiRoute',
        ];
    }

    public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
    {
        $router = $event->getRouter();

        // GET list, GET item, POST, PATCH and DELETE for the trail entity.
        $router->createCRUDRoutes(
            'v1/tallpine/trails',
            'trails',
            ['component' => 'com_tallpine']
        );

        // Categories reuse the core com_categories API, scoped to our extension.
        $router->createCRUDRoutes(
            'v1/tallpine/categories',
            'categories',
            ['component' => 'com_categories', 'extension' => 'com_tallpine']
        );

        // A custom, publicly readable route: only open trails, no token needed.
        $router->addRoutes([
            new Route(
                ['GET'],
                'v1/tallpine/open-trails',
                'trails.openTrails',
                [],
                ['component' => 'com_tallpine', 'public' => true]
            ),
        ]);
    }
}

The class is final, as the core plugin classes are. getSubscribedEvents() returns the mapping from event name to method name, and the method declares the concrete event class as its parameter type. The plugin needs neither the application nor the database, so it uses no Aware trait.

The service provider and the manifest

A Joomla 4.4 and later plugin is built by a service provider, exactly as the content plugin on Plugin development and events. The provider differs only in the group, the element and the class name.

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Tallpine\Plugin\WebServices\Tallpine\Extension\Tallpine;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            PluginInterface::class,
            $container->lazy(Tallpine::class, function (Container $container) {
                $plugin = new Tallpine(
                    (array) PluginHelper::getPlugin('webservices', 'tallpine')
                );
                $plugin->setApplication(Factory::getApplication());

                return $plugin;
            })
        );
    }
};
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="webservices" method="upgrade">
    <name>plg_webservices_tallpine</name>
    <author>Tallpine Hiking Club</author>
    <creationDate>2026-10</creationDate>
    <version>1.0.0</version>
    <description>PLG_WEBSERVICES_TALLPINE_XML_DESCRIPTION</description>
    <namespace path="src">Tallpine\Plugin\WebServices\Tallpine</namespace>
    <files>
        <folder plugin="tallpine">services</folder>
        <folder>src</folder>
    </files>
    <languages>
        <language tag="en-GB">language/en-GB/plg_webservices_tallpine.ini</language>
        <language tag="en-GB">language/en-GB/plg_webservices_tallpine.sys.ini</language>
    </languages>
</extension>

In the package pkg_tallpine the plugin is listed after the component with <file type="plugin" group="webservices" id="tallpine">plg_webservices_tallpine.zip</file>, as shown in Extension types, manifests and packaging. Check System → Manage → Plugins after the first installation: a disabled webservices plugin is the most common reason for an endpoint that answers 404.

Routes in detail

What createCRUDRoutes() registers

ApiRouter::createCRUDRoutes($baseName, $controller, $defaults = [], $publicGets = false) builds five Route objects and adds them to the router. With the arguments used above, the router receives:

Method Pattern Controller and task Public

GET

v1/tallpine/trails

trails.displayList

$publicGets

GET

v1/tallpine/trails/:id (id matches (\d+))

trails.displayItem

$publicGets

POST

v1/tallpine/trails

trails.add

no

PATCH

v1/tallpine/trails/:id

trails.edit

no

DELETE

v1/tallpine/trails/:id

trails.delete

no

The second argument is the controller name, not a class. The router splits the string trails.displayList at the dot: trails becomes the request’s controller and displayList its task. The defaults array becomes the route’s default variables. Two keys have a meaning for the application:

component

Copied to the request as option, which selects the component whose dispatcher runs. It is the folder name, com_tallpine.

public

When it is missing or false, the application requires an authenticated API user (see ACL and authentication on API routes). createCRUDRoutes() sets it to the $publicGets argument on the two GET routes only. Write routes can never be made public by that argument.

Every other key is copied to the request as an input variable. That is how the categories route passes extension=com_tallpine to com_categories, whose controllers use it to select the category tree. An id is captured from the URL and put in the same place, which is why controllers read it with $this->input->get('id', 0, 'int').

Custom routes with Route

Anything that does not fit the five CRUD shapes is a Joomla\Router\Route passed to addRoutes(). The constructor takes the list of HTTP methods, the pattern, the controller string (name.task), the regular-expression rules for each :variable of the pattern, and the defaults:

use Joomla\Router\Route;

$router->addRoutes([
    // GET .../v1/tallpine/trails/7/gpx  -> TrailsController::gpx()
    new Route(
        ['GET'],
        'v1/tallpine/trails/:id/gpx',
        'trails.gpx',
        ['id' => '(\d+)'],
        ['component' => 'com_tallpine', 'public' => false]
    ),
]);

Four rules follow from how the router matches.

  • The first matching route wins. The router walks its routes in registration order and returns the first whose method and regular expression both match. Core and extension plugins are called in plugin order. Give custom paths a prefix of your own (v1/tallpine/) so that no other extension can claim them, and register more specific patterns before general ones. Because :id is restricted to digits, v1/tallpine/trails/open cannot be swallowed by the item route.

  • The task must be a method. The string after the dot is the method that the controller executes. A string without a dot leaves the task undefined in the router code of 6.2, so always write name.task.

  • The method decides what exists. A request for a verb that no route lists, such as PUT on a trail, finds no match and the application answers with a 404 error document, not a 405.

  • The routes are shared state. Any plugin of the webservices group can add, and nothing prevents it from adding a public route that shadows yours. Keep the list of enabled webservices plugins short and audited.

Content negotiation

After routing, the application checks the request’s Accept header against application/vnd.api+json, or against the format list of the route if one was set in its defaults. If nothing matches, the API answers 406 Not Acceptable. A client that sends Accept: application/json therefore fails on the standard routes; this is a frequent surprise and is covered in The Web Services API.

The api folder of the component

Layout and manifest

The API part has its own folder in the extension zip and its own <api> element in the manifest. The installer copies it to api/components/com_tallpine in the web root, and the <namespace path="src"> declaration already maps the Api segment of the namespace to it.

api/components/com_tallpine/
  src/
    Controller/TrailsController.php
    View/Trails/JsonapiView.php
<api>
    <files folder="api/components/com_tallpine">
        <folder>src</folder>
    </files>
</api>

Source: Joomla Manual: Manifest files. The core folders for comparison are listed in joomla-cms 6.2-dev: api/components.

There is no services folder, no tmpl folder and, in the core components, no model or table: the API reuses the administrator part. The Tallpine\Component\Tallpine\Api\Controller namespace is found because the MVCFactory resolves class names from the namespace prefix plus the client name (see Namespaces, autoloading and dependency injection).

How the administrator model is found

Inside the API application the component’s MVCFactory is wrapped in ApiMVCFactory. When code asks it for a model or a table, it first looks in the Api namespace and, if nothing is there, silently falls back to the Administrator namespace. This is why TrailModel and TrailsModel written for the administrator screens are enough for the API. It also has a consequence: a bug or a missing check in the administrator model is reachable over HTTP. The model is the right place for validation and for filtering, because it is the only code that all clients share. The note on validation in the administrator MVC page applies with extra force.

The core dispatcher classes for the API are not part of the component either. The core API components contain no Dispatcher class; the application runs Joomla\CMS\Dispatcher\ApiDispatcher, which, unlike the administrator dispatcher, performs no core.manage check of its own. It reads the task (displayList, add, openTrails) from the request, builds the controller named by the route in the Api namespace and executes the task.

The controller

An API controller extends ApiController. Two properties tell the base class what the controller serves: $contentType (the plural resource name, used as the JSON:API type, the default model name, and in the singular for asset names) and $default_view. The base class provides the five CRUD tasks, so a minimal controller is only those two properties. The Tallpine controller adds filters and a custom task:

<?php

namespace Tallpine\Component\Tallpine\Api\Controller;

use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\MVC\Controller\ApiController;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

class TrailsController extends ApiController
{
    /** The resource type, the default model name and the JSON:API type. */
    protected $contentType = 'trails';

    /** The view used when the request does not name one. */
    protected $default_view = 'trails';

    /**
     * GET v1/tallpine/trails?filter[category]=8&filter[difficulty]=hard&filter[state]=1
     */
    public function displayList()
    {
        $apiFilterInfo = $this->input->get('filter', [], 'array');
        $filter        = InputFilter::getInstance();

        if (\array_key_exists('category', $apiFilterInfo)) {
            $this->modelState->set('filter.category_id', $filter->clean($apiFilterInfo['category'], 'INT'));
        }

        if (\array_key_exists('difficulty', $apiFilterInfo)) {
            $this->modelState->set('filter.difficulty', $filter->clean($apiFilterInfo['difficulty'], 'CMD'));
        }

        if (\array_key_exists('state', $apiFilterInfo)) {
            $this->modelState->set('filter.published', $filter->clean($apiFilterInfo['state'], 'INT'));
        }

        return parent::displayList();
    }

    /**
     * GET v1/tallpine/open-trails (public route): published, not closed, Public access only.
     */
    public function openTrails()
    {
        $this->modelState->set('filter.published', 1);
        $this->modelState->set('filter.closed', 0);

        return parent::displayList();
    }
}

What the base class does, and what that means for an extension author:

displayList()

Reads page[offset] and page[limit] from the request and stores them in the model state under the controller’s context (list.limit falls back to $itemsPerPage, 20). It builds the view and the model, discards an ordering column that the model does not declare valid, forces the direction to asc or desc, and answers 404 if the offset is beyond the total. The model is created with ignore_request set and with $this->modelState as its state, so the populateState() of the model does not see the HTTP query. Filters reach the model only if the controller puts them in $this->modelState. That is why the core controllers copy each filter[name] key by hand, and why an unknown filter is silently ignored.

displayItem()

Reads id, creates the model with the singular of $contentType (trails becomes trail) and asks the view for the item. A missing or invisible row ends in 404.

add() and edit()

Both call the protected save(). It reads the JSON body (data or the raw JSON), runs the model’s form validation (getForm(), validate()) and then save(), checks the record in again, and returns the id. On PATCH, the fields absent from the body are filled from the stored row first, so a partial update does not blank them. Validation errors become a 400-class error with up to three messages. The form is loaded from administrator/components/com_tallpine/forms.

delete()

Calls allowDelete(), then the model’s delete(). It answers 204 on success, 409 when the model flags that the item must be trashed first, and 404 if the row does not exist.

preprocessSaveData()

An empty hook for reshaping the incoming data before validation. The Articles controller uses it to move custom field values into com_fields. Use it when API field names differ from form field names.

A custom task such as openTrails() is an ordinary public method. It must produce the response itself, normally by setting model state and calling displayList() or displayItem(). A task that returns without rendering gives an empty response.

The model side of the filters

The controller only sets state. The administrator TrailsModel must read it in getListQuery(), with bound parameters and with the viewing access levels of the current user, because the base controller’s read tasks do not call authorise() themselves:

use Joomla\Database\ParameterType;

// In TrailsModel::getListQuery(), after the base select and from clauses:
$db    = $this->getDatabase();
$user  = $this->getCurrentUser();

// Only rows the caller may see at all (Public for a guest on a public route).
$query->whereIn($db->quoteName('a.access'), $user->getAuthorisedViewLevels());

$difficulty = (string) $this->getState('filter.difficulty', '');

if ($difficulty !== '') {
    $query->where($db->quoteName('a.difficulty') . ' = :difficulty')
        ->bind(':difficulty', $difficulty);
}

$closed = $this->getState('filter.closed', '');

if (is_numeric($closed)) {
    $closed = (int) $closed;
    $query->where($db->quoteName('a.closed') . ' = :closed')
        ->bind(':closed', $closed, ParameterType::INTEGER);
}

Source: Joomla Manual: Selecting data and the pattern described in ACL in Joomla extensions

The query builder rules, including why bind() takes a variable by reference, are in Database API.

The JsonapiView

Choosing the fields

The class must be called JsonapiView, because the view name is built from the document type of the API application (jsonapi). It extends Joomla\CMS\MVC\View\JsonApiView and declares two arrays: the attributes rendered for one item and for each item of a list. The lists are separate because a list should be cheap and an item can carry everything.

<?php

namespace Tallpine\Component\Tallpine\Api\View\Trails;

use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
use Joomla\CMS\Uri\Uri;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

class JsonapiView extends BaseApiView
{
    /** Attributes of GET v1/tallpine/trails/7 */
    protected $fieldsToRenderItem = [
        'id',
        'title',
        'alias',
        'catid',
        'distance_km',
        'elevation_gain',
        'difficulty',
        'seasons',
        'closed',
        'published',
        'access',
        'language',
        'created',
        'created_by',
        'public_url',
    ];

    /** Attributes of each entry of GET v1/tallpine/trails */
    protected $fieldsToRenderList = [
        'id',
        'title',
        'alias',
        'catid',
        'distance_km',
        'difficulty',
        'closed',
        'published',
        'public_url',
    ];

    /**
     * Adds a computed attribute to every row before it is serialised.
     */
    protected function prepareItem($item)
    {
        $item->public_url = Uri::root() . 'index.php?option=com_tallpine&view=trail&id=' . (int) $item->id;

        return $item;
    }
}

Things worth knowing about the base class:

  • The constructor receives the contentType the controller passed in its configuration and creates a JoomlaSerializer for that type. The type member of each resource object is the plural name, trails.

  • Only listed names are output. The serialiser takes the properties of the model row and keeps those named in the field list, so a column missing from the array never appears in the document, and a name that is not a property is skipped. Treat the arrays as an allow-list: do not add checked_out, asset_id or any internal column that a client has no use for.

  • prepareItem() is called for every row and may add properties, which must then also be in the field list. public_url above is a deliberately simple non-SEF link; a link through the site router needs the site application’s router, not the API’s.

  • displayList() adds the total-pages meta member and self, first, previous, next and last links, using the page[offset] and page[limit] of the request. displayItem() adds a self link and throws a 404 if the item has no id.

  • The $relationship property lists relationships to include; core views use it for modified_by and the language associations. A custom relationship needs a serialiser that can build it.

Hooks for other extensions

Before building a document, the view dispatches onApiGetFields with an OnGetApiFields event holding the field list and the context (the content type). A plugin can call addFields() and addRelations() on the event to add attributes or relationships, and the serialiser dispatches onGetApiAttributes and onGetApiRelation in the same way. That is the route other extensions use to add data to your resources without editing your view, and the route you use to add data to core resources from plg_webservices_tallpine or another plugin. The events are listed in the Manual’s plugin events index; the exact payload classes live in libraries/src/MVC/View/Event and libraries/src/Serializer/Events in the source tree.

A request in practice

With the component, plugin and package installed on the local site from the earlier pages, the token of an API user exported as an environment variable (never typed into a shell history or a file), a list request looks like this:

export TALLPINE_API_TOKEN="CHANGE_ME_TOKEN_FROM_THE_USER_PROFILE"

curl --silent \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${TALLPINE_API_TOKEN}" \
  "http://localhost:8080/api/index.php/v1/tallpine/trails?filter[difficulty]=hard&page[limit]=2"
{
  "links": {
    "self": "http://localhost:8080/api/index.php/v1/tallpine/trails?filter%5Bdifficulty%5D=hard&page%5Blimit%5D=2"
  },
  "data": [
    {
      "type": "trails",
      "id": "7",
      "attributes": {
        "id": 7,
        "title": "Alpine ridge traverse",
        "alias": "alpine-ridge-traverse",
        "catid": 8,
        "distance_km": "14.50",
        "difficulty": "hard",
        "closed": 0,
        "published": 1,
        "public_url": "http://localhost:8080/index.php?option=com_tallpine&view=trail&id=7"
      }
    }
  ],
  "meta": {
    "total-pages": 3
  }
}

Source: joomla-cms 6.2-dev: JsonApiView::displayList(). The payload is illustrative of the structure.

Creating and changing a trail uses the same URL pattern with POST and PATCH. The body is JSON with the field names of the administrator form, and the content type is application/json:

curl --silent --request POST \
  --header "Accept: application/vnd.api+json" \
  --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${TALLPINE_API_TOKEN}" \
  --data '{"title":"Valley loop","alias":"valley-loop","catid":9,"distance_km":6.2,"elevation_gain":120,"difficulty":"easy","published":1,"access":1,"language":"*"}' \
  "http://localhost:8080/api/index.php/v1/tallpine/trails"

curl --silent --request PATCH \
  --header "Accept: application/vnd.api+json" \
  --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${TALLPINE_API_TOKEN}" \
  --data '{"closed":1}' \
  "http://localhost:8080/api/index.php/v1/tallpine/trails/7"

curl --silent --request DELETE \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${TALLPINE_API_TOKEN}" \
  --write-out "%{http_code}\n" \
  "http://localhost:8080/api/index.php/v1/tallpine/trails/7"

The PATCH body carries only the changed field, which works because save() fills the other fields from the stored row. The base controller answers 409 when the model reports that the item is not in a state that can be deleted; the core models do so for items that are not yet trashed, mirroring the administrator, where Empty trash is a separate step.

The public route needs no header except Accept:

curl --silent --header "Accept: application/vnd.api+json" \
  "http://localhost:8080/api/index.php/v1/tallpine/open-trails"

ACL and authentication on API routes

The layers, in the order they run

Layer What it checks

Route public default

If it is missing or false, the application logs in with the action core.login.api before dispatching. A public route skips this step entirely and runs as a guest.

Authentication

The API application authenticates the request through the plugins of the api-authentication group. On a fresh 6.2 installation the token plugin is enabled and the Basic Auth plugin is not. The token travels in X-Joomla-Token.

core.login.api

The permission an account needs to use the API at all, set in Global Configuration → Permissions (or inherited from a group). A valid token of a user without it is rejected with 401.

Controller allow*() methods

allowAdd(): core.manage on the component plus core.create on the component or at least one authorised category. allowEdit(): core.manage, then core.edit on the asset com_tallpine.trail.ID, or core.edit.own if the row has a created_by equal to the user. allowDelete(): core.manage and core.delete on the component.

Model

Row filtering for reads (access levels, published state), form validation for writes, table asset handling.

Two points deserve emphasis.

Reads are not authorised by the base controller. In the 6.2 source, displayList() and displayItem() apply no authorise() call; an authenticated API user passes the route checks and the rest is decided by what the model returns. The core models filter by viewing access level and state, and so must TrailsModel, as shown above. The Tallpine member-only trail conditions (the Trail Warden access level of Users, groups, access levels and ACL) are protected over the API only if the query uses getAuthorisedViewLevels(). A reader token from the editor group does not see Trail Warden rows, and a guest on the public route sees only rows with the Public level.

The asset name the controller checks must match your access.xml and your table. allowEdit() builds com_tallpine.trail.7 from the option, the singular of the content type and the id. The asset is written by TrailTable, as explained in ACL in Joomla extensions. If the table stores its rows under another asset name (for example com_tallpine.trails.7), the API check finds no rule and falls back to the parent chain, so permissions set on the item appear to have no effect. Override allowEdit() in the controller if the names differ.

The API user

Give the front end its own account rather than a Super User token. A group API Reader with core.login.api and no other privilege fits a public map; a group API Trail Editor with core.login.api, core.manage, core.create, core.edit and core.edit.state on com_tallpine fits an importer. The token is generated in the user’s profile once the token plugin is enabled and the user’s group may log in to the API. Joomla stores a seed and derives the token from it, so the token is shown once and cannot be read back from the database. Treat it as a password: keep it in the environment or in the secret store of the front end, and rotate it by regenerating it. The wider hardening of the API (rate limits at the proxy, CORS, disabling Basic Auth) is on Security hardening, MFA and passkeys.

Common errors and pitfalls

Symptom Cause and fix

404 Unable to handle request for route

The webservices plugin is disabled or not installed, the pattern does not match, or the method has no route. Check System → Manage → Plugins, the URL (it contains index.php unless the web server rewrites it) and the verb.

404 on an item that exists

The model’s getItem() did not return the row because the caller’s access levels or the published state exclude it. Test with a Super User token to separate a routing problem from a visibility one.

406 Not Acceptable

The Accept header does not include application/vnd.api+json. application/json does not match.

401 for a valid token

The token’s user lacks core.login.api, the user is blocked, or the token plugin is disabled.

403 on POST and PATCH

allowAdd() or allowEdit() returned false: the user has core.login.api but no core.manage on com_tallpine.

Class Tallpine\Component\Tallpine\Api\Controller\TrailsController not found

The <api> element is missing from the manifest, the folder is not named api/components/com_tallpine, or the namespace does not match. Delete administrator/cache/autoload_psr4.php after adding a namespaced folder to an installed site.

Empty attributes

The names in $fieldsToRenderItem do not match properties of the row (a column alias in the model’s query, for example). Add the alias in the model or rename the entry.

A filter has no effect

The controller does not copy it into $this->modelState, or the model’s getListQuery() does not read that state key.

Everything works as Super User and fails as a normal user

The model or table assumes administrator-only context, such as $app->getIdentity()->authorise(). Check the code that runs through the API for assumptions about the client.

The route answers, but the page cache or a proxy serves stale data

The API honours no page cache, but a CDN in front of it may. Send Cache-Control as required and keep write routes out of any cached path.

Version notes and book corrections

Version Change

4.0

The Web Services API arrived in core with the webservices plugin group, ApiRouter::createCRUDRoutes(), ApiController and JsonApiView.

5.0

BeforeApiRouteEvent and the other concrete event classes arrived. A handler can receive the event object and call getRouter().

5.1

The core webservices plugins implement SubscriberInterface explicitly, the form every plugin of this page uses.

6.0

The filter[modified_start] and filter[modified_end] filters of the articles endpoint (described in The Web Services API). Legacy listener emulation is deprecated and removed in 7.

Developing Extensions for Joomla! 5 (2023) teaches Web Services with tokens and createCRUDRoutes(), which is still the right mechanism, but its plugin is a non-namespaced CMSPlugin with onBeforeApiRoute(&$router) and its components predate the concrete event classes. In Joomla 6 the plugin should be a namespaced final class that implements SubscriberInterface and receives a BeforeApiRouteEvent; the &$router signature belongs to the emulation layer for plugins that have not been converted. Joomla! 4 Masterclass (2023) does not cover web services development and targets an end-of-life release; its chapters on API tokens predate the 5.x and 6.x defaults. The Manual page on Web Services also documents the consumer side only, which is why this page cites the source tree for the extension side.