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
webservicesplugin group, then dispatches the eventonBeforeApiRoute. 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 ascomponent. -
Execution. The matched route’s
componentvariable becomes theoptionof the request, the component’s dispatcher runs, and a controller from the component’sApinamespace performs the task and asks aJsonapiViewto render the result.
| Piece | Where it lives (Tallpine example) | Role |
|---|---|---|
Webservices plugin |
|
Registers the routes. Contains no data logic. |
API controllers |
|
Extend |
API views |
|
Extend |
Models and tables |
|
Reused from the administrator part. The API ships no models unless it needs different behaviour. |
Service provider |
|
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.
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 |
|
|
|
GET |
|
|
|
POST |
|
|
no |
PATCH |
|
|
no |
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$publicGetsargument 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:idis restricted to digits,v1/tallpine/trails/opencannot 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
webservicesgroup 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]andpage[limit]from the request and stores them in the model state under the controller’s context (list.limitfalls 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 toascordesc, and answers 404 if the offset is beyond the total. The model is created withignore_requestset and with$this->modelStateas its state, so thepopulateState()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 eachfilter[name]key by hand, and why an unknown filter is silently ignored. displayItem()-
Reads
id, creates the model with the singular of$contentType(trailsbecomestrail) and asks the view for the item. A missing or invisible row ends in 404. add()andedit()-
Both call the protected
save(). It reads the JSON body (dataor the raw JSON), runs the model’s form validation (getForm(),validate()) and thensave(), checks the record in again, and returns the id. OnPATCH, 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 fromadministrator/components/com_tallpine/forms. delete()-
Calls
allowDelete(), then the model’sdelete(). 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
contentTypethe controller passed in its configuration and creates aJoomlaSerializerfor that type. Thetypemember 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_idor 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_urlabove 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 thetotal-pagesmeta member andself,first,previous,nextandlastlinks, using thepage[offset]andpage[limit]of the request.displayItem()adds aselflink and throws a 404 if the item has no id. -
The
$relationshipproperty lists relationships to include; core views use it formodified_byand 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"
Source: Joomla Manual: Web Services
{
"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"
Source: Joomla Manual: Web Services
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"
Source: Joomla Manual: Web Services
ACL and authentication on API routes
The layers, in the order they run
| Layer | What it checks |
|---|---|
Route |
If it is missing or false, the application logs in with the action |
Authentication |
The API application authenticates the request through the plugins of the |
|
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 |
|
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 |
The |
404 on an item that exists |
The model’s |
406 Not Acceptable |
The |
401 for a valid token |
The token’s user lacks |
403 on |
|
Class |
The |
Empty |
The names in |
A filter has no effect |
The controller does not copy it into |
Everything works as Super User and fails as a normal user |
The model or table assumes administrator-only context, such as |
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 |
Version notes and book corrections
| Version | Change |
|---|---|
4.0 |
The Web Services API arrived in core with the |
5.0 |
|
5.1 |
The core webservices plugins implement |
6.0 |
The |
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.