Storefront render points
A render point is a named place in a storefront theme where any module may
contribute HTML. The theme marks where ({{ render_point('product_buy', …) }}),
a module says what (an event listener that pushes markup), and the core
(App\Libraries\RenderPoint) glues them together. Neither side knows about
the other, so the same module shows up in every theme that fires the point, and
switching themes does not silently drop it.
Why they exist
In ECMS9 a module never writes into theme files. Before render points, a
module's front-end widget (the "buy in 1 click" button, a delivery widget)
appeared only where the active theme explicitly called module('x'). A theme
that did not make that call lost the module with no error anywhere. Render
points invert the dependency: the theme publishes extension slots, modules
fill them.
How it works
- A theme template calls the Twig function
render_point('<name>', { …context… }). The function is registered in both storefront renderers (TwigStorefrontRendererfor the classic themes andStorefront\ThemeRenderer), so it works in any theme. RenderPoint::render()fires the CI4 eventstorefront_render_point:<name>with a collector object.- Every listener receives the collector, reads the theme's context from
$collector->ctxand calls$collector->add($html). Empty output is ignored. - The fragments are concatenated in listener order (CI4 event priority — the
third argument of
Events::on()) and returned as safe HTML.
Every listener runs inside try/catch; one broken module logs an error and
contributes nothing — it never blanks the page. A point that no module handles
simply renders an empty string, so themes can fire points freely.
Point names are [a-z0-9_]+; anything else renders nothing.
Points in the core and bundled themes
| Point | Fired from | Context | Modules that answer it |
|---|---|---|---|
product_buy | product page, next to "Buy" | product | buy_one_click |
product_actions | product page, actions row (icons) | product | no bundled module — free for store/third-party modules |
product_share | product page | product | share |
product_related | product page, related block | product | related_products |
product_seo | product page (fauna theme) | product | seo_snippets |
cart_delivery_widget | cart, once per delivery method | delivery | nova_poshta, ukrposhta, rozetka |
checkout_extra | checkout templates | — | system_bonus (points field) |
body_end | every storefront page, before </body> (fired by BaseFront, not the theme) | — | ai_chat |
admin_order_delivery_widget | admin order create/edit (fired by Shop admin Orders) | mode, order | nova_poshta |
admin_order_customer_info | admin order edit, right column | order | buy_one_click |
The last two reuse the same mechanism for the admin order form, so a shipping module can add its widget there without editing Shop admin views.
A listener for cart_delivery_widget must check whether the given delivery
method is its own ($collector->ctx['delivery']) and return otherwise — several
carriers share the point.
Registering a listener in a module
Listeners live in the module's own Config/Events.php, which CI4
auto-discovery loads (see Module structure).
Never register them in app/Config/Events.php.
<?php
// app/Modules/MyModule/Config/Events.php
namespace App\Modules\MyModule\Config;
use CodeIgniter\Events\Events;
Events::on('storefront_render_point:product_buy', static function ($collector): void {
try {
helper('modules');
if (! module_enabled('my_module')) {
return;
}
$product = $collector->ctx['product'] ?? null;
if (! is_object($product) || ! method_exists($product, 'getId')) {
return;
}
$collector->add((new \App\Modules\MyModule\Libraries\Widget())->render($product->getId()));
} catch (\Throwable $e) {
log_message('error', 'my_module render point: ' . $e->getMessage());
}
});
Rules that every listener follows: module_enabled() guard, try/catch,
validate the context before using it (themes pass slightly different
objects), and return silently when the point is not relevant.
Use the optional third argument of Events::on() to control the order when
several modules answer the same point (lower priority runs first).
Exposing a point in a theme
{# themes/<theme>/shop/includes/product/product_purchase.twig #}
{{ render_point('product_buy', {'product': model}) }}
{# cart: inside the loop over delivery methods #}
{{ render_point('cart_delivery_widget', {'delivery': delivery}) }}
A theme may add its own points with new names — a module only needs the name and the context keys.
tpl_register_asset() is a no-op in the current build, so a render point
contributes markup only. JS/CSS for the widget must be loaded by the theme
(a <script> tag pointing to /templates/<theme>/<module>/js/…), and assets
must exist in both app4/themes/<theme> and the public templates/<theme>
copy. Do not rely on <script defer> ordering — jQuery may be loaded lower in
the document; wait for window.jQuery or the load event.
A render point returns an HTML widget. Data accessors (a method returning a number or array the theme formats itself), AJAX-injected widgets and session-driven features stay direct calls — do not force them into a point.
Checking theme coverage
php spark shop:theme-coverage # active theme
php spark shop:theme-coverage goodlook # a specific theme
The command scans the theme's .twig/.tpl files and reports which
installed, enabled front-facing modules the theme surfaces (a module('x')
call, or an HTML-level module that injects into head/body) and which it does
not, plus the list of render points the theme fires. It changes nothing.
Run it before switching a site to another theme to see what would disappear.
After editing .twig files clear the storefront Twig cache
(writable/cache/twig_storefront/).