contenir / extension-api
The add-on API for Contenir CMS: the contracts add-ons build against, the event catalogue, a conformance suite for hosts and an in-memory host. Framework free: PSR-3, PSR-7, PSR-11, PSR-14 and PSR-20 only.
Requires
- php: ~8.3.0 || ~8.4.0 || ~8.5.0
- ext-dom: *
- psr/clock: ^1.0
- psr/container: ^1.1 || ^2.0
- psr/event-dispatcher: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- contenir/contenir-qa-tools: 0.1.x-dev
- infection/infection: ^0.34.1
- nyholm/psr7: ^1.8.2
- phpunit/phpunit: ^11.5.42
Suggests
- nyholm/psr7: A PSR-7 and PSR-17 implementation for the in-memory host (Testing\InMemoryHost), or use any other
- phpunit/phpunit: Runs the conformance suite (Conformance\Abstract*ConformanceTestCase) against a host
Provides
None
Conflicts
None
Replaces
- contenir/extension-api: v0.6.1
- dev-main / 0.6.x-dev
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- dev-chore/mago-1.57.0
- dev-feat/site-settings
- dev-feat/parent-page
- dev-feat/storage-profile
- dev-feat/template-tabs-helper
- dev-feat/downloads
- dev-feat/mailer
- dev-feat/provide
- dev-feat/template-tabs
- dev-feat/fragments
- dev-feat/screen-tabs
- dev-feat/module-tier
- dev-ci/push-trigger
- dev-feat/importable-field-types
- dev-feat/record-panels
This package is auto-updated.
Last update: 2026-10-21 03:53:23 UTC
README
The add-on API for Contenir CMS. Add-ons build against this package and nothing else; each CMS implements it as a host. So an add-on works the same whether the CMS underneath is the legacy ZF1 CMS or the Mezzio rewrite.
It holds only contracts and value objects, with no framework types: PSR-3 logging, PSR-7 requests, PSR-11 services, PSR-14 events and PSR-20 time.
Requirements
- PHP 8.3, 8.4 or 8.5, with the DOM extension (the in-memory host reads a module's admin.xml)
An add-on
An add-on is a Composer package of type contenir-extension, named contenir-extension/<handle>, whose classes
live under Contenir\Extension\<Package>\. Its composer.json names its handle and its entry class:
{
"name": "contenir-extension/newsletter",
"type": "contenir-extension",
"require": {
"contenir/contenir-extension-api": "^0.6"
},
"autoload": {
"psr-4": { "Contenir\\Extension\\Newsletter\\": "src/" }
},
"extra": {
"contenir": {
"handle": "newsletter",
"extension": "Contenir\\Extension\\Newsletter\\NewsletterExtension"
}
}
}
The entry class implements ExtensionInterface. The host constructs it and calls register() with a Registry
for the add-on's handle; each registry method declares one thing the add-on adds to the CMS:
use Contenir\Extension\Api\ExtensionInterface; use Contenir\Extension\Api\Form\Field; use Contenir\Extension\Api\Form\Form; use Contenir\Extension\Api\Registry; use Contenir\Extension\Api\Schedule\Frequency; use Contenir\Extension\Api\Settings\SettingsCategory; use Contenir\Extension\Api\Settings\SettingsSection; final class NewsletterExtension implements ExtensionInterface { public function register(Registry $registry): void { $registry->settingsSection(new SettingsSection( name: 'newsletter', title: 'Newsletter', category: SettingsCategory::Integration, form: new Form([ new Field('list_id', 'List'), Field::password('api_key', 'API key'), ]), )); $registry->handler('connect-callback', ConnectCallback::class); $registry->webhook('unsubscribe', UnsubscribeWebhook::class); $registry->diagnostic('connection', ConnectionCheck::class); $registry->schedule('resync', Resync::class, Frequency::everyMinutes(60)); } }
Extension points
| Registry method | Adds |
|---|---|
settingsSection() |
a Site Settings section described as data, with its operations, contributions, save listeners and row hydrators |
fieldType() |
a field type any section can use, named <handle>:<name> (mailchimp:optin) so it never clashes; implementing ImportableFieldTypeInterface lets a CSV import fill it |
icons() |
a folder of SVG icons |
listen() |
a listener for an event, which a site binds to its sections in admin.xml; it may narrow the listener's field scope but never widen it |
diagnostic() |
a check in the update check, whose result can offer a one-click fix or a guided fix |
handler() |
an admin request handler at /admin/extension/<handle>/<name>, behind the ACL resource extension.<handle>.<name>, needing the Handler\Privilege it declares, or else View to read and Update for anything else |
webhook() |
a public handler at /api/extension/<handle>/<name>, which verifies its caller's signature or token before it runs |
migrations() |
tables of the add-on's own, ext_<handle>_*, on the site's database or the CMS's |
schedule() |
recurring work for the host's job runner, every so many minutes or on a cron expression |
script() |
scripts for public pages, each with its placement and consent category |
panel() |
a panel beside one record on its view and edit screens (its section, id and values in, a title, text and links out), shown on the sections a site binds it to; the host escapes everything in it |
module(), assets(), component(), templates(), menu() |
an admin area of the add-on's own, with its scripts, styles and dashboard components; only a module may ship templates or a section menu (see Modules) |
dependsOn() |
another add-on this one uses, through that add-on's Event\ and Api\ namespaces only |
provide() |
a service the host asks for by interface, such as the CDN purger core calls, made by a Service\ServiceFactoryInterface with the add-on's context, and only while the add-on is enabled |
A host reads everything declared back with Registry::registration().
A handler or webhook can answer with a file to save, such as an export or an upload behind a signed link. The host streams what the writer writes, as an attachment that isn't cached, so a large file is never held whole:
return HandlerResult::download('entries.csv', 'text/csv', static function ($stream) use ($rows): void { foreach ($rows as $row) { fputcsv($stream, $row); } });
WebhookResponse::download() takes the same arguments.
Events
Add-ons react to what happens in the CMS through events named <Type>Pre<Action>, <Type><Action> and
<Type>Post<Action>, one guarantee per stage:
| Stage | When it runs | A listener may |
|---|---|---|
RecordPreSave |
before anything is written | change values, add a field error, warn, flag, or stop the save |
RecordSave |
inside the save's transaction | write its own ext_<handle>_* rows; if it throws, the whole save rolls back |
RecordPostSave |
after the transaction, in the background by default | anything outside the database; it runs whatever happened, and succeeded() says whether the save went through |
Event\Catalogue::EVENTS lists every event the CMS fires: the record actions (save, create, delete, duplicate,
status change, reorder, move, publish), settings saves, asset uploads and deletes, permission changes, sign-ins,
form entries, cache clears, migration runs, and ExtensionPostFail, fired when an add-on's code throws. Only the CMS
fires these; an add-on fires its own events, under its own Event\ namespace, through its context's dispatcher.
A new record fires the Create events and then the Save events at each stage, so a listener to RecordPostSave hears
creations too. A chain of events fired from listeners may go Event\ChainLimit::DEPTH (8) deep, and an event can't
recur within its own chain.
When a Pre listener throws, the action stops with a plain message for everyone; the technical summary goes in the
result's detail, which hosts show only to administrators.
$registry->listen( 'sync-subscriber', RecordPostSave::class, SyncSubscriber::class, Scope::fields('email', 'name', 'newsletter'), );
Modules
A module is an add-on with an admin area of its own (plan section 10). module() names an admin.xml fragment
holding one area of categories and sections. A site's own admin.xml can still rename, reorder or hide it. An area or
a section names one of the module's handlers with <handler> to draw its screen:
<admin> <forms> <title>Forms</title> <icon>forms</icon> <handler>forms</handler> <category> <exports> <title>Exports</title> <section> <scheduled> <title>Scheduled exports</title> <handler>exports</handler> </scheduled> </section> </exports> </category> </forms> </admin>
A module's handlers are guarded by its area's ACL resource (forms above), or by the section that names them
(forms.exports.scheduled), as the host's own screens are, so a role allowed the area keeps its screens.
A handler answers with a described screen where it can, and with one of the module's templates where it can't
(decision M1). A described screen may carry a row of Handler\Tabs under its header, leading to the other screens
about the same record:
return HandlerResult::template('fields', 'Fields', ['form' => $form]);
The host draws the dashboard around it, with the title in its header and breadcrumb. A screen or template that
belongs under another page, such as one record's, names it as its parent (a Handler\ParentPage, with a title and
one of the add-on's handlers), and the breadcrumb shows it between the area and the screen. For a script on one of the
module's screens, HandlerResult::fragment('builder/row', $values) draws a template alone, as HTML to put in place. The template is
<templates>/fields.phtml and runs with two variables: $values, as the handler passed them, and $view, a
Module\TemplateHelpersInterface for escaping, URLs to the module's handlers and assets, the form token, icons and
other templates. It uses the dashboard theme's markup and classes, never the host's own view helpers, so it draws
the same in any host. Testing\InMemoryTemplates draws a template on its own in a module's tests.
menu() names a Module\MenuInterface listing what the area's section menu shows beyond its admin.xml, such as the
forms in the form builder's tables. Each Module\MenuItem leads to one of the module's handlers.
Host context
Wherever an add-on's code runs, the host gives it an ExtensionContextInterface: its handle, the host's services,
a logger whose entries carry source: extension.<handle> (hosts can use Log\SourceLogger for that), a clock, the
site path, a URL resolver for records (Url\UrlResolverInterface, given a section and record id), an event
dispatcher for the add-on's own events, and its own settings:
$settings = $context->settings(); // the section named after the handle $secret = $settings->get('secret', ''); $settings->set(['token' => $token]); // saves as the settings screen would, firing the settings events
A host may also lend add-ons some of its own services from services(), named by interface or class, which an
add-on asks has() for first: the site's mail (Mail\MailerInterface) and its storage (contenir/contenir-storage's
StorageManager, whose profiles include Storage\StorageProfile::PRIVATE, which no URL reaches). In tests,
Testing\MemoryMailer and Testing\MemoryStorage::manager() stand in for them.
A host may also lend add-ons the site's own settings, read-only, from services() as
Settings\SiteSettingsInterface, which an add-on asks has() for first: get('contact.telephone') reads a setting
by its dotted key as text. Testing\MemorySiteSettings holds them in memory for tests.
The operation contracts the legacy CMS ships today (Operation\Operation, Operation\Contribution,
Operation\OperationResult and the context, handler, save-listener and row-hydrator interfaces) and
IconPathProviderInterface live here too, with the same shape, so the legacy CMS can alias its old names to them for
one release. An operation on an add-on's settings section gets that add-on's context from
OperationContextInterface::extension(), for its settings, and its logger from logger(); a core section's operation
gets null.
Services
An add-on can provide a service that the CMS, or another add-on, asks the host for by interface. The interface lives in a library both can require, so neither names the other:
$registry->provide(PurgerInterface::class, PurgerFactory::class); final class PurgerFactory implements ServiceFactoryInterface { public function create(ExtensionContextInterface $context): PurgerInterface { return new CloudflarePurger(/* built from $context->settings(), logging to $context->logger() */); } }
The host makes the service once, with the add-on's context, and only while the add-on is enabled, so disabling the
add-on takes the service away. When several enabled add-ons provide one interface, the first to load provides it and
the host logs the others. A factory that throws, or makes something that isn't the interface, is reported like any
add-on failure (FailurePoint::Service), and the host has no service.
Conformance suite
A host proves it implements the API by running the conformance suite: abstract PHPUnit test cases in
Contenir\Extension\Api\Conformance\, each covering one part of the plan.
| Test case | Proves |
|---|---|
AbstractLoadingConformanceTestCase |
only enabled add-ons load, each once; one that fails to register is skipped and reported, and the rest still load |
AbstractStagesConformanceTestCase |
the three stages of a save: Pre changes values, stops or holds a save; the main stage rolls back on failure; Post runs after, whatever happened, in the background by default |
AbstractBindingConformanceTestCase |
listeners run only where a site binds them, within their field scope, in the site's order |
AbstractContextConformanceTestCase |
an add-on's context names it, stamps its log entries, and refuses core events and recurring chains |
AbstractHandlerConformanceTestCase |
admin handlers behind their ACL resource; public webhooks only once they verify, with GET and POST; downloads from both, as attachments |
AbstractModuleConformanceTestCase |
a module's handlers answer with its templates, values escaped, behind its area's or section's resource and the privilege each declares |
AbstractSettingsConformanceTestCase |
an add-on reads back what it saved to its settings, unsaved fields have their declared default, and writes fire the settings events; operations on its section run with its context, behind their privilege on settings.<category>.<section> |
AbstractServiceConformanceTestCase |
services add-ons provide: made once with the add-on's context, only while it is enabled, from the first to load; a failing factory is reported and provides nothing |
AbstractDiagnosticConformanceTestCase |
checks in the update check; fixes need extension.<handle>.fix and re-run the check |
A host's test suite extends each one and implements createHost(), returning a fresh
Conformance\HostDriverInterface: a small adapter that boots the host with the suite's probe add-on, saves records,
binds listeners, answers requests and runs checks the way the CMS itself would.
final class LegacyStagesTest extends AbstractStagesConformanceTestCase { protected function createHost(): HostDriverInterface { return new LegacyHostDriver(/* a fresh test site */); } }
The suite needs PHPUnit 11, which a host already has in its dev dependencies.
In-memory host
Testing\InMemoryHost is a host with no CMS behind it, holding records, bindings and logs in memory. It passes the
conformance suite, so add-ons can be tested against the API without either CMS running:
$http = new Psr17Factory(); $host = new InMemoryHost($http, $http, new FrozenClock(new DateTimeImmutable('2026-10-19 09:00'))); $host->boot(['newsletter' => new NewsletterExtension()], ['newsletter'], [ListClient::class => $fakeClient]); $host->bind('member', 'newsletter', 'sync-subscriber', 'RecordPostSave', ['email']); $host->save('member', 1, ['email' => 'ann@example.com']); $host->runBackground();
menu() lists what a module's section menu shows for a request. It takes any PSR-17 factories for the requests
and responses handlers see; Testing\FrozenClock,
Testing\MemoryLogger, Testing\ServiceMap and Testing\MapUrlResolver are there for tests too.
The boundary
An add-on may use PHP itself, this API, the PSR interfaces above, its own code, the libraries its composer.json
requires, and only the Event\ and Api\ namespaces of other add-ons it requires (plan section 12, P7). It may
not require either CMS, ZF1, Laminas MVC or Mezzio, and its code may not use this package's test tooling.
vendor/bin/extension-boundary writes those rules from the add-on's composer.json to mago-boundary.toml, which
the add-on's mago.toml extends, so mago guard enforces them in CI:
extends = ["vendor/contenir/contenir-qa-tools/mago.toml", "mago-boundary.toml"]
vendor/bin/extension-boundary
--check says whether the file is current without writing it. An add-on's test suite extends
Conformance\AbstractAddOnConformanceTestCase once, which checks that the add-on registers cleanly, that its
boundary file is current, and that every public webhook refuses a request it cannot verify (P1).
tests/Fixture/Example is a complete example add-on: a settings section with a masked secret, a listener, a signed
webhook and a check, with its boundary file, tests and CI workflow.
Status
This package is phase 3 of the Contenir extensions plan. It is not tagged yet: hosts and add-ons follow 0.1.x-dev
until it is.
Licence
BSD-3-Clause. See LICENSE.md.