Skip to main content

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

  1. A theme template calls the Twig function render_point('<name>', { …context… }). The function is registered in both storefront renderers (TwigStorefrontRenderer for the classic themes and Storefront\ThemeRenderer), so it works in any theme.
  2. RenderPoint::render() fires the CI4 event storefront_render_point:<name> with a collector object.
  3. Every listener receives the collector, reads the theme's context from $collector->ctx and calls $collector->add($html). Empty output is ignored.
  4. 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

PointFired fromContextModules that answer it
product_buyproduct page, next to "Buy"productbuy_one_click
product_actionsproduct page, actions row (icons)productno bundled module — free for store/third-party modules
product_shareproduct pageproductshare
product_relatedproduct page, related blockproductrelated_products
product_seoproduct page (fauna theme)productseo_snippets
cart_delivery_widgetcart, once per delivery methoddeliverynova_poshta, ukrposhta, rozetka
checkout_extracheckout templatessystem_bonus (points field)
body_endevery storefront page, before </body> (fired by BaseFront, not the theme)ai_chat
admin_order_delivery_widgetadmin order create/edit (fired by Shop admin Orders)mode, ordernova_poshta
admin_order_customer_infoadmin order edit, right columnorderbuy_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.

Scripts and styles of the widget

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/).